diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
164 files changed, 22011 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ebbba0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +node_modules/ +.next/ +.turbo/ +dist/ +out/ + +*.env +*.env.local +*.env.*.local + +.DS_Store +*.sw? +*.log + +# Generated service worker +apps/web/public/sw.js +apps/web/public/swe-worker-*.js + +# Go binary +services/worker/worker + +kaze/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..7938162 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "supabase": { + "type": "http", + "url": "https://mcp.supabase.com/mcp?project_ref=kbugptrwjnenmgkhdofn&features=storage%2Cbranching%2Cfunctions%2Cdevelopment%2Cdebugging%2Cdatabase%2Caccount%2Cdocs" + } + } +}
\ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/web/app/(auth)/forgot-password/page.tsx b/apps/web/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..748ba47 --- /dev/null +++ b/apps/web/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,101 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export default function ForgotPasswordPage() { + const [emailAddress, setEmailAddress] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isEmailSent, setIsEmailSent] = useState(false) + + async function handleResetRequest(event: React.FormEvent) { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.resetPasswordForEmail( + emailAddress, + { + redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`, + }, + ) + + if (error) { + setErrorMessage(error.message) + setIsSubmitting(false) + return + } + + setIsEmailSent(true) + } + + if (isEmailSent) { + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">check your email</h1> + <p className="text-text-secondary"> + we sent a password reset link to {emailAddress} + </p> + </div> + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) + } + + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">forgot password</h1> + <p className="text-text-secondary"> + enter your email to receive a reset link + </p> + </div> + + <form onSubmit={handleResetRequest} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="email" className="text-text-secondary"> + email + </label> + <input + id="email" + type="email" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + placeholder="[email protected]" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "sending reset link..." : "send reset link"} + </button> + </form> + + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) +} diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx new file mode 100644 index 0000000..6707b36 --- /dev/null +++ b/apps/web/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <div className="flex min-h-screen items-center justify-center px-4"> + <div className="w-full max-w-sm space-y-6">{children}</div> + </div> + ) +} diff --git a/apps/web/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..cb7432a --- /dev/null +++ b/apps/web/app/(auth)/reset-password/page.tsx @@ -0,0 +1,120 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export default function ResetPasswordPage() { + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isPasswordUpdated, setIsPasswordUpdated] = useState(false) + const router = useRouter() + + async function handlePasswordUpdate(event: React.FormEvent) { + event.preventDefault() + setErrorMessage(null) + + if (newPassword !== confirmPassword) { + setErrorMessage("passwords do not match") + return + } + + setIsSubmitting(true) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.updateUser({ + password: newPassword, + }) + + if (error) { + setErrorMessage(error.message) + setIsSubmitting(false) + return + } + + setIsPasswordUpdated(true) + } + + if (isPasswordUpdated) { + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">password updated</h1> + <p className="text-text-secondary"> + your password has been changed successfully + </p> + </div> + <Link + href="/reader" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + go to reader + </Link> + </> + ) + } + + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">reset password</h1> + <p className="text-text-secondary">enter your new password</p> + </div> + + <form onSubmit={handlePasswordUpdate} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="new-password" className="text-text-secondary"> + new password + </label> + <input + id="new-password" + type="password" + value={newPassword} + onChange={(event) => setNewPassword(event.target.value)} + required + minLength={8} + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + <div className="space-y-2"> + <label htmlFor="confirm-password" className="text-text-secondary"> + confirm password + </label> + <input + id="confirm-password" + type="password" + value={confirmPassword} + onChange={(event) => setConfirmPassword(event.target.value)} + required + minLength={8} + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "updating password..." : "update password"} + </button> + </form> + + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) +} diff --git a/apps/web/app/(auth)/sign-in/page.tsx b/apps/web/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..b7426d2 --- /dev/null +++ b/apps/web/app/(auth)/sign-in/page.tsx @@ -0,0 +1,230 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +type SignInMode = "password" | "magic-link" + +export default function SignInPage() { + const [signInMode, setSignInMode] = useState<SignInMode>("password") + const [emailAddress, setEmailAddress] = useState("") + const [password, setPassword] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isMagicLinkSent, setIsMagicLinkSent] = useState(false) + const router = useRouter() + + async function handlePasswordSignIn(event: React.FormEvent) { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.signInWithPassword({ + email: emailAddress, + password, + }) + + if (error) { + setErrorMessage(error.message) + setIsSubmitting(false) + return + } + + router.push("/reader") + router.refresh() + } + + async function handleMagicLinkSignIn(event: React.FormEvent) { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.signInWithOtp({ + email: emailAddress, + options: { + shouldCreateUser: false, + emailRedirectTo: `${window.location.origin}/auth/callback?next=/reader`, + }, + }) + + setIsSubmitting(false) + + if (error) { + if (error.message.toLowerCase().includes("signups not allowed")) { + setIsMagicLinkSent(true) + return + } + setErrorMessage("something went wrong — please try again") + return + } + + setIsMagicLinkSent(true) + } + + if (isMagicLinkSent) { + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">check your email</h1> + <p className="text-text-secondary"> + if an account exists for {emailAddress}, we sent a sign-in link + </p> + </div> + <button + onClick={() => { + setIsMagicLinkSent(false) + setEmailAddress("") + }} + className="block text-text-secondary transition-colors hover:text-text-primary" + > + try a different email + </button> + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) + } + + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">sign in</h1> + <p className="text-text-secondary"> + {signInMode === "password" + ? "enter your credentials to continue" + : "we\u2019ll send a sign-in link to your email"} + </p> + </div> + + <div className="flex border border-border"> + <button + type="button" + onClick={() => { + setSignInMode("password") + setErrorMessage(null) + }} + className={`flex-1 px-3 py-2 transition-colors ${ + signInMode === "password" + ? "bg-background-tertiary text-text-primary" + : "text-text-dim hover:text-text-secondary" + }`} + > + password + </button> + <button + type="button" + onClick={() => { + setSignInMode("magic-link") + setErrorMessage(null) + }} + className={`flex-1 px-3 py-2 transition-colors ${ + signInMode === "magic-link" + ? "bg-background-tertiary text-text-primary" + : "text-text-dim hover:text-text-secondary" + }`} + > + magic link + </button> + </div> + + {signInMode === "password" ? ( + <form onSubmit={handlePasswordSignIn} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="email" className="text-text-secondary"> + email + </label> + <input + id="email" + type="email" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + placeholder="[email protected]" + /> + </div> + + <div className="space-y-2"> + <label htmlFor="password" className="text-text-secondary"> + password + </label> + <input + id="password" + type="password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "signing in..." : "sign in"} + </button> + </form> + ) : ( + <form onSubmit={handleMagicLinkSignIn} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="magic-email" className="text-text-secondary"> + email + </label> + <input + id="magic-email" + type="email" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + placeholder="[email protected]" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "sending link..." : "send magic link"} + </button> + </form> + )} + + <div className="space-y-2 text-text-secondary"> + <Link + href="/forgot-password" + className="block transition-colors hover:text-text-primary" + > + forgot password? + </Link> + <Link + href="/sign-up" + className="block transition-colors hover:text-text-primary" + > + don't have an account? sign up + </Link> + </div> + </> + ) +} diff --git a/apps/web/app/(auth)/sign-up/page.tsx b/apps/web/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..9b78d90 --- /dev/null +++ b/apps/web/app/(auth)/sign-up/page.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export default function SignUpPage() { + const [emailAddress, setEmailAddress] = useState("") + const [password, setPassword] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isComplete, setIsComplete] = useState(false) + const router = useRouter() + + async function handleSignUp(event: React.FormEvent) { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.signUp({ + email: emailAddress, + password, + }) + + if (error) { + setErrorMessage(error.message) + setIsSubmitting(false) + return + } + + setIsComplete(true) + } + + if (isComplete) { + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">check your email</h1> + <p className="text-text-secondary"> + we sent a confirmation link to {emailAddress} + </p> + </div> + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) + } + + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">sign up</h1> + <p className="text-text-secondary">create your asa.news account</p> + </div> + + <form onSubmit={handleSignUp} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="email" className="text-text-secondary"> + email + </label> + <input + id="email" + type="email" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + placeholder="[email protected]" + /> + </div> + + <div className="space-y-2"> + <label htmlFor="password" className="text-text-secondary"> + password + </label> + <input + id="password" + type="password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + minLength={8} + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "creating account..." : "sign up"} + </button> + </form> + + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + already have an account? sign in + </Link> + </> + ) +} diff --git a/apps/web/app/(marketing)/_components/feature-grid.tsx b/apps/web/app/(marketing)/_components/feature-grid.tsx new file mode 100644 index 0000000..64fd1a4 --- /dev/null +++ b/apps/web/app/(marketing)/_components/feature-grid.tsx @@ -0,0 +1,48 @@ +const FEATURES = [ + { + title: "keyboard shortcuts", + description: + "full keyboard shortcut support for power users. works just as well with a mouse or trackpad.", + }, + { + title: "podcast support", + description: + "subscribe to podcast feeds and listen to episodes with the built-in audio player.", + }, + { + title: "highlights & notes", + description: + "highlight passages in articles and attach notes. free users get up to 50 highlights.", + }, + { + title: "sharing", + description: + "share articles via public links. links last 7 days on free, 30 days on pro.", + }, + { + title: "import & export", + description: + "import your feeds from any reader via OPML. pro users can export their full data.", + }, + { + title: "real-time updates", + description: + "new entries appear automatically as feeds are refreshed. no manual reload needed.", + }, +] + +export function FeatureGrid() { + return ( + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> + {FEATURES.map((feature) => ( + <div + key={feature.title} + className="border border-border bg-background-secondary p-4" + > + <h3 className="mb-1 text-text-primary">{feature.title}</h3> + <p className="text-text-secondary">{feature.description}</p> + </div> + ))} + </div> + ) +} diff --git a/apps/web/app/(marketing)/_components/interactive-demo.tsx b/apps/web/app/(marketing)/_components/interactive-demo.tsx new file mode 100644 index 0000000..a1c3755 --- /dev/null +++ b/apps/web/app/(marketing)/_components/interactive-demo.tsx @@ -0,0 +1,275 @@ +"use client" + +import { useState } from "react" +import { formatDistanceToNow } from "date-fns" +import { classNames } from "@/lib/utilities" +import type { ShowcaseEntry, ShowcaseFeed } from "./showcase-types" + +function estimateReadingTimeMinutes(html: string): number { + const text = html.replace(/<[^>]*>/g, "").replace(/&\w+;/g, " ") + const wordCount = text.split(/\s+/).filter(Boolean).length + return Math.max(1, Math.round(wordCount / 200)) +} + +function DemoSidebar({ feeds }: { feeds: ShowcaseFeed[] }) { + return ( + <aside className="hidden w-52 shrink-0 border-r border-border bg-background-secondary lg:block"> + <div className="border-b border-border px-3 py-2 text-text-primary"> + asa.news + </div> + <nav className="space-y-0.5 p-2"> + <span className="flex items-center bg-background-tertiary px-2 py-1 text-text-primary"> + <span>all entries</span> + <span className="ml-auto text-[0.625rem] tabular-nums text-text-dim"> + {feeds.reduce((sum, feed) => sum + feed.unreadCount, 0)} + </span> + </span> + <span className="block px-2 py-1 text-text-secondary">saved</span> + <span className="block px-2 py-1 text-text-secondary">highlights</span> + <span className="block px-2 py-1 text-text-secondary">shares</span> + + <div className="mt-3 space-y-0.5"> + {feeds.map((feed) => ( + <span + key={feed.url} + className="flex items-center truncate px-2 py-1 text-[0.85em] text-text-secondary" + > + <img + src={`https://www.google.com/s2/favicons?domain=${new URL(feed.url).hostname}&sz=16`} + alt="" + width={16} + height={16} + className="shrink-0" + loading="lazy" + /> + <span className="ml-2 truncate">{feed.title}</span> + {feed.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim">♫</span> + )} + <span className="ml-auto shrink-0 text-[0.625rem] tabular-nums text-text-dim"> + {feed.unreadCount > 0 ? feed.unreadCount : ""} + </span> + </span> + ))} + </div> + </nav> + </aside> + ) +} + +function DemoEntryList({ + entries, + selectedEntryIdentifier, + onSelectEntry, +}: { + entries: ShowcaseEntry[] + selectedEntryIdentifier: string | null + onSelectEntry: (identifier: string) => void +}) { + return ( + <div className="flex-1 overflow-y-auto border-r border-border md:max-w-sm lg:max-w-md"> + {entries.map((entry, index) => { + const isSelected = entry.entryIdentifier === selectedEntryIdentifier + const isRead = index % 3 === 2 + + const relativeTimestamp = entry.publishedAt + ? formatDistanceToNow(new Date(entry.publishedAt), { + addSuffix: true, + }) + : "" + + return ( + <div + key={entry.entryIdentifier} + onClick={() => onSelectEntry(entry.entryIdentifier)} + className={classNames( + "cursor-pointer border-b border-border px-4 py-2.5 transition-colors", + isSelected + ? "bg-background-tertiary" + : "hover:bg-background-secondary", + isRead ? "opacity-60" : "" + )} + > + <div className="truncate text-text-primary"> + {entry.entryTitle} + </div> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + <span>{entry.feedTitle}</span> + {entry.enclosureUrl && <span>♫</span>} + {entry.author && ( + <> + <span>·</span> + <span>{entry.author}</span> + </> + )} + <span>·</span> + <span>{relativeTimestamp}</span> + </div> + </div> + ) + })} + </div> + ) +} + +function DemoDetailPane({ entry }: { entry: ShowcaseEntry | null }) { + if (!entry) { + return ( + <div className="hidden flex-1 items-center justify-center text-text-dim md:flex"> + select an entry to read + </div> + ) + } + + const readingTime = entry.contentHtml + ? estimateReadingTimeMinutes(entry.contentHtml) + : null + + return ( + <div className="hidden flex-1 overflow-y-auto md:block"> + <div className="border-b border-border px-4 py-2"> + <div className="flex items-center gap-3 text-text-dim"> + <span>mark read</span> + <span>·</span> + <span>save</span> + <span>·</span> + <span>share</span> + <span>·</span> + <span>open original</span> + </div> + </div> + <article className="px-6 py-4"> + <h1 className="mb-2 text-lg text-text-primary">{entry.entryTitle}</h1> + <div className="mb-4 text-text-dim"> + <span>{entry.feedTitle}</span> + {entry.author && <span> · {entry.author}</span>} + {readingTime && <span> · {readingTime} min read</span>} + </div> + {entry.enclosureUrl && ( + <div className="mb-4 border border-border p-3"> + <div className="flex items-center gap-2 text-text-secondary"> + <span>♫</span> + <span>audio available</span> + </div> + </div> + )} + {entry.contentHtml ? ( + <div + className="prose-reader text-text-secondary" + dangerouslySetInnerHTML={{ __html: entry.contentHtml }} + /> + ) : entry.summary ? ( + <p className="text-text-secondary">{entry.summary}</p> + ) : null} + </article> + </div> + ) +} + +function DemoMobileDetail({ + entry, + onClose, +}: { + entry: ShowcaseEntry + onClose: () => void +}) { + const readingTime = entry.contentHtml + ? estimateReadingTimeMinutes(entry.contentHtml) + : null + + return ( + <div className="fixed inset-0 z-50 flex flex-col bg-background-primary md:hidden"> + <div className="flex items-center border-b border-border px-4 py-2"> + <button + type="button" + onClick={onClose} + className="text-text-secondary transition-colors hover:text-text-primary" + > + ← back + </button> + </div> + <div className="flex-1 overflow-y-auto"> + <article className="px-4 py-4"> + <h1 className="mb-2 text-lg text-text-primary"> + {entry.entryTitle} + </h1> + <div className="mb-4 text-text-dim"> + <span>{entry.feedTitle}</span> + {entry.author && <span> · {entry.author}</span>} + {readingTime && <span> · {readingTime} min read</span>} + </div> + {entry.contentHtml ? ( + <div + className="prose-reader text-text-secondary" + dangerouslySetInnerHTML={{ __html: entry.contentHtml }} + /> + ) : entry.summary ? ( + <p className="text-text-secondary">{entry.summary}</p> + ) : null} + </article> + </div> + </div> + ) +} + +export function InteractiveDemo({ + showcaseEntries, +}: { + showcaseEntries: ShowcaseEntry[] +}) { + const [selectedEntryIdentifier, setSelectedEntryIdentifier] = useState< + string | null + >(showcaseEntries.length > 0 ? showcaseEntries[0].entryIdentifier : null) + const [mobileDetailEntry, setMobileDetailEntry] = + useState<ShowcaseEntry | null>(null) + + const selectedEntry = + showcaseEntries.find( + (entry) => entry.entryIdentifier === selectedEntryIdentifier + ) ?? null + + const feedMap = new Map<string, ShowcaseFeed>() + for (const entry of showcaseEntries) { + if (!feedMap.has(entry.feedUrl)) { + const seededCount = + (entry.feedUrl.charCodeAt(0) + entry.feedUrl.length) % 12 + feedMap.set(entry.feedUrl, { + title: entry.feedTitle, + url: entry.feedUrl, + feedType: entry.feedType, + unreadCount: seededCount, + }) + } + } + const feeds = Array.from(feedMap.values()) + + function handleSelectEntry(identifier: string) { + setSelectedEntryIdentifier(identifier) + const entry = showcaseEntries.find( + (showcaseEntry) => showcaseEntry.entryIdentifier === identifier + ) + if (entry && typeof window !== "undefined" && window.innerWidth < 768) { + setMobileDetailEntry(entry) + } + } + + return ( + <div className="relative border border-border bg-background-primary"> + <div className="flex h-[500px]"> + <DemoSidebar feeds={feeds} /> + <DemoEntryList + entries={showcaseEntries} + selectedEntryIdentifier={selectedEntryIdentifier} + onSelectEntry={handleSelectEntry} + /> + <DemoDetailPane entry={selectedEntry} /> + </div> + {mobileDetailEntry && ( + <DemoMobileDetail + entry={mobileDetailEntry} + onClose={() => setMobileDetailEntry(null)} + /> + )} + </div> + ) +} diff --git a/apps/web/app/(marketing)/_components/pricing-table.tsx b/apps/web/app/(marketing)/_components/pricing-table.tsx new file mode 100644 index 0000000..c06b4f9 --- /dev/null +++ b/apps/web/app/(marketing)/_components/pricing-table.tsx @@ -0,0 +1,186 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { TIER_LIMITS } from "@asa-news/shared" +import { classNames } from "@/lib/utilities" + +function formatLimit(value: number): string { + if (!Number.isFinite(value)) return "unlimited" + return value.toLocaleString() +} + +const COMPARISON_ROWS = [ + { + label: "feeds", + free: formatLimit(TIER_LIMITS.free.maximumFeeds), + pro: formatLimit(TIER_LIMITS.pro.maximumFeeds), + developer: formatLimit(TIER_LIMITS.developer.maximumFeeds), + }, + { + label: "folders", + free: formatLimit(TIER_LIMITS.free.maximumFolders), + pro: formatLimit(TIER_LIMITS.pro.maximumFolders), + developer: formatLimit(TIER_LIMITS.developer.maximumFolders), + }, + { + label: "refresh interval", + free: `${TIER_LIMITS.free.refreshIntervalSeconds / 60} min`, + pro: `${TIER_LIMITS.pro.refreshIntervalSeconds / 60} min`, + developer: `${TIER_LIMITS.developer.refreshIntervalSeconds / 60} min`, + }, + { + label: "history", + free: `${TIER_LIMITS.free.historyRetentionDays} days`, + pro: formatLimit(TIER_LIMITS.pro.historyRetentionDays), + developer: formatLimit(TIER_LIMITS.developer.historyRetentionDays), + }, + { + label: "muted keywords", + free: formatLimit(TIER_LIMITS.free.maximumMutedKeywords), + pro: formatLimit(TIER_LIMITS.pro.maximumMutedKeywords), + developer: formatLimit(TIER_LIMITS.developer.maximumMutedKeywords), + }, + { + label: "custom feeds", + free: formatLimit(TIER_LIMITS.free.maximumCustomFeeds), + pro: formatLimit(TIER_LIMITS.pro.maximumCustomFeeds), + developer: formatLimit(TIER_LIMITS.developer.maximumCustomFeeds), + }, + { + label: "authenticated feeds", + free: "no", + pro: "yes", + developer: "yes", + }, + { + label: "data export", + free: "no", + pro: "yes", + developer: "yes", + }, + { + label: "rest api", + free: "no", + pro: "no", + developer: "yes", + }, + { + label: "webhooks", + free: "no", + pro: "no", + developer: "yes", + }, +] + +export function PricingTable() { + const [billingInterval, setBillingInterval] = useState<"monthly" | "yearly">( + "yearly" + ) + + const proPrice = billingInterval === "yearly" ? "$30" : "$3" + const proPeriod = billingInterval === "yearly" ? "/ year" : "/ month" + const developerPrice = billingInterval === "yearly" ? "$60" : "$6" + const developerPeriod = billingInterval === "yearly" ? "/ year" : "/ month" + + return ( + <div> + <div className="mb-6 flex items-center justify-center gap-2"> + <button + type="button" + onClick={() => setBillingInterval("monthly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "monthly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + monthly + </button> + <button + type="button" + onClick={() => setBillingInterval("yearly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "yearly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + yearly + </button> + </div> + + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> + <div className="border border-border p-6"> + <h3 className="mb-1 text-text-primary">free</h3> + <p className="mb-4 text-text-dim">$0 / month</p> + <ul className="space-y-2"> + {COMPARISON_ROWS.map((row) => ( + <li + key={row.label} + className="flex justify-between text-text-secondary" + > + <span>{row.label}</span> + <span className="whitespace-nowrap text-text-primary">{row.free}</span> + </li> + ))} + </ul> + <Link + href="/sign-up" + className="mt-6 block border border-border px-4 py-2 text-center text-text-primary transition-colors hover:bg-background-tertiary" + > + get started + </Link> + </div> + <div className="border border-text-primary p-6"> + <h3 className="mb-1 text-text-primary">pro</h3> + <p className="mb-4 text-text-dim"> + {proPrice} {proPeriod} + </p> + <ul className="space-y-2"> + {COMPARISON_ROWS.map((row) => ( + <li + key={row.label} + className="flex justify-between text-text-secondary" + > + <span>{row.label}</span> + <span className="whitespace-nowrap text-text-primary">{row.pro}</span> + </li> + ))} + </ul> + <Link + href="/sign-up" + className="mt-6 block bg-text-primary px-4 py-2 text-center text-background-primary transition-opacity hover:opacity-90" + > + get started + </Link> + </div> + <div className="border border-border p-6"> + <h3 className="mb-1 text-text-primary">developer</h3> + <p className="mb-4 text-text-dim"> + {developerPrice} {developerPeriod} + </p> + <ul className="space-y-2"> + {COMPARISON_ROWS.map((row) => ( + <li + key={row.label} + className="flex justify-between text-text-secondary" + > + <span>{row.label}</span> + <span className="whitespace-nowrap text-text-primary">{row.developer}</span> + </li> + ))} + </ul> + <Link + href="/sign-up" + className="mt-6 block border border-border px-4 py-2 text-center text-text-primary transition-colors hover:bg-background-tertiary" + > + get started + </Link> + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/(marketing)/_components/showcase-types.ts b/apps/web/app/(marketing)/_components/showcase-types.ts new file mode 100644 index 0000000..5517223 --- /dev/null +++ b/apps/web/app/(marketing)/_components/showcase-types.ts @@ -0,0 +1,22 @@ +export interface ShowcaseEntry { + entryIdentifier: string + feedTitle: string + feedUrl: string + feedType: string | null + entryTitle: string + entryUrl: string + author: string | null + summary: string | null + contentHtml: string | null + imageUrl: string | null + publishedAt: string + enclosureUrl: string | null + enclosureType: string | null +} + +export interface ShowcaseFeed { + title: string + url: string + feedType: string | null + unreadCount: number +} diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx new file mode 100644 index 0000000..534f252 --- /dev/null +++ b/apps/web/app/(marketing)/page.tsx @@ -0,0 +1,250 @@ +import Link from "next/link" +import { redirect } from "next/navigation" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { sanitizeEntryContent } from "@/lib/sanitize" +import { InteractiveDemo } from "./_components/interactive-demo" +import { FeatureGrid } from "./_components/feature-grid" +import { PricingTable } from "./_components/pricing-table" +import type { ShowcaseEntry } from "./_components/showcase-types" + +export const revalidate = 300 + +interface ShowcaseEntryRow { + id: string + title: string | null + url: string | null + author: string | null + summary: string | null + content_html: string | null + image_url: string | null + published_at: string | null + enclosure_url: string | null + enclosure_type: string | null + feeds: { + title: string | null + url: string + feed_type: string | null + } +} + +const SHOWCASE_FEED_URLS = [ + "https://techcrunch.com/feed/", + "https://blog.cloudflare.com/rss/", + "https://hacks.mozilla.org/feed/", + "https://feeds.arstechnica.com/arstechnica/index", + "https://www.wired.com/feed/rss", +] + +async function fetchShowcaseEntries(): Promise<ShowcaseEntry[]> { + const adminClient = createSupabaseAdminClient() + + const { data: feedRows } = await adminClient + .from("feeds") + .select("id") + .in("url", SHOWCASE_FEED_URLS) + + const feedIdentifiers = (feedRows ?? []).map((row) => row.id) + + if (feedIdentifiers.length === 0) { + return FALLBACK_ENTRIES + } + + const entriesPerFeed = 5 + const perFeedResults = await Promise.all( + feedIdentifiers.map((feedIdentifier) => + adminClient + .from("entries") + .select( + "id, title, url, author, summary, content_html, image_url, published_at, enclosure_url, enclosure_type, feeds!inner(title, url, feed_type)" + ) + .is("owner_id", null) + .eq("feed_id", feedIdentifier) + .order("published_at", { ascending: false }) + .limit(entriesPerFeed) + ) + ) + + const combinedRows = perFeedResults.flatMap( + ({ data: rows }) => (rows as unknown as ShowcaseEntryRow[]) ?? [] + ) + + if (combinedRows.length === 0) { + return FALLBACK_ENTRIES + } + + combinedRows.sort( + (a, b) => + new Date(b.published_at ?? 0).getTime() - + new Date(a.published_at ?? 0).getTime() + ) + + return combinedRows.map((row) => ({ + entryIdentifier: row.id, + feedTitle: row.feeds.title ?? "untitled feed", + feedUrl: row.feeds.url, + feedType: row.feeds.feed_type, + entryTitle: row.title ?? "untitled", + entryUrl: row.url ?? "", + author: row.author, + summary: row.summary, + contentHtml: row.content_html + ? sanitizeEntryContent(row.content_html) + : null, + imageUrl: row.image_url, + publishedAt: row.published_at ?? new Date().toISOString(), + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + })) +} + +const FALLBACK_ENTRIES: ShowcaseEntry[] = [ + { + entryIdentifier: "fallback-1", + feedTitle: "TechCrunch", + feedUrl: "https://techcrunch.com/feed/", + feedType: null, + entryTitle: "The resurgence of rss in 2026", + entryUrl: "https://example.com", + author: "Sarah Chen", + summary: "RSS feeds are making a comeback as users seek alternatives to algorithmic timelines.", + contentHtml: "<p>RSS feeds are making a comeback as users seek alternatives to algorithmic timelines. More people are turning to chronological, ad-free reading experiences.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 3600000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, + { + entryIdentifier: "fallback-2", + feedTitle: "The Cloudflare Blog", + feedUrl: "https://blog.cloudflare.com/rss/", + feedType: null, + entryTitle: "How we built a global edge caching layer", + entryUrl: "https://example.com", + author: "Cloudflare Engineering", + summary: "A deep dive into the architecture behind Cloudflare's edge caching infrastructure.", + contentHtml: "<p>A deep dive into the architecture behind Cloudflare's edge caching infrastructure. Learn how requests are routed, cached, and invalidated across hundreds of data centres worldwide.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 7200000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, + { + entryIdentifier: "fallback-3", + feedTitle: "Mozilla Hacks", + feedUrl: "https://hacks.mozilla.org/feed/", + feedType: null, + entryTitle: "Exploring the future of web components", + entryUrl: "https://example.com", + author: "Mozilla", + summary: "Web components are evolving rapidly. Here's what's coming next for the open web platform.", + contentHtml: "<p>Web components are evolving rapidly. Here's what's coming next for the open web platform. From declarative shadow DOM to scoped custom element registries, the standards are maturing fast.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 10800000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, + { + entryIdentifier: "fallback-4", + feedTitle: "Ars Technica", + feedUrl: "https://feeds.arstechnica.com/arstechnica/index", + feedType: null, + entryTitle: "Building a personal information diet", + entryUrl: "https://example.com", + author: "Jordan Lee", + summary: "How curating your own feeds leads to better focus and less information overload.", + contentHtml: "<p>How curating your own feeds leads to better focus and less information overload. The key is choosing sources deliberately rather than letting algorithms decide.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 14400000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, + { + entryIdentifier: "fallback-5", + feedTitle: "Wired", + feedUrl: "https://www.wired.com/feed/rss", + feedType: null, + entryTitle: "The quiet revolution in personal information tools", + entryUrl: "https://example.com", + author: "Morgan Hayes", + summary: "A new wave of tools is helping people take control of their information diet.", + contentHtml: "<p>A new wave of tools is helping people take control of their information diet. From RSS readers to read-later apps, the focus is shifting back to user choice.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 18000000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, +] + +export default async function LandingPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (user) redirect("/reader") + + const showcaseEntries = await fetchShowcaseEntries() + + return ( + <div className="min-h-screen"> + <header className="flex items-center justify-between border-b border-border px-6 py-3"> + <span className="text-text-primary">asa.news</span> + <Link + href="/sign-in" + className="text-text-secondary transition-colors hover:text-text-primary" + > + sign in + </Link> + </header> + + <section className="mx-auto max-w-4xl px-6 py-16 text-center"> + <h1 className="mb-3 text-xl text-text-primary">asa.news</h1> + <p className="mb-8 text-text-secondary"> + a fast, minimal rss reader for staying informed + </p> + <div className="flex items-center justify-center gap-4"> + <Link + href="/sign-up" + className="border border-border px-4 py-2 text-text-primary transition-colors hover:bg-background-tertiary" + > + sign up + </Link> + <Link + href="/sign-in" + className="px-4 py-2 text-text-secondary transition-colors hover:text-text-primary" + > + sign in + </Link> + </div> + </section> + + <section className="mx-auto max-w-6xl px-6 pb-16"> + <p className="mb-4 text-center text-text-dim"> + live preview — real entries from real feeds + </p> + <InteractiveDemo showcaseEntries={showcaseEntries} /> + </section> + + <section className="mx-auto max-w-4xl px-6 pb-16"> + <h2 className="mb-6 text-center text-text-primary">features</h2> + <FeatureGrid /> + </section> + + <section className="mx-auto max-w-4xl px-6 pb-16"> + <h2 className="mb-6 text-center text-text-primary">pricing</h2> + <PricingTable /> + </section> + + <section className="border-t border-border px-6 py-16 text-center"> + <p className="mb-6 text-text-secondary">start reading today</p> + <Link + href="/sign-up" + className="border border-border px-6 py-2 text-text-primary transition-colors hover:bg-background-tertiary" + > + sign up free + </Link> + </section> + </div> + ) +} diff --git a/apps/web/app/api/account/data/route.ts b/apps/web/app/api/account/data/route.ts new file mode 100644 index 0000000..dbee725 --- /dev/null +++ b/apps/web/app/api/account/data/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const [ + profileResult, + subscriptionsResult, + foldersResult, + mutedKeywordsResult, + customFeedsResult, + entryStatesResult, + highlightsResult, + sharedEntriesResult, + savedEntriesResult, + ] = await Promise.all([ + supabaseClient + .from("user_profiles") + .select("id, display_name, tier, created_at") + .eq("id", user.id) + .single(), + supabaseClient + .from("subscriptions") + .select("id, feed_id, folder_id, custom_title, created_at, feeds(title, url)") + .eq("user_id", user.id), + supabaseClient + .from("folders") + .select("id, name, position, created_at") + .eq("user_id", user.id), + supabaseClient + .from("muted_keywords") + .select("id, keyword, created_at") + .eq("user_id", user.id), + supabaseClient + .from("custom_feeds") + .select("id, name, query, position, created_at") + .eq("user_id", user.id), + supabaseClient + .from("user_entry_states") + .select("entry_id, read, saved, updated_at") + .eq("user_id", user.id), + supabaseClient + .from("user_highlights") + .select( + "id, entry_id, highlighted_text, note, color, text_offset, text_length, created_at, entries(title, url)" + ) + .eq("user_id", user.id), + supabaseClient + .from("shared_entries") + .select("id, entry_id, share_token, created_at, entries(title, url)") + .eq("user_id", user.id), + supabaseClient + .from("user_entry_states") + .select( + "entries(id, title, url, author, summary, published_at, feeds(title, url))" + ) + .eq("user_id", user.id) + .eq("saved", true), + ]) + + const exportData = { + exportedAt: new Date().toISOString(), + account: { + emailAddress: user.email, + ...profileResult.data, + }, + subscriptions: subscriptionsResult.data ?? [], + folders: foldersResult.data ?? [], + mutedKeywords: mutedKeywordsResult.data ?? [], + customFeeds: customFeedsResult.data ?? [], + entryStates: entryStatesResult.data ?? [], + highlights: highlightsResult.data ?? [], + sharedEntries: sharedEntriesResult.data ?? [], + savedEntries: + (savedEntriesResult.data ?? []).map( + (row) => (row as Record<string, unknown>).entries + ) ?? [], + } + + const jsonString = JSON.stringify(exportData, null, 2) + + return new Response(jsonString, { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json"`, + }, + }) +} diff --git a/apps/web/app/api/account/route.ts b/apps/web/app/api/account/route.ts new file mode 100644 index 0000000..6b1bc2d --- /dev/null +++ b/apps/web/app/api/account/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" + +export async function DELETE() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const adminClient = createSupabaseAdminClient() + + const { error } = await adminClient.auth.admin.deleteUser(user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to delete account" }, + { status: 500 } + ) + } + + return new Response(null, { status: 204 }) +} diff --git a/apps/web/app/api/billing/create-checkout-session/route.ts b/apps/web/app/api/billing/create-checkout-session/route.ts new file mode 100644 index 0000000..cfbb388 --- /dev/null +++ b/apps/web/app/api/billing/create-checkout-session/route.ts @@ -0,0 +1,153 @@ +import { NextResponse } from "next/server" +import { headers } from "next/headers" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { getStripe } from "@/lib/stripe" +import { rateLimit } from "@/lib/rate-limit" + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`checkout:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const body = await request.json().catch(() => ({})) + const billingInterval = + body.billingInterval === "yearly" ? "yearly" : "monthly" + const targetTier = + body.targetTier === "developer" ? "developer" : "pro" + + const priceIdentifierMap: Record<string, string | undefined> = { + "pro:monthly": process.env.STRIPE_PRO_MONTHLY_PRICE_IDENTIFIER, + "pro:yearly": process.env.STRIPE_PRO_YEARLY_PRICE_IDENTIFIER, + "developer:monthly": process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER, + "developer:yearly": process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER, + } + + const stripePriceIdentifier = + priceIdentifierMap[`${targetTier}:${billingInterval}`] + + if (!stripePriceIdentifier) { + return NextResponse.json( + { error: "Invalid plan configuration" }, + { status: 500 } + ) + } + + const { data: profile, error: profileError } = await supabaseClient + .from("user_profiles") + .select("tier, stripe_customer_identifier, stripe_subscription_identifier") + .eq("id", user.id) + .single() + + if (profileError || !profile) { + return NextResponse.json( + { error: "Failed to load profile" }, + { status: 500 } + ) + } + + const tierRank: Record<string, number> = { free: 0, pro: 1, developer: 2 } + const currentRank = tierRank[profile.tier] ?? 0 + const targetRank = tierRank[targetTier] ?? 0 + + if (currentRank >= targetRank) { + return NextResponse.json( + { error: `Already on ${profile.tier} plan` }, + { status: 400 } + ) + } + + if (profile.stripe_subscription_identifier && currentRank > 0) { + const subscription = await getStripe().subscriptions.retrieve( + profile.stripe_subscription_identifier + ) + + const existingItemIdentifier = subscription.items.data[0]?.id + + if (!existingItemIdentifier) { + return NextResponse.json( + { error: "Could not find existing subscription item" }, + { status: 500 } + ) + } + + await getStripe().subscriptions.update( + profile.stripe_subscription_identifier, + { + items: [ + { + id: existingItemIdentifier, + price: stripePriceIdentifier, + }, + ], + proration_behavior: "always_invoice", + metadata: { supabase_user_identifier: user.id }, + } + ) + + const adminClient = createSupabaseAdminClient() + await adminClient + .from("user_profiles") + .update({ tier: targetTier }) + .eq("id", user.id) + + return NextResponse.json({ upgraded: true }) + } + + let stripeCustomerIdentifier = profile.stripe_customer_identifier + + if (!stripeCustomerIdentifier) { + const customer = await getStripe().customers.create({ + email: user.email, + metadata: { supabase_user_identifier: user.id }, + }) + + stripeCustomerIdentifier = customer.id + + const adminClient = createSupabaseAdminClient() + const { error: updateError } = await adminClient + .from("user_profiles") + .update({ stripe_customer_identifier: stripeCustomerIdentifier }) + .eq("id", user.id) + + if (updateError) { + console.error("Admin client update error:", updateError) + return NextResponse.json( + { error: "Failed to save customer: " + updateError.message }, + { status: 500 } + ) + } + } + + const headersList = await headers() + const origin = headersList.get("origin") || "http://localhost:3000" + + const checkoutSession = await getStripe().checkout.sessions.create({ + customer: stripeCustomerIdentifier, + mode: "subscription", + line_items: [ + { + price: stripePriceIdentifier, + quantity: 1, + }, + ], + success_url: `${origin}/reader/settings?billing=success`, + cancel_url: `${origin}/reader/settings?billing=cancelled`, + subscription_data: { + metadata: { supabase_user_identifier: user.id }, + }, + client_reference_id: user.id, + }) + + return NextResponse.json({ url: checkoutSession.url }) +} diff --git a/apps/web/app/api/billing/create-portal-session/route.ts b/apps/web/app/api/billing/create-portal-session/route.ts new file mode 100644 index 0000000..3832c0d --- /dev/null +++ b/apps/web/app/api/billing/create-portal-session/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" +import { headers } from "next/headers" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { getStripe } from "@/lib/stripe" +import { rateLimit } from "@/lib/rate-limit" + +export async function POST() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`portal:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const { data: profile, error: profileError } = await supabaseClient + .from("user_profiles") + .select("stripe_customer_identifier") + .eq("id", user.id) + .single() + + if (profileError || !profile) { + return NextResponse.json( + { error: "Failed to load profile" }, + { status: 500 } + ) + } + + if (!profile.stripe_customer_identifier) { + return NextResponse.json( + { error: "No billing account found" }, + { status: 400 } + ) + } + + const headersList = await headers() + const origin = headersList.get("origin") || "http://localhost:3000" + + const portalSession = await getStripe().billingPortal.sessions.create({ + customer: profile.stripe_customer_identifier, + return_url: `${origin}/reader/settings`, + }) + + return NextResponse.json({ url: portalSession.url }) +} diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts new file mode 100644 index 0000000..8aed7d0 --- /dev/null +++ b/apps/web/app/api/billing/webhook/route.ts @@ -0,0 +1,181 @@ +import { NextResponse } from "next/server" +import type Stripe from "stripe" +import { getStripe } from "@/lib/stripe" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { rateLimit } from "@/lib/rate-limit" + +function determineTierFromSubscription( + subscription: Stripe.Subscription +): "pro" | "developer" { + const priceIdentifier = subscription.items?.data?.[0]?.price?.id + const developerPriceIdentifiers = [ + process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER, + process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER, + ] + + if (priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier)) { + return "developer" + } + + return "pro" +} + +function extractPeriodEnd(subscription: Stripe.Subscription): string | null { + const firstItem = subscription.items?.data?.[0] + if (firstItem?.current_period_end) { + return new Date(firstItem.current_period_end * 1000).toISOString() + } + + if (subscription.cancel_at) { + return new Date(subscription.cancel_at * 1000).toISOString() + } + + return null +} + +async function updateBillingState( + stripeCustomerIdentifier: string, + updates: Record<string, unknown> +) { + const adminClient = createSupabaseAdminClient() + const { error } = await adminClient + .from("user_profiles") + .update(updates) + .eq("stripe_customer_identifier", stripeCustomerIdentifier) + + if (error) { + console.error("Failed to update billing state:", error) + } +} + +async function handleCheckoutSessionCompleted( + session: Stripe.Checkout.Session +) { + if (session.mode !== "subscription" || !session.subscription) return + + const userIdentifier = session.client_reference_id + if (!userIdentifier) return + + const subscription = await getStripe().subscriptions.retrieve( + session.subscription as string, + { expand: ["items.data"] } + ) + + const adminClient = createSupabaseAdminClient() + await adminClient + .from("user_profiles") + .update({ + tier: determineTierFromSubscription(subscription), + stripe_customer_identifier: session.customer as string, + stripe_subscription_identifier: subscription.id, + stripe_subscription_status: subscription.status, + stripe_current_period_end: extractPeriodEnd(subscription), + }) + .eq("id", userIdentifier) +} + +async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { + const stripeCustomerIdentifier = subscription.customer as string + + const updates: Record<string, unknown> = { + stripe_subscription_status: subscription.status, + stripe_current_period_end: extractPeriodEnd(subscription), + } + + if (subscription.status === "active") { + updates.tier = determineTierFromSubscription(subscription) + } + + await updateBillingState(stripeCustomerIdentifier, updates) +} + +async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { + const stripeCustomerIdentifier = subscription.customer as string + + await updateBillingState(stripeCustomerIdentifier, { + tier: "free", + stripe_subscription_identifier: null, + stripe_subscription_status: "canceled", + stripe_current_period_end: null, + }) +} + +async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) { + const stripeCustomerIdentifier = invoice.customer as string + + await updateBillingState(stripeCustomerIdentifier, { + stripe_subscription_status: "past_due", + }) +} + +async function handleInvoicePaid(invoice: Stripe.Invoice) { + const stripeCustomerIdentifier = invoice.customer as string + const lineItem = invoice.lines?.data?.[0] + const priceIdentifier = (lineItem as unknown as { price?: { id?: string } } | undefined)?.price?.id + const developerPriceIdentifiers = [ + process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER, + process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER, + ] + const tier = + priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier) + ? "developer" + : "pro" + + await updateBillingState(stripeCustomerIdentifier, { + tier, + stripe_subscription_status: "active", + }) +} + +export async function POST(request: Request) { + const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown" + const rateLimitResult = rateLimit(`webhook:${clientIp}`, 60, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const body = await request.text() + const signature = request.headers.get("stripe-signature") + + if (!signature) { + return NextResponse.json({ error: "Missing signature" }, { status: 400 }) + } + + let event: Stripe.Event + + try { + event = getStripe().webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ) + } catch { + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }) + } + + switch (event.type) { + case "checkout.session.completed": + await handleCheckoutSessionCompleted( + event.data.object as Stripe.Checkout.Session + ) + break + case "customer.subscription.updated": + await handleSubscriptionUpdated( + event.data.object as Stripe.Subscription + ) + break + case "customer.subscription.deleted": + await handleSubscriptionDeleted( + event.data.object as Stripe.Subscription + ) + break + case "invoice.payment_failed": + await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice) + break + case "invoice.paid": + await handleInvoicePaid(event.data.object as Stripe.Invoice) + break + } + + return NextResponse.json({ received: true }) +} diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts new file mode 100644 index 0000000..4842f83 --- /dev/null +++ b/apps/web/app/api/export/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { data: profile } = await supabaseClient + .from("user_profiles") + .select("tier, display_name") + .eq("id", user.id) + .single() + + const tier = profile?.tier ?? "free" + + const { data: savedEntries } = await supabaseClient + .from("user_entry_states") + .select( + "entries(id, title, url, author, summary, published_at, feeds(title, url))" + ) + .eq("user_id", user.id) + .eq("saved", true) + + const exportData: Record<string, unknown> = { + exportedAt: new Date().toISOString(), + tier, + savedEntries: + (savedEntries ?? []).map((row) => (row as Record<string, unknown>).entries) ?? [], + } + + if (tier === "pro" || tier === "developer") { + const [subscriptionsResult, foldersResult, mutedKeywordsResult] = + await Promise.all([ + supabaseClient + .from("subscriptions") + .select("id, feed_id, folder_id, custom_title, feeds(title, url)") + .eq("user_id", user.id), + supabaseClient + .from("folders") + .select("id, name, position") + .eq("user_id", user.id), + supabaseClient + .from("muted_keywords") + .select("id, keyword") + .eq("user_id", user.id), + ]) + + exportData.subscriptions = subscriptionsResult.data ?? [] + exportData.folders = foldersResult.data ?? [] + exportData.mutedKeywords = mutedKeywordsResult.data ?? [] + } + + const jsonString = JSON.stringify(exportData, null, 2) + + return new Response(jsonString, { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="asa-news-export-${new Date().toISOString().slice(0, 10)}.json"`, + }, + }) +} diff --git a/apps/web/app/api/share/[token]/route.ts b/apps/web/app/api/share/[token]/route.ts new file mode 100644 index 0000000..45224aa --- /dev/null +++ b/apps/web/app/api/share/[token]/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +const MAX_NOTE_LENGTH = 1000 + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { token } = await params + + const { error } = await supabaseClient + .from("shared_entries") + .delete() + .eq("share_token", token) + .eq("user_id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to delete share" }, + { status: 500 } + ) + } + + return new Response(null, { status: 204 }) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { token } = await params + const body = await request.json() + const rawNote = body.note + + let note: string | null = null + if (rawNote !== undefined && rawNote !== null) { + if (typeof rawNote !== "string") { + return NextResponse.json( + { error: "note must be a string" }, + { status: 400 } + ) + } + if (rawNote.length > MAX_NOTE_LENGTH) { + return NextResponse.json( + { error: `note must be ${MAX_NOTE_LENGTH} characters or fewer` }, + { status: 400 } + ) + } + note = rawNote.trim() || null + } + + const { error } = await supabaseClient + .from("shared_entries") + .update({ note }) + .eq("share_token", token) + .eq("user_id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to update share" }, + { status: 500 } + ) + } + + return NextResponse.json({ note }) +} diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts new file mode 100644 index 0000000..2558560 --- /dev/null +++ b/apps/web/app/api/share/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server" +import { randomBytes } from "crypto" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +const MAX_NOTE_LENGTH = 1000 + +function buildOrigin(request: Request): string { + if (process.env.NEXT_PUBLIC_APP_URL) { + return process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/, "") + } + + return ( + request.headers.get("origin") ?? + `https://${request.headers.get("host")}` + ) +} + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { data: userProfile } = await supabaseClient + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single() + + const tier = userProfile?.tier ?? "free" + const expiryDays = tier === "pro" || tier === "developer" ? 30 : 7 + const expiresAt = new Date( + Date.now() + expiryDays * 24 * 60 * 60 * 1000 + ).toISOString() + + const body = await request.json() + const entryIdentifier = body.entryIdentifier as string + const rawNote = body.note + + if (!entryIdentifier || typeof entryIdentifier !== "string") { + return NextResponse.json( + { error: "entryIdentifier is required" }, + { status: 400 } + ) + } + + let note: string | null = null + if (rawNote !== undefined && rawNote !== null) { + if (typeof rawNote !== "string") { + return NextResponse.json( + { error: "note must be a string" }, + { status: 400 } + ) + } + if (rawNote.length > MAX_NOTE_LENGTH) { + return NextResponse.json( + { error: `note must be ${MAX_NOTE_LENGTH} characters or fewer` }, + { status: 400 } + ) + } + note = rawNote.trim() || null + } + + const { data: entryAccess } = await supabaseClient + .from("entries") + .select("id, feed_id") + .eq("id", entryIdentifier) + .maybeSingle() + + if (!entryAccess) { + return NextResponse.json( + { error: "Entry not found or not accessible" }, + { status: 404 } + ) + } + + const { data: subscriptionAccess } = await supabaseClient + .from("subscriptions") + .select("id") + .eq("feed_id", entryAccess.feed_id) + .eq("user_id", user.id) + .maybeSingle() + + if (!subscriptionAccess) { + return NextResponse.json( + { error: "You do not have access to this entry" }, + { status: 403 } + ) + } + + const origin = buildOrigin(request) + + const { data: existingShare } = await supabaseClient + .from("shared_entries") + .select("share_token") + .eq("entry_id", entryIdentifier) + .eq("user_id", user.id) + .maybeSingle() + + if (existingShare) { + const shareUrl = `${origin}/shared/${existingShare.share_token}` + return NextResponse.json({ + shareToken: existingShare.share_token, + shareUrl, + }) + } + + const shareToken = randomBytes(16).toString("base64url") + + const { error } = await supabaseClient.from("shared_entries").insert({ + user_id: user.id, + entry_id: entryIdentifier, + share_token: shareToken, + expires_at: expiresAt, + note, + }) + + if (error) { + return NextResponse.json( + { error: "Failed to create share" }, + { status: 500 } + ) + } + + const shareUrl = `${origin}/shared/${shareToken}` + + return NextResponse.json({ shareToken, shareUrl }) +} diff --git a/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts new file mode 100644 index 0000000..157366b --- /dev/null +++ b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET( + request: Request, + { params }: { params: Promise<{ entryIdentifier: string }> } +) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const { entryIdentifier } = await params + const adminClient = createSupabaseAdminClient() + + const { data: entry, error } = await adminClient + .from("entries") + .select( + "id, feed_id, guid, title, url, author, summary, content_html, image_url, published_at, enclosure_url, enclosure_type, enclosure_length, word_count" + ) + .eq("id", entryIdentifier) + .is("owner_id", null) + .single() + + if (error || !entry) { + return NextResponse.json({ error: "Entry not found" }, { status: 404 }) + } + + const { data: subscription } = await adminClient + .from("subscriptions") + .select("id") + .eq("user_id", authResult.user.userIdentifier) + .eq("feed_id", entry.feed_id) + .single() + + if (!subscription) { + return NextResponse.json({ error: "Entry not found" }, { status: 404 }) + } + + const { data: stateRow } = await adminClient + .from("user_entry_states") + .select("read, saved") + .eq("user_id", authResult.user.userIdentifier) + .eq("entry_id", entryIdentifier) + .single() + + return NextResponse.json({ + entry: { + entryIdentifier: entry.id, + feedIdentifier: entry.feed_id, + guid: entry.guid, + title: entry.title, + url: entry.url, + author: entry.author, + summary: entry.summary, + contentHtml: entry.content_html, + imageUrl: entry.image_url, + publishedAt: entry.published_at, + enclosureUrl: entry.enclosure_url, + enclosureType: entry.enclosure_type, + enclosureLength: entry.enclosure_length, + wordCount: entry.word_count, + isRead: stateRow?.read ?? false, + isSaved: stateRow?.saved ?? false, + }, + }) +} diff --git a/apps/web/app/api/v1/entries/route.ts b/apps/web/app/api/v1/entries/route.ts new file mode 100644 index 0000000..653c79b --- /dev/null +++ b/apps/web/app/api/v1/entries/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const { searchParams } = new URL(request.url) + const feedIdentifier = searchParams.get("feedIdentifier") + const isRead = searchParams.get("isRead") + const isSaved = searchParams.get("isSaved") + const cursor = searchParams.get("cursor") + const limitParameter = searchParams.get("limit") + const limit = Math.min(Math.max(Number(limitParameter) || 50, 1), 100) + + const adminClient = createSupabaseAdminClient() + + const { data: subscriptionRows } = await adminClient + .from("subscriptions") + .select("feed_id") + .eq("user_id", authResult.user.userIdentifier) + + const subscribedFeedIdentifiers = (subscriptionRows ?? []).map( + (row) => row.feed_id + ) + + if (subscribedFeedIdentifiers.length === 0) { + return NextResponse.json({ entries: [], nextCursor: null }) + } + + let query = adminClient + .from("entries") + .select( + "id, feed_id, guid, title, url, author, summary, image_url, published_at, enclosure_url, enclosure_type, user_entry_states!left(read, saved)" + ) + .in("feed_id", subscribedFeedIdentifiers) + .is("owner_id", null) + .order("published_at", { ascending: false }) + .limit(limit + 1) + + if (feedIdentifier) { + query = query.eq("feed_id", feedIdentifier) + } + + if (cursor) { + query = query.lt("published_at", cursor) + } + + const { data, error } = await query + + if (error) { + return NextResponse.json( + { error: "Failed to load entries" }, + { status: 500 } + ) + } + + interface EntryRow { + id: string + feed_id: string + guid: string | null + title: string | null + url: string | null + author: string | null + summary: string | null + image_url: string | null + published_at: string | null + enclosure_url: string | null + enclosure_type: string | null + user_entry_states: Array<{ read: boolean; saved: boolean }> | null + } + + let entries = (data as unknown as EntryRow[]).map((row) => { + const state = row.user_entry_states?.[0] + + return { + entryIdentifier: row.id, + feedIdentifier: row.feed_id, + guid: row.guid, + title: row.title, + url: row.url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + isRead: state?.read ?? false, + isSaved: state?.saved ?? false, + } + }) + + if (isRead === "true") entries = entries.filter((entry) => entry.isRead) + if (isRead === "false") entries = entries.filter((entry) => !entry.isRead) + if (isSaved === "true") entries = entries.filter((entry) => entry.isSaved) + if (isSaved === "false") entries = entries.filter((entry) => !entry.isSaved) + + const hasMore = entries.length > limit + if (hasMore) entries = entries.slice(0, limit) + + const nextCursor = + hasMore && entries.length > 0 + ? entries[entries.length - 1].publishedAt + : null + + return NextResponse.json({ entries, nextCursor }) +} diff --git a/apps/web/app/api/v1/feeds/route.ts b/apps/web/app/api/v1/feeds/route.ts new file mode 100644 index 0000000..adf5422 --- /dev/null +++ b/apps/web/app/api/v1/feeds/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const adminClient = createSupabaseAdminClient() + const { data, error } = await adminClient + .from("subscriptions") + .select( + "id, custom_title, folder_id, feeds!inner(id, url, title, feed_type, site_url)" + ) + .eq("user_id", authResult.user.userIdentifier) + + if (error) { + return NextResponse.json( + { error: "Failed to load feeds" }, + { status: 500 } + ) + } + + interface FeedRow { + id: string + custom_title: string | null + folder_id: string | null + feeds: { + id: string + url: string + title: string | null + feed_type: string | null + site_url: string | null + } + } + + return NextResponse.json({ + feeds: (data as unknown as FeedRow[]).map((row) => ({ + subscriptionIdentifier: row.id, + feedIdentifier: row.feeds.id, + feedUrl: row.feeds.url, + feedTitle: row.feeds.title, + customTitle: row.custom_title, + feedType: row.feeds.feed_type, + siteUrl: row.feeds.site_url, + folderIdentifier: row.folder_id, + })), + }) +} diff --git a/apps/web/app/api/v1/folders/route.ts b/apps/web/app/api/v1/folders/route.ts new file mode 100644 index 0000000..5fb006d --- /dev/null +++ b/apps/web/app/api/v1/folders/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const adminClient = createSupabaseAdminClient() + const { data, error } = await adminClient + .from("folders") + .select("id, name, position") + .eq("user_id", authResult.user.userIdentifier) + .order("position", { ascending: true }) + + if (error) { + return NextResponse.json( + { error: "Failed to load folders" }, + { status: 500 } + ) + } + + return NextResponse.json({ + folders: (data ?? []).map((row) => ({ + identifier: row.id, + name: row.name, + position: row.position, + })), + }) +} diff --git a/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts new file mode 100644 index 0000000..8026f27 --- /dev/null +++ b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ keyIdentifier: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { keyIdentifier } = await params + + const adminClient = createSupabaseAdminClient() + const { error } = await adminClient + .from("api_keys") + .update({ revoked_at: new Date().toISOString() }) + .eq("id", keyIdentifier) + .eq("user_id", user.id) + .is("revoked_at", null) + + if (error) { + return NextResponse.json( + { error: "Failed to revoke API key" }, + { status: 500 } + ) + } + + return NextResponse.json({ revoked: true }) +} diff --git a/apps/web/app/api/v1/keys/route.ts b/apps/web/app/api/v1/keys/route.ts new file mode 100644 index 0000000..7ac7144 --- /dev/null +++ b/apps/web/app/api/v1/keys/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { generateApiKey } from "@/lib/api-key" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" +import { rateLimit } from "@/lib/rate-limit" + +const MAXIMUM_ACTIVE_KEYS = 5 + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const adminClient = createSupabaseAdminClient() + const { data: keys, error } = await adminClient + .from("api_keys") + .select("id, key_prefix, label, created_at, last_used_at, revoked_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + + if (error) { + return NextResponse.json( + { error: "Failed to load API keys" }, + { status: 500 } + ) + } + + return NextResponse.json({ + keys: keys.map((key) => ({ + identifier: key.id, + keyPrefix: key.key_prefix, + label: key.label, + createdAt: key.created_at, + lastUsedAt: key.last_used_at, + isRevoked: key.revoked_at !== null, + })), + }) +} + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`api-keys:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const adminClient = createSupabaseAdminClient() + + const { data: userProfile } = await adminClient + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single() + + if ( + !userProfile || + !TIER_LIMITS[userProfile.tier as SubscriptionTier]?.allowsApiAccess + ) { + return NextResponse.json( + { error: "API access requires the developer plan" }, + { status: 403 } + ) + } + + const { count: activeKeyCount } = await adminClient + .from("api_keys") + .select("id", { count: "exact", head: true }) + .eq("user_id", user.id) + .is("revoked_at", null) + + if ((activeKeyCount ?? 0) >= MAXIMUM_ACTIVE_KEYS) { + return NextResponse.json( + { error: `Maximum of ${MAXIMUM_ACTIVE_KEYS} active keys allowed` }, + { status: 400 } + ) + } + + const body = await request.json().catch(() => ({})) + const label = typeof body.label === "string" ? body.label.trim() || null : null + + const { fullKey, keyHash, keyPrefix } = generateApiKey() + + const { error: insertError } = await adminClient.from("api_keys").insert({ + user_id: user.id, + key_hash: keyHash, + key_prefix: keyPrefix, + label, + }) + + if (insertError) { + return NextResponse.json( + { error: "Failed to create API key" }, + { status: 500 } + ) + } + + return NextResponse.json({ + key: fullKey, + keyPrefix, + label, + }) +} diff --git a/apps/web/app/api/v1/profile/route.ts b/apps/web/app/api/v1/profile/route.ts new file mode 100644 index 0000000..f7ec308 --- /dev/null +++ b/apps/web/app/api/v1/profile/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const adminClient = createSupabaseAdminClient() + const { data: profile, error } = await adminClient + .from("user_profiles") + .select( + "tier, feed_count, folder_count, muted_keyword_count, custom_feed_count" + ) + .eq("id", authResult.user.userIdentifier) + .single() + + if (error || !profile) { + return NextResponse.json( + { error: "Failed to load profile" }, + { status: 500 } + ) + } + + const tierLimits = TIER_LIMITS[profile.tier as SubscriptionTier] + + return NextResponse.json({ + profile: { + tier: profile.tier, + feedCount: profile.feed_count, + folderCount: profile.folder_count, + mutedKeywordCount: profile.muted_keyword_count, + customFeedCount: profile.custom_feed_count, + limits: { + maximumFeeds: tierLimits.maximumFeeds, + maximumFolders: tierLimits.maximumFolders, + maximumMutedKeywords: tierLimits.maximumMutedKeywords, + maximumCustomFeeds: tierLimits.maximumCustomFeeds, + }, + }, + }) +} diff --git a/apps/web/app/api/webhook-config/route.ts b/apps/web/app/api/webhook-config/route.ts new file mode 100644 index 0000000..1ce9a30 --- /dev/null +++ b/apps/web/app/api/webhook-config/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" +import { rateLimit } from "@/lib/rate-limit" + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const adminClient = createSupabaseAdminClient() + const { data: profile, error } = await adminClient + .from("user_profiles") + .select( + "tier, webhook_url, webhook_secret, webhook_enabled, webhook_consecutive_failures" + ) + .eq("id", user.id) + .single() + + if (error || !profile) { + return NextResponse.json( + { error: "Failed to load webhook config" }, + { status: 500 } + ) + } + + return NextResponse.json({ + webhookUrl: profile.webhook_url, + webhookSecret: profile.webhook_secret, + webhookEnabled: profile.webhook_enabled, + consecutiveFailures: profile.webhook_consecutive_failures, + }) +} + +export async function PUT(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`webhook-config:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const adminClient = createSupabaseAdminClient() + + const { data: profile } = await adminClient + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single() + + if ( + !profile || + !TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks + ) { + return NextResponse.json( + { error: "Webhooks require the developer plan" }, + { status: 403 } + ) + } + + const body = await request.json().catch(() => ({})) + + const updates: Record<string, unknown> = {} + + if (typeof body.webhookUrl === "string") { + const trimmedUrl = body.webhookUrl.trim() + if (trimmedUrl && !trimmedUrl.startsWith("https://")) { + return NextResponse.json( + { error: "Webhook URL must use HTTPS" }, + { status: 400 } + ) + } + updates.webhook_url = trimmedUrl || null + } + + if (typeof body.webhookSecret === "string") { + updates.webhook_secret = body.webhookSecret.trim() || null + } + + if (typeof body.webhookEnabled === "boolean") { + updates.webhook_enabled = body.webhookEnabled + if (body.webhookEnabled) { + updates.webhook_consecutive_failures = 0 + } + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No updates provided" }, { status: 400 }) + } + + const { error } = await adminClient + .from("user_profiles") + .update(updates) + .eq("id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to update webhook config" }, + { status: 500 } + ) + } + + return NextResponse.json({ updated: true }) +} diff --git a/apps/web/app/api/webhook-config/test/route.ts b/apps/web/app/api/webhook-config/test/route.ts new file mode 100644 index 0000000..684ec0c --- /dev/null +++ b/apps/web/app/api/webhook-config/test/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server" +import { createHmac } from "crypto" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" +import { rateLimit } from "@/lib/rate-limit" + +export async function POST() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`webhook-test:${user.id}`, 5, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const adminClient = createSupabaseAdminClient() + const { data: profile } = await adminClient + .from("user_profiles") + .select( + "tier, webhook_url, webhook_secret, webhook_enabled" + ) + .eq("id", user.id) + .single() + + if ( + !profile || + !TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks + ) { + return NextResponse.json( + { error: "Webhooks require the developer plan" }, + { status: 403 } + ) + } + + if (!profile.webhook_url) { + return NextResponse.json( + { error: "No webhook URL configured" }, + { status: 400 } + ) + } + + const testPayload = { + event: "test", + timestamp: new Date().toISOString(), + entries: [ + { + entryIdentifier: "test-entry-000", + feedIdentifier: "test-feed-000", + title: "Test webhook delivery", + url: "https://asa.news", + author: "asa.news", + summary: "This is a test webhook payload to verify your endpoint.", + publishedAt: new Date().toISOString(), + }, + ], + } + + const payloadString = JSON.stringify(testPayload) + const headers: Record<string, string> = { + "Content-Type": "application/json", + "User-Agent": "asa.news Webhook/1.0", + } + + if (profile.webhook_secret) { + const signature = createHmac("sha256", profile.webhook_secret) + .update(payloadString) + .digest("hex") + headers["X-Asa-Signature-256"] = `sha256=${signature}` + } + + try { + const response = await fetch(profile.webhook_url, { + method: "POST", + headers, + body: payloadString, + signal: AbortSignal.timeout(10_000), + }) + + return NextResponse.json({ + delivered: true, + statusCode: response.status, + }) + } catch (deliveryError) { + const errorMessage = + deliveryError instanceof Error + ? deliveryError.message + : "Unknown error" + + return NextResponse.json({ + delivered: false, + error: errorMessage, + }) + } +} diff --git a/apps/web/app/apple-icon.tsx b/apps/web/app/apple-icon.tsx new file mode 100644 index 0000000..f9e2d0b --- /dev/null +++ b/apps/web/app/apple-icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from "next/og" + +export const size = { width: 180, height: 180 } +export const contentType = "image/png" + +export default function AppleIcon() { + return new ImageResponse( + ( + <div + style={{ + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "#0a0a0a", + color: "#e5e5e5", + fontFamily: "monospace", + fontWeight: 700, + fontSize: 64, + }} + > + asa + </div> + ), + { ...size } + ) +} diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts new file mode 100644 index 0000000..a912da3 --- /dev/null +++ b/apps/web/app/auth/callback/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import type { EmailOtpType } from "@supabase/supabase-js" + +function sanitizeRedirectPath(rawPath: string | null): string { + if (!rawPath) return "/reader" + if (!rawPath.startsWith("/")) return "/reader" + if (rawPath.startsWith("//")) return "/reader" + if (rawPath.includes("\\")) return "/reader" + + return rawPath +} + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get("code") + const tokenHash = searchParams.get("token_hash") + const type = searchParams.get("type") as EmailOtpType | null + const next = sanitizeRedirectPath(searchParams.get("next")) + + const supabaseClient = await createSupabaseServerClient() + + if (tokenHash && type) { + const { error } = await supabaseClient.auth.verifyOtp({ + token_hash: tokenHash, + type, + }) + + if (!error) { + return NextResponse.redirect(`${origin}${next}`) + } + } + + if (code) { + const { error } = await supabaseClient.auth.exchangeCodeForSession(code) + + if (!error) { + return NextResponse.redirect(`${origin}${next}`) + } + } + + return NextResponse.redirect(`${origin}/sign-in?error=auth`) +} diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico Binary files differnew file mode 100644 index 0000000..718d6fe --- /dev/null +++ b/apps/web/app/favicon.ico diff --git a/apps/web/app/fonts/JetBrainsMono-Regular.woff2 b/apps/web/app/fonts/JetBrainsMono-Regular.woff2 Binary files differnew file mode 100644 index 0000000..66c5467 --- /dev/null +++ b/apps/web/app/fonts/JetBrainsMono-Regular.woff2 diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..c97ddb2 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,222 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --color-background-primary: #070707; + --color-background-secondary: #0f0f0f; + --color-background-tertiary: #1a1a1a; + --color-border: #363636; + --color-text-primary: #ffffff; + --color-text-secondary: #aaaaaa; + --color-text-tertiary: #808080; + --color-text-dim: #666666; + --color-status-operational: #d0d0d0; + --color-status-warning: #c08000; + --color-status-error: #c06060; + --color-status-unknown: #707070; + + --font-mono: "JetBrains Mono", Menlo, Monaco, "Courier New", monospace; + + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; + --radius-xl: 0px; + --radius-2xl: 0px; + --radius-3xl: 0px; + --radius-4xl: 0px; + --radius-full: 0px; +} + +:root { + --background: var(--color-background-primary); + --foreground: var(--color-text-primary); + --card: var(--color-background-secondary); + --card-foreground: var(--color-text-primary); + --popover: var(--color-background-secondary); + --popover-foreground: var(--color-text-primary); + --primary: var(--color-text-primary); + --primary-foreground: var(--color-background-primary); + --secondary: var(--color-background-tertiary); + --secondary-foreground: var(--color-text-primary); + --muted: var(--color-background-tertiary); + --muted-foreground: var(--color-text-secondary); + --accent: var(--color-background-tertiary); + --accent-foreground: var(--color-text-primary); + --destructive: var(--color-status-error); + --border: var(--color-border); + --input: var(--color-border); + --ring: var(--color-text-dim); + --radius: 0px; +} + +.light { + --color-background-primary: #ffffff; + --color-background-secondary: #f8f8f8; + --color-background-tertiary: #f0f0f0; + --color-border: #d0d0d0; + --color-text-primary: #000000; + --color-text-secondary: #555555; + --color-text-tertiary: #666666; + --color-text-dim: #767676; + --color-status-operational: #333333; + --color-status-warning: #8a6200; + --color-status-error: #c03030; + --color-status-unknown: #767676; + + --background: var(--color-background-primary); + --foreground: var(--color-text-primary); + --card: var(--color-background-secondary); + --card-foreground: var(--color-text-primary); + --popover: var(--color-background-secondary); + --popover-foreground: var(--color-text-primary); + --primary: var(--color-text-primary); + --primary-foreground: var(--color-background-primary); + --secondary: var(--color-background-tertiary); + --secondary-foreground: var(--color-text-primary); + --muted: var(--color-background-tertiary); + --muted-foreground: var(--color-text-secondary); + --accent: var(--color-background-tertiary); + --accent-foreground: var(--color-text-primary); + --destructive: var(--color-status-error); + --border: var(--color-border); + --input: var(--color-border); + --ring: var(--color-text-dim); +} + +* { + font-weight: 400 !important; +} + +*, +*::before, +*::after { + box-shadow: none !important; +} + +body { + font-family: var(--font-mono); + font-size: var(--base-font-size, 1rem); + line-height: 1.5; + background: var(--background); + color: var(--foreground); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.transition-colors { + transition-property: color, background-color, border-color; + transition-timing-function: ease; + transition-duration: 100ms; +} + +@keyframes pulse-status { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.prose-reader a { + color: var(--color-text-primary); + text-decoration: underline; + text-decoration-color: var(--color-text-dim); + text-underline-offset: 2px; +} + +.prose-reader a:hover { + text-decoration-color: var(--color-text-primary); +} + +.prose-reader h1, +.prose-reader h2, +.prose-reader h3, +.prose-reader h4, +.prose-reader h5, +.prose-reader h6 { + color: var(--color-text-primary); + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +.prose-reader p { + margin-bottom: 1em; +} + +.prose-reader img { + max-width: 100%; + height: auto; +} + +.prose-reader pre { + background: var(--color-background-tertiary); + border: 1px solid var(--color-border); + padding: 1em; + overflow-x: auto; + margin-bottom: 1em; +} + +.prose-reader code { + background: var(--color-background-tertiary); + padding: 0.15em 0.3em; +} + +.prose-reader pre code { + background: transparent; + padding: 0; +} + +.prose-reader blockquote { + border-left: 2px solid var(--color-border); + padding-left: 1em; + color: var(--color-text-secondary); + margin-bottom: 1em; +} + +.prose-reader ul, +.prose-reader ol { + padding-left: 1.5em; + margin-bottom: 1em; +} + +.prose-reader li { + margin-bottom: 0.25em; +} + +.prose-reader hr { + border: none; + border-top: 1px solid var(--color-border); + margin: 2em 0; +} + +.prose-reader table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1em; +} + +.prose-reader th, +.prose-reader td { + border: 1px solid var(--color-border); + padding: 0.5em; + text-align: left; +} + +.prose-reader mark[data-highlight-color] { + background-color: rgba(234, 179, 8, 0.18); + color: var(--color-text-primary); + border-bottom: 1px solid rgba(234, 179, 8, 0.4); + cursor: pointer; +} + +.prose-reader mark[data-has-note] { + border-bottom-style: dashed; +} + +.light .prose-reader mark[data-highlight-color] { + background-color: rgba(234, 179, 8, 0.15); + border-bottom-color: rgba(234, 179, 8, 0.35); +} diff --git a/apps/web/app/icon.tsx b/apps/web/app/icon.tsx new file mode 100644 index 0000000..a017579 --- /dev/null +++ b/apps/web/app/icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from "next/og" + +export const size = { width: 32, height: 32 } +export const contentType = "image/png" + +export default function Icon() { + return new ImageResponse( + ( + <div + style={{ + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "#0a0a0a", + color: "#e5e5e5", + fontFamily: "monospace", + fontWeight: 700, + fontSize: 14, + }} + > + asa + </div> + ), + { ...size } + ) +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..a3e3b8b --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,58 @@ +import type { Metadata, Viewport } from "next" +import localFont from "next/font/local" +import { ThemeProvider } from "next-themes" +import { SpeedInsights } from "@vercel/speed-insights/next" +import { Analytics } from "@vercel/analytics/next" +import { Providers } from "./providers" +import "./globals.css" + +const jetBrainsMono = localFont({ + src: "./fonts/JetBrainsMono-Regular.woff2", + variable: "--font-mono", + display: "swap", +}) + +export const metadata: Metadata = { + title: "asa.news", + description: "A fast, minimal RSS reader for staying informed", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "asa.news", + }, + formatDetection: { + telephone: false, + }, +} + +export const viewport: Viewport = { + themeColor: "#0a0a0a", + width: "device-width", + initialScale: 1, + maximumScale: 1, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + <html lang="en" suppressHydrationWarning> + <body className={`${jetBrainsMono.variable} antialiased`}> + <ThemeProvider + attribute="class" + defaultTheme="dark" + enableSystem + disableTransitionOnChange + > + <Providers> + {children} + </Providers> + </ThemeProvider> + <SpeedInsights /> + <Analytics /> + </body> + </html> + ) +} diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts new file mode 100644 index 0000000..0bef8b1 --- /dev/null +++ b/apps/web/app/manifest.ts @@ -0,0 +1,26 @@ +import type { MetadataRoute } from "next" + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "asa.news", + short_name: "asa.news", + description: "A dense, keyboard-first RSS reader", + start_url: "/reader", + display: "standalone", + background_color: "#0a0a0a", + theme_color: "#0a0a0a", + icons: [ + { + src: "/icons/icon.svg", + sizes: "any", + type: "image/svg+xml", + }, + { + src: "/icons/icon.svg", + sizes: "512x512", + type: "image/svg+xml", + purpose: "maskable", + }, + ], + } +} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx new file mode 100644 index 0000000..03c2320 --- /dev/null +++ b/apps/web/app/providers.tsx @@ -0,0 +1,29 @@ +"use client" + +import { useState } from "react" +import { QueryClientProvider } from "@tanstack/react-query" +import { Toaster } from "sonner" +import { createQueryClient } from "@/lib/query-client" + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(createQueryClient) + + return ( + <QueryClientProvider client={queryClient}> + {children} + <Toaster + position="bottom-right" + toastOptions={{ + style: { + background: "var(--color-background-secondary)", + border: "1px solid var(--color-border)", + color: "var(--color-text-primary)", + borderRadius: "0px", + fontFamily: "var(--font-mono)", + fontSize: "0.75rem", + }, + }} + /> + </QueryClientProvider> + ) +} diff --git a/apps/web/app/reader/_components/add-feed-dialog.tsx b/apps/web/app/reader/_components/add-feed-dialog.tsx new file mode 100644 index 0000000..4eb119c --- /dev/null +++ b/apps/web/app/reader/_components/add-feed-dialog.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useState } from "react" +import { useSubscribeToFeed } from "@/lib/queries/use-subscribe-to-feed" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +export function AddFeedDialog() { + const isOpen = useUserInterfaceStore((state) => state.isAddFeedDialogOpen) + const setOpen = useUserInterfaceStore((state) => state.setAddFeedDialogOpen) + const [feedUrl, setFeedUrl] = useState("") + const [customTitle, setCustomTitle] = useState("") + const [selectedFolderIdentifier, setSelectedFolderIdentifier] = useState< + string | null + >(null) + const subscribeToFeed = useSubscribeToFeed() + const { data: subscriptionsData } = useSubscriptions() + + function handleClose() { + setFeedUrl("") + setCustomTitle("") + setSelectedFolderIdentifier(null) + setOpen(false) + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + subscribeToFeed.mutate( + { + feedUrl, + folderIdentifier: selectedFolderIdentifier, + customTitle: customTitle || null, + }, + { + onSuccess: () => { + handleClose() + }, + } + ) + } + + if (!isOpen) return null + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center"> + <div + className="fixed inset-0 bg-background-primary/80" + onClick={handleClose} + /> + <div className="relative w-full max-w-md border border-border bg-background-secondary p-6"> + <h2 className="mb-4 text-text-primary">add feed</h2> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="feed-url" className="text-text-secondary"> + feed url + </label> + <input + id="feed-url" + type="url" + value={feedUrl} + onChange={(event) => setFeedUrl(event.target.value)} + required + placeholder="https://example.com/feed.xml" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + <div className="space-y-2"> + <label htmlFor="custom-title" className="text-text-secondary"> + custom title (optional) + </label> + <input + id="custom-title" + type="text" + value={customTitle} + onChange={(event) => setCustomTitle(event.target.value)} + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + <div className="space-y-2"> + <label htmlFor="folder-select" className="text-text-secondary"> + folder (optional) + </label> + <select + id="folder-select" + value={selectedFolderIdentifier ?? ""} + onChange={(event) => + setSelectedFolderIdentifier(event.target.value || null) + } + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none" + > + <option value="">no folder</option> + {subscriptionsData?.folders.map((folder) => ( + <option + key={folder.folderIdentifier} + value={folder.folderIdentifier} + > + {folder.name} + </option> + ))} + </select> + </div> + <div className="flex gap-2"> + <button + type="button" + onClick={handleClose} + className="flex-1 border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + cancel + </button> + <button + type="submit" + disabled={subscribeToFeed.isPending} + className="flex-1 border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {subscribeToFeed.isPending ? "adding..." : "add feed"} + </button> + </div> + </form> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/command-palette.tsx b/apps/web/app/reader/_components/command-palette.tsx new file mode 100644 index 0000000..f3ff992 --- /dev/null +++ b/apps/web/app/reader/_components/command-palette.tsx @@ -0,0 +1,200 @@ +"use client" + +import { Command } from "cmdk" +import { useEffect, useRef, useState } from "react" +import { useRouter } from "next/navigation" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" + +export function CommandPalette() { + const isOpen = useUserInterfaceStore((state) => state.isCommandPaletteOpen) + const setOpen = useUserInterfaceStore((state) => state.setCommandPaletteOpen) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const setAddFeedDialogOpen = useUserInterfaceStore( + (state) => state.setAddFeedDialogOpen + ) + const router = useRouter() + const { data: subscriptionsData } = useSubscriptions() + const listReference = useRef<HTMLDivElement>(null) + const [inputValue, setInputValue] = useState("") + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + setOpen(!isOpen) + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, setOpen]) + + useEffect(() => { + if (!isOpen) return + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false) + return + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + setTimeout(() => { + const list = listReference.current + if (!list) return + const selected = list.querySelector('[aria-selected="true"]') as HTMLElement + if (!selected) return + const listRect = list.getBoundingClientRect() + const selectedRect = selected.getBoundingClientRect() + if (selectedRect.bottom > listRect.bottom) { + list.scrollTop += selectedRect.bottom - listRect.bottom + } else if (selectedRect.top < listRect.top) { + list.scrollTop -= listRect.top - selectedRect.top + } + }, 0) + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, setOpen]) + + if (!isOpen) return null + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "Backspace" && inputValue === "") { + event.preventDefault() + setOpen(false) + } + } + + function navigateAndClose(path: string) { + router.push(path) + setOpen(false) + } + + function actionAndClose(action: () => void) { + action() + setOpen(false) + } + + return ( + <div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"> + <div + className="fixed inset-0 bg-background-primary/80" + onClick={() => setOpen(false)} + /> + <Command className="relative w-full max-w-lg border border-border bg-background-secondary"> + <Command.Input + placeholder="type a command..." + className="w-full border-b border-border bg-transparent px-4 py-3 text-text-primary outline-none placeholder:text-text-dim" + autoFocus + value={inputValue} + onValueChange={setInputValue} + onKeyDown={handleInputKeyDown} + /> + <Command.List ref={listReference} className="max-h-80 overflow-auto p-2"> + <Command.Empty className="p-4 text-center text-text-dim"> + no results found + </Command.Empty> + + <Command.Group + heading="navigation" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + <Command.Item + onSelect={() => navigateAndClose("/reader")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to all entries + </Command.Item> + <Command.Item + onSelect={() => navigateAndClose("/reader/saved")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to saved + </Command.Item> + <Command.Item + onSelect={() => navigateAndClose("/reader/settings")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to settings + </Command.Item> + </Command.Group> + + {subscriptionsData && + subscriptionsData.subscriptions.length > 0 && ( + <Command.Group + heading="feeds" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + {subscriptionsData.subscriptions.map((subscription) => ( + <Command.Item + key={subscription.subscriptionIdentifier} + value={`feed-${subscription.subscriptionIdentifier}-${subscription.customTitle ?? subscription.feedTitle}`} + onSelect={() => + navigateAndClose( + `/reader?feed=${subscription.feedIdentifier}` + ) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + {subscription.customTitle ?? subscription.feedTitle} + </Command.Item> + ))} + </Command.Group> + )} + + <Command.Group + heading="actions" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + <Command.Item + onSelect={() => + actionAndClose(() => setAddFeedDialogOpen(true)) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + add feed + </Command.Item> + <Command.Item + onSelect={() => actionAndClose(toggleSidebar)} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + toggle sidebar + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("compact")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + compact view + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("comfortable")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + comfortable view + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("expanded")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + expanded view + </Command.Item> + </Command.Group> + </Command.List> + </Command> + </div> + ) +} diff --git a/apps/web/app/reader/_components/entry-detail-panel.tsx b/apps/web/app/reader/_components/entry-detail-panel.tsx new file mode 100644 index 0000000..2e8e19c --- /dev/null +++ b/apps/web/app/reader/_components/entry-detail-panel.tsx @@ -0,0 +1,470 @@ +"use client" + +import { useEffect, useRef, useState, useCallback } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { sanitizeEntryContent } from "@/lib/sanitize" +import { + useToggleEntryReadState, + useToggleEntrySavedState, +} from "@/lib/queries/use-entry-state-mutations" +import { queryKeys } from "@/lib/queries/query-keys" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useTimeline } from "@/lib/queries/use-timeline" +import { useEntryShare } from "@/lib/queries/use-entry-share" +import { useEntryHighlights } from "@/lib/queries/use-entry-highlights" +import { + useCreateHighlight, + useUpdateHighlightNote, + useDeleteHighlight, +} from "@/lib/queries/use-highlight-mutations" +import { + serializeSelectionRange, + deserializeHighlightRange, + applyHighlightToRange, + removeHighlightFromDom, +} from "@/lib/highlight-positioning" +import { HighlightSelectionToolbar } from "./highlight-selection-toolbar" +import { HighlightPopover } from "./highlight-popover" +import { notify } from "@/lib/notify" +import type { Highlight } from "@/lib/types/highlight" + +interface EntryDetailRow { + id: string + title: string | null + url: string | null + author: string | null + content_html: string | null + summary: string | null + published_at: string | null + enclosure_url: string | null + feeds: { + title: string | null + } +} + +function estimateReadingTimeMinutes(html: string): number { + const text = html.replace(/<[^>]*>/g, "").replace(/&\w+;/g, " ") + const wordCount = text.split(/\s+/).filter(Boolean).length + return Math.max(1, Math.round(wordCount / 200)) +} + +export function EntryDetailPanel({ + entryIdentifier, +}: { + entryIdentifier: string +}) { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + const toggleReadState = useToggleEntryReadState() + const toggleSavedState = useToggleEntrySavedState() + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + + const proseContainerReference = useRef<HTMLDivElement>(null) + const [selectionToolbarState, setSelectionToolbarState] = useState<{ + selectionRect: DOMRect + containerRect: DOMRect + range: Range + } | null>(null) + const [highlightPopoverState, setHighlightPopoverState] = useState<{ + highlightIdentifier: string + note: string | null + anchorRect: DOMRect + containerRect: DOMRect + } | null>(null) + const [unpositionedHighlights, setUnpositionedHighlights] = useState<Highlight[]>([]) + + const { data: timelineData } = useTimeline() + const currentEntry = timelineData?.pages + .flatMap((page) => page) + .find((entry) => entry.entryIdentifier === entryIdentifier) + + const { data: entryDetail, isLoading } = useQuery({ + queryKey: queryKeys.entryDetail.single(entryIdentifier), + queryFn: async () => { + const { data, error } = await supabaseClient + .from("entries") + .select( + "id, title, url, author, content_html, summary, published_at, enclosure_url, feeds!inner(title)" + ) + .eq("id", entryIdentifier) + .single() + + if (error) throw error + + return data as unknown as EntryDetailRow + }, + }) + + const { data: shareData } = useEntryShare(entryIdentifier) + const { data: highlightsData } = useEntryHighlights(entryIdentifier) + + const createHighlight = useCreateHighlight() + const updateHighlightNote = useUpdateHighlightNote() + const deleteHighlight = useDeleteHighlight() + + const shareMutation = useMutation({ + mutationFn: async (note?: string | null) => { + const response = await fetch("/api/share", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entryIdentifier, note: note ?? null }), + }) + if (!response.ok) throw new Error("Failed to create share") + return response.json() as Promise<{ + shareToken: string + shareUrl: string + }> + }, + onSuccess: async (data) => { + await navigator.clipboard.writeText(data.shareUrl) + notify("link copied") + queryClient.invalidateQueries({ + queryKey: queryKeys.entryShare.single(entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + }, + }) + + const unshareMutation = useMutation({ + mutationFn: async (shareToken: string) => { + const response = await fetch(`/api/share/${shareToken}`, { + method: "DELETE", + }) + if (!response.ok) throw new Error("Failed to delete share") + }, + onSuccess: () => { + notify("share link removed") + queryClient.invalidateQueries({ + queryKey: queryKeys.entryShare.single(entryIdentifier), + }) + }, + }) + + useEffect(() => { + if (!currentEntry || currentEntry.isRead) return + + const autoReadTimeout = setTimeout(() => { + toggleReadState.mutate({ + entryIdentifier, + isRead: true, + }) + }, 1500) + + return () => clearTimeout(autoReadTimeout) + }, [entryIdentifier, currentEntry?.isRead]) + + const contentHtml = + entryDetail?.content_html || entryDetail?.summary || "" + const sanitisedContent = sanitizeEntryContent(contentHtml) + + useEffect(() => { + const container = proseContainerReference.current + if (!container || !sanitisedContent) return + + container.textContent = "" + const template = document.createElement("template") + template.innerHTML = sanitisedContent + container.appendChild(template.content.cloneNode(true)) + + const failedHighlights: Highlight[] = [] + + if (highlightsData && highlightsData.length > 0) { + const sortedHighlights = [...highlightsData].sort( + (a, b) => b.textOffset - a.textOffset + ) + for (const highlight of sortedHighlights) { + const range = deserializeHighlightRange(container, highlight) + if (range) { + applyHighlightToRange( + range, + highlight.identifier, + highlight.color, + !!highlight.note + ) + } else { + failedHighlights.push(highlight) + } + } + } + + setUnpositionedHighlights(failedHighlights) + }, [sanitisedContent, highlightsData]) + + const handleTextSelection = useCallback(() => { + const container = proseContainerReference.current + if (!container) return + + const selection = window.getSelection() + if (!selection || selection.isCollapsed || !selection.rangeCount) { + setSelectionToolbarState(null) + return + } + + const range = selection.getRangeAt(0) + if (!container.contains(range.commonAncestorContainer)) { + setSelectionToolbarState(null) + return + } + + const selectionRect = range.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + + setSelectionToolbarState({ + selectionRect, + containerRect, + range: range.cloneRange(), + }) + setHighlightPopoverState(null) + }, []) + + useEffect(() => { + document.addEventListener("mouseup", handleTextSelection) + document.addEventListener("touchend", handleTextSelection) + return () => { + document.removeEventListener("mouseup", handleTextSelection) + document.removeEventListener("touchend", handleTextSelection) + } + }, [handleTextSelection]) + + useEffect(() => { + const container = proseContainerReference.current + if (!container) return + + const currentContainer = container + + function handleMarkClick(event: MouseEvent) { + const target = event.target as HTMLElement + const markElement = target.closest("mark[data-highlight-identifier]") + if (!markElement) return + + const highlightIdentifier = markElement.getAttribute("data-highlight-identifier") + if (!highlightIdentifier) return + + const matchingHighlight = highlightsData?.find( + (h) => h.identifier === highlightIdentifier + ) + + const anchorRect = markElement.getBoundingClientRect() + const containerRect = currentContainer.getBoundingClientRect() + + setHighlightPopoverState({ + highlightIdentifier, + note: matchingHighlight?.note ?? null, + anchorRect, + containerRect, + }) + setSelectionToolbarState(null) + } + + container.addEventListener("click", handleMarkClick) + return () => container.removeEventListener("click", handleMarkClick) + }, [highlightsData]) + + function handleCreateHighlight(note: string | null) { + const container = proseContainerReference.current + if (!container || !selectionToolbarState) return + + const serialized = serializeSelectionRange( + container, + selectionToolbarState.range + ) + if (!serialized) return + + createHighlight.mutate({ + entryIdentifier, + highlightedText: serialized.highlightedText, + note, + textOffset: serialized.textOffset, + textLength: serialized.textLength, + textPrefix: serialized.textPrefix, + textSuffix: serialized.textSuffix, + color: "yellow", + }) + + window.getSelection()?.removeAllRanges() + setSelectionToolbarState(null) + } + + function handleUpdateHighlightNote(note: string | null) { + if (!highlightPopoverState) return + updateHighlightNote.mutate({ + highlightIdentifier: highlightPopoverState.highlightIdentifier, + note, + entryIdentifier, + }) + setHighlightPopoverState(null) + } + + function handleDeleteHighlight() { + if (!highlightPopoverState) return + const container = proseContainerReference.current + if (container) { + removeHighlightFromDom( + container, + highlightPopoverState.highlightIdentifier + ) + } + deleteHighlight.mutate({ + highlightIdentifier: highlightPopoverState.highlightIdentifier, + entryIdentifier, + }) + setHighlightPopoverState(null) + } + + if (isLoading || !entryDetail) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + loading ... + </div> + ) + } + + const readingTimeMinutes = estimateReadingTimeMinutes(contentHtml) + const isRead = currentEntry?.isRead ?? false + const isSaved = currentEntry?.isSaved ?? false + + return ( + <div data-detail-panel className="flex h-full flex-col"> + <div className="flex items-center gap-2 overflow-x-auto border-b border-border px-4 py-2"> + <button + type="button" + onClick={() => + toggleReadState.mutate({ + entryIdentifier, + isRead: !isRead, + }) + } + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {isRead ? "mark unread" : "mark read"} + </button> + <button + type="button" + onClick={() => + toggleSavedState.mutate({ + entryIdentifier, + isSaved: !isSaved, + }) + } + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {isSaved ? "unsave" : "save"} + </button> + {entryDetail.url && ( + <a + href={entryDetail.url} + target="_blank" + rel="noopener noreferrer" + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + open original + </a> + )} + {shareData?.isShared ? ( + <button + type="button" + onClick={() => unshareMutation.mutate(shareData.shareToken!)} + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + unshare + </button> + ) : ( + <button + type="button" + onClick={() => { + const note = window.prompt("add a note (optional):") + shareMutation.mutate(note || null) + }} + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + share + </button> + )} + <div className="flex-1" /> + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="hidden px-2 py-1 text-text-dim transition-colors hover:text-text-secondary md:block" + > + close + </button> + </div> + <article className="flex-1 overflow-auto px-6 py-4"> + <h2 className="mb-1 text-base text-text-primary"> + {entryDetail.title} + </h2> + <div className="mb-4 text-text-dim"> + {entryDetail.feeds?.title && ( + <span>{entryDetail.feeds.title}</span> + )} + {entryDetail.author && ( + <span> · {entryDetail.author}</span> + )} + <span> · {readingTimeMinutes} min read</span> + </div> + {entryDetail.enclosure_url && ( + <div className="mb-4 border border-border p-3"> + <audio + controls + preload="none" + src={entryDetail.enclosure_url} + className="w-full" + /> + </div> + )} + {unpositionedHighlights.length > 0 && ( + <div className="mb-4 border border-border px-3 py-2"> + <p className="mb-2 text-text-dim"> + {unpositionedHighlights.length} highlight + {unpositionedHighlights.length !== 1 && "s"} could not be positioned + (the article content may have changed) + </p> + {unpositionedHighlights.map((highlight) => ( + <div + key={highlight.identifier} + className="mb-1 border-l-2 border-text-dim pl-2 text-text-secondary last:mb-0" + > + <span className="bg-background-tertiary text-text-primary"> + {highlight.highlightedText} + </span> + {highlight.note && ( + <span className="ml-2 text-text-dim"> + — {highlight.note} + </span> + )} + </div> + ))} + </div> + )} + <div className="relative"> + <div + ref={proseContainerReference} + className="prose-reader text-text-secondary" + /> + {selectionToolbarState && ( + <HighlightSelectionToolbar + selectionRect={selectionToolbarState.selectionRect} + containerRect={selectionToolbarState.containerRect} + onHighlight={handleCreateHighlight} + onDismiss={() => setSelectionToolbarState(null)} + /> + )} + {highlightPopoverState && ( + <HighlightPopover + highlightIdentifier={highlightPopoverState.highlightIdentifier} + note={highlightPopoverState.note} + anchorRect={highlightPopoverState.anchorRect} + containerRect={highlightPopoverState.containerRect} + onUpdateNote={handleUpdateHighlightNote} + onDelete={handleDeleteHighlight} + onDismiss={() => setHighlightPopoverState(null)} + /> + )} + </div> + </article> + </div> + ) +} diff --git a/apps/web/app/reader/_components/entry-list-item.tsx b/apps/web/app/reader/_components/entry-list-item.tsx new file mode 100644 index 0000000..375b0f5 --- /dev/null +++ b/apps/web/app/reader/_components/entry-list-item.tsx @@ -0,0 +1,125 @@ +"use client" + +import { formatDistanceToNow } from "date-fns" +import { classNames } from "@/lib/utilities" +import type { TimelineEntry } from "@/lib/types/timeline" +import type { VirtualItem } from "@tanstack/react-virtual" + +interface EntryListItemProperties { + entry: TimelineEntry + isSelected: boolean + isFocused: boolean + viewMode: "compact" | "comfortable" | "expanded" + onSelect: () => void + measureReference: (element: HTMLElement | null) => void + virtualItem: VirtualItem +} + +function stripHtmlTags(html: string): string { + return html.replace(/<[^>]*>/g, "").replace(/&\w+;/g, " ").trim() +} + +export function EntryListItem({ + entry, + isSelected, + isFocused, + viewMode, + onSelect, + measureReference, + virtualItem, +}: EntryListItemProperties) { + const relativeTimestamp = entry.publishedAt + ? formatDistanceToNow(new Date(entry.publishedAt), { addSuffix: true }) + : "" + + const displayTitle = entry.customTitle ?? entry.feedTitle + + return ( + <div + ref={measureReference} + data-index={virtualItem.index} + onClick={onSelect} + className={classNames( + "absolute left-0 top-0 w-full cursor-pointer border-b border-border px-4 transition-colors", + isSelected + ? "bg-background-tertiary" + : isFocused + ? "bg-background-secondary" + : "hover:bg-background-secondary", + isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "", + entry.isRead ? "opacity-60" : "" + )} + style={{ transform: `translateY(${virtualItem.start}px)` }} + > + {viewMode === "compact" && ( + <div className="flex items-center gap-2 py-2.5"> + <span className="shrink-0 text-text-dim">{displayTitle}</span> + {entry.enclosureUrl && ( + <span className="shrink-0 text-text-dim" title="podcast episode">♫</span> + )} + <span className="min-w-0 flex-1 truncate text-text-primary"> + {entry.entryTitle} + </span> + <span className="shrink-0 text-text-dim">{relativeTimestamp}</span> + </div> + )} + + {viewMode === "comfortable" && ( + <div className="py-2.5"> + <div className="truncate text-text-primary">{entry.entryTitle}</div> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + <span>{displayTitle}</span> + {entry.enclosureUrl && ( + <span title="podcast episode">♫</span> + )} + {entry.author && ( + <> + <span>·</span> + <span>{entry.author}</span> + </> + )} + <span>·</span> + <span>{relativeTimestamp}</span> + </div> + </div> + )} + + {viewMode === "expanded" && ( + <div className="flex gap-3 py-3"> + <div className="min-w-0 flex-1"> + <div className="truncate text-text-primary"> + {entry.entryTitle} + </div> + {entry.summary && ( + <p className="mt-1 line-clamp-2 text-text-secondary"> + {stripHtmlTags(entry.summary)} + </p> + )} + <div className="mt-1 flex items-center gap-2 text-text-dim"> + <span>{displayTitle}</span> + {entry.enclosureUrl && ( + <span title="podcast episode">♫</span> + )} + {entry.author && ( + <> + <span>·</span> + <span>{entry.author}</span> + </> + )} + <span>·</span> + <span>{relativeTimestamp}</span> + </div> + </div> + {entry.imageUrl && ( + <img + src={entry.imageUrl} + alt="" + className="hidden h-16 w-16 shrink-0 object-cover sm:block" + loading="lazy" + /> + )} + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/_components/entry-list.tsx b/apps/web/app/reader/_components/entry-list.tsx new file mode 100644 index 0000000..6d4bcf3 --- /dev/null +++ b/apps/web/app/reader/_components/entry-list.tsx @@ -0,0 +1,217 @@ +"use client" + +import { useRef, useEffect } from "react" +import { useVirtualizer } from "@tanstack/react-virtual" +import { useTimeline } from "@/lib/queries/use-timeline" +import { useSavedEntries } from "@/lib/queries/use-saved-entries" +import { useCustomFeedTimeline } from "@/lib/queries/use-custom-feed-timeline" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { EntryListItem } from "./entry-list-item" + +interface EntryListProperties { + feedFilter: "all" | "saved" + folderIdentifier?: string | null + feedIdentifier?: string | null + customFeedIdentifier?: string | null +} + +function useEntryData( + feedFilter: "all" | "saved", + folderIdentifier?: string | null, + feedIdentifier?: string | null, + customFeedIdentifier?: string | null +) { + const timelineQuery = useTimeline( + feedFilter === "all" && !customFeedIdentifier ? folderIdentifier : undefined, + feedFilter === "all" && !customFeedIdentifier ? feedIdentifier : undefined, + false + ) + const savedQuery = useSavedEntries() + const customFeedQuery = useCustomFeedTimeline( + feedFilter === "all" ? (customFeedIdentifier ?? null) : null + ) + + if (feedFilter === "saved") { + return savedQuery + } + + if (customFeedIdentifier) { + return customFeedQuery + } + + return timelineQuery +} + +export function EntryList({ + feedFilter, + folderIdentifier, + feedIdentifier, + customFeedIdentifier, +}: EntryListProperties) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useEntryData(feedFilter, folderIdentifier, feedIdentifier, customFeedIdentifier) + + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setFocusedEntryIdentifier = useUserInterfaceStore( + (state) => state.setFocusedEntryIdentifier + ) + + const setNavigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.setNavigableEntryIdentifiers + ) + + const allEntries = data?.pages.flatMap((page) => page) ?? [] + const scrollContainerReference = useRef<HTMLDivElement>(null) + + const firstEntryIdentifier = allEntries[0]?.entryIdentifier + const lastEntryIdentifier = allEntries[allEntries.length - 1]?.entryIdentifier + + useEffect(() => { + setNavigableEntryIdentifiers( + allEntries.map((entry) => entry.entryIdentifier) + ) + }, [firstEntryIdentifier, lastEntryIdentifier, allEntries.length, setNavigableEntryIdentifiers]) + + function getEstimatedItemSize() { + switch (entryListViewMode) { + case "compact": + return 40 + case "comfortable": + return 60 + case "expanded": + return 108 + } + } + + const virtualizer = useVirtualizer({ + count: hasNextPage ? allEntries.length + 1 : allEntries.length, + getScrollElement: () => scrollContainerReference.current, + estimateSize: getEstimatedItemSize, + overscan: 10, + }) + + const virtualItems = virtualizer.getVirtualItems() + + useEffect(() => { + const lastItem = virtualItems[virtualItems.length - 1] + + if (!lastItem) return + + if ( + lastItem.index >= allEntries.length - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage() + } + }, [ + virtualItems, + allEntries.length, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + ]) + + const allEntriesReference = useRef(allEntries) + allEntriesReference.current = allEntries + + useEffect(() => { + if (!focusedEntryIdentifier) return + + const focusedIndex = allEntriesReference.current.findIndex( + (entry) => entry.entryIdentifier === focusedEntryIdentifier + ) + + if (focusedIndex !== -1) { + virtualizer.scrollToIndex(focusedIndex, { align: "auto" }) + } + }, [focusedEntryIdentifier]) + + if (isLoading) { + return ( + <div className="space-y-2 p-4"> + {Array.from({ length: 8 }).map((_, skeletonIndex) => ( + <div + key={skeletonIndex} + className="h-10 animate-[skeleton-shimmer_1.5s_ease-in-out_infinite] bg-background-tertiary" + /> + ))} + </div> + ) + } + + if (allEntries.length === 0) { + return ( + <div className="flex h-full items-center justify-center"> + <p className="text-text-tertiary"> + {feedFilter === "saved" + ? "no saved entries yet" + : "no entries yet \u2014 add a feed to get started"} + </p> + </div> + ) + } + + return ( + <div ref={scrollContainerReference} className="h-full overflow-auto"> + <div + style={{ + height: `${virtualizer.getTotalSize()}px`, + width: "100%", + position: "relative", + }} + > + {virtualItems.map((virtualItem) => { + const entry = allEntries[virtualItem.index] + + if (!entry) { + return ( + <div + key="loader" + data-index={virtualItem.index} + ref={virtualizer.measureElement} + className="absolute left-0 top-0 w-full" + style={{ + transform: `translateY(${virtualItem.start}px)`, + }} + > + <p className="p-4 text-center text-text-dim">loading ...</p> + </div> + ) + } + + return ( + <EntryListItem + key={entry.entryIdentifier} + entry={entry} + isSelected={ + entry.entryIdentifier === selectedEntryIdentifier + } + isFocused={ + entry.entryIdentifier === focusedEntryIdentifier + } + viewMode={entryListViewMode} + onSelect={() => { + setFocusedEntryIdentifier(entry.entryIdentifier) + setSelectedEntryIdentifier(entry.entryIdentifier) + }} + measureReference={virtualizer.measureElement} + virtualItem={virtualItem} + /> + ) + })} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/error-boundary.tsx b/apps/web/app/reader/_components/error-boundary.tsx new file mode 100644 index 0000000..6696e66 --- /dev/null +++ b/apps/web/app/reader/_components/error-boundary.tsx @@ -0,0 +1,55 @@ +"use client" + +import { Component, type ReactNode } from "react" + +interface ErrorBoundaryProperties { + fallback?: ReactNode + children: ReactNode +} + +interface ErrorBoundaryState { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component< + ErrorBoundaryProperties, + ErrorBoundaryState +> { + constructor(properties: ErrorBoundaryProperties) { + super(properties) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( + <div className="flex h-full items-center justify-center p-4"> + <div className="max-w-sm text-center"> + <p className="mb-2 text-text-primary">something went wrong</p> + <p className="mb-4 text-text-dim"> + {this.state.error?.message ?? "an unexpected error occurred"} + </p> + <button + type="button" + onClick={() => this.setState({ hasError: false, error: null })} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + try again + </button> + </div> + </div> + ) + } + + return this.props.children + } +} diff --git a/apps/web/app/reader/_components/highlight-popover.tsx b/apps/web/app/reader/_components/highlight-popover.tsx new file mode 100644 index 0000000..301c174 --- /dev/null +++ b/apps/web/app/reader/_components/highlight-popover.tsx @@ -0,0 +1,96 @@ +"use client" + +import { useState } from "react" + +interface HighlightPopoverProperties { + highlightIdentifier: string + note: string | null + anchorRect: DOMRect + containerRect: DOMRect + onUpdateNote: (note: string | null) => void + onDelete: () => void + onDismiss: () => void +} + +export function HighlightPopover({ + note, + anchorRect, + onUpdateNote, + onDelete, + onDismiss, +}: HighlightPopoverProperties) { + const [isEditingNote, setIsEditingNote] = useState(false) + const [editedNoteText, setEditedNoteText] = useState(note ?? "") + + const popoverLeft = anchorRect.left + anchorRect.width / 2 + const popoverTop = anchorRect.bottom + 4 + + function handleSaveNote() { + onUpdateNote(editedNoteText.trim() || null) + setIsEditingNote(false) + } + + return ( + <div + className="fixed z-[100] -translate-x-1/2" + style={{ left: popoverLeft, top: popoverTop }} + > + <div className="min-w-48 border border-border bg-background-secondary p-2"> + {isEditingNote ? ( + <div className="space-y-1"> + <input + type="text" + value={editedNoteText} + onChange={(event) => setEditedNoteText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveNote() + if (event.key === "Escape") onDismiss() + }} + placeholder="add a note..." + className="w-full border border-border bg-background-primary px-2 py-1 text-xs text-text-primary outline-none" + autoFocus + /> + <div className="flex gap-1"> + <button + type="button" + onClick={handleSaveNote} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + save + </button> + <button + type="button" + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-xs text-text-dim transition-colors hover:text-text-secondary" + > + cancel + </button> + </div> + </div> + ) : ( + <div className="space-y-1"> + {note && ( + <p className="text-xs text-text-secondary">{note}</p> + )} + <div className="flex gap-1"> + <button + type="button" + onClick={() => setIsEditingNote(true)} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {note ? "edit note" : "add note"} + </button> + <button + type="button" + onClick={onDelete} + className="px-2 py-1 text-xs text-status-error transition-colors hover:bg-background-tertiary" + > + remove + </button> + </div> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/highlight-selection-toolbar.tsx b/apps/web/app/reader/_components/highlight-selection-toolbar.tsx new file mode 100644 index 0000000..42522bf --- /dev/null +++ b/apps/web/app/reader/_components/highlight-selection-toolbar.tsx @@ -0,0 +1,80 @@ +"use client" + +import { useState } from "react" + +interface HighlightSelectionToolbarProperties { + selectionRect: DOMRect + containerRect: DOMRect + onHighlight: (note: string | null) => void + onDismiss: () => void +} + +export function HighlightSelectionToolbar({ + selectionRect, + onHighlight, + onDismiss, +}: HighlightSelectionToolbarProperties) { + const [showNoteInput, setShowNoteInput] = useState(false) + const [noteText, setNoteText] = useState("") + + const toolbarLeft = selectionRect.left + selectionRect.width / 2 + const toolbarTop = selectionRect.top - 8 + + function handleHighlightClick() { + if (showNoteInput) { + onHighlight(noteText.trim() || null) + } else { + onHighlight(null) + } + } + + return ( + <div + className="fixed z-[100] -translate-x-1/2 -translate-y-full" + style={{ left: toolbarLeft, top: toolbarTop }} + > + <div className="border border-border bg-background-secondary p-1"> + {showNoteInput ? ( + <div className="flex items-center gap-1"> + <input + type="text" + value={noteText} + onChange={(event) => setNoteText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleHighlightClick() + if (event.key === "Escape") onDismiss() + }} + placeholder="add a note..." + className="border border-border bg-background-primary px-2 py-1 text-xs text-text-primary outline-none" + autoFocus + /> + <button + type="button" + onClick={handleHighlightClick} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + save + </button> + </div> + ) : ( + <div className="flex items-center gap-1"> + <button + type="button" + onClick={handleHighlightClick} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + highlight + </button> + <button + type="button" + onClick={() => setShowNoteInput(true)} + className="px-2 py-1 text-xs text-text-dim transition-colors hover:bg-background-tertiary hover:text-text-secondary" + > + + note + </button> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/mfa-challenge.tsx b/apps/web/app/reader/_components/mfa-challenge.tsx new file mode 100644 index 0000000..347d8b4 --- /dev/null +++ b/apps/web/app/reader/_components/mfa-challenge.tsx @@ -0,0 +1,108 @@ +"use client" + +import { useState, useEffect } from "react" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export function MfaChallenge({ onVerified }: { onVerified: () => void }) { + const [verificationCode, setVerificationCode] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isVerifying, setIsVerifying] = useState(false) + const [factorIdentifier, setFactorIdentifier] = useState<string | null>(null) + const supabaseClient = createSupabaseBrowserClient() + + useEffect(() => { + async function loadFactor() { + const { data } = await supabaseClient.auth.mfa.listFactors() + + if (data?.totp && data.totp.length > 0) { + const verifiedFactor = data.totp.find( + (factor) => factor.status === "verified" + ) + + if (verifiedFactor) { + setFactorIdentifier(verifiedFactor.id) + } + } + } + + loadFactor() + }, []) + + async function handleVerify(event?: React.FormEvent) { + event?.preventDefault() + + if (!factorIdentifier || verificationCode.length !== 6) return + + setIsVerifying(true) + setErrorMessage(null) + + const { data: challengeData, error: challengeError } = + await supabaseClient.auth.mfa.challenge({ + factorId: factorIdentifier, + }) + + if (challengeError) { + setIsVerifying(false) + setErrorMessage("failed to create challenge — please try again") + return + } + + const { error: verifyError } = await supabaseClient.auth.mfa.verify({ + factorId: factorIdentifier, + challengeId: challengeData.id, + code: verificationCode, + }) + + setIsVerifying(false) + + if (verifyError) { + setErrorMessage("invalid code — please try again") + setVerificationCode("") + return + } + + onVerified() + } + + return ( + <div className="flex h-screen items-center justify-center bg-background-primary"> + <div className="w-full max-w-sm space-y-6 px-4"> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">two-factor authentication</h1> + <p className="text-text-secondary"> + enter the 6-digit code from your authenticator app + </p> + </div> + + <form onSubmit={handleVerify} className="space-y-4"> + <input + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={6} + value={verificationCode} + onChange={(event) => { + const filtered = event.target.value.replace(/\D/g, "") + setVerificationCode(filtered) + }} + placeholder="000000" + className="w-full border border-border bg-background-secondary px-3 py-3 text-center font-mono text-2xl tracking-[0.5em] text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + autoFocus + /> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isVerifying || verificationCode.length !== 6 || !factorIdentifier} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isVerifying ? "verifying ..." : "verify"} + </button> + </form> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/notification-panel.tsx b/apps/web/app/reader/_components/notification-panel.tsx new file mode 100644 index 0000000..216741f --- /dev/null +++ b/apps/web/app/reader/_components/notification-panel.tsx @@ -0,0 +1,129 @@ +"use client" + +import { useEffect, useRef } from "react" +import { formatDistanceToNow } from "date-fns" +import { toast } from "sonner" +import { + useNotificationStore, + type StoredNotification, +} from "@/lib/stores/notification-store" + +export function NotificationPanel({ onClose }: { onClose: () => void }) { + const panelReference = useRef<HTMLDivElement>(null) + const notifications = useNotificationStore((state) => state.notifications) + const dismissNotification = useNotificationStore( + (state) => state.dismissNotification + ) + const clearAllNotifications = useNotificationStore( + (state) => state.clearAllNotifications + ) + const markAllAsViewed = useNotificationStore( + (state) => state.markAllAsViewed + ) + + useEffect(() => { + markAllAsViewed() + }, [markAllAsViewed]) + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + panelReference.current && + !panelReference.current.contains(event.target as Node) + ) { + onClose() + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [onClose]) + + function handleNotificationClick(notification: StoredNotification) { + if (notification.actionUrl) { + navigator.clipboard.writeText(notification.actionUrl) + toast("link copied to clipboard") + } + } + + return ( + <div + ref={panelReference} + className="fixed bottom-16 left-2 z-50 w-80 max-w-[calc(100vw-1rem)] border border-border bg-background-secondary shadow-lg md:absolute md:bottom-full md:left-0 md:mb-1" + > + <div className="flex items-center justify-between border-b border-border px-3 py-2"> + <span className="text-text-primary">notifications</span> + {notifications.length > 0 && ( + <button + type="button" + onClick={() => { + clearAllNotifications() + onClose() + }} + className="text-text-dim transition-colors hover:text-text-secondary" + > + clear all + </button> + )} + </div> + <div className="max-h-64 overflow-auto"> + {notifications.length === 0 ? ( + <p className="px-3 py-4 text-center text-text-dim"> + no notifications + </p> + ) : ( + notifications.map((notification: StoredNotification) => ( + <div + key={notification.identifier} + className={`flex items-start gap-2 border-b border-border px-3 py-2 last:border-b-0 ${ + notification.actionUrl + ? "cursor-pointer transition-colors hover:bg-background-tertiary" + : "" + }`} + onClick={ + notification.actionUrl + ? () => handleNotificationClick(notification) + : undefined + } + > + <div className="min-w-0 flex-1"> + <p className="text-text-secondary">{notification.message}</p> + {notification.actionUrl && ( + <p className="mt-0.5 text-text-dim"> + tap to copy link + </p> + )} + <p className="mt-0.5 text-text-dim"> + {formatDistanceToNow(new Date(notification.timestamp), { + addSuffix: true, + })} + </p> + </div> + <button + type="button" + onClick={(event) => { + event.stopPropagation() + dismissNotification(notification.identifier) + }} + className="shrink-0 px-1 text-text-dim transition-colors hover:text-text-secondary" + > + × + </button> + </div> + )) + )} + </div> + </div> + ) +} + +export function useUnviewedNotificationCount(): number { + const notifications = useNotificationStore((state) => state.notifications) + const lastViewedAt = useNotificationStore((state) => state.lastViewedAt) + + if (!lastViewedAt) return notifications.length + + return notifications.filter( + (notification) => notification.timestamp > lastViewedAt + ).length +} diff --git a/apps/web/app/reader/_components/reader-layout-shell.tsx b/apps/web/app/reader/_components/reader-layout-shell.tsx new file mode 100644 index 0000000..7e0e80b --- /dev/null +++ b/apps/web/app/reader/_components/reader-layout-shell.tsx @@ -0,0 +1,204 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { classNames } from "@/lib/utilities" +import { ErrorBoundary } from "./error-boundary" +import { SidebarContent } from "./sidebar-content" +import { CommandPalette } from "./command-palette" +import { AddFeedDialog } from "./add-feed-dialog" +import { SearchOverlay } from "./search-overlay" +import { MfaChallenge } from "./mfa-challenge" +import { useKeyboardNavigation } from "@/lib/hooks/use-keyboard-navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +const DENSITY_FONT_SIZE_MAP: Record<string, string> = { + compact: "0.875rem", + default: "1rem", + spacious: "1.125rem", +} + +export function ReaderLayoutShell({ + sidebarFooter, + children, +}: { + sidebarFooter: React.ReactNode + children: React.ReactNode +}) { + const [requiresMfaVerification, setRequiresMfaVerification] = useState(false) + const [isMfaCheckComplete, setIsMfaCheckComplete] = useState(false) + + const isSidebarCollapsed = useUserInterfaceStore( + (state) => state.isSidebarCollapsed + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setSidebarCollapsed = useUserInterfaceStore( + (state) => state.setSidebarCollapsed + ) + const displayDensity = useUserInterfaceStore( + (state) => state.displayDensity + ) + const isSearchOpen = useUserInterfaceStore((state) => state.isSearchOpen) + const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const setFocusedPanel = useUserInterfaceStore((state) => state.setFocusedPanel) + const focusFollowsInteraction = useUserInterfaceStore( + (state) => state.focusFollowsInteraction + ) + + useKeyboardNavigation() + + useEffect(() => { + async function checkAssuranceLevel() { + const supabaseClient = createSupabaseBrowserClient() + const { data } = await supabaseClient.auth.mfa.getAuthenticatorAssuranceLevel() + + if ( + data && + data.currentLevel === "aal1" && + data.nextLevel === "aal2" + ) { + setRequiresMfaVerification(true) + } + + setIsMfaCheckComplete(true) + } + + checkAssuranceLevel() + }, []) + + useEffect(() => { + if (window.innerWidth < 768) { + setSidebarCollapsed(true) + } + }, [setSidebarCollapsed]) + + useEffect(() => { + document.body.style.setProperty( + "--base-font-size", + DENSITY_FONT_SIZE_MAP[displayDensity] ?? "0.8125rem" + ) + }, [displayDensity]) + + useEffect(() => { + if (!focusFollowsInteraction) return + + function handlePointerDown(event: PointerEvent) { + const target = event.target as HTMLElement + const zone = target.closest("[data-panel-zone]") + if (!zone) return + const panelZone = zone.getAttribute("data-panel-zone") + if ( + panelZone === "sidebar" || + panelZone === "entryList" || + panelZone === "detailPanel" + ) { + useUserInterfaceStore.getState().setFocusedPanel(panelZone) + } + } + + function handleScroll(event: Event) { + const target = event.target as HTMLElement + if (!target || !target.closest) return + const zone = target.closest("[data-panel-zone]") + if (!zone) return + const panelZone = zone.getAttribute("data-panel-zone") + if ( + panelZone === "sidebar" || + panelZone === "entryList" || + panelZone === "detailPanel" + ) { + const currentPanel = useUserInterfaceStore.getState().focusedPanel + if (currentPanel !== panelZone) { + useUserInterfaceStore.getState().setFocusedPanel(panelZone) + } + } + } + + document.addEventListener("pointerdown", handlePointerDown) + document.addEventListener("scroll", handleScroll, true) + return () => { + document.removeEventListener("pointerdown", handlePointerDown) + document.removeEventListener("scroll", handleScroll, true) + } + }, [focusFollowsInteraction]) + + if (!isMfaCheckComplete) { + return ( + <div className="flex h-screen items-center justify-center bg-background-primary"> + <span className="text-text-dim">loading ...</span> + </div> + ) + } + + if (requiresMfaVerification) { + return <MfaChallenge onVerified={() => setRequiresMfaVerification(false)} /> + } + + return ( + <div className="flex h-screen"> + <div + className={classNames( + "fixed inset-0 z-30 bg-black/50 transition-opacity md:hidden", + !isSidebarCollapsed + ? "pointer-events-auto opacity-100" + : "pointer-events-none opacity-0" + )} + onClick={toggleSidebar} + /> + + <aside + data-panel-zone="sidebar" + className={classNames( + "fixed z-40 flex h-full shrink-0 flex-col border-r border-border bg-background-secondary transition-transform duration-200 md:relative md:z-10 md:transition-[width]", + "w-64", + isSidebarCollapsed + ? "-translate-x-full md:w-0 md:translate-x-0 md:overflow-hidden" + : "translate-x-0", + focusedPanel === "sidebar" && !isSidebarCollapsed + ? "border-r-text-dim" + : "" + )} + > + <div className="flex items-center justify-between p-4"> + <h2 className="text-text-primary">asa.news</h2> + <button + type="button" + onClick={toggleSidebar} + className="px-1 py-0.5 text-lg leading-none text-text-dim transition-colors hover:text-text-secondary" + > + × + </button> + </div> + <ErrorBoundary> + <Suspense> + <SidebarContent /> + </Suspense> + </ErrorBoundary> + {sidebarFooter} + </aside> + + <main className="flex-1 overflow-hidden"> + <div className="flex h-full flex-col"> + {isSidebarCollapsed && ( + <div className="flex items-center border-b border-border px-2 py-1"> + <button + type="button" + onClick={toggleSidebar} + className="px-2 py-1 text-lg leading-none text-text-secondary transition-colors hover:text-text-primary" + > + ☰ + </button> + </div> + )} + <div className="flex-1 overflow-hidden">{children}</div> + </div> + </main> + <CommandPalette /> + <AddFeedDialog /> + {isSearchOpen && ( + <SearchOverlay onClose={() => setSearchOpen(false)} /> + )} + </div> + ) +} diff --git a/apps/web/app/reader/_components/reader-shell.tsx b/apps/web/app/reader/_components/reader-shell.tsx new file mode 100644 index 0000000..fe7e4c2 --- /dev/null +++ b/apps/web/app/reader/_components/reader-shell.tsx @@ -0,0 +1,208 @@ +"use client" + +import { Group, Panel, Separator } from "react-resizable-panels" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useMarkAllAsRead } from "@/lib/queries/use-mark-all-as-read" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUnreadCounts } from "@/lib/queries/use-unread-counts" +import { useIsMobile } from "@/lib/hooks/use-is-mobile" +import { classNames } from "@/lib/utilities" +import { EntryList } from "./entry-list" +import { EntryDetailPanel } from "./entry-detail-panel" +import { ErrorBoundary } from "./error-boundary" +import { useRealtimeEntries } from "@/lib/hooks/use-realtime-entries" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" + +interface ReaderShellProperties { + userEmailAddress: string | null + feedFilter: "all" | "saved" + folderIdentifier?: string | null + feedIdentifier?: string | null + customFeedIdentifier?: string | null +} + +export function ReaderShell({ + userEmailAddress, + feedFilter, + folderIdentifier, + feedIdentifier, + customFeedIdentifier, +}: ReaderShellProperties) { + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) + const markAllAsRead = useMarkAllAsRead() + const { data: subscriptionsData } = useSubscriptions() + const { data: unreadCounts } = useUnreadCounts() + const { data: customFeedsData } = useCustomFeeds() + const isMobile = useIsMobile() + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + + useRealtimeEntries() + + let pageTitle = feedFilter === "saved" ? "saved" : "all entries" + + if (feedFilter === "all" && customFeedIdentifier && customFeedsData) { + const matchingCustomFeed = customFeedsData.find( + (customFeed) => customFeed.identifier === customFeedIdentifier + ) + + if (matchingCustomFeed) { + pageTitle = matchingCustomFeed.name + } + } + + if (feedFilter === "all" && feedIdentifier && subscriptionsData) { + const matchingSubscription = subscriptionsData.subscriptions.find( + (subscription) => subscription.feedIdentifier === feedIdentifier + ) + + if (matchingSubscription) { + pageTitle = + matchingSubscription.customTitle || + matchingSubscription.feedTitle || + "feed" + } + } + + if (feedFilter === "all" && folderIdentifier && subscriptionsData) { + const matchingFolder = subscriptionsData.folders.find( + (folder) => folder.folderIdentifier === folderIdentifier + ) + + if (matchingFolder) { + pageTitle = matchingFolder.name + } + } + + const totalUnreadCount = Object.values(unreadCounts ?? {}).reduce( + (sum, count) => sum + count, + 0 + ) + const allAreRead = totalUnreadCount === 0 + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center justify-between border-b border-border px-4 py-3"> + {isMobile && selectedEntryIdentifier ? ( + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + ← back + </button> + ) : ( + <h1 className="text-text-primary">{pageTitle}</h1> + )} + <div className="flex items-center gap-3"> + {!(isMobile && selectedEntryIdentifier) && ( + <> + <button + type="button" + onClick={() => setSearchOpen(true)} + className="text-text-dim transition-colors hover:text-text-secondary" + > + search + </button> + {feedFilter === "all" && ( + <button + type="button" + onClick={() => + markAllAsRead.mutate({ readState: !allAreRead }) + } + disabled={markAllAsRead.isPending} + className="text-text-dim transition-colors hover:text-text-secondary disabled:opacity-50" + > + {allAreRead ? "mark all unread" : "mark all read"} + </button> + )} + <select + value={entryListViewMode} + onChange={(event) => + setEntryListViewMode( + event.target.value as "compact" | "comfortable" | "expanded" + ) + } + className="hidden border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none sm:block" + > + <option value="compact">compact</option> + <option value="comfortable">comfortable</option> + <option value="expanded">expanded</option> + </select> + </> + )} + </div> + </header> + <ErrorBoundary> + {isMobile ? ( + selectedEntryIdentifier ? ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryDetailPanel + entryIdentifier={selectedEntryIdentifier} + /> + </ErrorBoundary> + </div> + ) : ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryList + feedFilter={feedFilter} + folderIdentifier={folderIdentifier} + feedIdentifier={feedIdentifier} + customFeedIdentifier={customFeedIdentifier} + /> + </ErrorBoundary> + </div> + ) + ) : ( + <Group orientation="horizontal" className="flex-1"> + <Panel defaultSize={selectedEntryIdentifier ? 40 : 100} minSize={25}> + <div data-panel-zone="entryList" className={classNames( + "h-full", + focusedPanel === "entryList" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <ErrorBoundary> + <EntryList + feedFilter={feedFilter} + folderIdentifier={folderIdentifier} + feedIdentifier={feedIdentifier} + customFeedIdentifier={customFeedIdentifier} + /> + </ErrorBoundary> + </div> + </Panel> + {selectedEntryIdentifier && ( + <> + <Separator className="w-px bg-border transition-colors hover:bg-text-dim" /> + <Panel defaultSize={60} minSize={30}> + <div data-panel-zone="detailPanel" className={classNames( + "h-full", + focusedPanel === "detailPanel" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <ErrorBoundary> + <EntryDetailPanel + entryIdentifier={selectedEntryIdentifier} + /> + </ErrorBoundary> + </div> + </Panel> + </> + )} + </Group> + )} + </ErrorBoundary> + </div> + ) +} diff --git a/apps/web/app/reader/_components/search-overlay.tsx b/apps/web/app/reader/_components/search-overlay.tsx new file mode 100644 index 0000000..5cfdb57 --- /dev/null +++ b/apps/web/app/reader/_components/search-overlay.tsx @@ -0,0 +1,180 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useEntrySearch } from "@/lib/queries/use-entry-search" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +function getMatchSnippet(text: string, query: string): string | null { + const stripped = text.replace(/<[^>]*>/g, "") + const lowerStripped = stripped.toLowerCase() + const matchIndex = lowerStripped.indexOf(query) + if (matchIndex === -1) return null + const start = Math.max(0, matchIndex - 40) + const end = Math.min(stripped.length, matchIndex + query.length + 80) + const prefix = start > 0 ? "\u2026" : "" + const suffix = end < stripped.length ? "\u2026" : "" + return prefix + stripped.slice(start, end) + suffix +} + +function highlightText(text: string, query: string): React.ReactNode { + if (!query) return text + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const parts = text.split(new RegExp(`(${escapedQuery})`, "gi")) + return parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + <mark key={index} className="bg-[rgba(234,179,8,0.18)] text-text-primary"> + {part} + </mark> + ) : ( + part + ) + ) +} + +interface SearchOverlayProperties { + onClose: () => void +} + +export function SearchOverlay({ onClose }: SearchOverlayProperties) { + const [searchQuery, setSearchQuery] = useState("") + const [selectedResultIndex, setSelectedResultIndex] = useState(-1) + const inputReference = useRef<HTMLInputElement>(null) + const resultListReference = useRef<HTMLDivElement>(null) + const { data: results, isLoading } = useEntrySearch(searchQuery) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + + useEffect(() => { + inputReference.current?.focus() + }, []) + + useEffect(() => { + setSelectedResultIndex(-1) + }, [searchQuery]) + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + onClose() + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + function handleSelectEntry(entryIdentifier: string) { + setSelectedEntryIdentifier(entryIdentifier) + onClose() + } + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "Backspace" && searchQuery === "") { + onClose() + return + } + + if (!results || results.length === 0) return + + if (event.key === "ArrowDown") { + event.preventDefault() + setSelectedResultIndex((previous) => { + const nextIndex = previous < results.length - 1 ? previous + 1 : 0 + scrollResultIntoView(nextIndex) + return nextIndex + }) + } else if (event.key === "ArrowUp") { + event.preventDefault() + setSelectedResultIndex((previous) => { + const nextIndex = previous > 0 ? previous - 1 : results.length - 1 + scrollResultIntoView(nextIndex) + return nextIndex + }) + } else if (event.key === "Enter" && selectedResultIndex >= 0) { + event.preventDefault() + handleSelectEntry(results[selectedResultIndex].entryIdentifier) + } + } + + function scrollResultIntoView(index: number) { + const container = resultListReference.current + if (!container) return + const items = container.querySelectorAll("[data-result-item]") + items[index]?.scrollIntoView({ block: "nearest" }) + } + + function handleBackdropClick(event: React.MouseEvent) { + if (event.target === event.currentTarget) { + onClose() + } + } + + return ( + <div + className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh]" + onClick={handleBackdropClick} + > + <div className="w-full max-w-lg border border-border bg-background-primary shadow-lg"> + <div className="border-b border-border px-4 py-3"> + <input + ref={inputReference} + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + onKeyDown={handleInputKeyDown} + placeholder="search entries..." + className="w-full bg-transparent text-text-primary outline-none placeholder:text-text-dim" + /> + </div> + <div ref={resultListReference} className="max-h-80 overflow-auto"> + {isLoading && searchQuery.trim().length >= 2 && ( + <p className="px-4 py-3 text-text-dim">searching...</p> + )} + {!isLoading && + searchQuery.trim().length >= 2 && + results?.length === 0 && ( + <p className="px-4 py-3 text-text-dim">no results</p> + )} + {results?.map((entry, index) => { + const query = searchQuery.trim().toLowerCase() + const titleMatches = (entry.entryTitle ?? "").toLowerCase().includes(query) + const summarySnippet = !titleMatches && entry.summary + ? getMatchSnippet(entry.summary, query) + : null + + return ( + <button + key={entry.entryIdentifier} + type="button" + data-result-item + onClick={() => handleSelectEntry(entry.entryIdentifier)} + className={`block w-full px-4 py-2 text-left transition-colors hover:bg-background-tertiary ${ + index === selectedResultIndex + ? "bg-background-tertiary" + : "" + }`} + > + <p className="truncate text-text-primary"> + {titleMatches + ? highlightText(entry.entryTitle ?? "", query) + : entry.entryTitle} + </p> + <p className="truncate text-[0.6875rem] text-text-dim"> + {entry.customTitle ?? entry.feedTitle} + {entry.author && ` \u00b7 ${entry.author}`} + </p> + {summarySnippet && ( + <p className="mt-0.5 line-clamp-2 text-[0.6875rem] text-text-secondary"> + {highlightText(summarySnippet, query)} + </p> + )} + </button> + ) + })} + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/sidebar-content.tsx b/apps/web/app/reader/_components/sidebar-content.tsx new file mode 100644 index 0000000..ee5c873 --- /dev/null +++ b/apps/web/app/reader/_components/sidebar-content.tsx @@ -0,0 +1,356 @@ +"use client" + +import Link from "next/link" +import { usePathname, useSearchParams } from "next/navigation" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUnreadCounts } from "@/lib/queries/use-unread-counts" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { classNames } from "@/lib/utilities" + +const NAVIGATION_LINK_CLASS = + "block px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + +const ACTIVE_LINK_CLASS = "bg-background-tertiary text-text-primary" + +function getFaviconUrl(feedUrl: string): string | null { + try { + const hostname = new URL(feedUrl).hostname + return `https://www.google.com/s2/favicons?domain=${hostname}&sz=16` + } catch { + return null + } +} + +function FeedFavicon({ feedUrl }: { feedUrl: string }) { + const faviconUrl = getFaviconUrl(feedUrl) + if (!faviconUrl) return null + + return ( + <img + src={faviconUrl} + alt="" + width={16} + height={16} + className="shrink-0" + loading="lazy" + /> + ) +} + +function displayNameForSubscription(subscription: { + customTitle: string | null + feedTitle: string + feedUrl: string +}): string { + if (subscription.customTitle) return subscription.customTitle + if (subscription.feedTitle) return subscription.feedTitle + + try { + return new URL(subscription.feedUrl).hostname + } catch { + return subscription.feedUrl || "untitled feed" + } +} + +function UnreadBadge({ count }: { count: number }) { + if (count === 0) return null + + return ( + <span className="ml-auto shrink-0 text-[0.625rem] tabular-nums text-text-dim"> + {count > 999 ? "999+" : count} + </span> + ) +} + +function sidebarFocusClass( + focusedPanel: string, + focusedSidebarIndex: number, + navIndex: number +): string { + return focusedPanel === "sidebar" && focusedSidebarIndex === navIndex + ? "bg-background-tertiary text-text-primary" + : "" +} + +export function SidebarContent() { + const pathname = usePathname() + const searchParameters = useSearchParams() + const { data } = useSubscriptions() + const { data: unreadCounts } = useUnreadCounts() + const { data: customFeedsData } = useCustomFeeds() + const setAddFeedDialogOpen = useUserInterfaceStore( + (state) => state.setAddFeedDialogOpen + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const showFeedFavicons = useUserInterfaceStore( + (state) => state.showFeedFavicons + ) + const expandedFolderIdentifiers = useUserInterfaceStore( + (state) => state.expandedFolderIdentifiers + ) + const toggleFolderExpansion = useUserInterfaceStore( + (state) => state.toggleFolderExpansion + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const focusedSidebarIndex = useUserInterfaceStore( + (state) => state.focusedSidebarIndex + ) + + function closeSidebarOnMobile() { + if (typeof window !== "undefined" && window.innerWidth < 768) { + toggleSidebar() + } + } + + const folders = data?.folders ?? [] + const subscriptions = data?.subscriptions ?? [] + const ungroupedSubscriptions = subscriptions.filter( + (subscription) => !subscription.folderIdentifier + ) + + const totalUnreadCount = Object.values(unreadCounts ?? {}).reduce( + (sum, count) => sum + count, + 0 + ) + + function getFolderUnreadCount(folderIdentifier: string): number { + return subscriptions + .filter( + (subscription) => + subscription.folderIdentifier === folderIdentifier + ) + .reduce( + (sum, subscription) => + sum + (unreadCounts?.[subscription.feedIdentifier] ?? 0), + 0 + ) + } + + const activeFeedIdentifier = searchParameters.get("feed") + const activeFolderIdentifier = searchParameters.get("folder") + const activeCustomFeedIdentifier = searchParameters.get("custom_feed") + + let navIndex = 0 + + return ( + <nav className="flex-1 space-y-1 overflow-auto px-2"> + <Link + href="/reader" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center", + pathname === "/reader" && + !activeFeedIdentifier && + !activeFolderIdentifier && + !activeCustomFeedIdentifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + <span>all entries</span> + <UnreadBadge count={totalUnreadCount} /> + </Link> + <Link + href="/reader/saved" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/saved" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + saved + </Link> + <Link + href="/reader/highlights" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/highlights" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + highlights + </Link> + <Link + href="/reader/shares" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/shares" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + shares + </Link> + + {customFeedsData && customFeedsData.length > 0 && ( + <div className="mt-3 space-y-0.5"> + {customFeedsData.map((customFeed) => ( + <Link + key={customFeed.identifier} + href={`/reader?custom_feed=${customFeed.identifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "truncate pl-4 text-[0.85em]", + activeCustomFeedIdentifier === customFeed.identifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {customFeed.name} + </Link> + ))} + </div> + )} + + {ungroupedSubscriptions.length > 0 && ( + <div className="mt-3 space-y-0.5"> + {ungroupedSubscriptions.map((subscription) => ( + <Link + key={subscription.subscriptionIdentifier} + href={`/reader?feed=${subscription.feedIdentifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center truncate pl-4 text-[0.85em]", + activeFeedIdentifier === subscription.feedIdentifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {showFeedFavicons && ( + <FeedFavicon feedUrl={subscription.feedUrl} /> + )} + <span className={classNames("truncate", showFeedFavicons && "ml-2")}> + {displayNameForSubscription(subscription)} + </span> + {subscription.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> + )} + {subscription.consecutiveFailures > 0 && ( + <span className="ml-1 shrink-0 text-status-warning" title={subscription.lastFetchError ?? "feed error"}> + [!] + </span> + )} + <UnreadBadge + count={unreadCounts?.[subscription.feedIdentifier] ?? 0} + /> + </Link> + ))} + </div> + )} + + {folders.map((folder) => { + const isExpanded = expandedFolderIdentifiers.includes( + folder.folderIdentifier + ) + const folderSubscriptions = subscriptions.filter( + (subscription) => + subscription.folderIdentifier === folder.folderIdentifier + ) + const folderUnreadCount = getFolderUnreadCount( + folder.folderIdentifier + ) + + const folderNavIndex = navIndex++ + + return ( + <div key={folder.folderIdentifier} className="mt-2"> + <div + data-sidebar-nav-item + className={classNames( + "flex w-full items-center gap-1 px-2 py-1", + sidebarFocusClass(focusedPanel, focusedSidebarIndex, folderNavIndex) + )} + > + <button + type="button" + onClick={() => + toggleFolderExpansion(folder.folderIdentifier) + } + className="shrink-0 px-0.5 text-text-secondary transition-colors hover:text-text-primary" + > + {isExpanded ? "\u25BE" : "\u25B8"} + </button> + <Link + href={`/reader?folder=${folder.folderIdentifier}`} + onClick={closeSidebarOnMobile} + className={classNames( + "flex-1 truncate text-text-secondary transition-colors hover:text-text-primary", + activeFolderIdentifier === folder.folderIdentifier && + "text-text-primary" + )} + > + {folder.name} + </Link> + <UnreadBadge count={folderUnreadCount} /> + </div> + {isExpanded && ( + <div className="space-y-0.5"> + {folderSubscriptions.map((subscription) => ( + <Link + key={subscription.subscriptionIdentifier} + href={`/reader?feed=${subscription.feedIdentifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center truncate pl-6 text-[0.85em]", + activeFeedIdentifier === + subscription.feedIdentifier && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {showFeedFavicons && ( + <FeedFavicon feedUrl={subscription.feedUrl} /> + )} + <span className={classNames("truncate", showFeedFavicons && "ml-2")}> + {displayNameForSubscription(subscription)} + </span> + {subscription.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> + )} + {subscription.consecutiveFailures > 0 && ( + <span className="ml-1 shrink-0 text-status-warning" title={subscription.lastFetchError ?? "feed error"}> + [!] + </span> + )} + <UnreadBadge + count={ + unreadCounts?.[subscription.feedIdentifier] ?? 0 + } + /> + </Link> + ))} + </div> + )} + </div> + ) + })} + + <div className="mt-3"> + <button + type="button" + data-sidebar-nav-item + onClick={() => setAddFeedDialogOpen(true)} + className={classNames( + "w-full px-2 py-1 text-left text-text-dim transition-colors hover:bg-background-tertiary hover:text-text-secondary", + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + + add feed + </button> + </div> + </nav> + ) +} diff --git a/apps/web/app/reader/_components/sidebar-footer.tsx b/apps/web/app/reader/_components/sidebar-footer.tsx new file mode 100644 index 0000000..8c520c3 --- /dev/null +++ b/apps/web/app/reader/_components/sidebar-footer.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { signOut } from "../actions" +import { + NotificationPanel, + useUnviewedNotificationCount, +} from "./notification-panel" + +export function SidebarFooter() { + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setActiveSettingsTab = useUserInterfaceStore( + (state) => state.setActiveSettingsTab + ) + const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false) + const unviewedNotificationCount = useUnviewedNotificationCount() + const { data: userProfile } = useUserProfile() + + const displayName = userProfile?.displayName ?? "account" + + function closeSidebarOnMobile() { + if (typeof window !== "undefined" && window.innerWidth < 768) { + toggleSidebar() + } + } + + return ( + <div className="border-t border-border p-2"> + <Link + href="/reader/settings" + onClick={() => { + setActiveSettingsTab("account") + closeSidebarOnMobile() + }} + className="block truncate px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {displayName} + </Link> + <Link + href="/reader/settings" + onClick={closeSidebarOnMobile} + className="block px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + settings + </Link> + <div className="relative"> + <button + type="button" + onClick={() => setIsNotificationPanelOpen(!isNotificationPanelOpen)} + className="w-full px-2 py-1 text-left text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + notifications + {unviewedNotificationCount > 0 && ( + <span className="ml-1 inline-flex h-4 min-w-4 items-center justify-center bg-accent-primary px-1 text-[0.6875rem] text-background-primary"> + {unviewedNotificationCount} + </span> + )} + </button> + {isNotificationPanelOpen && ( + <NotificationPanel + onClose={() => setIsNotificationPanelOpen(false)} + /> + )} + </div> + <form action={signOut}> + <button + type="submit" + onClick={closeSidebarOnMobile} + className="w-full px-2 py-1 text-left text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + sign out + </button> + </form> + </div> + ) +} diff --git a/apps/web/app/reader/actions.ts b/apps/web/app/reader/actions.ts new file mode 100644 index 0000000..efcc1ec --- /dev/null +++ b/apps/web/app/reader/actions.ts @@ -0,0 +1,10 @@ +"use server" + +import { redirect } from "next/navigation" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +export async function signOut() { + const supabaseClient = await createSupabaseServerClient() + await supabaseClient.auth.signOut() + redirect("/sign-in") +} diff --git a/apps/web/app/reader/highlights/_components/highlights-content.tsx b/apps/web/app/reader/highlights/_components/highlights-content.tsx new file mode 100644 index 0000000..4034210 --- /dev/null +++ b/apps/web/app/reader/highlights/_components/highlights-content.tsx @@ -0,0 +1,452 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { formatDistanceToNow } from "date-fns" +import { Group, Panel, Separator } from "react-resizable-panels" +import { useAllHighlights } from "@/lib/queries/use-all-highlights" +import { + useDeleteHighlight, + useUpdateHighlightNote, +} from "@/lib/queries/use-highlight-mutations" +import { useIsMobile } from "@/lib/hooks/use-is-mobile" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { EntryDetailPanel } from "@/app/reader/_components/entry-detail-panel" +import { ErrorBoundary } from "@/app/reader/_components/error-boundary" +import { classNames } from "@/lib/utilities" +import type { HighlightWithEntryContext } from "@/lib/types/highlight" + +function groupHighlightsByEntry( + highlights: HighlightWithEntryContext[] +): Map<string, HighlightWithEntryContext[]> { + const grouped = new Map<string, HighlightWithEntryContext[]>() + + for (const highlight of highlights) { + const existing = grouped.get(highlight.entryIdentifier) + if (existing) { + existing.push(highlight) + } else { + grouped.set(highlight.entryIdentifier, [highlight]) + } + } + + return grouped +} + +function HighlightItem({ + highlight, + entryIdentifier, +}: { + highlight: HighlightWithEntryContext + entryIdentifier: string +}) { + const [showRemoveConfirm, setShowRemoveConfirm] = useState(false) + const [isEditingNote, setIsEditingNote] = useState(false) + const [editedNote, setEditedNote] = useState(highlight.note ?? "") + const deleteHighlight = useDeleteHighlight() + const updateNote = useUpdateHighlightNote() + + function handleSaveNote() { + const trimmedNote = editedNote.trim() + updateNote.mutate({ + highlightIdentifier: highlight.identifier, + note: trimmedNote || null, + entryIdentifier, + }) + setIsEditingNote(false) + } + + return ( + <div className="border-l-2 border-text-dim pl-3"> + <p className="text-text-secondary"> + {highlight.highlightedText} + </p> + {isEditingNote ? ( + <div className="mt-1 flex items-center gap-2"> + <input + type="text" + value={editedNote} + onChange={(event) => setEditedNote(event.target.value)} + placeholder="add a note..." + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveNote() + if (event.key === "Escape") setIsEditingNote(false) + }} + autoFocus + /> + <button + onClick={handleSaveNote} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : highlight.note ? ( + <p className="mt-1 text-text-dim"> + {highlight.note} + </p> + ) : null} + <div className="mt-1 flex items-center gap-2 text-text-dim"> + <span> + {formatDistanceToNow( + new Date(highlight.createdAt), + { addSuffix: true } + )} + </span> + <button + type="button" + onClick={() => { + setEditedNote(highlight.note ?? "") + setIsEditingNote(true) + }} + className="text-text-secondary transition-colors hover:text-text-primary" + > + {highlight.note ? "edit note" : "add note"} + </button> + {showRemoveConfirm ? ( + <div className="flex items-center gap-1"> + <span>remove?</span> + <button + type="button" + onClick={() => { + deleteHighlight.mutate({ + highlightIdentifier: highlight.identifier, + entryIdentifier, + }) + setShowRemoveConfirm(false) + }} + className="text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + type="button" + onClick={() => setShowRemoveConfirm(false)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + type="button" + onClick={() => setShowRemoveConfirm(true)} + className="text-text-secondary transition-colors hover:text-status-error" + > + remove + </button> + )} + </div> + </div> + ) +} + +function HighlightsList({ + groupedByEntry, + entryIdentifiers, + selectedEntryIdentifier, + focusedEntryIdentifier, + viewMode, + onSelect, + lastElementReference, + hasNextPage, + isFetchingNextPage, +}: { + groupedByEntry: Map<string, HighlightWithEntryContext[]> + entryIdentifiers: string[] + selectedEntryIdentifier: string | null + focusedEntryIdentifier: string | null + viewMode: "compact" | "comfortable" | "expanded" + onSelect: (entryIdentifier: string) => void + lastElementReference: (node: HTMLDivElement | null) => (() => void) | undefined + hasNextPage: boolean + isFetchingNextPage: boolean +}) { + const listReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (!focusedEntryIdentifier) return + const container = listReference.current + if (!container) return + const items = container.querySelectorAll("[data-highlight-group-item]") + const focusedIndex = entryIdentifiers.indexOf(focusedEntryIdentifier) + items[focusedIndex]?.scrollIntoView({ block: "nearest" }) + }, [focusedEntryIdentifier, entryIdentifiers]) + + if (groupedByEntry.size === 0) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + <div className="text-center"> + <p>no highlights yet</p> + <p className="mt-1 text-xs"> + select text in an entry and click “highlight” to get started + </p> + </div> + </div> + ) + } + + return ( + <div ref={listReference} className="h-full overflow-auto"> + {entryIdentifiers.map((entryIdentifier) => { + const highlights = groupedByEntry.get(entryIdentifier)! + const firstHighlight = highlights[0]! + const isSelected = entryIdentifier === selectedEntryIdentifier + const isFocused = entryIdentifier === focusedEntryIdentifier + + const rowClassName = classNames( + "cursor-pointer border-b border-border px-4 transition-colors last:border-b-0", + isSelected + ? "bg-background-tertiary" + : isFocused + ? "bg-background-secondary" + : "hover:bg-background-secondary", + isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "" + ) + + if (viewMode === "compact") { + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={rowClassName} + > + <div className="flex items-center gap-2 py-2.5"> + <span className="min-w-0 flex-1 truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + <span className="shrink-0 text-text-dim"> + {highlights.length} highlight{highlights.length !== 1 && "s"} + </span> + {firstHighlight.feedTitle && ( + <span className="shrink-0 text-text-dim"> + {firstHighlight.feedTitle} + </span> + )} + </div> + </div> + ) + } + + if (viewMode === "comfortable") { + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={rowClassName} + > + <div className="py-2.5"> + <span className="block truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + {firstHighlight.feedTitle && ( + <span>{firstHighlight.feedTitle}</span> + )} + <span> + {highlights.length} highlight{highlights.length !== 1 && "s"} + </span> + <span>·</span> + <span className="truncate"> + {firstHighlight.highlightedText} + </span> + </div> + </div> + </div> + ) + } + + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={classNames(rowClassName, "py-3")} + > + <div className="mb-2 flex items-center gap-2"> + <span className="truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + {firstHighlight.feedTitle && ( + <span className="shrink-0 text-text-dim"> + {firstHighlight.feedTitle} + </span> + )} + </div> + <div className="space-y-2"> + {highlights.map((highlight) => ( + <HighlightItem + key={highlight.identifier} + highlight={highlight} + entryIdentifier={entryIdentifier} + /> + ))} + </div> + </div> + ) + })} + {hasNextPage && ( + <div ref={lastElementReference} className="py-4 text-center"> + {isFetchingNextPage ? ( + <span className="text-text-dim">loading more ...</span> + ) : ( + <span className="text-text-dim"> </span> + )} + </div> + )} + </div> + ) +} + +export function HighlightsContent() { + const { + data, + isLoading, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useAllHighlights() + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setNavigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.setNavigableEntryIdentifiers + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const isMobile = useIsMobile() + + const lastElementReference = useCallback( + (node: HTMLDivElement | null) => { + if (!node || !hasNextPage || isFetchingNextPage) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + fetchNextPage() + } + }, + { threshold: 0.1 } + ) + + observer.observe(node) + return () => observer.disconnect() + }, + [hasNextPage, isFetchingNextPage, fetchNextPage] + ) + + const allHighlights = data?.pages.flat() ?? [] + const groupedByEntry = groupHighlightsByEntry(allHighlights) + const entryIdentifiers = Array.from(groupedByEntry.keys()) + + useEffect(() => { + setSelectedEntryIdentifier(null) + setNavigableEntryIdentifiers([]) + }, []) + + useEffect(() => { + setNavigableEntryIdentifiers(entryIdentifiers) + }, [entryIdentifiers.length, setNavigableEntryIdentifiers]) + + if (isLoading) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + loading ... + </div> + ) + } + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center justify-between border-b border-border px-4 py-3"> + <div className="flex items-center gap-3"> + {isMobile && selectedEntryIdentifier && ( + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + ← back + </button> + )} + <h1 className="text-text-primary">highlights</h1> + </div> + <span className="text-text-dim">{allHighlights.length} highlight{allHighlights.length !== 1 && "s"}</span> + </header> + <ErrorBoundary> + {isMobile ? ( + selectedEntryIdentifier ? ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + ) : ( + <div className="flex-1 overflow-hidden"> + <HighlightsList + groupedByEntry={groupedByEntry} + entryIdentifiers={entryIdentifiers} + selectedEntryIdentifier={null} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + lastElementReference={lastElementReference} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + /> + </div> + ) + ) : ( + <Group orientation="horizontal" className="flex-1"> + <Panel defaultSize={selectedEntryIdentifier ? 40 : 100} minSize={25}> + <div data-panel-zone="entryList" className={classNames( + "h-full", + focusedPanel === "entryList" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <HighlightsList + groupedByEntry={groupedByEntry} + entryIdentifiers={entryIdentifiers} + selectedEntryIdentifier={selectedEntryIdentifier} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + lastElementReference={lastElementReference} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + /> + </div> + </Panel> + {selectedEntryIdentifier && ( + <> + <Separator className="w-px bg-border transition-colors hover:bg-text-dim" /> + <Panel defaultSize={60} minSize={30}> + <div data-panel-zone="detailPanel" className={classNames( + "h-full", + focusedPanel === "detailPanel" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + </Panel> + </> + )} + </Group> + )} + </ErrorBoundary> + </div> + ) +} diff --git a/apps/web/app/reader/highlights/page.tsx b/apps/web/app/reader/highlights/page.tsx new file mode 100644 index 0000000..c73c032 --- /dev/null +++ b/apps/web/app/reader/highlights/page.tsx @@ -0,0 +1,16 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { redirect } from "next/navigation" +import { HighlightsContent } from "./_components/highlights-content" + +export default async function HighlightsPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + redirect("/") + } + + return <HighlightsContent /> +} diff --git a/apps/web/app/reader/layout.tsx b/apps/web/app/reader/layout.tsx new file mode 100644 index 0000000..8efedbe --- /dev/null +++ b/apps/web/app/reader/layout.tsx @@ -0,0 +1,14 @@ +import { ReaderLayoutShell } from "./_components/reader-layout-shell" +import { SidebarFooter } from "./_components/sidebar-footer" + +export default function ReaderLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <ReaderLayoutShell sidebarFooter={<SidebarFooter />}> + {children} + </ReaderLayoutShell> + ) +} diff --git a/apps/web/app/reader/page.tsx b/apps/web/app/reader/page.tsx new file mode 100644 index 0000000..4773fd8 --- /dev/null +++ b/apps/web/app/reader/page.tsx @@ -0,0 +1,25 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { ReaderShell } from "./_components/reader-shell" + +export default async function ReaderPage({ + searchParams, +}: { + searchParams: Promise<{ folder?: string; feed?: string; custom_feed?: string }> +}) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + const resolvedSearchParams = await searchParams + + return ( + <ReaderShell + userEmailAddress={user?.email ?? null} + feedFilter="all" + folderIdentifier={resolvedSearchParams.folder} + feedIdentifier={resolvedSearchParams.feed} + customFeedIdentifier={resolvedSearchParams.custom_feed} + /> + ) +} diff --git a/apps/web/app/reader/saved/page.tsx b/apps/web/app/reader/saved/page.tsx new file mode 100644 index 0000000..0ad5ba3 --- /dev/null +++ b/apps/web/app/reader/saved/page.tsx @@ -0,0 +1,16 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { ReaderShell } from "../_components/reader-shell" + +export default async function SavedPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + return ( + <ReaderShell + userEmailAddress={user?.email ?? null} + feedFilter="saved" + /> + ) +} diff --git a/apps/web/app/reader/settings/_components/account-settings.tsx b/apps/web/app/reader/settings/_components/account-settings.tsx new file mode 100644 index 0000000..b9ed8c3 --- /dev/null +++ b/apps/web/app/reader/settings/_components/account-settings.tsx @@ -0,0 +1,368 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { TIER_LIMITS } from "@asa-news/shared" +import { notify } from "@/lib/notify" + +export function AccountSettings() { + const { data: userProfile, isLoading } = useUserProfile() + const [isEditingName, setIsEditingName] = useState(false) + const [editedName, setEditedName] = useState("") + const [isRequestingData, setIsRequestingData] = useState(false) + const [newEmailAddress, setNewEmailAddress] = useState("") + const [emailPassword, setEmailPassword] = useState("") + const [currentPassword, setCurrentPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmNewPassword, setConfirmNewPassword] = useState("") + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + const router = useRouter() + + const updateDisplayName = useMutation({ + mutationFn: async (displayName: string | null) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_profiles") + .update({ display_name: displayName }) + .eq("id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("display name updated") + }, + onError: (error: Error) => { + notify("failed to update display name: " + error.message) + }, + }) + + const updateEmailAddress = useMutation({ + mutationFn: async ({ + emailAddress, + password, + }: { + emailAddress: string + password: string + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user?.email) throw new Error("Not authenticated") + + const { error: signInError } = await supabaseClient.auth.signInWithPassword({ + email: user.email, + password, + }) + + if (signInError) throw new Error("incorrect password") + + const { error } = await supabaseClient.auth.updateUser({ + email: emailAddress, + }) + + if (error) throw error + }, + onSuccess: () => { + setNewEmailAddress("") + setEmailPassword("") + notify("confirmation email sent to your new address") + }, + onError: (error: Error) => { + notify("failed to update email: " + error.message) + }, + }) + + const updatePassword = useMutation({ + mutationFn: async ({ + currentPassword: current, + newPassword: updated, + }: { + currentPassword: string + newPassword: string + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user?.email) throw new Error("Not authenticated") + + const { error: signInError } = await supabaseClient.auth.signInWithPassword({ + email: user.email, + password: current, + }) + + if (signInError) throw new Error("current password is incorrect") + + const { error } = await supabaseClient.auth.updateUser({ + password: updated, + }) + + if (error) throw error + }, + onSuccess: async () => { + setCurrentPassword("") + setNewPassword("") + setConfirmNewPassword("") + notify("password updated — signing out all sessions") + await supabaseClient.auth.signOut({ scope: "global" }) + router.push("/sign-in") + }, + onError: (error: Error) => { + notify("failed to update password: " + error.message) + }, + }) + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading account ...</p> + } + + if (!userProfile) { + return <p className="px-4 py-6 text-text-dim">failed to load account</p> + } + + const tier = userProfile.tier + const tierLimits = TIER_LIMITS[tier] + + async function handleRequestData() { + setIsRequestingData(true) + try { + const response = await fetch("/api/account/data") + if (!response.ok) throw new Error("Export failed") + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json` + anchor.click() + URL.revokeObjectURL(url) + notify("data exported") + } catch { + notify("failed to export data") + } finally { + setIsRequestingData(false) + } + } + + function handleSaveName() { + const trimmedName = editedName.trim() + updateDisplayName.mutate(trimmedName || null) + setIsEditingName(false) + } + + function handleUpdateEmail(event: React.FormEvent) { + event.preventDefault() + const trimmedEmail = newEmailAddress.trim() + if (!trimmedEmail || !emailPassword) return + updateEmailAddress.mutate({ emailAddress: trimmedEmail, password: emailPassword }) + } + + function handleUpdatePassword(event: React.FormEvent) { + event.preventDefault() + if (!currentPassword) { + notify("current password is required") + return + } + if (!newPassword || newPassword !== confirmNewPassword) { + notify("passwords do not match") + return + } + if (newPassword.length < 8) { + notify("password must be at least 8 characters") + return + } + updatePassword.mutate({ currentPassword, newPassword }) + } + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">display name</h3> + {isEditingName ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + placeholder="display name" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveName() + if (event.key === "Escape") setIsEditingName(false) + }} + autoFocus + /> + <button + onClick={handleSaveName} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingName(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <span className="text-text-secondary"> + {userProfile.displayName ?? "not set"} + </span> + <button + onClick={() => { + setEditedName(userProfile.displayName ?? "") + setIsEditingName(true) + }} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + edit + </button> + </div> + )} + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">email address</h3> + <p className="mb-2 text-text-dim"> + {userProfile.email ?? "no email on file"} + </p> + <form onSubmit={handleUpdateEmail} className="space-y-2"> + <input + type="email" + value={newEmailAddress} + onChange={(event) => setNewEmailAddress(event.target.value)} + placeholder="new email address" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={emailPassword} + onChange={(event) => setEmailPassword(event.target.value)} + placeholder="current password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={updateEmailAddress.isPending || !newEmailAddress.trim() || !emailPassword} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + update email + </button> + </form> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">change password</h3> + <form onSubmit={handleUpdatePassword} className="space-y-2"> + <input + type="password" + value={currentPassword} + onChange={(event) => setCurrentPassword(event.target.value)} + placeholder="current password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={newPassword} + onChange={(event) => setNewPassword(event.target.value)} + placeholder="new password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={confirmNewPassword} + onChange={(event) => setConfirmNewPassword(event.target.value)} + placeholder="confirm new password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={updatePassword.isPending || !currentPassword || !newPassword || newPassword !== confirmNewPassword} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + change password + </button> + </form> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">usage</h3> + <div className="space-y-1"> + <UsageRow + label="feeds" + current={userProfile.feedCount} + maximum={tierLimits.maximumFeeds} + /> + <UsageRow + label="folders" + current={userProfile.folderCount} + maximum={tierLimits.maximumFolders} + /> + <UsageRow + label="muted keywords" + current={userProfile.mutedKeywordCount} + maximum={tierLimits.maximumMutedKeywords} + /> + <UsageRow + label="custom feeds" + current={userProfile.customFeedCount} + maximum={tierLimits.maximumCustomFeeds} + /> + </div> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">your data</h3> + <p className="mb-3 text-text-dim"> + download all your data (profile, subscriptions, folders, highlights, saved entries) + </p> + <button + onClick={handleRequestData} + disabled={isRequestingData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isRequestingData ? "exporting ..." : "request all data"} + </button> + </div> + </div> + ) +} + +function UsageRow({ + label, + current, + maximum, +}: { + label: string + current: number + maximum: number +}) { + const isNearLimit = current >= maximum * 0.8 + const isAtLimit = current >= maximum + + return ( + <div className="flex items-center justify-between py-1"> + <span className="text-text-secondary">{label}</span> + <span + className={ + isAtLimit + ? "text-status-error" + : isNearLimit + ? "text-text-primary" + : "text-text-dim" + } + > + {current} / {maximum} + </span> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/api-settings.tsx b/apps/web/app/reader/settings/_components/api-settings.tsx new file mode 100644 index 0000000..cb69958 --- /dev/null +++ b/apps/web/app/reader/settings/_components/api-settings.tsx @@ -0,0 +1,529 @@ +"use client" + +import { useState, useEffect } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { notify } from "@/lib/notify" + +interface ApiKey { + keyIdentifier: string + keyPrefix: string + label: string | null + createdAt: string + lastUsedAt: string | null +} + +interface WebhookConfiguration { + webhookUrl: string | null + webhookSecret: string | null + webhookEnabled: boolean + consecutiveFailures: number +} + +function useApiKeys() { + return useQuery({ + queryKey: ["apiKeys"], + queryFn: async () => { + const response = await fetch("/api/v1/keys") + if (!response.ok) throw new Error("Failed to load API keys") + const data = await response.json() + return data.keys as ApiKey[] + }, + }) +} + +function useCreateApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (label: string | null) => { + const response = await fetch("/api/v1/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create API key") + } + return response.json() as Promise<{ + fullKey: string + keyPrefix: string + keyIdentifier: string + }> + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useRevokeApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (keyIdentifier: string) => { + const response = await fetch(`/api/v1/keys/${keyIdentifier}`, { + method: "DELETE", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to revoke API key") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useWebhookConfig() { + return useQuery({ + queryKey: ["webhookConfig"], + queryFn: async () => { + const response = await fetch("/api/webhook-config") + if (!response.ok) throw new Error("Failed to load webhook config") + return response.json() as Promise<WebhookConfiguration> + }, + }) +} + +function useUpdateWebhookConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ( + updates: Partial<{ + webhookUrl: string + webhookSecret: string + webhookEnabled: boolean + }> + ) => { + const response = await fetch("/api/webhook-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to update webhook config") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["webhookConfig"] }) + }, + }) +} + +function useTestWebhook() { + return useMutation({ + mutationFn: async () => { + const response = await fetch("/api/webhook-config/test", { + method: "POST", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to send test webhook") + } + return response.json() as Promise<{ + delivered: boolean + statusCode?: number + error?: string + }> + }, + }) +} + +function ApiKeysSection() { + const { data: apiKeys, isLoading } = useApiKeys() + const createApiKey = useCreateApiKey() + const revokeApiKey = useRevokeApiKey() + const [newKeyLabel, setNewKeyLabel] = useState("") + const [revealedKey, setRevealedKey] = useState<string | null>(null) + const [confirmRevokeIdentifier, setConfirmRevokeIdentifier] = useState< + string | null + >(null) + + function handleCreateKey() { + createApiKey.mutate(newKeyLabel.trim() || null, { + onSuccess: (data) => { + setRevealedKey(data.fullKey) + setNewKeyLabel("") + notify("API key created") + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleCopyKey() { + if (revealedKey) { + navigator.clipboard.writeText(revealedKey) + notify("API key copied to clipboard") + } + } + + function handleRevokeKey(keyIdentifier: string) { + revokeApiKey.mutate(keyIdentifier, { + onSuccess: () => { + notify("API key revoked") + setConfirmRevokeIdentifier(null) + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">API keys</h3> + <p className="mb-3 text-text-dim"> + use API keys to authenticate requests to the REST API + </p> + + {revealedKey && ( + <div className="mb-4 border border-status-warning p-3"> + <p className="mb-2 text-text-secondary"> + copy this key now — it will not be shown again + </p> + <div className="flex items-center gap-2"> + <code className="min-w-0 flex-1 overflow-x-auto bg-background-tertiary px-2 py-1 text-text-primary"> + {revealedKey} + </code> + <button + onClick={handleCopyKey} + className="shrink-0 border border-border px-3 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + copy + </button> + </div> + <button + onClick={() => setRevealedKey(null)} + className="mt-2 text-text-dim transition-colors hover:text-text-secondary" + > + dismiss + </button> + </div> + )} + + {isLoading ? ( + <p className="text-text-dim">loading keys ...</p> + ) : ( + <> + {apiKeys && apiKeys.length > 0 && ( + <div className="mb-4 border border-border"> + {apiKeys.map((apiKey) => ( + <div + key={apiKey.keyIdentifier} + className="flex items-center justify-between border-b border-border px-3 py-2 last:border-b-0" + > + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <code className="text-text-primary"> + {apiKey.keyPrefix}... + </code> + {apiKey.label && ( + <span className="text-text-dim">{apiKey.label}</span> + )} + </div> + <div className="text-text-dim"> + created{" "} + {new Date(apiKey.createdAt).toLocaleDateString()} + {apiKey.lastUsedAt && + ` · last used ${new Date(apiKey.lastUsedAt).toLocaleDateString()}`} + </div> + </div> + {confirmRevokeIdentifier === apiKey.keyIdentifier ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">revoke?</span> + <button + onClick={() => + handleRevokeKey(apiKey.keyIdentifier) + } + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setConfirmRevokeIdentifier(null)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => + setConfirmRevokeIdentifier(apiKey.keyIdentifier) + } + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + revoke + </button> + )} + </div> + ))} + </div> + )} + + {(!apiKeys || apiKeys.length < 5) && ( + <div className="flex items-center gap-2"> + <input + type="text" + value={newKeyLabel} + onChange={(event) => setNewKeyLabel(event.target.value)} + placeholder="key label (optional)" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleCreateKey() + }} + /> + <button + onClick={handleCreateKey} + disabled={createApiKey.isPending} + className="shrink-0 border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {createApiKey.isPending ? "creating ..." : "create key"} + </button> + </div> + )} + </> + )} + </div> + ) +} + +function WebhookSection() { + const { data: webhookConfig, isLoading } = useWebhookConfig() + const updateWebhookConfig = useUpdateWebhookConfig() + const testWebhook = useTestWebhook() + const [webhookUrl, setWebhookUrl] = useState("") + const [webhookSecret, setWebhookSecret] = useState("") + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + + useEffect(() => { + if (webhookConfig) { + setWebhookUrl(webhookConfig.webhookUrl ?? "") + setWebhookSecret(webhookConfig.webhookSecret ?? "") + } + }, [webhookConfig]) + + function handleSaveWebhookConfig() { + updateWebhookConfig.mutate( + { + webhookUrl: webhookUrl.trim(), + webhookSecret: webhookSecret.trim(), + }, + { + onSuccess: () => { + notify("webhook configuration saved") + setHasUnsavedChanges(false) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleToggleEnabled() { + if (!webhookConfig) return + + updateWebhookConfig.mutate( + { webhookEnabled: !webhookConfig.webhookEnabled }, + { + onSuccess: () => { + notify( + webhookConfig.webhookEnabled + ? "webhooks disabled" + : "webhooks enabled" + ) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleTestWebhook() { + testWebhook.mutate(undefined, { + onSuccess: (data) => { + if (data.delivered) { + notify(`test webhook delivered (status ${data.statusCode})`) + } else { + notify(`test webhook failed: ${data.error}`) + } + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleGenerateSecret() { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + const generatedSecret = Array.from(array) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + setWebhookSecret(generatedSecret) + setHasUnsavedChanges(true) + } + + if (isLoading) { + return <p className="text-text-dim">loading webhook configuration ...</p> + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">webhooks</h3> + <p className="mb-3 text-text-dim"> + receive HTTP POST notifications when new entries arrive in your + subscribed feeds + </p> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary">webhook URL</label> + <input + type="url" + value={webhookUrl} + onChange={(event) => { + setWebhookUrl(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="https://example.com/webhook" + className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary"> + signing secret + </label> + <div className="flex items-center gap-2"> + <input + type="text" + value={webhookSecret} + onChange={(event) => { + setWebhookSecret(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="optional HMAC-SHA256 signing secret" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + onClick={handleGenerateSecret} + className="shrink-0 border border-border px-3 py-1.5 text-text-secondary transition-colors hover:text-text-primary" + > + generate + </button> + </div> + </div> + + <div className="mb-4 flex items-center gap-4"> + <button + onClick={handleSaveWebhookConfig} + disabled={!hasUnsavedChanges || updateWebhookConfig.isPending} + className="border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {updateWebhookConfig.isPending ? "saving ..." : "save"} + </button> + <button + onClick={handleToggleEnabled} + disabled={updateWebhookConfig.isPending} + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {webhookConfig?.webhookEnabled ? "disable" : "enable"} + </button> + <button + onClick={handleTestWebhook} + disabled={ + testWebhook.isPending || + !webhookConfig?.webhookEnabled || + !webhookConfig?.webhookUrl + } + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {testWebhook.isPending ? "sending ..." : "send test"} + </button> + </div> + + {webhookConfig && ( + <div className="flex items-center gap-3 text-text-dim"> + <span> + status:{" "} + <span + className={ + webhookConfig.webhookEnabled + ? "text-text-primary" + : "text-text-dim" + } + > + {webhookConfig.webhookEnabled ? "enabled" : "disabled"} + </span> + </span> + {webhookConfig.consecutiveFailures > 0 && ( + <span className="text-status-warning"> + {webhookConfig.consecutiveFailures} consecutive failure + {webhookConfig.consecutiveFailures !== 1 && "s"} + </span> + )} + </div> + )} + </div> + ) +} + +export function ApiSettings() { + const { data: userProfile, isLoading } = useUserProfile() + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading API settings ...</p> + } + + if (!userProfile) { + return ( + <p className="px-4 py-6 text-text-dim">failed to load API settings</p> + ) + } + + if (userProfile.tier !== "developer") { + return ( + <div className="px-4 py-3"> + <h3 className="mb-2 text-text-primary">developer API</h3> + <p className="mb-3 text-text-dim"> + the developer plan includes a read-only REST API and webhook push + notifications. upgrade to developer to access these features. + </p> + </div> + ) + } + + return ( + <div className="px-4 py-3"> + <ApiKeysSection /> + <WebhookSection /> + + <div> + <h3 className="mb-2 text-text-primary">API documentation</h3> + <p className="mb-3 text-text-dim"> + authenticate requests with an API key in the Authorization header: + </p> + <code className="block bg-background-tertiary px-3 py-2 text-text-secondary"> + Authorization: Bearer asn_your_key_here + </code> + <div className="mt-3 space-y-1 text-text-dim"> + <p>GET /api/v1/profile — your account info and limits</p> + <p>GET /api/v1/feeds — your subscribed feeds</p> + <p>GET /api/v1/folders — your folders</p> + <p> + GET /api/v1/entries — entries with ?cursor, ?limit, ?feedIdentifier, + ?readStatus, ?savedStatus filters + </p> + <p>GET /api/v1/entries/:id — single entry with full content</p> + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/appearance-settings.tsx b/apps/web/app/reader/settings/_components/appearance-settings.tsx new file mode 100644 index 0000000..9c0e214 --- /dev/null +++ b/apps/web/app/reader/settings/_components/appearance-settings.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useTheme } from "next-themes" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +export function AppearanceSettings() { + const { theme, setTheme } = useTheme() + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const displayDensity = useUserInterfaceStore( + (state) => state.displayDensity + ) + const setDisplayDensity = useUserInterfaceStore( + (state) => state.setDisplayDensity + ) + const showFeedFavicons = useUserInterfaceStore( + (state) => state.showFeedFavicons + ) + const setShowFeedFavicons = useUserInterfaceStore( + (state) => state.setShowFeedFavicons + ) + const focusFollowsInteraction = useUserInterfaceStore( + (state) => state.focusFollowsInteraction + ) + const setFocusFollowsInteraction = useUserInterfaceStore( + (state) => state.setFocusFollowsInteraction + ) + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">theme</h3> + <p className="mb-3 text-text-dim"> + controls the colour scheme of the application + </p> + <select + value={theme ?? "system"} + onChange={(event) => setTheme(event.target.value)} + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="system">system</option> + <option value="light">light</option> + <option value="dark">dark</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">display density</h3> + <p className="mb-3 text-text-dim"> + controls the overall text size and spacing + </p> + <select + value={displayDensity} + onChange={(event) => + setDisplayDensity( + event.target.value as "compact" | "default" | "spacious" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="compact">compact</option> + <option value="default">default</option> + <option value="spacious">spacious</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">entry list view</h3> + <p className="mb-3 text-text-dim"> + controls how entries are displayed in the list + </p> + <select + value={entryListViewMode} + onChange={(event) => + setEntryListViewMode( + event.target.value as "compact" | "comfortable" | "expanded" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="compact">compact</option> + <option value="comfortable">comfortable</option> + <option value="expanded">expanded</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">feed favicons</h3> + <p className="mb-3 text-text-dim"> + show website icons next to feed names in the sidebar + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={showFeedFavicons} + onChange={(event) => setShowFeedFavicons(event.target.checked)} + className="accent-text-primary" + /> + <span>show favicons</span> + </label> + </div> + <div> + <h3 className="mb-2 text-text-primary">focus follows interaction</h3> + <p className="mb-3 text-text-dim"> + automatically move keyboard panel focus to the last pane you + interacted with (clicked or scrolled) + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={focusFollowsInteraction} + onChange={(event) => + setFocusFollowsInteraction(event.target.checked) + } + className="accent-text-primary" + /> + <span>enable focus follows interaction</span> + </label> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/billing-settings.tsx b/apps/web/app/reader/settings/_components/billing-settings.tsx new file mode 100644 index 0000000..e49720a --- /dev/null +++ b/apps/web/app/reader/settings/_components/billing-settings.tsx @@ -0,0 +1,301 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useSearchParams } from "next/navigation" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { TIER_LIMITS } from "@asa-news/shared" +import { classNames } from "@/lib/utilities" +import { notify } from "@/lib/notify" + +function useCreateCheckoutSession() { + return useMutation({ + mutationFn: async ({ + billingInterval, + targetTier, + }: { + billingInterval: "monthly" | "yearly" + targetTier: "pro" | "developer" + }) => { + const response = await fetch("/api/billing/create-checkout-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ billingInterval, targetTier }), + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create checkout session") + } + + const data = await response.json() + return data as { url?: string; upgraded?: boolean } + }, + }) +} + +function useCreatePortalSession() { + return useMutation({ + mutationFn: async () => { + const response = await fetch("/api/billing/create-portal-session", { + method: "POST", + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create portal session") + } + + const data = await response.json() + return data as { url: string } + }, + }) +} + +const PRO_FEATURES = [ + `${TIER_LIMITS.pro.maximumFeeds} feeds`, + `${Number.isFinite(TIER_LIMITS.pro.historyRetentionDays) ? TIER_LIMITS.pro.historyRetentionDays.toLocaleString() + " days" : "unlimited"} history retention`, + `${TIER_LIMITS.pro.refreshIntervalSeconds / 60}-minute refresh interval`, + "authenticated feeds", + "OPML export", + "manual feed refresh", +] + +const DEVELOPER_FEATURES = [ + `${TIER_LIMITS.developer.maximumFeeds} feeds`, + "everything in pro", + "read-only REST API", + "webhook push notifications", +] + +function UpgradeCard({ + targetTier, + features, + monthlyPrice, + yearlyPrice, +}: { + targetTier: "pro" | "developer" + features: string[] + monthlyPrice: string + yearlyPrice: string +}) { + const [billingInterval, setBillingInterval] = useState< + "monthly" | "yearly" + >("yearly") + const queryClient = useQueryClient() + const createCheckoutSession = useCreateCheckoutSession() + + function handleUpgrade() { + createCheckoutSession.mutate( + { billingInterval, targetTier }, + { + onSuccess: (data) => { + if (data.upgraded) { + queryClient.invalidateQueries({ + queryKey: queryKeys.userProfile.all, + }) + notify(`upgraded to ${targetTier}!`) + } else if (data.url) { + window.location.href = data.url + } + }, + onError: (error: Error) => { + notify("failed to start checkout: " + error.message) + }, + } + ) + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">upgrade to {targetTier}</h3> + <ul className="mb-4 space-y-1"> + {features.map((feature) => ( + <li key={feature} className="text-text-secondary"> + + {feature} + </li> + ))} + </ul> + <div className="mb-4 flex items-center gap-2"> + <button + type="button" + onClick={() => setBillingInterval("monthly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "monthly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + {monthlyPrice} / month + </button> + <button + type="button" + onClick={() => setBillingInterval("yearly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "yearly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + {yearlyPrice} / year + </button> + </div> + <button + onClick={handleUpgrade} + disabled={createCheckoutSession.isPending} + className="border border-text-primary px-4 py-2 text-text-primary transition-colors hover:bg-text-primary hover:text-background-primary disabled:opacity-50" + > + {createCheckoutSession.isPending + ? "redirecting ..." + : `upgrade to ${targetTier}`} + </button> + </div> + ) +} + +export function BillingSettings() { + const { data: userProfile, isLoading } = useUserProfile() + const queryClient = useQueryClient() + const searchParameters = useSearchParams() + const hasShownSuccessToast = useRef(false) + + const createPortalSession = useCreatePortalSession() + + useEffect(() => { + if ( + searchParameters.get("billing") === "success" && + !hasShownSuccessToast.current + ) { + hasShownSuccessToast.current = true + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("subscription activated!") + const url = new URL(window.location.href) + url.searchParams.delete("billing") + window.history.replaceState({}, "", url.pathname) + } + }, [searchParameters, queryClient]) + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading billing ...</p> + } + + if (!userProfile) { + return <p className="px-4 py-6 text-text-dim">failed to load billing</p> + } + + function handleManageSubscription() { + createPortalSession.mutate(undefined, { + onSuccess: (data) => { + window.location.href = data.url + }, + onError: (error: Error) => { + notify("failed to open billing portal: " + error.message) + }, + }) + } + + const isPaidTier = + userProfile.tier === "pro" || userProfile.tier === "developer" + const isCancelling = + userProfile.stripeSubscriptionStatus === "active" && + isPaidTier && + userProfile.stripeCurrentPeriodEnd !== null + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">current plan</h3> + <span className="border border-border px-2 py-1 text-text-secondary"> + {userProfile.tier} + </span> + {userProfile.stripeSubscriptionStatus && ( + <span className="ml-2 text-text-dim"> + ({userProfile.stripeSubscriptionStatus}) + </span> + )} + </div> + + {userProfile.tier === "free" && ( + <> + <UpgradeCard + targetTier="pro" + features={PRO_FEATURES} + monthlyPrice="$3" + yearlyPrice="$30" + /> + <UpgradeCard + targetTier="developer" + features={DEVELOPER_FEATURES} + monthlyPrice="$6" + yearlyPrice="$60" + /> + </> + )} + + {userProfile.tier === "pro" && ( + <> + <UpgradeCard + targetTier="developer" + features={DEVELOPER_FEATURES} + monthlyPrice="$6" + yearlyPrice="$60" + /> + <div className="mb-6"> + {isCancelling && userProfile.stripeCurrentPeriodEnd && ( + <p className="mb-3 text-text-secondary"> + your pro plan is active until{" "} + {new Date( + userProfile.stripeCurrentPeriodEnd + ).toLocaleDateString()} + </p> + )} + {userProfile.stripeSubscriptionStatus === "past_due" && ( + <p className="mb-3 text-status-error"> + payment failed — please update your payment method + </p> + )} + <button + onClick={handleManageSubscription} + disabled={createPortalSession.isPending} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50" + > + {createPortalSession.isPending + ? "redirecting ..." + : "manage subscription"} + </button> + </div> + </> + )} + + {userProfile.tier === "developer" && ( + <div className="mb-6"> + {isCancelling && userProfile.stripeCurrentPeriodEnd && ( + <p className="mb-3 text-text-secondary"> + your developer plan is active until{" "} + {new Date( + userProfile.stripeCurrentPeriodEnd + ).toLocaleDateString()} + </p> + )} + {userProfile.stripeSubscriptionStatus === "past_due" && ( + <p className="mb-3 text-status-error"> + payment failed — please update your payment method + </p> + )} + <button + onClick={handleManageSubscription} + disabled={createPortalSession.isPending} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50" + > + {createPortalSession.isPending + ? "redirecting ..." + : "manage subscription"} + </button> + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx new file mode 100644 index 0000000..b7b588b --- /dev/null +++ b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx @@ -0,0 +1,283 @@ +"use client" + +import { useState } from "react" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" +import { + useCreateCustomFeed, + useUpdateCustomFeed, + useDeleteCustomFeed, +} from "@/lib/queries/use-custom-feed-mutations" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function CustomFeedsSettings() { + const { data: customFeeds, isLoading } = useCustomFeeds() + const { data: subscriptionsData } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const createCustomFeed = useCreateCustomFeed() + + const [newName, setNewName] = useState("") + const [newKeywords, setNewKeywords] = useState("") + const [newMatchMode, setNewMatchMode] = useState<"and" | "or">("or") + const [newSourceFolderId, setNewSourceFolderId] = useState<string>("") + + const folders = subscriptionsData?.folders ?? [] + const tier = userProfile?.tier ?? "free" + const maximumCustomFeeds = TIER_LIMITS[tier].maximumCustomFeeds + + function handleCreate(event: React.FormEvent) { + event.preventDefault() + const trimmedName = newName.trim() + const trimmedKeywords = newKeywords.trim() + + if (!trimmedName || !trimmedKeywords) return + + createCustomFeed.mutate({ + name: trimmedName, + query: trimmedKeywords, + matchMode: newMatchMode, + sourceFolderIdentifier: newSourceFolderId || null, + }) + + setNewName("") + setNewKeywords("") + setNewMatchMode("or") + setNewSourceFolderId("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading custom feeds ...</p> + } + + const feedsList = customFeeds ?? [] + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-2 text-text-dim"> + {feedsList.length} / {maximumCustomFeeds} custom feeds used + </p> + <form onSubmit={handleCreate} className="space-y-2"> + <input + type="text" + value={newName} + onChange={(event) => setNewName(event.target.value)} + placeholder="feed name" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="text" + value={newKeywords} + onChange={(event) => setNewKeywords(event.target.value)} + placeholder="keywords (space-separated)" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <div className="flex gap-2"> + <select + value={newMatchMode} + onChange={(event) => + setNewMatchMode(event.target.value as "and" | "or") + } + className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none" + > + <option value="or">match any keyword</option> + <option value="and">match all keywords</option> + </select> + <select + value={newSourceFolderId} + onChange={(event) => setNewSourceFolderId(event.target.value)} + className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none" + > + <option value="">all feeds</option> + {folders.map((folder) => ( + <option key={folder.folderIdentifier} value={folder.folderIdentifier}> + {folder.name} + </option> + ))} + </select> + <button + type="submit" + disabled={ + createCustomFeed.isPending || + !newName.trim() || + !newKeywords.trim() + } + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + create + </button> + </div> + </form> + </div> + {feedsList.length === 0 ? ( + <p className="px-4 py-6 text-text-dim">no custom feeds yet</p> + ) : ( + feedsList.map((customFeed) => ( + <CustomFeedRow + key={customFeed.identifier} + customFeed={customFeed} + folders={folders} + /> + )) + )} + </div> + ) +} + +function CustomFeedRow({ + customFeed, + folders, +}: { + customFeed: { + identifier: string + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + } + folders: { folderIdentifier: string; name: string }[] +}) { + const updateCustomFeed = useUpdateCustomFeed() + const deleteCustomFeed = useDeleteCustomFeed() + const [isEditing, setIsEditing] = useState(false) + const [editedName, setEditedName] = useState(customFeed.name) + const [editedKeywords, setEditedKeywords] = useState(customFeed.query) + const [editedMatchMode, setEditedMatchMode] = useState(customFeed.matchMode) + const [editedSourceFolderId, setEditedSourceFolderId] = useState( + customFeed.sourceFolderIdentifier ?? "" + ) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + function handleSave() { + const trimmedName = editedName.trim() + const trimmedKeywords = editedKeywords.trim() + + if (!trimmedName || !trimmedKeywords) return + + updateCustomFeed.mutate({ + customFeedIdentifier: customFeed.identifier, + name: trimmedName, + query: trimmedKeywords, + matchMode: editedMatchMode, + sourceFolderIdentifier: editedSourceFolderId || null, + }) + setIsEditing(false) + } + + const sourceFolderName = customFeed.sourceFolderIdentifier + ? folders.find( + (folder) => + folder.folderIdentifier === customFeed.sourceFolderIdentifier + )?.name ?? "unknown folder" + : "all feeds" + + return ( + <div className="border-b border-border px-4 py-3 last:border-b-0"> + {isEditing ? ( + <div className="space-y-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + autoFocus + /> + <input + type="text" + value={editedKeywords} + onChange={(event) => setEditedKeywords(event.target.value)} + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + /> + <div className="flex gap-2"> + <select + value={editedMatchMode} + onChange={(event) => + setEditedMatchMode(event.target.value as "and" | "or") + } + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="or">match any</option> + <option value="and">match all</option> + </select> + <select + value={editedSourceFolderId} + onChange={(event) => setEditedSourceFolderId(event.target.value)} + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="">all feeds</option> + {folders.map((folder) => ( + <option + key={folder.folderIdentifier} + value={folder.folderIdentifier} + > + {folder.name} + </option> + ))} + </select> + </div> + <div className="flex gap-2"> + <button + onClick={handleSave} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditing(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + ) : ( + <div> + <div className="flex items-center justify-between"> + <span className="text-text-primary">{customFeed.name}</span> + <div className="flex items-center gap-2"> + <button + onClick={() => setIsEditing(true)} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + edit + </button> + {showDeleteConfirm ? ( + <div className="flex items-center gap-1"> + <button + onClick={() => { + deleteCustomFeed.mutate({ + customFeedIdentifier: customFeed.identifier, + }) + setShowDeleteConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowDeleteConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + delete + </button> + )} + </div> + </div> + <p className="text-text-dim"> + keywords: {customFeed.query} ({customFeed.matchMode === "and" ? "all" : "any"}) + </p> + <p className="text-text-dim">source: {sourceFolderName}</p> + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/danger-zone-settings.tsx b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx new file mode 100644 index 0000000..76c48d4 --- /dev/null +++ b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx @@ -0,0 +1,156 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation } from "@tanstack/react-query" +import { useUnsubscribeAll } from "@/lib/queries/use-subscription-mutations" +import { useDeleteAllFolders } from "@/lib/queries/use-folder-mutations" +import { notify } from "@/lib/notify" + +export function DangerZoneSettings() { + const router = useRouter() + const unsubscribeAll = useUnsubscribeAll() + const deleteAllFolders = useDeleteAllFolders() + const [showDeleteSubsConfirm, setShowDeleteSubsConfirm] = useState(false) + const [showDeleteFoldersConfirm, setShowDeleteFoldersConfirm] = useState(false) + const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false) + const [deleteConfirmText, setDeleteConfirmText] = useState("") + + const deleteAccount = useMutation({ + mutationFn: async () => { + const response = await fetch("/api/account", { method: "DELETE" }) + if (!response.ok) throw new Error("Failed to delete account") + }, + onSuccess: () => { + router.push("/sign-in") + }, + onError: (error: Error) => { + notify("failed to delete account: " + error.message) + }, + }) + + return ( + <div className="px-4 py-3"> + <p className="mb-6 text-text-dim"> + these actions are irreversible. proceed with caution. + </p> + + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">remove all subscriptions</h3> + <p className="mb-2 text-text-dim"> + unsubscribe from every feed. entries will remain but no new ones will be fetched. + </p> + {showDeleteSubsConfirm ? ( + <div className="flex items-center gap-2"> + <span className="text-status-error">are you sure?</span> + <button + onClick={() => { + unsubscribeAll.mutate() + setShowDeleteSubsConfirm(false) + }} + disabled={unsubscribeAll.isPending} + className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + yes, remove all + </button> + <button + onClick={() => setShowDeleteSubsConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteSubsConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + remove all subscriptions + </button> + )} + </div> + + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">delete all folders</h3> + <p className="mb-2 text-text-dim"> + remove all folders. feeds will be ungrouped but not unsubscribed. + </p> + {showDeleteFoldersConfirm ? ( + <div className="flex items-center gap-2"> + <span className="text-status-error">are you sure?</span> + <button + onClick={() => { + deleteAllFolders.mutate() + setShowDeleteFoldersConfirm(false) + }} + disabled={deleteAllFolders.isPending} + className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + yes, delete all + </button> + <button + onClick={() => setShowDeleteFoldersConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteFoldersConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + delete all folders + </button> + )} + </div> + + <div> + <h3 className="mb-2 text-text-primary">delete account</h3> + <p className="mb-2 text-text-dim"> + permanently delete your account and all associated data. this cannot be undone. + </p> + {showDeleteAccountConfirm ? ( + <div> + <p className="mb-2 text-status-error"> + type DELETE to confirm account deletion. + </p> + <div className="flex items-center gap-2"> + <input + type="text" + value={deleteConfirmText} + onChange={(event) => setDeleteConfirmText(event.target.value)} + placeholder="type DELETE" + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-status-error" + autoFocus + /> + <button + onClick={() => deleteAccount.mutate()} + disabled={deleteConfirmText !== "DELETE" || deleteAccount.isPending} + className="border border-status-error px-4 py-2 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + {deleteAccount.isPending ? "deleting ..." : "confirm delete"} + </button> + <button + onClick={() => { + setShowDeleteAccountConfirm(false) + setDeleteConfirmText("") + }} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + ) : ( + <button + onClick={() => setShowDeleteAccountConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + delete account and all data + </button> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/folders-settings.tsx b/apps/web/app/reader/settings/_components/folders-settings.tsx new file mode 100644 index 0000000..8a0012e --- /dev/null +++ b/apps/web/app/reader/settings/_components/folders-settings.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { + useCreateFolder, + useRenameFolder, + useDeleteFolder, +} from "@/lib/queries/use-folder-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function FoldersSettings() { + const [newFolderName, setNewFolderName] = useState("") + const [searchQuery, setSearchQuery] = useState("") + const { data: subscriptionsData, isLoading } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const createFolder = useCreateFolder() + const renameFolder = useRenameFolder() + const deleteFolder = useDeleteFolder() + + const folders = subscriptionsData?.folders ?? [] + const subscriptions = subscriptionsData?.subscriptions ?? [] + const tier = userProfile?.tier ?? "free" + const tierLimits = TIER_LIMITS[tier] + + function feedCountForFolder(folderIdentifier: string): number { + return subscriptions.filter( + (subscription) => subscription.folderIdentifier === folderIdentifier + ).length + } + + function handleCreateFolder(event: React.FormEvent) { + event.preventDefault() + const trimmedName = newFolderName.trim() + + if (!trimmedName) return + + createFolder.mutate({ name: trimmedName }) + setNewFolderName("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading folders ...</p> + } + + const normalizedQuery = searchQuery.toLowerCase().trim() + const filteredFolders = normalizedQuery + ? folders.filter((folder) => folder.name.toLowerCase().includes(normalizedQuery)) + : folders + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-2 text-text-dim"> + {folders.length} / {tierLimits.maximumFolders} folders used + </p> + <form onSubmit={handleCreateFolder} className="mb-2 flex gap-2"> + <input + type="text" + value={newFolderName} + onChange={(event) => setNewFolderName(event.target.value)} + placeholder="new folder name" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={createFolder.isPending || !newFolderName.trim()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + create + </button> + </form> + {folders.length > 5 && ( + <input + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="search folders..." + className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + )} + </div> + {filteredFolders.length === 0 ? ( + <p className="px-4 py-6 text-text-dim"> + {folders.length === 0 ? "no folders yet" : "no folders match your search"} + </p> + ) : ( + <div> + {filteredFolders.map((folder) => ( + <FolderRow + key={folder.folderIdentifier} + folderIdentifier={folder.folderIdentifier} + name={folder.name} + feedCount={feedCountForFolder(folder.folderIdentifier)} + onRename={(name) => + renameFolder.mutate({ + folderIdentifier: folder.folderIdentifier, + name, + }) + } + onDelete={() => + deleteFolder.mutate({ + folderIdentifier: folder.folderIdentifier, + }) + } + /> + ))} + </div> + )} + </div> + ) +} + +function FolderRow({ + folderIdentifier, + name, + feedCount, + onRename, + onDelete, +}: { + folderIdentifier: string + name: string + feedCount: number + onRename: (name: string) => void + onDelete: () => void +}) { + const [isEditing, setIsEditing] = useState(false) + const [editedName, setEditedName] = useState(name) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + function handleSave() { + const trimmedName = editedName.trim() + + if (trimmedName && trimmedName !== name) { + onRename(trimmedName) + } + + setIsEditing(false) + } + + return ( + <div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0"> + <div className="min-w-0 flex-1"> + {isEditing ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSave() + if (event.key === "Escape") setIsEditing(false) + }} + autoFocus + /> + <button + onClick={handleSave} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => { + setEditedName(name) + setIsEditing(false) + }} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <span className="text-text-primary">{name}</span> + <span className="text-text-dim"> + ({feedCount} feed{feedCount !== 1 && "s"}) + </span> + <button + onClick={() => setIsEditing(true)} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + rename + </button> + </div> + )} + </div> + {showDeleteConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim"> + {feedCount > 0 ? "has feeds, delete?" : "delete?"} + </span> + <button + onClick={() => { + onDelete() + setShowDeleteConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowDeleteConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + delete + </button> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/import-export-settings.tsx b/apps/web/app/reader/settings/_components/import-export-settings.tsx new file mode 100644 index 0000000..efb3f09 --- /dev/null +++ b/apps/web/app/reader/settings/_components/import-export-settings.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState, useRef } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useSubscribeToFeed } from "@/lib/queries/use-subscribe-to-feed" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { downloadOpml, parseOpml } from "@/lib/opml" +import type { ParsedOpmlGroup } from "@/lib/opml" +import { notify } from "@/lib/notify" + +export function ImportExportSettings() { + const { data: subscriptionsData } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const subscribeToFeed = useSubscribeToFeed() + const [parsedGroups, setParsedGroups] = useState<ParsedOpmlGroup[] | null>( + null + ) + const [isImporting, setIsImporting] = useState(false) + const [isExportingData, setIsExportingData] = useState(false) + const fileInputReference = useRef<HTMLInputElement>(null) + + const tier = userProfile?.tier ?? "free" + + function handleExport() { + if (!subscriptionsData) return + + downloadOpml(subscriptionsData.subscriptions, subscriptionsData.folders) + notify("subscriptions exported") + } + + function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + + if (!file) return + + const reader = new FileReader() + reader.onload = (loadEvent) => { + const xmlString = loadEvent.target?.result as string + + try { + const groups = parseOpml(xmlString) + setParsedGroups(groups) + } catch { + notify("failed to parse OPML file") + } + } + reader.readAsText(file) + + if (fileInputReference.current) { + fileInputReference.current.value = "" + } + } + + async function handleImport() { + if (!parsedGroups) return + + setIsImporting(true) + let importedCount = 0 + let failedCount = 0 + + for (const group of parsedGroups) { + for (const feed of group.feeds) { + try { + await new Promise<void>((resolve, reject) => { + subscribeToFeed.mutate( + { + feedUrl: feed.url, + customTitle: feed.title || null, + }, + { + onSuccess: () => { + importedCount++ + resolve() + }, + onError: (error) => { + failedCount++ + resolve() + }, + } + ) + }) + } catch { + failedCount++ + } + } + } + + setIsImporting(false) + setParsedGroups(null) + + if (failedCount > 0) { + notify(`imported ${importedCount} feeds, ${failedCount} failed`) + } else { + notify(`imported ${importedCount} feeds`) + } + } + + async function handleDataExport() { + setIsExportingData(true) + try { + const response = await fetch("/api/export") + if (!response.ok) throw new Error("Export failed") + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `asa-news-export-${new Date().toISOString().slice(0, 10)}.json` + anchor.click() + URL.revokeObjectURL(url) + notify("data exported") + } catch { + notify("failed to export data") + } finally { + setIsExportingData(false) + } + } + + const totalFeedsInImport = + parsedGroups?.reduce((sum, group) => sum + group.feeds.length, 0) ?? 0 + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">export OPML</h3> + <p className="mb-3 text-text-dim"> + download your subscriptions as an OPML file + </p> + <button + onClick={handleExport} + disabled={!subscriptionsData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + export OPML + </button> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">export data</h3> + <p className="mb-3 text-text-dim"> + {tier === "pro" || tier === "developer" + ? "download all your data as JSON (subscriptions, folders, saved entries)" + : "download your saved entries as JSON (upgrade to pro for full export)"} + </p> + <button + onClick={handleDataExport} + disabled={isExportingData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isExportingData ? "exporting..." : "export data"} + </button> + </div> + <div> + <h3 className="mb-2 text-text-primary">import</h3> + <p className="mb-3 text-text-dim"> + import subscriptions from an OPML file + </p> + {parsedGroups === null ? ( + <div> + <input + ref={fileInputReference} + type="file" + accept=".opml,.xml" + onChange={handleFileSelect} + className="hidden" + /> + <button + onClick={() => fileInputReference.current?.click()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border" + > + select OPML file + </button> + </div> + ) : ( + <div> + <p className="mb-3 text-text-secondary"> + found {totalFeedsInImport} feed + {totalFeedsInImport !== 1 && "s"} to import: + </p> + <div className="mb-3 max-h-60 overflow-y-auto border border-border"> + {parsedGroups.map((group, groupIndex) => ( + <div key={groupIndex}> + {group.folderName && ( + <div className="bg-background-tertiary px-3 py-1 text-text-secondary"> + {group.folderName} + </div> + )} + {group.feeds.map((feed, feedIndex) => ( + <div + key={feedIndex} + className="border-b border-border px-3 py-2 last:border-b-0" + > + <p className="truncate text-text-primary">{feed.title}</p> + <p className="truncate text-text-dim">{feed.url}</p> + </div> + ))} + </div> + ))} + </div> + <div className="flex gap-2"> + <button + onClick={() => setParsedGroups(null)} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + cancel + </button> + <button + onClick={handleImport} + disabled={isImporting} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isImporting + ? "importing..." + : `import ${totalFeedsInImport} feeds`} + </button> + </div> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx new file mode 100644 index 0000000..bef4786 --- /dev/null +++ b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx @@ -0,0 +1,89 @@ +"use client" + +import { useState } from "react" +import { useMutedKeywords } from "@/lib/queries/use-muted-keywords" +import { + useAddMutedKeyword, + useDeleteMutedKeyword, +} from "@/lib/queries/use-muted-keyword-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function MutedKeywordsSettings() { + const [newKeyword, setNewKeyword] = useState("") + const { data: keywords, isLoading } = useMutedKeywords() + const { data: userProfile } = useUserProfile() + const addKeyword = useAddMutedKeyword() + const deleteKeyword = useDeleteMutedKeyword() + + const tier = userProfile?.tier ?? "free" + const tierLimits = TIER_LIMITS[tier] + + function handleAddKeyword(event: React.FormEvent) { + event.preventDefault() + const trimmedKeyword = newKeyword.trim() + + if (!trimmedKeyword) return + + addKeyword.mutate({ keyword: trimmedKeyword }) + setNewKeyword("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading muted keywords...</p> + } + + const keywordList = keywords ?? [] + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-1 text-text-dim"> + {keywordList.length} / {tierLimits.maximumMutedKeywords} keywords used + </p> + <p className="mb-2 text-text-dim"> + entries containing muted keywords are hidden from your timeline + </p> + <form onSubmit={handleAddKeyword} className="flex gap-2"> + <input + type="text" + value={newKeyword} + onChange={(event) => setNewKeyword(event.target.value)} + placeholder="keyword to mute" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={addKeyword.isPending || !newKeyword.trim()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + mute + </button> + </form> + </div> + {keywordList.length === 0 ? ( + <p className="px-4 py-6 text-text-dim">no muted keywords</p> + ) : ( + keywordList.map((keyword) => ( + <div + key={keyword.identifier} + className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0" + > + <span className="text-text-primary">{keyword.keyword}</span> + <button + onClick={() => + deleteKeyword.mutate({ + keywordIdentifier: keyword.identifier, + }) + } + disabled={deleteKeyword.isPending} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error disabled:opacity-50" + > + unmute + </button> + </div> + )) + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/security-settings.tsx b/apps/web/app/reader/settings/_components/security-settings.tsx new file mode 100644 index 0000000..4a00241 --- /dev/null +++ b/apps/web/app/reader/settings/_components/security-settings.tsx @@ -0,0 +1,280 @@ +"use client" + +import { useState, useEffect } from "react" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { notify } from "@/lib/notify" +import type { Factor } from "@supabase/supabase-js" + +type EnrollmentState = + | { step: "idle" } + | { step: "enrolling"; factorIdentifier: string; qrCodeSvg: string; otpauthUri: string } + | { step: "verifying"; factorIdentifier: string; challengeIdentifier: string } + +export function SecuritySettings() { + const [enrolledFactors, setEnrolledFactors] = useState<Factor[]>([]) + const [isLoading, setIsLoading] = useState(true) + const [enrollmentState, setEnrollmentState] = useState<EnrollmentState>({ step: "idle" }) + const [factorName, setFactorName] = useState("") + const [verificationCode, setVerificationCode] = useState("") + const [isProcessing, setIsProcessing] = useState(false) + const [unenrollConfirmIdentifier, setUnenrollConfirmIdentifier] = useState<string | null>(null) + const supabaseClient = createSupabaseBrowserClient() + + async function loadFactors() { + const { data, error } = await supabaseClient.auth.mfa.listFactors() + + if (error) { + notify("failed to load MFA factors") + setIsLoading(false) + return + } + + setEnrolledFactors( + data.totp.filter((factor) => factor.status === "verified") + ) + setIsLoading(false) + } + + useEffect(() => { + loadFactors() + }, []) + + async function handleBeginEnrollment() { + setIsProcessing(true) + + const enrollOptions: { factorType: "totp"; friendlyName?: string } = { + factorType: "totp", + } + if (factorName.trim()) { + enrollOptions.friendlyName = factorName.trim() + } + + const { data, error } = await supabaseClient.auth.mfa.enroll(enrollOptions) + + setIsProcessing(false) + + if (error) { + notify("failed to start MFA enrolment: " + error.message) + return + } + + setEnrollmentState({ + step: "enrolling", + factorIdentifier: data.id, + qrCodeSvg: data.totp.qr_code, + otpauthUri: data.totp.uri, + }) + setVerificationCode("") + } + + async function handleVerifyEnrollment() { + if (enrollmentState.step !== "enrolling") return + if (verificationCode.length !== 6) return + + setIsProcessing(true) + + const { data: challengeData, error: challengeError } = + await supabaseClient.auth.mfa.challenge({ + factorId: enrollmentState.factorIdentifier, + }) + + if (challengeError) { + setIsProcessing(false) + notify("failed to create MFA challenge: " + challengeError.message) + return + } + + const { error: verifyError } = await supabaseClient.auth.mfa.verify({ + factorId: enrollmentState.factorIdentifier, + challengeId: challengeData.id, + code: verificationCode, + }) + + setIsProcessing(false) + + if (verifyError) { + notify("invalid code — please try again") + setVerificationCode("") + return + } + + notify("two-factor authentication enabled") + setEnrollmentState({ step: "idle" }) + setVerificationCode("") + setFactorName("") + await supabaseClient.auth.refreshSession() + await loadFactors() + } + + async function handleCancelEnrollment() { + if (enrollmentState.step === "enrolling") { + await supabaseClient.auth.mfa.unenroll({ + factorId: enrollmentState.factorIdentifier, + }) + } + + setEnrollmentState({ step: "idle" }) + setVerificationCode("") + setFactorName("") + } + + async function handleUnenrollFactor(factorIdentifier: string) { + setIsProcessing(true) + + const { error } = await supabaseClient.auth.mfa.unenroll({ + factorId: factorIdentifier, + }) + + setIsProcessing(false) + + if (error) { + notify("failed to remove factor: " + error.message) + return + } + + notify("two-factor authentication removed") + setUnenrollConfirmIdentifier(null) + await supabaseClient.auth.refreshSession() + await loadFactors() + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading security settings ...</p> + } + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">two-factor authentication</h3> + <p className="mb-4 text-text-dim"> + add an extra layer of security to your account with a time-based one-time password (TOTP) authenticator app + </p> + + {enrollmentState.step === "idle" && enrolledFactors.length === 0 && ( + <div className="flex items-center gap-2"> + <input + type="text" + value={factorName} + onChange={(event) => setFactorName(event.target.value)} + placeholder="authenticator name (optional)" + className="w-64 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + onClick={handleBeginEnrollment} + disabled={isProcessing} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isProcessing ? "setting up ..." : "set up"} + </button> + </div> + )} + + {enrollmentState.step === "enrolling" && ( + <div className="space-y-4"> + <p className="text-text-secondary"> + scan this QR code with your authenticator app, then enter the 6-digit code below + </p> + <div className="inline-block bg-white p-4"> + <img + src={enrollmentState.qrCodeSvg} + alt="TOTP QR code" + className="h-48 w-48" + /> + </div> + <details className="text-text-dim"> + <summary className="cursor-pointer transition-colors hover:text-text-secondary"> + can't scan? copy manual entry key + </summary> + <code className="mt-2 block break-all bg-background-secondary p-2 text-text-secondary"> + {enrollmentState.otpauthUri} + </code> + </details> + <div className="flex items-center gap-2"> + <input + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={6} + value={verificationCode} + onChange={(event) => { + const filtered = event.target.value.replace(/\D/g, "") + setVerificationCode(filtered) + }} + placeholder="000000" + className="w-32 border border-border bg-background-primary px-3 py-2 text-center font-mono text-lg tracking-widest text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + autoFocus + onKeyDown={(event) => { + if (event.key === "Enter") handleVerifyEnrollment() + if (event.key === "Escape") handleCancelEnrollment() + }} + /> + <button + onClick={handleVerifyEnrollment} + disabled={isProcessing || verificationCode.length !== 6} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isProcessing ? "verifying ..." : "verify"} + </button> + <button + onClick={handleCancelEnrollment} + className="px-4 py-2 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + )} + + {enrolledFactors.length > 0 && enrollmentState.step === "idle" && ( + <div className="space-y-3"> + {enrolledFactors.map((factor) => ( + <div + key={factor.id} + className="flex items-center justify-between border border-border px-4 py-3" + > + <div> + <span className="text-text-primary"> + {factor.friendly_name || "TOTP authenticator"} + </span> + <span className="ml-2 text-text-dim"> + added{" "} + {new Date(factor.created_at).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + </span> + </div> + {unenrollConfirmIdentifier === factor.id ? ( + <div className="flex items-center gap-2"> + <span className="text-text-dim">remove?</span> + <button + onClick={() => handleUnenrollFactor(factor.id)} + disabled={isProcessing} + className="text-status-error transition-colors hover:text-text-primary disabled:opacity-50" + > + yes + </button> + <button + onClick={() => setUnenrollConfirmIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setUnenrollConfirmIdentifier(factor.id)} + className="text-text-secondary transition-colors hover:text-status-error" + > + remove + </button> + )} + </div> + ))} + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/settings-shell.tsx b/apps/web/app/reader/settings/_components/settings-shell.tsx new file mode 100644 index 0000000..ae432f3 --- /dev/null +++ b/apps/web/app/reader/settings/_components/settings-shell.tsx @@ -0,0 +1,86 @@ +"use client" + +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { SubscriptionsSettings } from "./subscriptions-settings" +import { FoldersSettings } from "./folders-settings" +import { MutedKeywordsSettings } from "./muted-keywords-settings" +import { CustomFeedsSettings } from "./custom-feeds-settings" +import { ImportExportSettings } from "./import-export-settings" +import { AppearanceSettings } from "./appearance-settings" +import { AccountSettings } from "./account-settings" +import { SecuritySettings } from "./security-settings" +import { BillingSettings } from "./billing-settings" +import { ApiSettings } from "./api-settings" +import { DangerZoneSettings } from "./danger-zone-settings" + +const TABS = [ + { key: "subscriptions", label: "subscriptions" }, + { key: "folders", label: "folders" }, + { key: "muted-keywords", label: "muted keywords" }, + { key: "custom-feeds", label: "custom feeds" }, + { key: "import-export", label: "import / export" }, + { key: "appearance", label: "appearance" }, + { key: "account", label: "account" }, + { key: "security", label: "security" }, + { key: "billing", label: "billing" }, + { key: "api", label: "API" }, + { key: "danger", label: "danger zone" }, +] as const + +export function SettingsShell() { + const activeTab = useUserInterfaceStore((state) => state.activeSettingsTab) + const setActiveTab = useUserInterfaceStore( + (state) => state.setActiveSettingsTab + ) + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center border-b border-border px-4 py-3"> + <h1 className="text-text-primary">settings</h1> + </header> + <nav className="border-b border-border"> + <select + value={activeTab} + onChange={(event) => setActiveTab(event.target.value as typeof activeTab)} + className="w-full border-none bg-background-primary px-4 py-2 text-text-primary outline-none md:hidden" + > + {TABS.map((tab) => ( + <option key={tab.key} value={tab.key}> + {tab.label} + </option> + ))} + </select> + <div className="hidden md:flex"> + {TABS.map((tab) => ( + <button + key={tab.key} + onClick={() => setActiveTab(tab.key)} + className={`shrink-0 px-4 py-2 transition-colors ${ + activeTab === tab.key + ? "border-b-2 border-text-primary text-text-primary" + : "text-text-dim hover:text-text-secondary" + }`} + > + {tab.label} + </button> + ))} + </div> + </nav> + <div className="flex-1 overflow-y-auto"> + <div className="max-w-3xl"> + {activeTab === "subscriptions" && <SubscriptionsSettings />} + {activeTab === "folders" && <FoldersSettings />} + {activeTab === "muted-keywords" && <MutedKeywordsSettings />} + {activeTab === "custom-feeds" && <CustomFeedsSettings />} + {activeTab === "import-export" && <ImportExportSettings />} + {activeTab === "appearance" && <AppearanceSettings />} + {activeTab === "account" && <AccountSettings />} + {activeTab === "security" && <SecuritySettings />} + {activeTab === "billing" && <BillingSettings />} + {activeTab === "api" && <ApiSettings />} + {activeTab === "danger" && <DangerZoneSettings />} + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/subscriptions-settings.tsx b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx new file mode 100644 index 0000000..7257231 --- /dev/null +++ b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx @@ -0,0 +1,281 @@ +"use client" + +import { useState } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { + useUpdateSubscriptionTitle, + useMoveSubscriptionToFolder, + useUnsubscribe, + useRequestFeedRefresh, +} from "@/lib/queries/use-subscription-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" +import type { Subscription } from "@/lib/types/subscription" + +function formatRelativeTime(isoString: string | null): string { + if (!isoString) return "never" + const date = new Date(isoString) + const now = new Date() + const differenceSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) + if (differenceSeconds < 60) return "just now" + if (differenceSeconds < 3600) return `${Math.floor(differenceSeconds / 60)}m ago` + if (differenceSeconds < 86400) return `${Math.floor(differenceSeconds / 3600)}h ago` + return `${Math.floor(differenceSeconds / 86400)}d ago` +} + +function formatRefreshInterval(seconds: number): string { + if (seconds < 3600) return `${Math.round(seconds / 60)} min` + return `${Math.round(seconds / 3600)} hr` +} + +function SubscriptionRow({ + subscription, + folderOptions, +}: { + subscription: Subscription + folderOptions: { identifier: string; name: string }[] +}) { + const [isEditingTitle, setIsEditingTitle] = useState(false) + const [editedTitle, setEditedTitle] = useState( + subscription.customTitle ?? "" + ) + const [showUnsubscribeConfirm, setShowUnsubscribeConfirm] = useState(false) + const updateTitle = useUpdateSubscriptionTitle() + const moveToFolder = useMoveSubscriptionToFolder() + const unsubscribe = useUnsubscribe() + const requestRefresh = useRequestFeedRefresh() + const { data: userProfile } = useUserProfile() + + function handleSaveTitle() { + const trimmedTitle = editedTitle.trim() + updateTitle.mutate({ + subscriptionIdentifier: subscription.subscriptionIdentifier, + customTitle: trimmedTitle || null, + }) + setIsEditingTitle(false) + } + + function handleFolderChange(folderIdentifier: string) { + const sourceFolder = folderOptions.find( + (folder) => folder.identifier === subscription.folderIdentifier + ) + const targetFolder = folderOptions.find( + (folder) => folder.identifier === folderIdentifier + ) + moveToFolder.mutate({ + subscriptionIdentifier: subscription.subscriptionIdentifier, + folderIdentifier: folderIdentifier || null, + feedTitle: subscription.customTitle ?? subscription.feedTitle ?? undefined, + sourceFolderName: sourceFolder?.name, + folderName: targetFolder?.name, + }) + } + + return ( + <div className="flex flex-col gap-2 border-b border-border px-4 py-3 last:border-b-0"> + <div className="flex items-center justify-between gap-4"> + <div className="min-w-0 flex-1"> + {isEditingTitle ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedTitle} + onChange={(event) => setEditedTitle(event.target.value)} + placeholder={subscription.feedTitle} + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveTitle() + if (event.key === "Escape") setIsEditingTitle(false) + }} + autoFocus + /> + <button + onClick={handleSaveTitle} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingTitle(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <span className="truncate text-text-primary"> + {subscription.customTitle ?? subscription.feedTitle} + </span> + <button + onClick={() => { + setEditedTitle(subscription.customTitle ?? "") + setIsEditingTitle(true) + }} + className="shrink-0 px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + rename + </button> + </div> + )} + <p className="truncate text-text-dim">{subscription.feedUrl}</p> + <div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-text-dim"> + <span>last fetched: {formatRelativeTime(subscription.lastFetchedAt)}</span> + <span>interval: {formatRefreshInterval(subscription.fetchIntervalSeconds)}</span> + {subscription.consecutiveFailures > 0 && ( + <span className="text-status-warning"> + {subscription.consecutiveFailures} consecutive failure{subscription.consecutiveFailures !== 1 && "s"} + </span> + )} + </div> + {subscription.lastFetchError && subscription.consecutiveFailures > 0 && ( + <p className="mt-1 truncate text-status-warning"> + {subscription.lastFetchError} + </p> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <select + value={subscription.folderIdentifier ?? ""} + onChange={(event) => handleFolderChange(event.target.value)} + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="">no folder</option> + {folderOptions.map((folder) => ( + <option key={folder.identifier} value={folder.identifier}> + {folder.name} + </option> + ))} + </select> + {(userProfile?.tier === "pro" || userProfile?.tier === "developer") && ( + <button + onClick={() => + requestRefresh.mutate({ + subscriptionIdentifier: + subscription.subscriptionIdentifier, + }) + } + disabled={requestRefresh.isPending} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + refresh + </button> + )} + {showUnsubscribeConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">confirm?</span> + <button + onClick={() => { + unsubscribe.mutate({ + subscriptionIdentifier: + subscription.subscriptionIdentifier, + }) + setShowUnsubscribeConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowUnsubscribeConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowUnsubscribeConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + unsubscribe + </button> + )} + </div> + </div> + ) +} + +export function SubscriptionsSettings() { + const { data: subscriptionsData, isLoading } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const [searchQuery, setSearchQuery] = useState("") + const [folderFilter, setFolderFilter] = useState<string>("all") + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading subscriptions ...</p> + } + + const subscriptions = subscriptionsData?.subscriptions ?? [] + const folders = subscriptionsData?.folders ?? [] + const folderOptions = folders.map((folder) => ({ + identifier: folder.folderIdentifier, + name: folder.name, + })) + + if (subscriptions.length === 0) { + return ( + <p className="px-4 py-6 text-text-dim"> + no subscriptions yet — add a feed to get started + </p> + ) + } + + const normalizedQuery = searchQuery.toLowerCase().trim() + + const filteredSubscriptions = subscriptions.filter((subscription) => { + if (folderFilter === "ungrouped" && subscription.folderIdentifier !== null) return false + if (folderFilter !== "all" && folderFilter !== "ungrouped" && subscription.folderIdentifier !== folderFilter) return false + + if (normalizedQuery) { + const title = (subscription.customTitle ?? subscription.feedTitle ?? "").toLowerCase() + const url = (subscription.feedUrl ?? "").toLowerCase() + if (!title.includes(normalizedQuery) && !url.includes(normalizedQuery)) return false + } + + return true + }) + + return ( + <div> + <div className="flex flex-wrap items-center gap-2 px-4 py-3"> + <input + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="search subscriptions..." + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <select + value={folderFilter} + onChange={(event) => setFolderFilter(event.target.value)} + className="border border-border bg-background-primary px-2 py-1.5 text-text-secondary outline-none" + > + <option value="all">all folders</option> + <option value="ungrouped">ungrouped</option> + {folderOptions.map((folder) => ( + <option key={folder.identifier} value={folder.identifier}> + {folder.name} + </option> + ))} + </select> + <span className="text-text-dim"> + {filteredSubscriptions.length} / {TIER_LIMITS[userProfile?.tier ?? "free"].maximumFeeds} + </span> + </div> + <div> + {filteredSubscriptions.map((subscription) => ( + <SubscriptionRow + key={subscription.subscriptionIdentifier} + subscription={subscription} + folderOptions={folderOptions} + /> + ))} + {filteredSubscriptions.length === 0 && ( + <p className="px-4 py-6 text-text-dim">no subscriptions match your filters</p> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/page.tsx b/apps/web/app/reader/settings/page.tsx new file mode 100644 index 0000000..3a49bd7 --- /dev/null +++ b/apps/web/app/reader/settings/page.tsx @@ -0,0 +1,16 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { redirect } from "next/navigation" +import { SettingsShell } from "./_components/settings-shell" + +export default async function SettingsPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + redirect("/sign-in") + } + + return <SettingsShell /> +} diff --git a/apps/web/app/reader/shares/_components/shares-content.tsx b/apps/web/app/reader/shares/_components/shares-content.tsx new file mode 100644 index 0000000..e9ce7a4 --- /dev/null +++ b/apps/web/app/reader/shares/_components/shares-content.tsx @@ -0,0 +1,504 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Group, Panel, Separator } from "react-resizable-panels" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { useIsMobile } from "@/lib/hooks/use-is-mobile" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { EntryDetailPanel } from "@/app/reader/_components/entry-detail-panel" +import { ErrorBoundary } from "@/app/reader/_components/error-boundary" +import { classNames } from "@/lib/utilities" +import { notify } from "@/lib/notify" + +interface SharedEntry { + identifier: string + entryIdentifier: string + shareToken: string + createdAt: string + expiresAt: string | null + note: string | null + entryTitle: string | null + entryUrl: string | null +} + +function useSharedEntries() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: ["shared-entries"], + queryFn: async () => { + const { data, error } = await supabaseClient + .from("shared_entries") + .select("id, entry_id, share_token, created_at, expires_at, note, entries(title, url)") + .order("created_at", { ascending: false }) + + if (error) throw error + + return (data ?? []).map( + (row) => { + const entryData = row.entries as unknown as { + title: string | null + url: string | null + } | null + + return { + identifier: row.id, + entryIdentifier: row.entry_id, + shareToken: row.share_token, + createdAt: row.created_at, + expiresAt: row.expires_at, + note: (row as Record<string, unknown>).note as string | null, + entryTitle: entryData?.title ?? null, + entryUrl: entryData?.url ?? null, + } + } + ) + }, + }) +} + +function useRevokeShare() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (shareIdentifier: string) => { + const { error } = await supabaseClient + .from("shared_entries") + .delete() + .eq("id", shareIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + notify("share revoked") + }, + onError: () => { + notify("failed to revoke share") + }, + }) +} + +function useUpdateShareNote() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ shareToken, note }: { shareToken: string; note: string | null }) => { + const response = await fetch(`/api/share/${shareToken}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ note }), + }) + if (!response.ok) throw new Error("Failed to update note") + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + notify("note updated") + }, + onError: () => { + notify("failed to update note") + }, + }) +} + +function isExpired(expiresAt: string | null): boolean { + if (!expiresAt) return false + return new Date(expiresAt) < new Date() +} + +function ShareRow({ + share, + isSelected, + isFocused, + viewMode, + onSelect, +}: { + share: SharedEntry + isSelected: boolean + isFocused: boolean + viewMode: "compact" | "comfortable" | "expanded" + onSelect: (entryIdentifier: string) => void +}) { + const [showRevokeConfirm, setShowRevokeConfirm] = useState(false) + const [isEditingNote, setIsEditingNote] = useState(false) + const [editedNote, setEditedNote] = useState(share.note ?? "") + const revokeShare = useRevokeShare() + const updateNote = useUpdateShareNote() + const expired = isExpired(share.expiresAt) + + function handleCopyLink() { + const shareUrl = `${window.location.origin}/shared/${share.shareToken}` + navigator.clipboard.writeText(shareUrl) + notify("link copied") + } + + function handleSaveNote() { + const trimmedNote = editedNote.trim() + updateNote.mutate({ shareToken: share.shareToken, note: trimmedNote || null }) + setIsEditingNote(false) + } + + const sharedDate = new Date(share.createdAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }) + + const rowClassName = classNames( + "cursor-pointer border-b border-border px-4 transition-colors last:border-b-0", + isSelected + ? "bg-background-tertiary" + : isFocused + ? "bg-background-secondary" + : "hover:bg-background-secondary", + isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "" + ) + + if (viewMode === "compact") { + return ( + <div + data-share-list-item + onClick={() => onSelect(share.entryIdentifier)} + className={rowClassName} + > + <div className="flex items-center gap-2 py-2.5"> + <span className="min-w-0 flex-1 truncate text-text-primary"> + {share.entryTitle ?? "untitled"} + </span> + {expired && <span className="shrink-0 text-status-error">expired</span>} + <span className="shrink-0 text-text-dim">{sharedDate}</span> + </div> + </div> + ) + } + + if (viewMode === "comfortable") { + return ( + <div + data-share-list-item + onClick={() => onSelect(share.entryIdentifier)} + className={rowClassName} + > + <div className="py-2.5"> + <span className="block truncate text-text-primary"> + {share.entryTitle ?? "untitled"} + </span> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + <span>shared {sharedDate}</span> + {share.expiresAt && ( + <> + <span>·</span> + {expired ? ( + <span className="text-status-error">expired</span> + ) : ( + <span> + expires{" "} + {new Date(share.expiresAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + </span> + )} + </> + )} + {share.note && ( + <> + <span>·</span> + <span className="truncate">{share.note}</span> + </> + )} + </div> + </div> + </div> + ) + } + + return ( + <div + data-share-list-item + onClick={() => onSelect(share.entryIdentifier)} + className={classNames(rowClassName, "flex flex-col gap-1 py-3")} + > + <div className="flex items-center justify-between"> + <div className="min-w-0 flex-1"> + <span className="block truncate text-text-primary"> + {share.entryTitle ?? "untitled"} + </span> + {isEditingNote ? ( + <div className="mt-1 flex items-center gap-2"> + <input + type="text" + value={editedNote} + onChange={(event) => setEditedNote(event.target.value)} + placeholder="add a note..." + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveNote() + if (event.key === "Escape") setIsEditingNote(false) + }} + autoFocus + /> + <button + type="button" + onClick={handleSaveNote} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + type="button" + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : share.note ? ( + <p className="truncate text-text-secondary">{share.note}</p> + ) : null} + <p className="text-text-dim"> + shared {sharedDate} + {share.expiresAt && ( + <> + {" \u00b7 "} + {expired ? ( + <span className="text-status-error">expired</span> + ) : ( + <> + expires{" "} + {new Date(share.expiresAt).toLocaleDateString( + "en-GB", + { + day: "numeric", + month: "short", + year: "numeric", + } + )} + </> + )} + </> + )} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + {!expired && ( + <button + type="button" + onClick={handleCopyLink} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + copy link + </button> + )} + <button + type="button" + onClick={() => { + setEditedNote(share.note ?? "") + setIsEditingNote(true) + }} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + {share.note ? "edit note" : "add note"} + </button> + {showRevokeConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">revoke?</span> + <button + type="button" + onClick={() => { + revokeShare.mutate(share.identifier) + setShowRevokeConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + type="button" + onClick={() => setShowRevokeConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + type="button" + onClick={() => setShowRevokeConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + revoke + </button> + )} + </div> + </div> + ) +} + +function SharesList({ + shares, + selectedEntryIdentifier, + focusedEntryIdentifier, + viewMode, + onSelect, +}: { + shares: SharedEntry[] + selectedEntryIdentifier: string | null + focusedEntryIdentifier: string | null + viewMode: "compact" | "comfortable" | "expanded" + onSelect: (entryIdentifier: string) => void +}) { + const listReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (!focusedEntryIdentifier) return + const container = listReference.current + if (!container) return + const items = container.querySelectorAll("[data-share-list-item]") + const focusedIndex = shares.findIndex( + (share) => share.entryIdentifier === focusedEntryIdentifier + ) + items[focusedIndex]?.scrollIntoView({ block: "nearest" }) + }, [focusedEntryIdentifier, shares]) + + if (shares.length === 0) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + no shared entries yet + </div> + ) + } + + return ( + <div ref={listReference} className="h-full overflow-auto"> + {shares.map((share) => ( + <ShareRow + key={share.identifier} + share={share} + isSelected={share.entryIdentifier === selectedEntryIdentifier} + isFocused={share.entryIdentifier === focusedEntryIdentifier} + viewMode={viewMode} + onSelect={onSelect} + /> + ))} + </div> + ) +} + +export function SharesContent() { + const { data: shares, isLoading } = useSharedEntries() + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setNavigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.setNavigableEntryIdentifiers + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const isMobile = useIsMobile() + + const sharesList = shares ?? [] + + useEffect(() => { + setSelectedEntryIdentifier(null) + setNavigableEntryIdentifiers([]) + }, []) + + useEffect(() => { + setNavigableEntryIdentifiers( + sharesList.map((share) => share.entryIdentifier) + ) + }, [sharesList.length, setNavigableEntryIdentifiers]) + + if (isLoading) { + return <p className="px-6 py-8 text-text-dim">loading shares ...</p> + } + + const activeShareCount = sharesList.filter( + (share) => !isExpired(share.expiresAt) + ).length + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center justify-between border-b border-border px-4 py-3"> + <div className="flex items-center gap-3"> + {isMobile && selectedEntryIdentifier && ( + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + ← back + </button> + )} + <h1 className="text-text-primary">shares</h1> + </div> + <span className="text-text-dim"> + {activeShareCount} active share{activeShareCount !== 1 ? "s" : ""} + </span> + </header> + <ErrorBoundary> + {isMobile ? ( + selectedEntryIdentifier ? ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + ) : ( + <div className="flex-1 overflow-hidden"> + <SharesList + shares={sharesList} + selectedEntryIdentifier={null} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + /> + </div> + ) + ) : ( + <Group orientation="horizontal" className="flex-1"> + <Panel defaultSize={selectedEntryIdentifier ? 40 : 100} minSize={25}> + <div data-panel-zone="entryList" className={classNames( + "h-full", + focusedPanel === "entryList" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <SharesList + shares={sharesList} + selectedEntryIdentifier={selectedEntryIdentifier} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + /> + </div> + </Panel> + {selectedEntryIdentifier && ( + <> + <Separator className="w-px bg-border transition-colors hover:bg-text-dim" /> + <Panel defaultSize={60} minSize={30}> + <div data-panel-zone="detailPanel" className={classNames( + "h-full", + focusedPanel === "detailPanel" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + </Panel> + </> + )} + </Group> + )} + </ErrorBoundary> + </div> + ) +} diff --git a/apps/web/app/reader/shares/page.tsx b/apps/web/app/reader/shares/page.tsx new file mode 100644 index 0000000..d912b9e --- /dev/null +++ b/apps/web/app/reader/shares/page.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { SharesContent } from "./_components/shares-content" + +export default async function SharesPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + redirect("/sign-in") + } + + return <SharesContent /> +} diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx new file mode 100644 index 0000000..222c1c8 --- /dev/null +++ b/apps/web/app/shared/[token]/page.tsx @@ -0,0 +1,165 @@ +import type { Metadata } from "next" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { sanitizeEntryContent } from "@/lib/sanitize" + +interface SharedPageProperties { + params: Promise<{ token: string }> +} + +interface SharedEntryRow { + entry_id: string + expires_at: string | null + entries: { + id: string + title: string | null + url: string | null + author: string | null + summary: string | null + content_html: string | null + published_at: string | null + enclosure_url: string | null + feeds: { + title: string | null + } + } +} + +async function fetchSharedEntry(token: string) { + const adminClient = createSupabaseAdminClient() + + const { data, error } = await adminClient + .from("shared_entries") + .select( + "entry_id, expires_at, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))" + ) + .eq("share_token", token) + .maybeSingle() + + if (error || !data) return null + + const row = data as unknown as SharedEntryRow + + if (row.expires_at && new Date(row.expires_at) < new Date()) { + return { expired: true as const } + } + + return { expired: false as const, entry: row.entries } +} + +export async function generateMetadata({ + params, +}: SharedPageProperties): Promise<Metadata> { + const { token } = await params + const result = await fetchSharedEntry(token) + + if (!result || result.expired) { + return { title: "shared entry — asa.news" } + } + + return { + title: `${result.entry.title ?? "untitled"} — asa.news`, + description: result.entry.summary?.slice(0, 200) ?? undefined, + openGraph: { + title: result.entry.title ?? "shared entry", + description: result.entry.summary?.slice(0, 200) ?? undefined, + siteName: "asa.news", + }, + } +} + +function SanitisedContent({ htmlContent }: { htmlContent: string }) { + // Content is sanitised via sanitize-html before rendering + const sanitisedHtml = sanitizeEntryContent(htmlContent) + return ( + <div + className="prose-reader text-text-secondary" + // eslint-disable-next-line react/no-danger -- content sanitised by sanitize-html + dangerouslySetInnerHTML={{ __html: sanitisedHtml }} + /> + ) +} + +export default async function SharedPage({ params }: SharedPageProperties) { + const { token } = await params + const result = await fetchSharedEntry(token) + + if (!result) { + return ( + <div className="mx-auto max-w-2xl px-6 py-16 text-center"> + <h1 className="mb-4 text-text-primary">shared entry not found</h1> + <p className="text-text-secondary"> + this shared link is no longer available or has been removed. + </p> + </div> + ) + } + + if (result.expired) { + return ( + <div className="mx-auto max-w-2xl px-6 py-16 text-center"> + <h1 className="mb-4 text-text-primary">this share has expired</h1> + <p className="text-text-secondary"> + shared links expire after a set period. the owner may share it again if needed. + </p> + </div> + ) + } + + const entry = result.entry + const formattedDate = entry.published_at + ? new Date(entry.published_at).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }) + : null + + return ( + <div className="mx-auto max-w-2xl px-6 py-8"> + <article> + <h1 className="mb-2 text-lg text-text-primary">{entry.title}</h1> + <div className="mb-6 text-text-dim"> + {entry.feeds?.title && <span>{entry.feeds.title}</span>} + {entry.author && <span> · {entry.author}</span>} + {formattedDate && <span> · {formattedDate}</span>} + </div> + {entry.enclosure_url && ( + <div className="mb-4 border border-border p-3"> + <audio + controls + preload="none" + src={entry.enclosure_url} + className="w-full" + /> + </div> + )} + <SanitisedContent + htmlContent={entry.content_html || entry.summary || ""} + /> + </article> + <footer className="mt-12 border-t border-border pt-4 text-text-dim"> + <p> + shared from{" "} + <a + href="/" + className="text-text-secondary transition-colors hover:text-text-primary" + > + asa.news + </a> + </p> + {entry.url && ( + <p className="mt-1"> + <a + href={entry.url} + target="_blank" + rel="noopener noreferrer" + className="text-text-secondary transition-colors hover:text-text-primary" + > + view original + </a> + </p> + )} + </footer> + </div> + ) +} diff --git a/apps/web/app/sw.ts b/apps/web/app/sw.ts new file mode 100644 index 0000000..894c9ae --- /dev/null +++ b/apps/web/app/sw.ts @@ -0,0 +1,22 @@ +/// <reference lib="webworker" /> +import { defaultCache } from "@serwist/next/worker" +import type { PrecacheEntry, SerwistGlobalConfig } from "serwist" +import { Serwist } from "serwist" + +declare global { + interface WorkerGlobalScope extends SerwistGlobalConfig { + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined + } +} + +declare const self: ServiceWorkerGlobalScope + +const serwist = new Serwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: defaultCache, +}) + +serwist.addEventListeners() diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts new file mode 100644 index 0000000..309fbe9 --- /dev/null +++ b/apps/web/lib/api-auth.ts @@ -0,0 +1,80 @@ +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { hashApiKey } from "@/lib/api-key" +import { rateLimit } from "@/lib/rate-limit" + +interface AuthenticatedApiUser { + userIdentifier: string + tier: string +} + +export async function authenticateApiRequest( + request: Request +): Promise< + | { authenticated: true; user: AuthenticatedApiUser } + | { authenticated: false; status: number; error: string } +> { + const authorizationHeader = request.headers.get("authorization") + + if (!authorizationHeader?.startsWith("Bearer ")) { + return { + authenticated: false, + status: 401, + error: "Missing or invalid Authorization header", + } + } + + const apiKey = authorizationHeader.slice(7) + + if (!apiKey.startsWith("asn_")) { + return { authenticated: false, status: 401, error: "Invalid API key format" } + } + + const keyHash = hashApiKey(apiKey) + const adminClient = createSupabaseAdminClient() + + const { data: keyRow } = await adminClient + .from("api_keys") + .select("user_id") + .eq("key_hash", keyHash) + .is("revoked_at", null) + .single() + + if (!keyRow) { + return { authenticated: false, status: 401, error: "Invalid or revoked API key" } + } + + const { data: userProfile } = await adminClient + .from("user_profiles") + .select("tier") + .eq("id", keyRow.user_id) + .single() + + if (!userProfile || userProfile.tier !== "developer") { + return { + authenticated: false, + status: 403, + error: "API access requires the developer plan", + } + } + + const rateLimitResult = rateLimit(`api:${keyRow.user_id}`, 100, 60_000) + + if (!rateLimitResult.success) { + return { + authenticated: false, + status: 429, + error: `Rate limit exceeded. ${rateLimitResult.remaining} requests remaining.`, + } + } + + adminClient + .from("api_keys") + .update({ last_used_at: new Date().toISOString() }) + .eq("key_hash", keyHash) + .then(() => {}) + + return { + authenticated: true, + user: { userIdentifier: keyRow.user_id, tier: userProfile.tier }, + } +} diff --git a/apps/web/lib/api-key.ts b/apps/web/lib/api-key.ts new file mode 100644 index 0000000..ce59f89 --- /dev/null +++ b/apps/web/lib/api-key.ts @@ -0,0 +1,20 @@ +import { randomBytes, createHash } from "crypto" + +const API_KEY_PREFIX = "asn_" + +export function generateApiKey(): { + fullKey: string + keyHash: string + keyPrefix: string +} { + const randomPart = randomBytes(20).toString("hex") + const fullKey = `${API_KEY_PREFIX}${randomPart}` + const keyHash = hashApiKey(fullKey) + const keyPrefix = fullKey.slice(0, 8) + + return { fullKey, keyHash, keyPrefix } +} + +export function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex") +} diff --git a/apps/web/lib/highlight-positioning.ts b/apps/web/lib/highlight-positioning.ts new file mode 100644 index 0000000..4c4c068 --- /dev/null +++ b/apps/web/lib/highlight-positioning.ts @@ -0,0 +1,258 @@ +interface SerializedHighlightRange { + highlightedText: string + textOffset: number + textLength: number + textPrefix: string + textSuffix: string +} + +function collectTextContent(containerElement: HTMLElement): string { + const treeWalker = document.createTreeWalker( + containerElement, + NodeFilter.SHOW_TEXT + ) + let fullText = "" + while (treeWalker.nextNode()) { + fullText += treeWalker.currentNode.textContent ?? "" + } + return fullText +} + +function computeAbsoluteTextOffset( + containerElement: HTMLElement, + targetNode: Node, + targetOffset: number +): number { + const treeWalker = document.createTreeWalker( + containerElement, + NodeFilter.SHOW_TEXT + ) + let absoluteOffset = 0 + while (treeWalker.nextNode()) { + if (treeWalker.currentNode === targetNode) { + return absoluteOffset + targetOffset + } + absoluteOffset += (treeWalker.currentNode.textContent ?? "").length + } + return absoluteOffset + targetOffset +} + +function findTextNodeAtOffset( + containerElement: HTMLElement, + targetOffset: number +): { node: Text; offset: number } | null { + const treeWalker = document.createTreeWalker( + containerElement, + NodeFilter.SHOW_TEXT + ) + let currentOffset = 0 + while (treeWalker.nextNode()) { + const textNode = treeWalker.currentNode as Text + const nodeLength = (textNode.textContent ?? "").length + if (currentOffset + nodeLength >= targetOffset) { + return { node: textNode, offset: targetOffset - currentOffset } + } + currentOffset += nodeLength + } + return null +} + +export function serializeSelectionRange( + containerElement: HTMLElement, + selectionRange: Range +): SerializedHighlightRange | null { + const selectedText = selectionRange.toString() + if (!selectedText.trim()) return null + + if (!containerElement.contains(selectionRange.startContainer)) return null + + const fullText = collectTextContent(containerElement) + const textOffset = computeAbsoluteTextOffset( + containerElement, + selectionRange.startContainer, + selectionRange.startOffset + ) + const textLength = selectedText.length + + const prefixStart = Math.max(0, textOffset - 50) + const textPrefix = fullText.slice(prefixStart, textOffset) + const textSuffix = fullText.slice( + textOffset + textLength, + textOffset + textLength + 50 + ) + + return { + highlightedText: selectedText, + textOffset, + textLength, + textPrefix, + textSuffix, + } +} + +export function deserializeHighlightRange( + containerElement: HTMLElement, + highlight: SerializedHighlightRange +): Range | null { + const fullText = collectTextContent(containerElement) + + let matchOffset = -1 + + const candidateText = fullText.slice( + highlight.textOffset, + highlight.textOffset + highlight.textLength + ) + if (candidateText === highlight.highlightedText) { + matchOffset = highlight.textOffset + } + + if (matchOffset === -1) { + const searchStart = Math.max(0, highlight.textOffset - 100) + const searchEnd = Math.min( + fullText.length, + highlight.textOffset + highlight.textLength + 100 + ) + const searchWindow = fullText.slice(searchStart, searchEnd) + const foundIndex = searchWindow.indexOf(highlight.highlightedText) + if (foundIndex !== -1) { + matchOffset = searchStart + foundIndex + } + } + + if (matchOffset === -1) { + const globalIndex = fullText.indexOf(highlight.highlightedText) + if (globalIndex !== -1) { + matchOffset = globalIndex + } + } + + if (matchOffset === -1) return null + + const startPosition = findTextNodeAtOffset(containerElement, matchOffset) + const endPosition = findTextNodeAtOffset( + containerElement, + matchOffset + highlight.textLength + ) + + if (!startPosition || !endPosition) return null + + const highlightRange = document.createRange() + highlightRange.setStart(startPosition.node, startPosition.offset) + highlightRange.setEnd(endPosition.node, endPosition.offset) + + return highlightRange +} + +interface TextNodeSegment { + node: Text + startOffset: number + endOffset: number +} + +function collectTextNodesInRange(range: Range): TextNodeSegment[] { + const segments: TextNodeSegment[] = [] + + if ( + range.startContainer === range.endContainer && + range.startContainer.nodeType === Node.TEXT_NODE + ) { + segments.push({ + node: range.startContainer as Text, + startOffset: range.startOffset, + endOffset: range.endOffset, + }) + return segments + } + + const ancestor = range.commonAncestorContainer + const walkRoot = + ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode! : ancestor + const treeWalker = document.createTreeWalker( + walkRoot, + NodeFilter.SHOW_TEXT + ) + + let foundStart = false + + while (treeWalker.nextNode()) { + const textNode = treeWalker.currentNode as Text + + if (textNode === range.startContainer) { + foundStart = true + segments.push({ + node: textNode, + startOffset: range.startOffset, + endOffset: (textNode.textContent ?? "").length, + }) + } else if (textNode === range.endContainer) { + segments.push({ + node: textNode, + startOffset: 0, + endOffset: range.endOffset, + }) + break + } else if (foundStart) { + segments.push({ + node: textNode, + startOffset: 0, + endOffset: (textNode.textContent ?? "").length, + }) + } + } + + return segments +} + +export function applyHighlightToRange( + highlightRange: Range, + highlightIdentifier: string, + color: string, + hasNote: boolean +): void { + const segments = collectTextNodesInRange(highlightRange) + + if (segments.length === 0) return + + for (const segment of segments) { + let targetNode = segment.node + + if (segment.endOffset < (targetNode.textContent ?? "").length) { + targetNode.splitText(segment.endOffset) + } + + if (segment.startOffset > 0) { + targetNode = targetNode.splitText(segment.startOffset) + } + + const markElement = document.createElement("mark") + markElement.setAttribute("data-highlight-identifier", highlightIdentifier) + markElement.setAttribute("data-highlight-color", color) + if (hasNote) { + markElement.setAttribute("data-has-note", "true") + } + + targetNode.parentNode!.insertBefore(markElement, targetNode) + markElement.appendChild(targetNode) + } +} + +export function removeHighlightFromDom( + containerElement: HTMLElement, + highlightIdentifier: string +): void { + const markElements = containerElement.querySelectorAll( + `mark[data-highlight-identifier="${highlightIdentifier}"]` + ) + + for (const markElement of markElements) { + const parentNode = markElement.parentNode + if (!parentNode) continue + + while (markElement.firstChild) { + parentNode.insertBefore(markElement.firstChild, markElement) + } + + parentNode.removeChild(markElement) + parentNode.normalize() + } +} diff --git a/apps/web/lib/hooks/use-is-mobile.ts b/apps/web/lib/hooks/use-is-mobile.ts new file mode 100644 index 0000000..a56e36c --- /dev/null +++ b/apps/web/lib/hooks/use-is-mobile.ts @@ -0,0 +1,26 @@ +"use client" + +import { useState, useEffect } from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(false) + + useEffect(() => { + const mediaQuery = window.matchMedia( + `(max-width: ${MOBILE_BREAKPOINT - 1}px)` + ) + + setIsMobile(mediaQuery.matches) + + function handleChange(event: MediaQueryListEvent) { + setIsMobile(event.matches) + } + + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, []) + + return isMobile +} diff --git a/apps/web/lib/hooks/use-keyboard-navigation.ts b/apps/web/lib/hooks/use-keyboard-navigation.ts new file mode 100644 index 0000000..c4b3f5f --- /dev/null +++ b/apps/web/lib/hooks/use-keyboard-navigation.ts @@ -0,0 +1,380 @@ +"use client" + +import { useEffect } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { + useToggleEntryReadState, + useToggleEntrySavedState, +} from "@/lib/queries/use-entry-state-mutations" +import { useMarkAllAsRead } from "@/lib/queries/use-mark-all-as-read" +import { queryKeys } from "@/lib/queries/query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" +import type { InfiniteData } from "@tanstack/react-query" + +function findEntryInCache( + queryClient: ReturnType<typeof useQueryClient>, + entryIdentifier: string +): TimelineEntry | undefined { + const allQueries = [ + ...queryClient.getQueriesData<InfiniteData<TimelineEntry[]>>({ + queryKey: queryKeys.timeline.all, + }), + ...queryClient.getQueriesData<InfiniteData<TimelineEntry[]>>({ + queryKey: ["custom-feed-timeline"], + }), + ...queryClient.getQueriesData<InfiniteData<TimelineEntry[]>>({ + queryKey: queryKeys.savedEntries.all, + }), + ] + + for (const [, data] of allQueries) { + if (!data) continue + for (const page of data.pages) { + const match = page.find( + (entry) => entry.entryIdentifier === entryIdentifier + ) + if (match) return match + } + } + + return undefined +} + +const PANEL_ORDER = ["sidebar", "entryList", "detailPanel"] as const + +export function useKeyboardNavigation() { + const queryClient = useQueryClient() + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setFocusedEntryIdentifier = useUserInterfaceStore( + (state) => state.setFocusedEntryIdentifier + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const setFocusedPanel = useUserInterfaceStore( + (state) => state.setFocusedPanel + ) + const focusedSidebarIndex = useUserInterfaceStore( + (state) => state.focusedSidebarIndex + ) + const setFocusedSidebarIndex = useUserInterfaceStore( + (state) => state.setFocusedSidebarIndex + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setCommandPaletteOpen = useUserInterfaceStore( + (state) => state.setCommandPaletteOpen + ) + const isCommandPaletteOpen = useUserInterfaceStore( + (state) => state.isCommandPaletteOpen + ) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const isSearchOpen = useUserInterfaceStore((state) => state.isSearchOpen) + const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) + const setSidebarCollapsed = useUserInterfaceStore( + (state) => state.setSidebarCollapsed + ) + const isSidebarCollapsed = useUserInterfaceStore( + (state) => state.isSidebarCollapsed + ) + const navigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.navigableEntryIdentifiers + ) + const toggleReadState = useToggleEntryReadState() + const toggleSavedState = useToggleEntrySavedState() + const markAllAsRead = useMarkAllAsRead() + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement + + if ( + event.key !== "Escape" && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return + } + + if ((isCommandPaletteOpen || isSearchOpen) && event.key !== "Escape") return + + if (event.ctrlKey) { + switch (event.key) { + case "h": { + event.preventDefault() + const currentPanelIndex = PANEL_ORDER.indexOf(focusedPanel) + if (currentPanelIndex > 0) { + const targetPanel = PANEL_ORDER[currentPanelIndex - 1] + setFocusedPanel(targetPanel) + if (targetPanel === "sidebar") { + setSidebarCollapsed(false) + } + } else { + setSidebarCollapsed(false) + setFocusedPanel("sidebar") + } + return + } + case "l": { + event.preventDefault() + const currentPanelIndex = PANEL_ORDER.indexOf(focusedPanel) + if (currentPanelIndex < PANEL_ORDER.length - 1) { + const targetPanel = PANEL_ORDER[currentPanelIndex + 1] + if (targetPanel === "detailPanel" && !selectedEntryIdentifier) { + return + } + setFocusedPanel(targetPanel) + } + return + } + } + + return + } + + if (focusedPanel === "sidebar") { + handleSidebarKeyDown(event) + return + } + + if (focusedPanel === "detailPanel") { + handleDetailPanelKeyDown(event) + return + } + + const activeIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + const currentIndex = activeIdentifier + ? navigableEntryIdentifiers.indexOf(activeIdentifier) + : -1 + + switch (event.key) { + case "j": + case "ArrowDown": { + event.preventDefault() + + if (navigableEntryIdentifiers.length === 0) break + + const nextIndex = + currentIndex === -1 + ? 0 + : Math.min(currentIndex + 1, navigableEntryIdentifiers.length - 1) + + setFocusedEntryIdentifier(navigableEntryIdentifiers[nextIndex]) + + break + } + case "k": + case "ArrowUp": { + event.preventDefault() + + if (navigableEntryIdentifiers.length === 0) break + + if (currentIndex === -1) { + setFocusedEntryIdentifier(navigableEntryIdentifiers[0]) + } else { + const previousIndex = Math.max(currentIndex - 1, 0) + setFocusedEntryIdentifier(navigableEntryIdentifiers[previousIndex]) + } + + break + } + case "Enter": { + if (focusedEntryIdentifier) { + event.preventDefault() + setSelectedEntryIdentifier(focusedEntryIdentifier) + } + + break + } + case "Escape": { + if (isCommandPaletteOpen) { + setCommandPaletteOpen(false) + } else if (isSearchOpen) { + setSearchOpen(false) + } else if (selectedEntryIdentifier) { + setSelectedEntryIdentifier(null) + } else if (focusedEntryIdentifier) { + setFocusedEntryIdentifier(null) + } + + break + } + case "r": { + const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + if (targetIdentifier) { + const entry = findEntryInCache(queryClient, targetIdentifier) + if (entry) { + toggleReadState.mutate({ + entryIdentifier: entry.entryIdentifier, + isRead: !entry.isRead, + }) + } + } + + break + } + case "s": { + const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + if (targetIdentifier) { + const entry = findEntryInCache(queryClient, targetIdentifier) + if (entry) { + toggleSavedState.mutate({ + entryIdentifier: entry.entryIdentifier, + isSaved: !entry.isSaved, + }) + } + } + + break + } + case "o": { + const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + if (targetIdentifier) { + const entry = findEntryInCache(queryClient, targetIdentifier) + if (entry?.entryUrl) { + window.open(entry.entryUrl, "_blank", "noopener,noreferrer") + } + } + + break + } + case "A": { + if (event.shiftKey) { + event.preventDefault() + markAllAsRead.mutate({}) + } + + break + } + case "/": { + event.preventDefault() + setSearchOpen(true) + + break + } + case "b": { + toggleSidebar() + + break + } + case "1": { + setEntryListViewMode("compact") + + break + } + case "2": { + setEntryListViewMode("comfortable") + + break + } + case "3": { + setEntryListViewMode("expanded") + + break + } + } + } + + function handleDetailPanelKeyDown(event: KeyboardEvent) { + const SCROLL_AMOUNT = 100 + + switch (event.key) { + case "j": + case "ArrowDown": { + event.preventDefault() + const detailArticle = document.querySelector( + "[data-detail-panel] article" + ) + detailArticle?.scrollBy({ top: SCROLL_AMOUNT, behavior: "smooth" }) + break + } + case "k": + case "ArrowUp": { + event.preventDefault() + const detailArticle = document.querySelector( + "[data-detail-panel] article" + ) + detailArticle?.scrollBy({ top: -SCROLL_AMOUNT, behavior: "smooth" }) + break + } + case "Escape": { + setFocusedPanel("entryList") + break + } + } + } + + function handleSidebarKeyDown(event: KeyboardEvent) { + const sidebarLinks = document.querySelectorAll<HTMLElement>( + "[data-sidebar-nav-item]" + ) + const itemCount = sidebarLinks.length + + if (itemCount === 0) return + + switch (event.key) { + case "j": + case "ArrowDown": { + event.preventDefault() + const nextIndex = Math.min(focusedSidebarIndex + 1, itemCount - 1) + setFocusedSidebarIndex(nextIndex) + sidebarLinks[nextIndex]?.scrollIntoView({ block: "nearest" }) + break + } + case "k": + case "ArrowUp": { + event.preventDefault() + const previousIndex = Math.max(focusedSidebarIndex - 1, 0) + setFocusedSidebarIndex(previousIndex) + sidebarLinks[previousIndex]?.scrollIntoView({ block: "nearest" }) + break + } + case "Enter": { + event.preventDefault() + sidebarLinks[focusedSidebarIndex]?.click() + setFocusedPanel("entryList") + break + } + case "Escape": { + setFocusedPanel("entryList") + break + } + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [ + selectedEntryIdentifier, + focusedEntryIdentifier, + focusedPanel, + focusedSidebarIndex, + isCommandPaletteOpen, + isSearchOpen, + isSidebarCollapsed, + navigableEntryIdentifiers, + queryClient, + setSelectedEntryIdentifier, + setFocusedEntryIdentifier, + setFocusedPanel, + setFocusedSidebarIndex, + setCommandPaletteOpen, + setSidebarCollapsed, + toggleSidebar, + setEntryListViewMode, + setSearchOpen, + toggleReadState, + toggleSavedState, + markAllAsRead, + ]) +} diff --git a/apps/web/lib/hooks/use-realtime-entries.ts b/apps/web/lib/hooks/use-realtime-entries.ts new file mode 100644 index 0000000..0eaba77 --- /dev/null +++ b/apps/web/lib/hooks/use-realtime-entries.ts @@ -0,0 +1,74 @@ +"use client" + +import { useEffect, useRef } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "@/lib/queries/query-keys" +import { toast } from "sonner" +import { useNotificationStore } from "@/lib/stores/notification-store" + +const DEBOUNCE_MILLISECONDS = 3000 + +export function useRealtimeEntries() { + const queryClient = useQueryClient() + const supabaseClientReference = useRef(createSupabaseBrowserClient()) + const pendingCountReference = useRef(0) + const debounceTimerReference = useRef<ReturnType<typeof setTimeout> | null>(null) + + useEffect(() => { + function flushPendingNotifications() { + const count = pendingCountReference.current + if (count === 0) return + + pendingCountReference.current = 0 + debounceTimerReference.current = null + + const message = + count === 1 ? "1 new entry" : `${count} new entries` + + useNotificationStore.getState().addNotification(message) + toast(message, { + action: { + label: "refresh", + onClick: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.timeline.all, + }) + }, + }, + }) + } + + const channel = supabaseClientReference.current + .channel("entries-realtime") + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "entries", + }, + () => { + pendingCountReference.current++ + + if (debounceTimerReference.current) { + clearTimeout(debounceTimerReference.current) + } + + debounceTimerReference.current = setTimeout( + flushPendingNotifications, + DEBOUNCE_MILLISECONDS + ) + } + ) + .subscribe() + + return () => { + if (debounceTimerReference.current) { + clearTimeout(debounceTimerReference.current) + } + + supabaseClientReference.current.removeChannel(channel) + } + }, [queryClient]) +} diff --git a/apps/web/lib/notify.ts b/apps/web/lib/notify.ts new file mode 100644 index 0000000..911364f --- /dev/null +++ b/apps/web/lib/notify.ts @@ -0,0 +1,11 @@ +import { toast } from "sonner" +import { useNotificationStore } from "./stores/notification-store" + +export function notify( + message: string, + type: "info" | "success" | "error" = "info", + actionUrl?: string +) { + toast(message) + useNotificationStore.getState().addNotification(message, type, actionUrl) +} diff --git a/apps/web/lib/opml.ts b/apps/web/lib/opml.ts new file mode 100644 index 0000000..bd0c3a7 --- /dev/null +++ b/apps/web/lib/opml.ts @@ -0,0 +1,161 @@ +import type { Folder, Subscription } from "@/lib/types/subscription" + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +export function generateOpml( + subscriptions: Subscription[], + folders: Folder[] +): string { + const lines: string[] = [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<opml version="2.0">', + " <head>", + " <title>asa.news subscriptions</title>", + ` <dateCreated>${new Date().toUTCString()}</dateCreated>`, + " </head>", + " <body>", + ] + + const folderMap = new Map<string, Folder>() + + for (const folder of folders) { + folderMap.set(folder.folderIdentifier, folder) + } + + const subscriptionsByFolder = new Map<string | null, Subscription[]>() + + for (const subscription of subscriptions) { + const key = subscription.folderIdentifier + const existing = subscriptionsByFolder.get(key) ?? [] + existing.push(subscription) + subscriptionsByFolder.set(key, existing) + } + + const ungrouped = subscriptionsByFolder.get(null) ?? [] + + for (const subscription of ungrouped) { + const title = escapeXml(subscription.customTitle ?? subscription.feedTitle) + const xmlUrl = escapeXml(subscription.feedUrl) + lines.push( + ` <outline type="rss" text="${title}" title="${title}" xmlUrl="${xmlUrl}" />` + ) + } + + for (const folder of folders) { + const folderSubscriptions = + subscriptionsByFolder.get(folder.folderIdentifier) ?? [] + const folderName = escapeXml(folder.name) + lines.push(` <outline text="${folderName}" title="${folderName}">`) + + for (const subscription of folderSubscriptions) { + const title = escapeXml(subscription.customTitle ?? subscription.feedTitle) + const xmlUrl = escapeXml(subscription.feedUrl) + lines.push( + ` <outline type="rss" text="${title}" title="${title}" xmlUrl="${xmlUrl}" />` + ) + } + + lines.push(" </outline>") + } + + lines.push(" </body>") + lines.push("</opml>") + + return lines.join("\n") +} + +export interface ParsedOpmlFeed { + url: string + title: string +} + +export interface ParsedOpmlGroup { + folderName: string | null + feeds: ParsedOpmlFeed[] +} + +export function parseOpml(xmlString: string): ParsedOpmlGroup[] { + const parser = new DOMParser() + const document = parser.parseFromString(xmlString, "application/xml") + const parseError = document.querySelector("parsererror") + + if (parseError) { + throw new Error("Invalid OPML file") + } + + const body = document.querySelector("body") + + if (!body) { + throw new Error("Invalid OPML: no body element") + } + + const groups: ParsedOpmlGroup[] = [] + const ungroupedFeeds: ParsedOpmlFeed[] = [] + const topLevelOutlines = body.querySelectorAll(":scope > outline") + + for (const outline of topLevelOutlines) { + const xmlUrl = outline.getAttribute("xmlUrl") + + if (xmlUrl) { + ungroupedFeeds.push({ + url: xmlUrl, + title: + outline.getAttribute("title") ?? + outline.getAttribute("text") ?? + xmlUrl, + }) + } else { + const folderName = + outline.getAttribute("title") ?? outline.getAttribute("text") + const feeds: ParsedOpmlFeed[] = [] + const childOutlines = outline.querySelectorAll(":scope > outline") + + for (const child of childOutlines) { + const childXmlUrl = child.getAttribute("xmlUrl") + + if (childXmlUrl) { + feeds.push({ + url: childXmlUrl, + title: + child.getAttribute("title") ?? + child.getAttribute("text") ?? + childXmlUrl, + }) + } + } + + if (feeds.length > 0) { + groups.push({ folderName: folderName, feeds }) + } + } + } + + if (ungroupedFeeds.length > 0) { + groups.unshift({ folderName: null, feeds: ungroupedFeeds }) + } + + return groups +} + +export function downloadOpml( + subscriptions: Subscription[], + folders: Folder[] +): void { + const opmlContent = generateOpml(subscriptions, folders) + const blob = new Blob([opmlContent], { type: "application/xml" }) + const url = URL.createObjectURL(blob) + const anchor = window.document.createElement("a") + anchor.href = url + anchor.download = "asa-news-subscriptions.opml" + window.document.body.appendChild(anchor) + anchor.click() + window.document.body.removeChild(anchor) + URL.revokeObjectURL(url) +} diff --git a/apps/web/lib/queries/query-keys.ts b/apps/web/lib/queries/query-keys.ts new file mode 100644 index 0000000..69e3407 --- /dev/null +++ b/apps/web/lib/queries/query-keys.ts @@ -0,0 +1,43 @@ +export const queryKeys = { + timeline: { + all: ["timeline"] as const, + list: (folderIdentifier?: string | null, feedIdentifier?: string | null, unreadOnly?: boolean) => + ["timeline", { folderIdentifier, feedIdentifier, unreadOnly }] as const, + }, + savedEntries: { + all: ["saved-entries"] as const, + }, + subscriptions: { + all: ["subscriptions"] as const, + }, + entryDetail: { + single: (entryIdentifier: string) => + ["entry-detail", entryIdentifier] as const, + }, + userProfile: { + all: ["user-profile"] as const, + }, + mutedKeywords: { + all: ["muted-keywords"] as const, + }, + unreadCounts: { + all: ["unread-counts"] as const, + }, + entrySearch: { + query: (searchQuery: string) => ["entry-search", searchQuery] as const, + }, + entryShare: { + single: (entryIdentifier: string) => + ["entry-share", entryIdentifier] as const, + }, + highlights: { + forEntry: (entryIdentifier: string) => + ["highlights", entryIdentifier] as const, + all: ["highlights"] as const, + }, + customFeeds: { + all: ["custom-feeds"] as const, + timeline: (customFeedIdentifier: string) => + ["custom-feed-timeline", customFeedIdentifier] as const, + }, +} diff --git a/apps/web/lib/queries/use-all-highlights.ts b/apps/web/lib/queries/use-all-highlights.ts new file mode 100644 index 0000000..39988de --- /dev/null +++ b/apps/web/lib/queries/use-all-highlights.ts @@ -0,0 +1,85 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { HighlightWithEntryContext } from "@/lib/types/highlight" + +const HIGHLIGHTS_PAGE_SIZE = 50 + +interface HighlightWithContextRow { + id: string + entry_id: string + highlighted_text: string + note: string | null + text_offset: number + text_length: number + text_prefix: string + text_suffix: string + color: string + created_at: string + entries: { + id: string + title: string | null + feeds: { + title: string | null + } + } +} + +function mapRowToHighlightWithContext( + row: HighlightWithContextRow +): HighlightWithEntryContext { + return { + identifier: row.id, + entryIdentifier: row.entry_id, + highlightedText: row.highlighted_text, + note: row.note, + textOffset: row.text_offset, + textLength: row.text_length, + textPrefix: row.text_prefix, + textSuffix: row.text_suffix, + color: row.color, + createdAt: row.created_at, + entryTitle: row.entries?.title ?? null, + feedTitle: row.entries?.feeds?.title ?? null, + } +} + +export function useAllHighlights() { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.highlights.all, + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + let query = supabaseClient + .from("user_highlights") + .select( + "id, entry_id, highlighted_text, note, text_offset, text_length, text_prefix, text_suffix, color, created_at, entries!inner(id, title, feeds!inner(title))" + ) + .order("created_at", { ascending: false }) + .limit(HIGHLIGHTS_PAGE_SIZE) + + if (pageParam) { + query = query.lt("created_at", pageParam) + } + + const { data, error } = await query + + if (error) throw error + + return ( + (data as unknown as HighlightWithContextRow[]) ?? [] + ).map(mapRowToHighlightWithContext) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: HighlightWithEntryContext[]) => { + if (lastPage.length < HIGHLIGHTS_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].createdAt + }, + }) +} diff --git a/apps/web/lib/queries/use-custom-feed-mutations.ts b/apps/web/lib/queries/use-custom-feed-mutations.ts new file mode 100644 index 0000000..f0751db --- /dev/null +++ b/apps/web/lib/queries/use-custom-feed-mutations.ts @@ -0,0 +1,122 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useCreateCustomFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + name, + query, + matchMode, + sourceFolderIdentifier, + }: { + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient.from("custom_feeds").insert({ + user_id: user.id, + name, + query, + match_mode: matchMode, + source_folder_id: sourceFolderIdentifier, + position: 0, + }) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("custom feed created") + }, + onError: (error: Error) => { + notify( + error.message.includes("limit") + ? "custom feed limit reached for your plan" + : "failed to create custom feed: " + error.message + ) + }, + }) +} + +export function useUpdateCustomFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + customFeedIdentifier, + name, + query, + matchMode, + sourceFolderIdentifier, + }: { + customFeedIdentifier: string + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + }) => { + const { error } = await supabaseClient + .from("custom_feeds") + .update({ + name, + query, + match_mode: matchMode, + source_folder_id: sourceFolderIdentifier, + }) + .eq("id", customFeedIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + notify("custom feed updated") + }, + onError: (error: Error) => { + notify("failed to update custom feed: " + error.message) + }, + }) +} + +export function useDeleteCustomFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + customFeedIdentifier, + }: { + customFeedIdentifier: string + }) => { + const { error } = await supabaseClient + .from("custom_feeds") + .delete() + .eq("id", customFeedIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("custom feed deleted") + }, + onError: (error: Error) => { + notify("failed to delete custom feed: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-custom-feed-timeline.ts b/apps/web/lib/queries/use-custom-feed-timeline.ts new file mode 100644 index 0000000..4224123 --- /dev/null +++ b/apps/web/lib/queries/use-custom-feed-timeline.ts @@ -0,0 +1,76 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +const TIMELINE_PAGE_SIZE = 50 + +interface TimelineRow { + entry_id: string + feed_id: string + feed_title: string + custom_title: string | null + entry_title: string + entry_url: string + author: string | null + summary: string | null + image_url: string | null + published_at: string + is_read: boolean + is_saved: boolean + enclosure_url: string | null + enclosure_type: string | null +} + +function mapRowToTimelineEntry(row: TimelineRow): TimelineEntry { + return { + entryIdentifier: row.entry_id, + feedIdentifier: row.feed_id, + feedTitle: row.feed_title, + customTitle: row.custom_title, + entryTitle: row.entry_title, + entryUrl: row.entry_url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + isRead: row.is_read, + isSaved: row.is_saved, + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + } +} + +export function useCustomFeedTimeline(customFeedIdentifier: string | null) { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.customFeeds.timeline(customFeedIdentifier ?? ""), + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + const { data, error } = await supabaseClient.rpc( + "get_custom_feed_timeline", + { + p_custom_feed_id: customFeedIdentifier!, + p_result_limit: TIMELINE_PAGE_SIZE, + p_pagination_cursor: pageParam ?? undefined, + } + ) + + if (error) throw error + + return ((data as TimelineRow[]) ?? []).map(mapRowToTimelineEntry) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: TimelineEntry[]) => { + if (lastPage.length < TIMELINE_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].publishedAt + }, + enabled: !!customFeedIdentifier, + }) +} diff --git a/apps/web/lib/queries/use-custom-feeds.ts b/apps/web/lib/queries/use-custom-feeds.ts new file mode 100644 index 0000000..5c11721 --- /dev/null +++ b/apps/web/lib/queries/use-custom-feeds.ts @@ -0,0 +1,49 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { CustomFeed } from "@/lib/types/custom-feed" + +interface CustomFeedRow { + id: string + name: string + query: string + match_mode: string + source_folder_id: string | null + position: number +} + +export function useCustomFeeds() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.customFeeds.all, + queryFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { data, error } = await supabaseClient + .from("custom_feeds") + .select("id, name, query, match_mode, source_folder_id, position") + .eq("user_id", user.id) + .order("position") + + if (error) throw error + + return ((data as CustomFeedRow[]) ?? []).map( + (row): CustomFeed => ({ + identifier: row.id, + name: row.name, + query: row.query, + matchMode: row.match_mode as "and" | "or", + sourceFolderIdentifier: row.source_folder_id, + position: row.position, + }) + ) + }, + }) +} diff --git a/apps/web/lib/queries/use-entry-highlights.ts b/apps/web/lib/queries/use-entry-highlights.ts new file mode 100644 index 0000000..3fdada5 --- /dev/null +++ b/apps/web/lib/queries/use-entry-highlights.ts @@ -0,0 +1,56 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { Highlight } from "@/lib/types/highlight" + +interface HighlightRow { + id: string + entry_id: string + highlighted_text: string + note: string | null + text_offset: number + text_length: number + text_prefix: string + text_suffix: string + color: string + created_at: string +} + +function mapRowToHighlight(row: HighlightRow): Highlight { + return { + identifier: row.id, + entryIdentifier: row.entry_id, + highlightedText: row.highlighted_text, + note: row.note, + textOffset: row.text_offset, + textLength: row.text_length, + textPrefix: row.text_prefix, + textSuffix: row.text_suffix, + color: row.color, + createdAt: row.created_at, + } +} + +export function useEntryHighlights(entryIdentifier: string | null) { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.highlights.forEntry(entryIdentifier ?? ""), + enabled: !!entryIdentifier, + queryFn: async () => { + const { data, error } = await supabaseClient + .from("user_highlights") + .select( + "id, entry_id, highlighted_text, note, text_offset, text_length, text_prefix, text_suffix, color, created_at" + ) + .eq("entry_id", entryIdentifier!) + .order("text_offset", { ascending: true }) + + if (error) throw error + + return ((data as unknown as HighlightRow[]) ?? []).map(mapRowToHighlight) + }, + }) +} diff --git a/apps/web/lib/queries/use-entry-search.ts b/apps/web/lib/queries/use-entry-search.ts new file mode 100644 index 0000000..9e05ac8 --- /dev/null +++ b/apps/web/lib/queries/use-entry-search.ts @@ -0,0 +1,58 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +interface SearchResultRow { + entry_id: string + feed_id: string + feed_title: string + custom_title: string | null + entry_title: string + entry_url: string + author: string | null + summary: string | null + image_url: string | null + published_at: string + is_read: boolean + is_saved: boolean +} + +export function useEntrySearch(searchQuery: string) { + const supabaseClient = createSupabaseBrowserClient() + const trimmedQuery = searchQuery.trim() + + return useQuery({ + queryKey: queryKeys.entrySearch.query(trimmedQuery), + queryFn: async () => { + const { data, error } = await supabaseClient.rpc("search_entries", { + p_query: trimmedQuery, + p_result_limit: 30, + }) + + if (error) throw error + + return ((data as SearchResultRow[]) ?? []).map( + (row): TimelineEntry => ({ + entryIdentifier: row.entry_id, + feedIdentifier: row.feed_id, + feedTitle: row.feed_title, + customTitle: row.custom_title, + entryTitle: row.entry_title, + entryUrl: row.entry_url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + isRead: row.is_read, + isSaved: row.is_saved, + enclosureUrl: null, + enclosureType: null, + }) + ) + }, + enabled: trimmedQuery.length >= 2, + }) +} diff --git a/apps/web/lib/queries/use-entry-share.ts b/apps/web/lib/queries/use-entry-share.ts new file mode 100644 index 0000000..bba7aa3 --- /dev/null +++ b/apps/web/lib/queries/use-entry-share.ts @@ -0,0 +1,36 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" + +interface EntryShareResult { + shareToken: string | null + isShared: boolean +} + +export function useEntryShare(entryIdentifier: string | null): { + data: EntryShareResult | undefined + isLoading: boolean +} { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.entryShare.single(entryIdentifier ?? ""), + enabled: !!entryIdentifier, + queryFn: async (): Promise<EntryShareResult> => { + const { data, error } = await supabaseClient + .from("shared_entries") + .select("share_token") + .eq("entry_id", entryIdentifier!) + .maybeSingle() + + if (error) throw error + + return { + shareToken: data?.share_token ?? null, + isShared: !!data, + } + }, + }) +} diff --git a/apps/web/lib/queries/use-entry-state-mutations.ts b/apps/web/lib/queries/use-entry-state-mutations.ts new file mode 100644 index 0000000..5f79fc0 --- /dev/null +++ b/apps/web/lib/queries/use-entry-state-mutations.ts @@ -0,0 +1,133 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" +import type { InfiniteData } from "@tanstack/react-query" + +export function useToggleEntryReadState() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + entryIdentifier, + isRead, + }: { + entryIdentifier: string + isRead: boolean + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_entry_states") + .upsert( + { + user_id: user.id, + entry_id: entryIdentifier, + read: isRead, + read_at: isRead ? new Date().toISOString() : null, + }, + { onConflict: "user_id,entry_id" } + ) + + if (error) throw error + }, + onMutate: async ({ entryIdentifier, isRead }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.timeline.all }) + + const previousTimeline = queryClient.getQueriesData< + InfiniteData<TimelineEntry[]> + >({ queryKey: queryKeys.timeline.all }) + + queryClient.setQueriesData<InfiniteData<TimelineEntry[]>>( + { queryKey: queryKeys.timeline.all }, + (existingData) => { + if (!existingData) return existingData + + return { + ...existingData, + pages: existingData.pages.map((page) => + page.map((entry) => + entry.entryIdentifier === entryIdentifier + ? { ...entry, isRead } + : entry + ) + ), + } + } + ) + + return { previousTimeline } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + }, + }) +} + +export function useToggleEntrySavedState() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + entryIdentifier, + isSaved, + }: { + entryIdentifier: string + isSaved: boolean + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_entry_states") + .upsert( + { + user_id: user.id, + entry_id: entryIdentifier, + saved: isSaved, + saved_at: isSaved ? new Date().toISOString() : null, + }, + { onConflict: "user_id,entry_id" } + ) + + if (error) throw error + }, + onMutate: async ({ entryIdentifier, isSaved }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.timeline.all }) + + queryClient.setQueriesData<InfiniteData<TimelineEntry[]>>( + { queryKey: queryKeys.timeline.all }, + (existingData) => { + if (!existingData) return existingData + + return { + ...existingData, + pages: existingData.pages.map((page) => + page.map((entry) => + entry.entryIdentifier === entryIdentifier + ? { ...entry, isSaved } + : entry + ) + ), + } + } + ) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + }, + }) +} diff --git a/apps/web/lib/queries/use-folder-mutations.ts b/apps/web/lib/queries/use-folder-mutations.ts new file mode 100644 index 0000000..8595a60 --- /dev/null +++ b/apps/web/lib/queries/use-folder-mutations.ts @@ -0,0 +1,137 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useCreateFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ name }: { name: string }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient.from("folders").insert({ + user_id: user.id, + name, + position: 0, + }) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("folder created") + }, + onError: (error: Error) => { + notify(error.message.includes("limit") + ? "folder limit reached for your plan" + : "failed to create folder: " + error.message) + }, + }) +} + +export function useDeleteAllFolders() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + await supabaseClient + .from("subscriptions") + .update({ folder_id: null }) + .eq("user_id", user.id) + .not("folder_id", "is", null) + + const { error } = await supabaseClient + .from("folders") + .delete() + .eq("user_id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("all folders deleted") + }, + onError: (error: Error) => { + notify("failed to delete all folders: " + error.message) + }, + }) +} + +export function useRenameFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + folderIdentifier, + name, + }: { + folderIdentifier: string + name: string + }) => { + const { error } = await supabaseClient + .from("folders") + .update({ name }) + .eq("id", folderIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + notify("folder renamed") + }, + onError: (error: Error) => { + notify("failed to rename folder: " + error.message) + }, + }) +} + +export function useDeleteFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + folderIdentifier, + }: { + folderIdentifier: string + }) => { + await supabaseClient + .from("subscriptions") + .update({ folder_id: null }) + .eq("folder_id", folderIdentifier) + + const { error } = await supabaseClient + .from("folders") + .delete() + .eq("id", folderIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("folder deleted") + }, + onError: (error: Error) => { + notify("failed to delete folder: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-highlight-mutations.ts b/apps/web/lib/queries/use-highlight-mutations.ts new file mode 100644 index 0000000..0e228c8 --- /dev/null +++ b/apps/web/lib/queries/use-highlight-mutations.ts @@ -0,0 +1,132 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +interface CreateHighlightParameters { + entryIdentifier: string + highlightedText: string + textOffset: number + textLength: number + textPrefix: string + textSuffix: string + note?: string | null + color?: string +} + +export function useCreateHighlight() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (parameters: CreateHighlightParameters) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { data, error } = await supabaseClient + .from("user_highlights") + .insert({ + user_id: user.id, + entry_id: parameters.entryIdentifier, + highlighted_text: parameters.highlightedText, + text_offset: parameters.textOffset, + text_length: parameters.textLength, + text_prefix: parameters.textPrefix, + text_suffix: parameters.textSuffix, + note: parameters.note ?? null, + color: parameters.color ?? "yellow", + }) + .select("id") + .single() + + if (error) throw error + + return data.id as string + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("text highlighted") + }, + onError: (error: Error) => { + notify( + error.message.includes("limit") + ? "highlight limit reached for your plan" + : "failed to create highlight" + ) + }, + }) +} + +export function useUpdateHighlightNote() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + highlightIdentifier, + note, + }: { + highlightIdentifier: string + note: string | null + entryIdentifier: string + }) => { + const { error } = await supabaseClient + .from("user_highlights") + .update({ note }) + .eq("id", highlightIdentifier) + + if (error) throw error + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all }) + notify("note updated") + }, + onError: () => { + notify("failed to update note") + }, + }) +} + +export function useDeleteHighlight() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + highlightIdentifier, + }: { + highlightIdentifier: string + entryIdentifier: string + }) => { + const { error } = await supabaseClient + .from("user_highlights") + .delete() + .eq("id", highlightIdentifier) + + if (error) throw error + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("highlight removed") + }, + onError: () => { + notify("failed to remove highlight") + }, + }) +} diff --git a/apps/web/lib/queries/use-mark-all-as-read.ts b/apps/web/lib/queries/use-mark-all-as-read.ts new file mode 100644 index 0000000..fdda661 --- /dev/null +++ b/apps/web/lib/queries/use-mark-all-as-read.ts @@ -0,0 +1,48 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useMarkAllAsRead() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + feedIdentifier, + folderIdentifier, + readState = true, + }: { + feedIdentifier?: string | null + folderIdentifier?: string | null + readState?: boolean + } = {}) => { + const { data, error } = await supabaseClient.rpc("mark_all_as_read", { + p_feed_id: feedIdentifier ?? null, + p_folder_id: folderIdentifier ?? null, + p_read_state: readState, + }) + + if (error) throw error + + return data as number + }, + onSuccess: (affectedCount, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.unreadCounts.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + + const action = variables?.readState === false ? "unread" : "read" + + if (affectedCount > 0) { + notify(`marked ${affectedCount} entries as ${action}`) + } + }, + onError: (_, variables) => { + const action = variables?.readState === false ? "unread" : "read" + notify(`failed to mark entries as ${action}`) + }, + }) +} diff --git a/apps/web/lib/queries/use-muted-keyword-mutations.ts b/apps/web/lib/queries/use-muted-keyword-mutations.ts new file mode 100644 index 0000000..67bcf33 --- /dev/null +++ b/apps/web/lib/queries/use-muted-keyword-mutations.ts @@ -0,0 +1,68 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useAddMutedKeyword() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ keyword }: { keyword: string }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient.from("muted_keywords").insert({ + user_id: user.id, + keyword: keyword.toLowerCase().trim(), + }) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("keyword muted") + }, + onError: (error: Error) => { + notify(error.message.includes("limit") + ? "muted keyword limit reached for your plan" + : "failed to mute keyword: " + error.message) + }, + }) +} + +export function useDeleteMutedKeyword() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + keywordIdentifier, + }: { + keywordIdentifier: string + }) => { + const { error } = await supabaseClient + .from("muted_keywords") + .delete() + .eq("id", keywordIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("keyword unmuted") + }, + onError: (error: Error) => { + notify("failed to unmute keyword: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-muted-keywords.ts b/apps/web/lib/queries/use-muted-keywords.ts new file mode 100644 index 0000000..ce1b53e --- /dev/null +++ b/apps/web/lib/queries/use-muted-keywords.ts @@ -0,0 +1,30 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { MutedKeyword } from "@/lib/types/user-profile" + +export function useMutedKeywords() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.mutedKeywords.all, + queryFn: async () => { + const { data, error } = await supabaseClient + .from("muted_keywords") + .select("id, keyword, created_at") + .order("created_at", { ascending: false }) + + if (error) throw error + + const keywords: MutedKeyword[] = (data ?? []).map((row) => ({ + identifier: row.id, + keyword: row.keyword, + createdAt: row.created_at, + })) + + return keywords + }, + }) +} diff --git a/apps/web/lib/queries/use-saved-entries.ts b/apps/web/lib/queries/use-saved-entries.ts new file mode 100644 index 0000000..bdfcec9 --- /dev/null +++ b/apps/web/lib/queries/use-saved-entries.ts @@ -0,0 +1,88 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +const SAVED_PAGE_SIZE = 50 + +interface SavedEntryRow { + entry_id: string + read: boolean + saved: boolean + saved_at: string + entries: { + id: string + feed_id: string + title: string | null + url: string | null + author: string | null + summary: string | null + image_url: string | null + published_at: string | null + enclosure_url: string | null + enclosure_type: string | null + feeds: { + title: string | null + } + } +} + +function mapSavedRowToTimelineEntry(row: SavedEntryRow): TimelineEntry { + return { + entryIdentifier: row.entries.id, + feedIdentifier: row.entries.feed_id, + feedTitle: row.entries.feeds?.title ?? "", + customTitle: null, + entryTitle: row.entries.title ?? "", + entryUrl: row.entries.url ?? "", + author: row.entries.author, + summary: row.entries.summary, + imageUrl: row.entries.image_url, + publishedAt: row.entries.published_at ?? "", + isRead: row.read, + isSaved: row.saved, + enclosureUrl: row.entries.enclosure_url, + enclosureType: row.entries.enclosure_type, + } +} + +export function useSavedEntries() { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.savedEntries.all, + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + let query = supabaseClient + .from("user_entry_states") + .select( + "entry_id, read, saved, saved_at, entries!inner(id, feed_id, title, url, author, summary, image_url, published_at, enclosure_url, enclosure_type, feeds!inner(title))" + ) + .eq("saved", true) + .order("saved_at", { ascending: false }) + .limit(SAVED_PAGE_SIZE) + + if (pageParam) { + query = query.lt("saved_at", pageParam) + } + + const { data, error } = await query + + if (error) throw error + + return ((data as unknown as SavedEntryRow[]) ?? []).map( + mapSavedRowToTimelineEntry + ) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: TimelineEntry[]) => { + if (lastPage.length < SAVED_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].publishedAt + }, + }) +} diff --git a/apps/web/lib/queries/use-subscribe-to-feed.ts b/apps/web/lib/queries/use-subscribe-to-feed.ts new file mode 100644 index 0000000..5e585a9 --- /dev/null +++ b/apps/web/lib/queries/use-subscribe-to-feed.ts @@ -0,0 +1,37 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useSubscribeToFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (parameters: { + feedUrl: string + folderIdentifier?: string | null + customTitle?: string | null + }) => { + const { data, error } = await supabaseClient.rpc("subscribe_to_feed", { + feed_url: parameters.feedUrl, + target_folder_id: parameters.folderIdentifier ?? undefined, + feed_custom_title: parameters.customTitle ?? undefined, + }) + + if (error) throw error + + return data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + notify("feed added successfully") + }, + onError: (error: Error) => { + notify("failed to add feed: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-subscription-mutations.ts b/apps/web/lib/queries/use-subscription-mutations.ts new file mode 100644 index 0000000..3b4b3ba --- /dev/null +++ b/apps/web/lib/queries/use-subscription-mutations.ts @@ -0,0 +1,158 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useUpdateSubscriptionTitle() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + customTitle, + }: { + subscriptionIdentifier: string + customTitle: string | null + }) => { + const { error } = await supabaseClient + .from("subscriptions") + .update({ custom_title: customTitle }) + .eq("id", subscriptionIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + notify("title updated") + }, + onError: (error: Error) => { + notify("failed to update title: " + error.message) + }, + }) +} + +export function useMoveSubscriptionToFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + folderIdentifier, + }: { + subscriptionIdentifier: string + folderIdentifier: string | null + feedTitle?: string + sourceFolderName?: string + folderName?: string + }) => { + const { error } = await supabaseClient + .from("subscriptions") + .update({ folder_id: folderIdentifier }) + .eq("id", subscriptionIdentifier) + + if (error) throw error + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + const source = variables.sourceFolderName ?? "no folder" + const destination = variables.folderName ?? "no folder" + const feedLabel = variables.feedTitle ?? "feed" + notify(`moved "${feedLabel}" from ${source} to ${destination}`) + }, + onError: (error: Error) => { + notify("failed to move feed: " + error.message) + }, + }) +} + +export function useUnsubscribe() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + }: { + subscriptionIdentifier: string + }) => { + const { error } = await supabaseClient + .from("subscriptions") + .delete() + .eq("id", subscriptionIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("unsubscribed") + }, + onError: (error: Error) => { + notify("failed to unsubscribe: " + error.message) + }, + }) +} + +export function useUnsubscribeAll() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("subscriptions") + .delete() + .eq("user_id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.unreadCounts.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("all feeds removed") + }, + onError: (error: Error) => { + notify("failed to remove all feeds: " + error.message) + }, + }) +} + +export function useRequestFeedRefresh() { + const supabaseClient = createSupabaseBrowserClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + }: { + subscriptionIdentifier: string + }) => { + const { error } = await supabaseClient.rpc("request_feed_refresh", { + target_subscription_id: subscriptionIdentifier, + }) + + if (error) throw error + }, + onSuccess: () => { + notify("refresh requested") + }, + onError: (error: Error) => { + notify(error.message.includes("Pro") + ? "manual refresh requires a pro subscription" + : "failed to request refresh: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-subscriptions.ts b/apps/web/lib/queries/use-subscriptions.ts new file mode 100644 index 0000000..ebf099d --- /dev/null +++ b/apps/web/lib/queries/use-subscriptions.ts @@ -0,0 +1,78 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { Folder, Subscription } from "@/lib/types/subscription" + +interface SubscriptionRow { + id: string + feed_id: string + folder_id: string | null + custom_title: string | null + position: number + feeds: { + title: string | null + url: string + consecutive_failures: number + last_fetch_error: string | null + last_fetched_at: string | null + fetch_interval_seconds: number + feed_type: string | null + } +} + +interface FolderRow { + id: string + name: string + position: number +} + +export function useSubscriptions() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.subscriptions.all, + queryFn: async () => { + const [subscriptionsResult, foldersResult] = await Promise.all([ + supabaseClient + .from("subscriptions") + .select("id, feed_id, folder_id, custom_title, position, feeds(title, url, consecutive_failures, last_fetch_error, last_fetched_at, fetch_interval_seconds, feed_type)") + .order("position", { ascending: true }), + supabaseClient + .from("folders") + .select("id, name, position") + .order("position", { ascending: true }), + ]) + + if (subscriptionsResult.error) throw subscriptionsResult.error + if (foldersResult.error) throw foldersResult.error + + const subscriptions: Subscription[] = ( + (subscriptionsResult.data as unknown as SubscriptionRow[]) ?? [] + ).map((row) => ({ + subscriptionIdentifier: row.id, + feedIdentifier: row.feed_id, + folderIdentifier: row.folder_id, + customTitle: row.custom_title, + feedTitle: row.feeds?.title ?? "", + feedUrl: row.feeds?.url ?? "", + consecutiveFailures: row.feeds?.consecutive_failures ?? 0, + lastFetchError: row.feeds?.last_fetch_error ?? null, + lastFetchedAt: row.feeds?.last_fetched_at ?? null, + fetchIntervalSeconds: row.feeds?.fetch_interval_seconds ?? 3600, + feedType: row.feeds?.feed_type ?? null, + })) + + const folders: Folder[] = ( + (foldersResult.data as unknown as FolderRow[]) ?? [] + ).map((row) => ({ + folderIdentifier: row.id, + name: row.name, + position: row.position, + })) + + return { subscriptions, folders } + }, + }) +} diff --git a/apps/web/lib/queries/use-timeline.ts b/apps/web/lib/queries/use-timeline.ts new file mode 100644 index 0000000..5a38aba --- /dev/null +++ b/apps/web/lib/queries/use-timeline.ts @@ -0,0 +1,78 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +const TIMELINE_PAGE_SIZE = 50 + +interface TimelineRow { + entry_id: string + feed_id: string + feed_title: string + custom_title: string | null + entry_title: string + entry_url: string + author: string | null + summary: string | null + image_url: string | null + published_at: string + is_read: boolean + is_saved: boolean + enclosure_url: string | null + enclosure_type: string | null +} + +function mapRowToTimelineEntry(row: TimelineRow): TimelineEntry { + return { + entryIdentifier: row.entry_id, + feedIdentifier: row.feed_id, + feedTitle: row.feed_title, + customTitle: row.custom_title, + entryTitle: row.entry_title, + entryUrl: row.entry_url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + isRead: row.is_read, + isSaved: row.is_saved, + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + } +} + +export function useTimeline( + folderIdentifier?: string | null, + feedIdentifier?: string | null, + unreadOnly?: boolean +) { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.timeline.list(folderIdentifier, feedIdentifier, unreadOnly), + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + const { data, error } = await supabaseClient.rpc("get_timeline", { + target_folder_id: folderIdentifier ?? undefined, + target_feed_id: feedIdentifier ?? undefined, + result_limit: TIMELINE_PAGE_SIZE, + pagination_cursor: pageParam ?? undefined, + unread_only: unreadOnly ?? false, + }) + + if (error) throw error + + return ((data as TimelineRow[]) ?? []).map(mapRowToTimelineEntry) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: TimelineEntry[]) => { + if (lastPage.length < TIMELINE_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].publishedAt + }, + }) +} diff --git a/apps/web/lib/queries/use-unread-counts.ts b/apps/web/lib/queries/use-unread-counts.ts new file mode 100644 index 0000000..75deccb --- /dev/null +++ b/apps/web/lib/queries/use-unread-counts.ts @@ -0,0 +1,32 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" + +interface UnreadCountRow { + feed_id: string + unread_count: number +} + +export function useUnreadCounts() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.unreadCounts.all, + queryFn: async () => { + const { data, error } = await supabaseClient.rpc("get_unread_counts") + + if (error) throw error + + const countsByFeedIdentifier: Record<string, number> = {} + + for (const row of (data as UnreadCountRow[]) ?? []) { + countsByFeedIdentifier[row.feed_id] = row.unread_count + } + + return countsByFeedIdentifier + }, + refetchInterval: 60_000, + }) +} diff --git a/apps/web/lib/queries/use-user-profile.ts b/apps/web/lib/queries/use-user-profile.ts new file mode 100644 index 0000000..760f970 --- /dev/null +++ b/apps/web/lib/queries/use-user-profile.ts @@ -0,0 +1,46 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { UserProfile } from "@/lib/types/user-profile" + +export function useUserProfile() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.userProfile.all, + queryFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { data, error } = await supabaseClient + .from("user_profiles") + .select( + "id, display_name, tier, feed_count, folder_count, muted_keyword_count, custom_feed_count, stripe_subscription_status, stripe_current_period_end" + ) + .eq("id", user.id) + .single() + + if (error) throw error + + const profile: UserProfile = { + identifier: data.id, + email: user.email ?? null, + displayName: data.display_name, + tier: data.tier, + feedCount: data.feed_count, + folderCount: data.folder_count, + mutedKeywordCount: data.muted_keyword_count, + customFeedCount: data.custom_feed_count, + stripeSubscriptionStatus: data.stripe_subscription_status, + stripeCurrentPeriodEnd: data.stripe_current_period_end, + } + + return profile + }, + }) +} diff --git a/apps/web/lib/query-client.ts b/apps/web/lib/query-client.ts new file mode 100644 index 0000000..82be2df --- /dev/null +++ b/apps/web/lib/query-client.ts @@ -0,0 +1,12 @@ +import { QueryClient } from "@tanstack/react-query" + +export function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + refetchOnWindowFocus: false, + }, + }, + }) +} diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts new file mode 100644 index 0000000..4016781 --- /dev/null +++ b/apps/web/lib/rate-limit.ts @@ -0,0 +1,24 @@ +const requestTimestamps = new Map<string, number[]>() + +export function rateLimit( + identifier: string, + limit: number, + windowMilliseconds: number +): { success: boolean; remaining: number } { + const now = Date.now() + const timestamps = requestTimestamps.get(identifier) ?? [] + const windowStart = now - windowMilliseconds + const recentTimestamps = timestamps.filter( + (timestamp) => timestamp > windowStart + ) + + if (recentTimestamps.length >= limit) { + requestTimestamps.set(identifier, recentTimestamps) + return { success: false, remaining: 0 } + } + + recentTimestamps.push(now) + requestTimestamps.set(identifier, recentTimestamps) + + return { success: true, remaining: limit - recentTimestamps.length } +} diff --git a/apps/web/lib/sanitize.ts b/apps/web/lib/sanitize.ts new file mode 100644 index 0000000..b63cee1 --- /dev/null +++ b/apps/web/lib/sanitize.ts @@ -0,0 +1,43 @@ +import sanitizeHtml from "sanitize-html" + +const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { + allowedTags: [ + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "p", + "a", + "ul", + "ol", + "li", + "blockquote", + "pre", + "code", + "em", + "strong", + "del", + "br", + "hr", + "img", + "figure", + "figcaption", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + ], + allowedAttributes: { + a: ["href", "title", "rel"], + img: ["src", "alt", "title", "width", "height"], + }, + allowedSchemes: ["http", "https"], +} + +export function sanitizeEntryContent(htmlContent: string): string { + return sanitizeHtml(htmlContent, SANITIZE_OPTIONS) +} diff --git a/apps/web/lib/stores/notification-store.ts b/apps/web/lib/stores/notification-store.ts new file mode 100644 index 0000000..d7eee57 --- /dev/null +++ b/apps/web/lib/stores/notification-store.ts @@ -0,0 +1,66 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" + +const MAXIMUM_NOTIFICATIONS = 50 + +export interface StoredNotification { + identifier: string + message: string + timestamp: string + type: "info" | "success" | "error" + actionUrl?: string +} + +interface NotificationState { + notifications: StoredNotification[] + lastViewedAt: string | null + + addNotification: ( + message: string, + type?: "info" | "success" | "error", + actionUrl?: string + ) => void + dismissNotification: (identifier: string) => void + clearAllNotifications: () => void + markAllAsViewed: () => void +} + +export const useNotificationStore = create<NotificationState>()( + persist( + (set) => ({ + notifications: [], + lastViewedAt: null, + + addNotification: (message, type = "info", actionUrl) => + set((state) => { + const newNotification: StoredNotification = { + identifier: crypto.randomUUID(), + message, + timestamp: new Date().toISOString(), + type, + ...(actionUrl ? { actionUrl } : {}), + } + const updated = [newNotification, ...state.notifications].slice( + 0, + MAXIMUM_NOTIFICATIONS + ) + return { notifications: updated } + }), + + dismissNotification: (identifier) => + set((state) => ({ + notifications: state.notifications.filter( + (notification) => notification.identifier !== identifier + ), + })), + + clearAllNotifications: () => set({ notifications: [] }), + + markAllAsViewed: () => + set({ lastViewedAt: new Date().toISOString() }), + }), + { + name: "asa-news-notifications", + } + ) +) diff --git a/apps/web/lib/stores/user-interface-store.ts b/apps/web/lib/stores/user-interface-store.ts new file mode 100644 index 0000000..468542d --- /dev/null +++ b/apps/web/lib/stores/user-interface-store.ts @@ -0,0 +1,135 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" + +type EntryListViewMode = "compact" | "comfortable" | "expanded" + +type DisplayDensity = "compact" | "default" | "spacious" + +type FocusedPanel = "sidebar" | "entryList" | "detailPanel" + +type SettingsTab = + | "subscriptions" + | "folders" + | "muted-keywords" + | "custom-feeds" + | "import-export" + | "appearance" + | "account" + | "security" + | "billing" + | "api" + | "danger" + +interface UserInterfaceState { + isSidebarCollapsed: boolean + isCommandPaletteOpen: boolean + isAddFeedDialogOpen: boolean + isSearchOpen: boolean + selectedEntryIdentifier: string | null + focusedEntryIdentifier: string | null + focusedPanel: FocusedPanel + focusedSidebarIndex: number + entryListViewMode: EntryListViewMode + displayDensity: DisplayDensity + activeSettingsTab: SettingsTab + showFeedFavicons: boolean + focusFollowsInteraction: boolean + expandedFolderIdentifiers: string[] + navigableEntryIdentifiers: string[] + + toggleSidebar: () => void + setSidebarCollapsed: (isCollapsed: boolean) => void + setCommandPaletteOpen: (isOpen: boolean) => void + setAddFeedDialogOpen: (isOpen: boolean) => void + setSearchOpen: (isOpen: boolean) => void + setSelectedEntryIdentifier: (identifier: string | null) => void + setFocusedEntryIdentifier: (identifier: string | null) => void + setFocusedPanel: (panel: FocusedPanel) => void + setFocusedSidebarIndex: (index: number) => void + setEntryListViewMode: (mode: EntryListViewMode) => void + setDisplayDensity: (density: DisplayDensity) => void + setActiveSettingsTab: (tab: SettingsTab) => void + setShowFeedFavicons: (show: boolean) => void + setFocusFollowsInteraction: (enabled: boolean) => void + toggleFolderExpansion: (folderIdentifier: string) => void + setNavigableEntryIdentifiers: (identifiers: string[]) => void +} + +export const useUserInterfaceStore = create<UserInterfaceState>()( + persist( + (set) => ({ + isSidebarCollapsed: false, + isCommandPaletteOpen: false, + isAddFeedDialogOpen: false, + isSearchOpen: false, + selectedEntryIdentifier: null, + focusedEntryIdentifier: null, + focusedPanel: "entryList", + focusedSidebarIndex: 0, + entryListViewMode: "comfortable", + displayDensity: "default", + activeSettingsTab: "subscriptions", + showFeedFavicons: true, + focusFollowsInteraction: false, + expandedFolderIdentifiers: [], + navigableEntryIdentifiers: [], + + toggleSidebar: () => + set((state) => ({ isSidebarCollapsed: !state.isSidebarCollapsed })), + + setSidebarCollapsed: (isCollapsed) => + set({ isSidebarCollapsed: isCollapsed }), + + setCommandPaletteOpen: (isOpen) => set({ isCommandPaletteOpen: isOpen }), + + setAddFeedDialogOpen: (isOpen) => set({ isAddFeedDialogOpen: isOpen }), + + setSearchOpen: (isOpen) => set({ isSearchOpen: isOpen }), + + setSelectedEntryIdentifier: (identifier) => + set({ selectedEntryIdentifier: identifier }), + + setFocusedEntryIdentifier: (identifier) => + set({ focusedEntryIdentifier: identifier }), + + setFocusedPanel: (panel) => set({ focusedPanel: panel }), + + setFocusedSidebarIndex: (index) => set({ focusedSidebarIndex: index }), + + setEntryListViewMode: (mode) => set({ entryListViewMode: mode }), + + setDisplayDensity: (density) => set({ displayDensity: density }), + + setActiveSettingsTab: (tab) => set({ activeSettingsTab: tab }), + + setShowFeedFavicons: (show) => set({ showFeedFavicons: show }), + + setFocusFollowsInteraction: (enabled) => + set({ focusFollowsInteraction: enabled }), + + toggleFolderExpansion: (folderIdentifier) => + set((state) => { + const current = state.expandedFolderIdentifiers + const isExpanded = current.includes(folderIdentifier) + return { + expandedFolderIdentifiers: isExpanded + ? current.filter((id) => id !== folderIdentifier) + : [...current, folderIdentifier], + } + }), + + setNavigableEntryIdentifiers: (identifiers) => + set({ navigableEntryIdentifiers: identifiers }), + }), + { + name: "asa-news-ui-preferences", + partialize: (state) => ({ + entryListViewMode: state.entryListViewMode, + displayDensity: state.displayDensity, + showFeedFavicons: state.showFeedFavicons, + focusFollowsInteraction: state.focusFollowsInteraction, + expandedFolderIdentifiers: state.expandedFolderIdentifiers, + }), + } + ) +) diff --git a/apps/web/lib/stripe.ts b/apps/web/lib/stripe.ts new file mode 100644 index 0000000..1955c02 --- /dev/null +++ b/apps/web/lib/stripe.ts @@ -0,0 +1,11 @@ +import Stripe from "stripe" + +let stripeInstance: Stripe | null = null + +export function getStripe(): Stripe { + if (!stripeInstance) { + stripeInstance = new Stripe(process.env.STRIPE_SECRET_KEY!) + } + + return stripeInstance +} diff --git a/apps/web/lib/supabase/admin.ts b/apps/web/lib/supabase/admin.ts new file mode 100644 index 0000000..5f5684d --- /dev/null +++ b/apps/web/lib/supabase/admin.ts @@ -0,0 +1,8 @@ +import { createClient } from "@supabase/supabase-js" + +export function createSupabaseAdminClient() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) +} diff --git a/apps/web/lib/supabase/client.ts b/apps/web/lib/supabase/client.ts new file mode 100644 index 0000000..c6747fb --- /dev/null +++ b/apps/web/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from "@supabase/ssr" + +export function createSupabaseBrowserClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} diff --git a/apps/web/lib/supabase/middleware.ts b/apps/web/lib/supabase/middleware.ts new file mode 100644 index 0000000..038e7c0 --- /dev/null +++ b/apps/web/lib/supabase/middleware.ts @@ -0,0 +1,39 @@ +import { createServerClient } from "@supabase/ssr" +import { NextResponse, type NextRequest } from "next/server" + +export async function updateSupabaseSession(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }) + + const supabaseClient = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ) + + supabaseResponse = NextResponse.next({ + request, + }) + + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + return { user, supabaseResponse } +} diff --git a/apps/web/lib/supabase/server.ts b/apps/web/lib/supabase/server.ts new file mode 100644 index 0000000..f781393 --- /dev/null +++ b/apps/web/lib/supabase/server.ts @@ -0,0 +1,27 @@ +import { createServerClient } from "@supabase/ssr" +import { cookies } from "next/headers" + +export async function createSupabaseServerClient() { + const cookieStore = await cookies() + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll() + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ) + } catch { + // no-op + } + }, + }, + } + ) +} diff --git a/apps/web/lib/types/custom-feed.ts b/apps/web/lib/types/custom-feed.ts new file mode 100644 index 0000000..d729a12 --- /dev/null +++ b/apps/web/lib/types/custom-feed.ts @@ -0,0 +1,8 @@ +export interface CustomFeed { + identifier: string + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + position: number +} diff --git a/apps/web/lib/types/highlight.ts b/apps/web/lib/types/highlight.ts new file mode 100644 index 0000000..60ec53c --- /dev/null +++ b/apps/web/lib/types/highlight.ts @@ -0,0 +1,17 @@ +export interface Highlight { + identifier: string + entryIdentifier: string + highlightedText: string + note: string | null + textOffset: number + textLength: number + textPrefix: string + textSuffix: string + color: string + createdAt: string +} + +export interface HighlightWithEntryContext extends Highlight { + entryTitle: string | null + feedTitle: string | null +} diff --git a/apps/web/lib/types/subscription.ts b/apps/web/lib/types/subscription.ts new file mode 100644 index 0000000..36d16d4 --- /dev/null +++ b/apps/web/lib/types/subscription.ts @@ -0,0 +1,19 @@ +export interface Folder { + folderIdentifier: string + name: string + position: number +} + +export interface Subscription { + subscriptionIdentifier: string + feedIdentifier: string + folderIdentifier: string | null + customTitle: string | null + feedTitle: string + feedUrl: string + consecutiveFailures: number + lastFetchError: string | null + lastFetchedAt: string | null + fetchIntervalSeconds: number + feedType: string | null +} diff --git a/apps/web/lib/types/timeline.ts b/apps/web/lib/types/timeline.ts new file mode 100644 index 0000000..888e428 --- /dev/null +++ b/apps/web/lib/types/timeline.ts @@ -0,0 +1,16 @@ +export interface TimelineEntry { + entryIdentifier: string + feedIdentifier: string + feedTitle: string + customTitle: string | null + entryTitle: string + entryUrl: string + author: string | null + summary: string | null + imageUrl: string | null + publishedAt: string + isRead: boolean + isSaved: boolean + enclosureUrl: string | null + enclosureType: string | null +} diff --git a/apps/web/lib/types/user-profile.ts b/apps/web/lib/types/user-profile.ts new file mode 100644 index 0000000..68eeb75 --- /dev/null +++ b/apps/web/lib/types/user-profile.ts @@ -0,0 +1,18 @@ +export interface UserProfile { + identifier: string + email: string | null + displayName: string | null + tier: "free" | "pro" | "developer" + feedCount: number + folderCount: number + mutedKeywordCount: number + customFeedCount: number + stripeSubscriptionStatus: string | null + stripeCurrentPeriodEnd: string | null +} + +export interface MutedKeyword { + identifier: string + keyword: string + createdAt: string +} diff --git a/apps/web/lib/utilities.ts b/apps/web/lib/utilities.ts new file mode 100644 index 0000000..c4b84f2 --- /dev/null +++ b/apps/web/lib/utilities.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function classNames(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..e54008a --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,27 @@ +import { type NextRequest, NextResponse } from "next/server" +import { updateSupabaseSession } from "@/lib/supabase/middleware" + +export async function middleware(request: NextRequest) { + const { user, supabaseResponse } = await updateSupabaseSession(request) + + const isReaderRoute = request.nextUrl.pathname.startsWith("/reader") + const isAuthRoute = request.nextUrl.pathname.startsWith("/sign-") + + if (!user && isReaderRoute) { + const signInUrl = new URL("/sign-in", request.url) + return NextResponse.redirect(signInUrl) + } + + if (user && isAuthRoute) { + const readerUrl = new URL("/reader", request.url) + return NextResponse.redirect(readerUrl) + } + + return supabaseResponse +} + +export const config = { + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|manifest.json|sw.js|icons).*)", + ], +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..f580efd --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,54 @@ +import withSerwistInit from "@serwist/next" +import type { NextConfig } from "next" + +const withSerwist = withSerwistInit({ + swSrc: "app/sw.ts", + swDest: "public/sw.js", + disable: process.env.NODE_ENV === "development", +}) + +const securityHeaders = [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https: http:", + "font-src 'self'", + "connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com", + "frame-src https://js.stripe.com https://hooks.stripe.com", + "media-src 'self' https: http:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ].join("; "), + }, +] + +const nextConfig: NextConfig = { + reactCompiler: true, + turbopack: {}, + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ] + }, +} + +export default withSerwist(nextConfig) diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..31a0a1f --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,47 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build --webpack", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@asa-news/shared": "workspace:*", + "@serwist/next": "^9.5.4", + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.95.2", + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-virtual": "^3.13.18", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "next": "16.1.6", + "next-themes": "^0.4.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-resizable-panels": "^4.6.0", + "sanitize-html": "^2.17.0", + "serwist": "^9.5.4", + "sonner": "^2.0.7", + "stripe": "^20.3.1", + "tailwind-merge": "^3.4.0", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/sanitize-html": "^2.16.0", + "@vercel/analytics": "^1.6.1", + "@vercel/speed-insights": "^1.3.1", + "babel-plugin-react-compiler": "1.0.0", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/apps/web/pnpm-workspace.yaml b/apps/web/pnpm-workspace.yaml new file mode 100644 index 0000000..581a9d5 --- /dev/null +++ b/apps/web/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - sharp + - unrs-resolver diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/apps/web/public/file.svg b/apps/web/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/apps/web/public/file.svg @@ -0,0 +1 @@ +<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file diff --git a/apps/web/public/globe.svg b/apps/web/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/apps/web/public/globe.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file diff --git a/apps/web/public/icons/icon.svg b/apps/web/public/icons/icon.svg new file mode 100644 index 0000000..0733979 --- /dev/null +++ b/apps/web/public/icons/icon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> + <rect width="512" height="512" fill="#0a0a0a"/> + <text x="256" y="272" text-anchor="middle" font-family="monospace" font-weight="bold" font-size="180" fill="#e5e5e5">asa</text> +</svg> diff --git a/apps/web/public/next.svg b/apps/web/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/apps/web/public/next.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file diff --git a/apps/web/public/vercel.svg b/apps/web/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/apps/web/public/vercel.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file diff --git a/apps/web/public/window.svg b/apps/web/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/apps/web/public/window.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..3a13f90 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6dee777 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "asa-news", + "private": true, + "scripts": { + "build": "turbo build", + "dev": "turbo dev", + "lint": "turbo lint" + }, + "devDependencies": { + "turbo": "^2" + }, + "packageManager": "[email protected]" +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..e17e81c --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,15 @@ +{ + "name": "@asa-news/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./source/index.ts", + "types": "./source/index.ts", + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.7" + } +} diff --git a/packages/shared/source/index.ts b/packages/shared/source/index.ts new file mode 100644 index 0000000..a8108fd --- /dev/null +++ b/packages/shared/source/index.ts @@ -0,0 +1,45 @@ +export const APPLICATION_NAME = "asa.news" + +export const TIER_LIMITS = { + free: { + maximumFeeds: 10, + maximumFolders: 3, + maximumMutedKeywords: 5, + maximumCustomFeeds: 1, + historyRetentionDays: 14, + refreshIntervalSeconds: 1800, + allowsAuthenticatedFeeds: false, + allowsExport: false, + allowsManualRefresh: false, + allowsApiAccess: false, + allowsWebhooks: false, + }, + pro: { + maximumFeeds: 200, + maximumFolders: 10000, + maximumMutedKeywords: 10000, + maximumCustomFeeds: 1000, + historyRetentionDays: Infinity, + refreshIntervalSeconds: 300, + allowsAuthenticatedFeeds: true, + allowsExport: true, + allowsManualRefresh: true, + allowsApiAccess: false, + allowsWebhooks: false, + }, + developer: { + maximumFeeds: 500, + maximumFolders: 10000, + maximumMutedKeywords: 10000, + maximumCustomFeeds: 1000, + historyRetentionDays: Infinity, + refreshIntervalSeconds: 300, + allowsAuthenticatedFeeds: true, + allowsExport: true, + allowsManualRefresh: true, + allowsApiAccess: true, + allowsWebhooks: true, + }, +} as const + +export type SubscriptionTier = keyof typeof TIER_LIMITS diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..1636f4d --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["source"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b6654dd --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5403 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + turbo: + specifier: ^2 + version: 2.8.3 + + apps/web: + dependencies: + '@asa-news/shared': + specifier: workspace:* + version: link:../../packages/shared + '@serwist/next': + specifier: ^9.5.4 + version: 9.5.4([email protected](@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]) + '@supabase/ssr': + specifier: ^0.8.0 + version: 0.8.0(@supabase/[email protected]) + '@supabase/supabase-js': + specifier: ^2.95.2 + version: 2.95.2 + '@tanstack/react-query': + specifier: ^5.90.20 + version: 5.90.20([email protected]) + '@tanstack/react-virtual': + specifier: ^3.13.18 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + next: + specifier: 16.1.6 + version: 16.1.6(@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]) + next-themes: + specifier: ^0.4.6 + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3([email protected]) + react-resizable-panels: + specifier: ^4.6.0 + sanitize-html: + specifier: ^2.17.0 + version: 2.17.0 + serwist: + specifier: ^9.5.4 + version: 9.5.4([email protected])([email protected]) + sonner: + specifier: ^2.0.7 + stripe: + specifier: ^20.3.1 + version: 20.3.1(@types/[email protected]) + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 + zustand: + specifier: ^5.0.11 + version: 5.0.11(@types/[email protected])([email protected]) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.1.18 + '@types/node': + specifier: ^20 + version: 20.19.32 + '@types/react': + specifier: ^19 + version: 19.2.13 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/[email protected]) + '@types/sanitize-html': + specifier: ^2.16.0 + version: 2.16.0 + '@vercel/analytics': + specifier: ^1.6.1 + version: 1.6.1([email protected](@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]) + '@vercel/speed-insights': + specifier: ^1.3.1 + version: 1.3.1([email protected](@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]) + babel-plugin-react-compiler: + specifier: 1.0.0 + version: 1.0.0 + eslint: + specifier: ^9 + version: 9.39.2([email protected]) + eslint-config-next: + specifier: 16.1.6 + version: 16.1.6(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]) + tailwindcss: + specifier: ^4 + version: 4.1.18 + typescript: + specifier: ^5 + version: 5.9.3 + + packages/shared: + devDependencies: + typescript: + specifier: ^5.7 + version: 5.9.3 + +packages: + + '@alloc/[email protected]': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/[email protected]': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/[email protected]': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/[email protected]': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/[email protected]': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/[email protected]': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/[email protected]': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/[email protected]': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@eslint-community/[email protected]': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/[email protected]': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/[email protected]': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/[email protected]': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/[email protected]': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/[email protected]': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/[email protected]': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/[email protected]': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/[email protected]': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/[email protected]': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/[email protected]': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/[email protected]': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/[email protected]': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/[email protected]': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/[email protected]': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/[email protected]': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/[email protected]': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/[email protected]': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/[email protected]': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/[email protected]': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/[email protected]': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/[email protected]': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/[email protected]': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/[email protected]': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/[email protected]': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/[email protected]': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/[email protected]': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/[email protected]': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/[email protected]': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/[email protected]': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/[email protected]': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/[email protected]': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/[email protected]': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/[email protected]': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/[email protected]': + resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + + '@next/[email protected]': + resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} + + '@next/[email protected]': + resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/[email protected]': + resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/[email protected]': + resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/[email protected]': + resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/[email protected]': + resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/[email protected]': + resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/[email protected]': + resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/[email protected]': + resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/[email protected]': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/[email protected]': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/[email protected]': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/[email protected]': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@pkgjs/[email protected]': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/[email protected]': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rtsao/[email protected]': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@serwist/[email protected]': + resolution: {integrity: sha512-FTiNsNb3luKsLIxjKCvkPiqFZSbx7yVNOFGSUhp4lyfzgnelT1M3/lMC88kLiak90emkuFjSkQgwa6OnyhMZlQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + '@serwist/[email protected]': + resolution: {integrity: sha512-bkPkvMs4GfNV3C3tSZg9O5mb+B7Rq8zLpkZNF2hV96zep6d/ujA7yBp8o8bD1rmLb7I++ifsIMQzH2a+vVc/ag==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@serwist/cli': ^9.5.4 + next: '>=14.0.0' + react: '>=18.0.0' + typescript: '>=5.0.0' + peerDependenciesMeta: + '@serwist/cli': + optional: true + typescript: + optional: true + + '@serwist/[email protected]': + resolution: {integrity: sha512-uyriGQF1qjNEHXXfsd8XJ5kfK3/MezEaUw//XdHjZeJ0LvLamrgnLJGQQoyJqUfEPCiJ4jJwc4uYMB9LjLiHxA==} + peerDependencies: + browserslist: '>=4' + peerDependenciesMeta: + browserslist: + optional: true + + '@serwist/[email protected]': + resolution: {integrity: sha512-Zmrce/fuKIBYPOFlCSutIk2rE3949ihFjrXiiR3SNOhnBhxYDOcf6oz3zXFQDLAaih8wdjDL4PlzPEqKg1OmJA==} + engines: {node: '>=18.0.0'} + peerDependencies: + typescript: '>=5.0.0' + webpack: 4.4.0 || ^5.9.0 + peerDependenciesMeta: + typescript: + optional: true + webpack: + optional: true + + '@serwist/[email protected]': + resolution: {integrity: sha512-52t2G+TgiWDdRwGG0ArU28uy6/oQYICQfNLHs4ywybyS6mHy3BxHFl+JjB5vhg8znIG1LMpGvOmS5b7AuPVYDw==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + '@supabase/[email protected]': + resolution: {integrity: sha512-0dW1Y0vFcjkQpjJb/vR92lVqVaWSjM8v6VBPoaJfiIQ94+F/qnc+yV1ZTSZ96LqM61TaLAhqt7S2fLS//lc8ew==} + engines: {node: '>=20.0.0'} + + '@supabase/[email protected]': + resolution: {integrity: sha512-SInf19qm2aWEp49O5OJ+gftzifSPeaA5w0L1qF96Ul13tilRbQOlQAk1e7py9xH4tPkOkCDMf6n+H5Js5uzIUQ==} + engines: {node: '>=20.0.0'} + + '@supabase/[email protected]': + resolution: {integrity: sha512-v0DHzy3vFALRLaFNZPbBWK7PESPvI+cC7/A2HtqwmLuX7XdOwdHwaz2yooNOfcYkoNOLS5p8WbgCA9DDXs+QlQ==} + engines: {node: '>=20.0.0'} + + '@supabase/[email protected]': + resolution: {integrity: sha512-8LhHR28Xv1/jjtfiospyqMdE+sL0SVdk24ziio4vmgsUHApzh4ucr+sVY3LXInzSVycrgNnYwStMiWuTZUVxoA==} + engines: {node: '>=20.0.0'} + + '@supabase/[email protected]': + resolution: {integrity: sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==} + peerDependencies: + '@supabase/supabase-js': ^2.76.1 + + '@supabase/[email protected]': + resolution: {integrity: sha512-vUNMnHgbyHIHGFmwkQkp1VUdWVGWoFqaEgAAmlXx3Ca0+GzlyScwhkYm8dvtM+mgUo/nJQXwPRPOcWTZMo/tpg==} + engines: {node: '>=20.0.0'} + + '@supabase/[email protected]': + resolution: {integrity: sha512-CkNIPJCdOkokX5E92RJt6yHNyHprJ2V8wRuGK8wCPwbaEmWvW0CvMj+qr4uGozwnOd1ixeAsQUSg6+roA0+muA==} + engines: {node: '>=20.0.0'} + + '@swc/[email protected]': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + + '@tailwindcss/[email protected]': + resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + + '@tanstack/[email protected]': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/[email protected]': + resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/[email protected]': + resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/[email protected]': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} + + '@tybys/[email protected]': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/[email protected]': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/[email protected]': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/[email protected]': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/[email protected]': + resolution: {integrity: sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==} + + '@types/[email protected]': + resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + + '@types/[email protected]': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/[email protected]': + resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + + '@types/[email protected]': + resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} + + '@types/[email protected]': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/[email protected]': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.54.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/[email protected]': + resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/[email protected]': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/[email protected]': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/[email protected]': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/[email protected]': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/[email protected]': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/[email protected]': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/[email protected]': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/[email protected]': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/[email protected]': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/[email protected]': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/[email protected]': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/[email protected]': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/[email protected]': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/[email protected]': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/[email protected]': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/[email protected]': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/[email protected]': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/[email protected]': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/[email protected]': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + '@vercel/[email protected]': + resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + + '@vercel/[email protected]': + resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==} + peerDependencies: + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + engines: {node: '>=4'} + + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-v4/4xAEpBRp6SvCkWhnGCaLkJf9IwWzrsygJPxD/+p2/xPE3C5m2fA9FD0Ry9tG+Rqqq3gBzHSl6y1/T9V/tMQ==} + + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + + resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} + + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + resolution: {integrity: sha512-I0GUBybvHQ9xde06MH1pmhnnoZfj3lytVhA8r9Pu6r6zunoUfVRy3tU1XT9lE83yUfjlCIMaXKxPrQMmjANIkA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==} + + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + resolution: {integrity: sha512-uTHBzpIeA6rE3oyRt392MbtNQDs2JVZelKD1KkT18UkhX6HRwCeassoI1Nd1h52DqYqa7ZfBeldJ4awy+PYrnQ==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions + + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + resolution: {integrity: sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw==} + cpu: [x64] + os: [darwin] + + resolution: {integrity: sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ==} + cpu: [arm64] + os: [darwin] + + resolution: {integrity: sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA==} + cpu: [x64] + os: [linux] + + resolution: {integrity: sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q==} + cpu: [arm64] + os: [linux] + + resolution: {integrity: sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw==} + cpu: [x64] + os: [win32] + + resolution: {integrity: sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw==} + cpu: [arm64] + os: [win32] + + resolution: {integrity: sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ==} + hasBin: true + + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@alloc/[email protected]': {} + + '@babel/[email protected]': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/[email protected]': {} + + '@babel/[email protected]': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/[email protected]) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/[email protected]': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/[email protected]': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/[email protected]': {} + + '@babel/[email protected]': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/[email protected](@babel/[email protected])': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/[email protected]': {} + + '@babel/[email protected]': {} + + '@babel/[email protected]': {} + + '@babel/[email protected]': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/[email protected]': + dependencies: + '@babel/types': 7.29.0 + + '@babel/[email protected]': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/[email protected]': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/[email protected]': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/[email protected]': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/[email protected]': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/[email protected]': + dependencies: + tslib: 2.8.1 + optional: true + + dependencies: + eslint: 9.39.2([email protected]) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/[email protected]': {} + + '@eslint/[email protected]': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/[email protected]': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/[email protected]': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/[email protected]': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/[email protected]': {} + + '@eslint/[email protected]': {} + + '@eslint/[email protected]': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/[email protected]': {} + + '@humanfs/[email protected]': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/[email protected]': {} + + '@humanwhocodes/[email protected]': {} + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/[email protected]': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/[email protected]': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@img/[email protected]': + optional: true + + '@isaacs/[email protected]': + dependencies: + string-width: 5.1.2 + string-width-cjs: [email protected] + strip-ansi: 7.1.2 + strip-ansi-cjs: [email protected] + wrap-ansi: 8.1.0 + wrap-ansi-cjs: [email protected] + + '@jridgewell/[email protected]': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/[email protected]': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/[email protected]': {} + + '@jridgewell/[email protected]': {} + + '@jridgewell/[email protected]': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/[email protected]': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/[email protected]': {} + + '@next/[email protected]': + dependencies: + fast-glob: 3.3.1 + + '@next/[email protected]': + optional: true + + '@next/[email protected]': + optional: true + + '@next/[email protected]': + optional: true + + '@next/[email protected]': + optional: true + + '@next/[email protected]': + optional: true + + '@next/[email protected]': + optional: true + + '@next/[email protected]': + optional: true + + '@next/[email protected]': + optional: true + + '@nodelib/[email protected]': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/[email protected]': {} + + '@nodelib/[email protected]': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/[email protected]': {} + + '@pkgjs/[email protected]': + optional: true + + '@radix-ui/[email protected]': {} + + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) + '@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected]) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + '@radix-ui/react-focus-guards': 1.1.3(@types/[email protected])([email protected]) + '@radix-ui/react-focus-scope': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + '@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected]) + '@radix-ui/react-portal': 1.1.9(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + '@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + '@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + '@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected]) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected]) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3([email protected]) + react-remove-scroll: 2.7.2(@types/[email protected])([email protected]) + optionalDependencies: + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/[email protected]) + + '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) + '@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected]) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/[email protected])([email protected]) + react: 19.2.3 + react-dom: 19.2.3([email protected]) + optionalDependencies: + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/[email protected]) + + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) + '@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected]) + react: 19.2.3 + react-dom: 19.2.3([email protected]) + optionalDependencies: + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/[email protected]) + + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected]) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected]) + react: 19.2.3 + react-dom: 19.2.3([email protected]) + optionalDependencies: + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/[email protected]) + + '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected]) + react: 19.2.3 + react-dom: 19.2.3([email protected]) + optionalDependencies: + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/[email protected]) + + '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected]) + react: 19.2.3 + react-dom: 19.2.3([email protected]) + optionalDependencies: + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/[email protected]) + + '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/[email protected])([email protected]) + react: 19.2.3 + react-dom: 19.2.3([email protected]) + optionalDependencies: + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/[email protected]) + + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/[email protected])([email protected]) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected]) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected]) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected]) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.13 + + '@rtsao/[email protected]': {} + + dependencies: + '@serwist/utils': 9.5.4([email protected]) + common-tags: 1.8.2 + glob: 10.5.0 + pretty-bytes: 6.1.1 + source-map: 0.8.0-beta.0 + zod: 4.3.6 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - browserslist + + '@serwist/[email protected]([email protected](@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])': + dependencies: + '@serwist/build': 9.5.4([email protected])([email protected]) + '@serwist/utils': 9.5.4([email protected]) + '@serwist/webpack-plugin': 9.5.4([email protected])([email protected]) + '@serwist/window': 9.5.4([email protected])([email protected]) + browserslist: 4.28.1 + glob: 10.5.0 + kolorist: 1.8.0 + next: 16.1.6(@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]) + react: 19.2.3 + semver: 7.7.3 + serwist: 9.5.4([email protected])([email protected]) + zod: 4.3.6 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - webpack + + '@serwist/[email protected]([email protected])': + optionalDependencies: + browserslist: 4.28.1 + + dependencies: + '@serwist/build': 9.5.4([email protected])([email protected]) + '@serwist/utils': 9.5.4([email protected]) + pretty-bytes: 6.1.1 + zod: 4.3.6 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - browserslist + + dependencies: + '@types/trusted-types': 2.0.7 + serwist: 9.5.4([email protected])([email protected]) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - browserslist + + '@supabase/[email protected]': + dependencies: + tslib: 2.8.1 + + '@supabase/[email protected]': + dependencies: + tslib: 2.8.1 + + '@supabase/[email protected]': + dependencies: + tslib: 2.8.1 + + '@supabase/[email protected]': + dependencies: + '@types/phoenix': 1.6.7 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/[email protected](@supabase/[email protected])': + dependencies: + '@supabase/supabase-js': 2.95.2 + cookie: 1.1.1 + + '@supabase/[email protected]': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/[email protected]': + dependencies: + '@supabase/auth-js': 2.95.2 + '@supabase/functions-js': 2.95.2 + '@supabase/postgrest-js': 2.95.2 + '@supabase/realtime-js': 2.95.2 + '@supabase/storage-js': 2.95.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@swc/[email protected]': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/[email protected]': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optional: true + + '@tailwindcss/[email protected]': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + + '@tailwindcss/[email protected]': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + postcss: 8.5.6 + tailwindcss: 4.1.18 + + '@tanstack/[email protected]': {} + + '@tanstack/[email protected]([email protected])': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.3 + + dependencies: + '@tanstack/virtual-core': 3.13.18 + react: 19.2.3 + react-dom: 19.2.3([email protected]) + + '@tanstack/[email protected]': {} + + '@tybys/[email protected]': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/[email protected]': {} + + '@types/[email protected]': {} + + '@types/[email protected]': {} + + '@types/[email protected]': + dependencies: + undici-types: 6.21.0 + + '@types/[email protected]': {} + + '@types/[email protected](@types/[email protected])': + dependencies: + '@types/react': 19.2.13 + + '@types/[email protected]': + dependencies: + csstype: 3.2.3 + + '@types/[email protected]': + dependencies: + htmlparser2: 8.0.2 + + '@types/[email protected]': {} + + '@types/[email protected]': + dependencies: + '@types/node': 20.19.32 + + '@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/visitor-keys': 8.54.0 + eslint: 9.39.2([email protected]) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0([email protected]) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + dependencies: + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0([email protected]) + '@typescript-eslint/visitor-keys': 8.54.0 + debug: 4.4.3 + eslint: 9.39.2([email protected]) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/[email protected]([email protected])': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.54.0([email protected]) + '@typescript-eslint/types': 8.54.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/[email protected]': + dependencies: + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/visitor-keys': 8.54.0 + + '@typescript-eslint/[email protected]([email protected])': + dependencies: + typescript: 5.9.3 + + dependencies: + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0([email protected]) + debug: 4.4.3 + eslint: 9.39.2([email protected]) + ts-api-utils: 2.4.0([email protected]) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/[email protected]': {} + + '@typescript-eslint/[email protected]([email protected])': + dependencies: + '@typescript-eslint/project-service': 8.54.0([email protected]) + '@typescript-eslint/tsconfig-utils': 8.54.0([email protected]) + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/visitor-keys': 8.54.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0([email protected]) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + dependencies: + '@eslint-community/eslint-utils': 4.9.1([email protected]([email protected])) + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0([email protected]) + eslint: 9.39.2([email protected]) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/[email protected]': + dependencies: + '@typescript-eslint/types': 8.54.0 + eslint-visitor-keys: 4.2.1 + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@unrs/[email protected]': + optional: true + + '@vercel/[email protected]([email protected](@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])': + optionalDependencies: + next: 16.1.6(@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]) + react: 19.2.3 + + '@vercel/[email protected]([email protected](@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])': + optionalDependencies: + next: 16.1.6(@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]) + react: 19.2.3 + + dependencies: + acorn: 8.15.0 + + [email protected]: {} + + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + [email protected]: {} + + [email protected]: {} + + dependencies: + color-convert: 2.0.1 + + [email protected]: {} + + [email protected]: {} + + dependencies: + tslib: 2.8.1 + + [email protected]: {} + + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + [email protected]: {} + + [email protected]: {} + + dependencies: + possible-typed-array-names: 1.1.0 + + [email protected]: {} + + [email protected]: {} + + dependencies: + '@babel/types': 7.29.0 + + [email protected]: {} + + [email protected]: {} + + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + dependencies: + balanced-match: 1.0.2 + + dependencies: + fill-range: 7.1.1 + + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3([email protected]) + + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + [email protected]: {} + + [email protected]: {} + + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + [email protected]: {} + + [email protected]: {} + + [email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) + '@radix-ui/react-dialog': 1.1.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + '@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected]) + '@radix-ui/react-primitive': 2.1.4(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) + react: 19.2.3 + react-dom: 19.2.3([email protected]) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + dependencies: + color-name: 1.1.4 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + [email protected]: {} + + [email protected]: {} + + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + [email protected]: {} + + dependencies: + ms: 2.1.3 + + dependencies: + ms: 2.1.3 + + [email protected]: {} + + [email protected]: {} + + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + [email protected]: {} + + [email protected]: {} + + dependencies: + esutils: 2.0.3 + + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + [email protected]: {} + + dependencies: + domelementtype: 2.3.0 + + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + [email protected]: {} + + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + [email protected]: {} + + [email protected]: {} + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + + dependencies: + es-errors: 1.3.0 + + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + dependencies: + hasown: 2.0.2 + + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + [email protected]: {} + + [email protected]: {} + + [email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]): + dependencies: + '@next/eslint-plugin-next': 16.1.6 + eslint: 9.39.2([email protected]) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected])) + eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected]([email protected])) + eslint-plugin-jsx-a11y: 6.10.2([email protected]([email protected])) + eslint-plugin-react: 7.37.5([email protected]([email protected])) + eslint-plugin-react-hooks: 7.0.1([email protected]([email protected])) + globals: 16.4.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.2([email protected]) + get-tsconfig: 4.13.5 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected]([email protected])) + transitivePeerDependencies: + - supports-color + + [email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])([email protected]([email protected])): + dependencies: + debug: 3.2.7 + optionalDependencies: + eslint: 9.39.2([email protected]) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected])) + transitivePeerDependencies: + - supports-color + + [email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected]([email protected])): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.2([email protected]) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])([email protected]([email protected])) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.2([email protected]) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.2([email protected]) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2([email protected]) + transitivePeerDependencies: + - supports-color + + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.2 + eslint: 9.39.2([email protected]) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + [email protected]: {} + + [email protected]: {} + + dependencies: + '@eslint-community/eslint-utils': 4.9.1([email protected]([email protected])) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2([email protected]) + eslint-visitor-keys: 4.2.1 + + dependencies: + estraverse: 5.3.0 + + dependencies: + estraverse: 5.3.0 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + [email protected]: {} + + [email protected]: {} + + dependencies: + reusify: 1.1.0 + + optionalDependencies: + picomatch: 4.0.3 + + dependencies: + flat-cache: 4.0.1 + + dependencies: + to-regex-range: 5.0.1 + + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + [email protected]: {} + + dependencies: + is-callable: 1.2.7 + + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + [email protected]: {} + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + [email protected]: {} + + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + dependencies: + resolve-pkg-maps: 1.0.0 + + dependencies: + is-glob: 4.0.3 + + dependencies: + is-glob: 4.0.3 + + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + [email protected]: {} + + [email protected]: {} + + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + es-define-property: 1.0.1 + + dependencies: + dunder-proto: 1.0.1 + + [email protected]: {} + + dependencies: + has-symbols: 1.1.0 + + dependencies: + function-bind: 1.1.2 + + [email protected]: {} + + dependencies: + hermes-estree: 0.25.1 + + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + [email protected]: {} + + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + dependencies: + has-bigints: 1.1.0 + + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + dependencies: + semver: 7.7.4 + + [email protected]: {} + + dependencies: + hasown: 2.0.2 + + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + [email protected]: {} + + dependencies: + call-bound: 1.0.4 + + [email protected]: {} + + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + dependencies: + is-extglob: 2.1.1 + + [email protected]: {} + + [email protected]: {} + + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + [email protected]: {} + + [email protected]: {} + + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + [email protected]: {} + + dependencies: + call-bound: 1.0.4 + + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + dependencies: + which-typed-array: 1.1.20 + + [email protected]: {} + + dependencies: + call-bound: 1.0.4 + + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + [email protected]: {} + + [email protected]: {} + + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + [email protected]: {} + + [email protected]: {} + + dependencies: + argparse: 2.0.1 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + minimist: 1.2.8 + + [email protected]: {} + + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + dependencies: + json-buffer: 3.0.1 + + [email protected]: {} + + [email protected]: {} + + dependencies: + language-subtag-registry: 0.3.23 + + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + optional: true + + optional: true + + optional: true + + optional: true + + optional: true + + optional: true + + optional: true + + optional: true + + optional: true + + optional: true + + optional: true + + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + dependencies: + p-locate: 5.0.0 + + [email protected]: {} + + [email protected]: {} + + dependencies: + js-tokens: 4.0.0 + + [email protected]: {} + + dependencies: + yallist: 3.1.1 + + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + [email protected]: {} + + [email protected]: {} + + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + dependencies: + brace-expansion: 1.1.12 + + dependencies: + brace-expansion: 2.0.2 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + react: 19.2.3 + react-dom: 19.2.3([email protected]) + + [email protected](@babel/[email protected])([email protected])([email protected]([email protected]))([email protected]): + dependencies: + '@next/env': 16.1.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + postcss: 8.4.31 + react: 19.2.3 + react-dom: 19.2.3([email protected]) + styled-jsx: 5.1.6(@babel/[email protected])([email protected]) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + dependencies: + yocto-queue: 0.1.0 + + dependencies: + p-limit: 3.1.0 + + [email protected]: {} + + dependencies: + callsites: 3.1.0 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + [email protected]: {} + + [email protected]: {} + + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + [email protected]: {} + + [email protected]: {} + + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + [email protected]: {} + + dependencies: + react: 19.2.3 + react-style-singleton: 2.2.3(@types/[email protected])([email protected]) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/[email protected])([email protected]) + react-style-singleton: 2.2.3(@types/[email protected])([email protected]) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/[email protected])([email protected]) + use-sidecar: 1.1.3(@types/[email protected])([email protected]) + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + react: 19.2.3 + react-dom: 19.2.3([email protected]) + + dependencies: + get-nonce: 1.0.1 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.13 + + [email protected]: {} + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + [email protected]: {} + + [email protected]: {} + + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + [email protected]: {} + + dependencies: + queue-microtask: 1.2.3 + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.5.6 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + '@serwist/utils': 9.5.4([email protected]) + idb: 8.0.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - browserslist + + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + dependencies: + shebang-regex: 3.0.0 + + [email protected]: {} + + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + [email protected]: {} + + dependencies: + react: 19.2.3 + react-dom: 19.2.3([email protected]) + + [email protected]: {} + + dependencies: + whatwg-url: 7.1.0 + + [email protected]: {} + + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + dependencies: + ansi-regex: 5.0.1 + + dependencies: + ansi-regex: 6.2.2 + + [email protected]: {} + + [email protected]: {} + + [email protected](@types/[email protected]): + optionalDependencies: + '@types/node': 20.19.32 + + dependencies: + client-only: 0.0.1 + react: 19.2.3 + optionalDependencies: + '@babel/core': 7.29.0 + + dependencies: + has-flag: 4.0.0 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + fdir: 6.5.0([email protected]) + picomatch: 4.0.3 + + dependencies: + is-number: 7.0.0 + + dependencies: + punycode: 2.3.1 + + dependencies: + typescript: 5.9.3 + + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + [email protected]: {} + + optional: true + + optional: true + + optional: true + + optional: true + + optional: true + + optional: true + + optionalDependencies: + turbo-darwin-64: 2.8.3 + turbo-darwin-arm64: 2.8.3 + turbo-linux-64: 2.8.3 + turbo-linux-arm64: 2.8.3 + turbo-windows-64: 2.8.3 + turbo-windows-arm64: 2.8.3 + + dependencies: + prelude-ls: 1.2.1 + + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + dependencies: + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]) + '@typescript-eslint/typescript-estree': 8.54.0([email protected]) + eslint: 9.39.2([email protected]) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + [email protected]: {} + + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + [email protected]: {} + + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + dependencies: + punycode: 2.3.1 + + dependencies: + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.13 + + dependencies: + detect-node-es: 1.1.0 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.13 + + [email protected]: {} + + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + dependencies: + isexe: 2.0.0 + + [email protected]: {} + + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + [email protected]: {} + + [email protected]: {} + + [email protected]: {} + + dependencies: + zod: 4.3.6 + + [email protected]: {} + + optionalDependencies: + '@types/react': 19.2.13 + react: 19.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/services/worker/Dockerfile b/services/worker/Dockerfile new file mode 100644 index 0000000..59678ff --- /dev/null +++ b/services/worker/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.23-bookworm AS builder + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /build/worker ./cmd/worker + +FROM gcr.io/distroless/static:nonroot + +COPY --from=builder /build/worker /worker + +USER nonroot:nonroot + +ENTRYPOINT ["/worker"] diff --git a/services/worker/Taskfile.yaml b/services/worker/Taskfile.yaml new file mode 100644 index 0000000..ea32aab --- /dev/null +++ b/services/worker/Taskfile.yaml @@ -0,0 +1,57 @@ +version: "3" + +vars: + BINARY: worker + +tasks: + default: + desc: Build the application + cmds: + - task: build + + build: + desc: Build the binary with optimisations + cmds: + - go build -ldflags="-s -w" -o {{.BINARY}} ./cmd/worker + sources: + - ./**/*.go + generates: + - ./{{.BINARY}} + + run: + desc: Build and run the application + deps: [build] + cmds: + - ./{{.BINARY}} + + clean: + desc: Remove build artifacts + cmds: + - rm -f {{.BINARY}} + - go clean + + test: + desc: Run tests + cmds: + - go test ./... + + fmt: + desc: Format code + cmds: + - iku -w . || go fmt ./... + + lint: + desc: Run linter + cmds: + - golangci-lint run + + dev: + desc: Build and run in development mode + cmds: + - go run ./cmd/worker + + tidy: + desc: Tidy and verify module dependencies + cmds: + - go mod tidy + - go mod verify diff --git a/services/worker/cmd/worker/main.go b/services/worker/cmd/worker/main.go new file mode 100644 index 0000000..9732ec6 --- /dev/null +++ b/services/worker/cmd/worker/main.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "fmt" + "github.com/Fuwn/asa-news/internal/configuration" + "github.com/Fuwn/asa-news/internal/database" + "github.com/Fuwn/asa-news/internal/fetcher" + "github.com/Fuwn/asa-news/internal/health" + "github.com/Fuwn/asa-news/internal/parser" + "github.com/Fuwn/asa-news/internal/pool" + "github.com/Fuwn/asa-news/internal/scheduler" + "github.com/Fuwn/asa-news/internal/webhook" + "github.com/Fuwn/asa-news/internal/writer" + "log/slog" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + exitCode := run() + + os.Exit(exitCode) +} + +func run() int { + applicationConfiguration, configurationError := configuration.Load() + + if configurationError != nil { + fmt.Fprintf(os.Stderr, "failed to load configuration: %v\n", configurationError) + + return 1 + } + + logger := createLogger(applicationConfiguration) + + logger.Info("starting feed worker service") + + applicationContext, cancelApplication := signal.NotifyContext( + context.Background(), + syscall.SIGINT, + syscall.SIGTERM, + ) + + defer cancelApplication() + + databaseConnectionPool, databaseError := database.CreateConnectionPool( + applicationContext, + applicationConfiguration.DatabaseURL, + ) + + if databaseError != nil { + logger.Error("failed to create database connection pool", "error", databaseError) + + return 1 + } + + defer databaseConnectionPool.Close() + + feedFetcher := fetcher.NewFetcher(applicationConfiguration.FetchTimeout) + feedParser := parser.NewParser() + feedWriter := writer.NewWriter(databaseConnectionPool) + webhookDispatcher := webhook.NewDispatcher(databaseConnectionPool, logger) + workerPool := pool.NewWorkerPool(applicationConfiguration.WorkerConcurrency, logger) + healthServer := health.NewHealthServer( + applicationConfiguration.HealthPort, + databaseConnectionPool, + logger, + ) + + healthServer.Start() + + feedScheduler := scheduler.NewScheduler( + databaseConnectionPool, + feedFetcher, + feedParser, + feedWriter, + webhookDispatcher, + workerPool, + applicationConfiguration.PollInterval, + applicationConfiguration.QueuePollInterval, + applicationConfiguration.BatchSize, + logger, + ) + + logger.Info("feed worker service started", + "worker_concurrency", applicationConfiguration.WorkerConcurrency, + "poll_interval", applicationConfiguration.PollInterval, + "health_port", applicationConfiguration.HealthPort, + ) + feedScheduler.Run(applicationContext) + logger.Info("shutting down feed worker service") + logger.Info("waiting for in-flight work to complete") + workerPool.Wait() + + shutdownContext, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second) + + defer cancelShutdown() + + healthShutdownError := healthServer.Stop(shutdownContext) + + if healthShutdownError != nil { + logger.Error("health server shutdown error", "error", healthShutdownError) + } + + logger.Info("feed worker service stopped") + + return 0 +} + +func createLogger(applicationConfiguration configuration.Configuration) *slog.Logger { + logLevel := resolveLogLevel(applicationConfiguration.LogLevel) + handlerOptions := &slog.HandlerOptions{ + Level: logLevel, + } + + var logHandler slog.Handler + + if applicationConfiguration.LogJSON { + logHandler = slog.NewJSONHandler(os.Stdout, handlerOptions) + } else { + logHandler = slog.NewTextHandler(os.Stdout, handlerOptions) + } + + return slog.New(logHandler) +} + +func resolveLogLevel(levelString string) slog.Level { + switch levelString { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/services/worker/go.mod b/services/worker/go.mod new file mode 100644 index 0000000..2588959 --- /dev/null +++ b/services/worker/go.mod @@ -0,0 +1,81 @@ +module github.com/Fuwn/asa-news + +go 1.24.0 + +toolchain go1.25.6 + +require ( + github.com/craigpastro/pgmq-go v0.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/mmcdole/gofeed v1.3.0 +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/PuerkitoBio/goquery v1.8.0 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/avast/retry-go/v4 v4.6.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.3.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/testcontainers/testcontainers-go v0.35.0 // indirect + github.com/tklauser/go-sysconf v0.3.14 // indirect + github.com/tklauser/numcpus v0.9.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/services/worker/go.sum b/services/worker/go.sum new file mode 100644 index 0000000..8a61ca7 --- /dev/null +++ b/services/worker/go.sum @@ -0,0 +1,226 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/avast/retry-go/v4 v4.6.0 h1:K9xNA+KeB8HHc2aWFuLb25Offp+0iVRXEvFx8IinRJA= +github.com/avast/retry-go/v4 v4.6.0/go.mod h1:gvWlPhBVsvBbLkVGDg/KwvBv0bEkCOLRRSHKIr2PyOE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/craigpastro/pgmq-go v0.6.0 h1:6hrQngiiB6SkpXJNR88dX4bqrZBfCLiuWPQzKoy50RI= +github.com/craigpastro/pgmq-go v0.6.0/go.mod h1:cDDC2UWrRXP9l0e1dKlKnW3hHftNDobMlCgbw+V56og= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= +github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= +github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= +github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= +github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= +github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= +github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/services/worker/internal/configuration/configuration.go b/services/worker/internal/configuration/configuration.go new file mode 100644 index 0000000..84a5995 --- /dev/null +++ b/services/worker/internal/configuration/configuration.go @@ -0,0 +1,110 @@ +package configuration + +import ( + "fmt" + "os" + "strconv" + "time" +) + +type Configuration struct { + DatabaseURL string + WorkerConcurrency int + PollInterval time.Duration + FetchTimeout time.Duration + QueuePollInterval time.Duration + BatchSize int + HealthPort int + EncryptionKey string + LogLevel string + LogJSON bool +} + +func Load() (Configuration, error) { + databaseURL := os.Getenv("DATABASE_URL") + + if databaseURL == "" { + return Configuration{}, fmt.Errorf("DATABASE_URL is required") + } + + workerConcurrency := getEnvironmentInteger("WORKER_CONCURRENCY", 10) + pollInterval := getEnvironmentDuration("POLL_INTERVAL", 30*time.Second) + fetchTimeout := getEnvironmentDuration("FETCH_TIMEOUT", 30*time.Second) + queuePollInterval := getEnvironmentDuration("QUEUE_POLL_INTERVAL", 5*time.Second) + batchSize := getEnvironmentInteger("BATCH_SIZE", 50) + healthPort := getEnvironmentInteger("HEALTH_PORT", 8080) + encryptionKey := os.Getenv("ENCRYPTION_KEY") + logLevel := getEnvironmentString("LOG_LEVEL", "info") + logJSON := getEnvironmentBoolean("LOG_JSON", false) + + return Configuration{ + DatabaseURL: databaseURL, + WorkerConcurrency: workerConcurrency, + PollInterval: pollInterval, + FetchTimeout: fetchTimeout, + QueuePollInterval: queuePollInterval, + BatchSize: batchSize, + HealthPort: healthPort, + EncryptionKey: encryptionKey, + LogLevel: logLevel, + LogJSON: logJSON, + }, nil +} + +func getEnvironmentString(key string, defaultValue string) string { + value := os.Getenv(key) + + if value == "" { + return defaultValue + } + + return value +} + +func getEnvironmentInteger(key string, defaultValue int) int { + raw := os.Getenv(key) + + if raw == "" { + return defaultValue + } + + parsed, parseError := strconv.Atoi(raw) + + if parseError != nil { + return defaultValue + } + + return parsed +} + +func getEnvironmentDuration(key string, defaultValue time.Duration) time.Duration { + raw := os.Getenv(key) + + if raw == "" { + return defaultValue + } + + parsed, parseError := time.ParseDuration(raw) + + if parseError != nil { + return defaultValue + } + + return parsed +} + +func getEnvironmentBoolean(key string, defaultValue bool) bool { + raw := os.Getenv(key) + + if raw == "" { + return defaultValue + } + + parsed, parseError := strconv.ParseBool(raw) + + if parseError != nil { + return defaultValue + } + + return parsed +} diff --git a/services/worker/internal/database/database.go b/services/worker/internal/database/database.go new file mode 100644 index 0000000..2b2900f --- /dev/null +++ b/services/worker/internal/database/database.go @@ -0,0 +1,39 @@ +package database + +import ( + "context" + "fmt" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "time" +) + +func CreateConnectionPool(parentContext context.Context, databaseURL string) (*pgxpool.Pool, error) { + poolConfiguration, parseError := pgxpool.ParseConfig(databaseURL) + + if parseError != nil { + return nil, fmt.Errorf("failed to parse database URL: %w", parseError) + } + + poolConfiguration.MaxConns = 25 + poolConfiguration.MinConns = 5 + poolConfiguration.MaxConnLifetime = 30 * time.Minute + poolConfiguration.MaxConnIdleTime = 5 * time.Minute + poolConfiguration.HealthCheckPeriod = 30 * time.Second + poolConfiguration.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExec + connectionPool, connectionError := pgxpool.NewWithConfig(parentContext, poolConfiguration) + + if connectionError != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", connectionError) + } + + pingError := connectionPool.Ping(parentContext) + + if pingError != nil { + connectionPool.Close() + + return nil, fmt.Errorf("failed to ping database: %w", pingError) + } + + return connectionPool, nil +} diff --git a/services/worker/internal/fetcher/authentication.go b/services/worker/internal/fetcher/authentication.go new file mode 100644 index 0000000..ba10196 --- /dev/null +++ b/services/worker/internal/fetcher/authentication.go @@ -0,0 +1,43 @@ +package fetcher + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" +) + +type AuthenticationConfiguration struct { + AuthenticationType string + AuthenticationValue string +} + +func ApplyAuthentication(request *http.Request, authenticationConfig AuthenticationConfiguration) error { + switch authenticationConfig.AuthenticationType { + case "bearer": + request.Header.Set("Authorization", "Bearer "+authenticationConfig.AuthenticationValue) + case "basic": + encodedCredentials := base64.StdEncoding.EncodeToString([]byte(authenticationConfig.AuthenticationValue)) + + request.Header.Set("Authorization", "Basic "+encodedCredentials) + case "query_param": + existingURL, parseError := url.Parse(request.URL.String()) + + if parseError != nil { + return fmt.Errorf("failed to parse request URL for query param authentication: %w", parseError) + } + + queryParameters := existingURL.Query() + + queryParameters.Set("key", authenticationConfig.AuthenticationValue) + + existingURL.RawQuery = queryParameters.Encode() + request.URL = existingURL + case "": + return nil + default: + return fmt.Errorf("unsupported authentication type: %s", authenticationConfig.AuthenticationType) + } + + return nil +} diff --git a/services/worker/internal/fetcher/errors.go b/services/worker/internal/fetcher/errors.go new file mode 100644 index 0000000..abf5553 --- /dev/null +++ b/services/worker/internal/fetcher/errors.go @@ -0,0 +1,145 @@ +package fetcher + +import ( + "errors" + "fmt" + "net" + "net/url" + "strings" +) + +type FetchError struct { + StatusCode int + UserMessage string + Retryable bool + UnderlyingError error +} + +func (fetchError *FetchError) Error() string { + if fetchError.UnderlyingError != nil { + return fmt.Sprintf("%s: %v", fetchError.UserMessage, fetchError.UnderlyingError) + } + + return fetchError.UserMessage +} + +func (fetchError *FetchError) Unwrap() error { + return fetchError.UnderlyingError +} + +func ClassifyError(originalError error, statusCode int) *FetchError { + if statusCode >= 400 { + return classifyHTTPStatusCode(statusCode, originalError) + } + + return classifyNetworkError(originalError) +} + +func classifyHTTPStatusCode(statusCode int, originalError error) *FetchError { + switch statusCode { + case 401: + return &FetchError{ + StatusCode: statusCode, + UserMessage: "authentication required - check feed credentials", + Retryable: false, + UnderlyingError: originalError, + } + case 403: + return &FetchError{ + StatusCode: statusCode, + UserMessage: "access forbidden - feed may require different credentials", + Retryable: false, + UnderlyingError: originalError, + } + case 404: + return &FetchError{ + StatusCode: statusCode, + UserMessage: "feed not found - URL may be incorrect or feed removed", + Retryable: false, + UnderlyingError: originalError, + } + case 410: + return &FetchError{ + StatusCode: statusCode, + UserMessage: "feed permanently removed", + Retryable: false, + UnderlyingError: originalError, + } + case 429: + return &FetchError{ + StatusCode: statusCode, + UserMessage: "rate limited by feed server - will retry later", + Retryable: true, + UnderlyingError: originalError, + } + case 500, 502, 503, 504: + return &FetchError{ + StatusCode: statusCode, + UserMessage: fmt.Sprintf("feed server error (HTTP %d) - will retry later", statusCode), + Retryable: true, + UnderlyingError: originalError, + } + default: + return &FetchError{ + StatusCode: statusCode, + UserMessage: fmt.Sprintf("unexpected HTTP status %d", statusCode), + Retryable: statusCode >= 500, + UnderlyingError: originalError, + } + } +} + +func classifyNetworkError(originalError error) *FetchError { + if originalError == nil { + return &FetchError{ + UserMessage: "unknown fetch error", + Retryable: true, + } + } + + var dnsError *net.DNSError + + if errors.As(originalError, &dnsError) { + return &FetchError{ + UserMessage: "DNS resolution failed - check feed URL", + Retryable: !dnsError.IsNotFound, + UnderlyingError: originalError, + } + } + + var urlError *url.Error + + if errors.As(originalError, &urlError) { + if urlError.Timeout() { + return &FetchError{ + UserMessage: "connection timed out - feed server may be slow", + Retryable: true, + UnderlyingError: originalError, + } + } + } + + var netOpError *net.OpError + + if errors.As(originalError, &netOpError) { + return &FetchError{ + UserMessage: "network connection error - will retry later", + Retryable: true, + UnderlyingError: originalError, + } + } + + if strings.Contains(originalError.Error(), "certificate") || strings.Contains(originalError.Error(), "tls") { + return &FetchError{ + UserMessage: "TLS/certificate error - feed server has invalid certificate", + Retryable: false, + UnderlyingError: originalError, + } + } + + return &FetchError{ + UserMessage: "unexpected network error", + Retryable: true, + UnderlyingError: originalError, + } +} diff --git a/services/worker/internal/fetcher/fetcher.go b/services/worker/internal/fetcher/fetcher.go new file mode 100644 index 0000000..019bd39 --- /dev/null +++ b/services/worker/internal/fetcher/fetcher.go @@ -0,0 +1,116 @@ +package fetcher + +import ( + "context" + "fmt" + "io" + "net/http" + "time" +) + +type FetchResult struct { + Body []byte + StatusCode int + EntityTag string + LastModifiedHeader string + NotModified bool +} + +type Fetcher struct { + httpClient *http.Client +} + +func NewFetcher(fetchTimeout time.Duration) *Fetcher { + return &Fetcher{ + httpClient: &http.Client{ + Timeout: fetchTimeout, + CheckRedirect: func(request *http.Request, previousRequests []*http.Request) error { + if len(previousRequests) >= 5 { + return fmt.Errorf("too many redirects (exceeded 5)") + } + + redirectValidationError := ValidateRedirectTarget(request.URL.String()) + if redirectValidationError != nil { + return fmt.Errorf("blocked redirect to reserved address: %w", redirectValidationError) + } + + return nil + }, + }, + } +} + +func (feedFetcher *Fetcher) Fetch( + requestContext context.Context, + feedURL string, + previousEntityTag string, + previousLastModified string, + authenticationConfig AuthenticationConfiguration, +) (*FetchResult, error) { + urlValidationError := ValidateFeedURL(feedURL) + if urlValidationError != nil { + return nil, fmt.Errorf("blocked request to disallowed URL: %w", urlValidationError) + } + + request, requestCreationError := http.NewRequestWithContext(requestContext, http.MethodGet, feedURL, nil) + + if requestCreationError != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", requestCreationError) + } + + request.Header.Set("User-Agent", "asa.news Feed Worker/1.0") + request.Header.Set("Accept", "application/rss+xml, application/atom+xml, application/xml, text/xml, */*") + + if previousEntityTag != "" { + request.Header.Set("If-None-Match", previousEntityTag) + } + + if previousLastModified != "" { + request.Header.Set("If-Modified-Since", previousLastModified) + } + + authenticationError := ApplyAuthentication(request, authenticationConfig) + + if authenticationError != nil { + return nil, fmt.Errorf("failed to apply authentication: %w", authenticationError) + } + + response, requestError := feedFetcher.httpClient.Do(request) + + if requestError != nil { + classifiedError := ClassifyError(requestError, 0) + + return nil, classifiedError + } + + defer response.Body.Close() + + if response.StatusCode == http.StatusNotModified { + return &FetchResult{ + StatusCode: response.StatusCode, + EntityTag: response.Header.Get("ETag"), + LastModifiedHeader: response.Header.Get("Last-Modified"), + NotModified: true, + }, nil + } + + if response.StatusCode >= 400 { + classifiedError := ClassifyError(nil, response.StatusCode) + + return nil, classifiedError + } + + responseBody, readError := io.ReadAll(io.LimitReader(response.Body, 10*1024*1024)) + + if readError != nil { + return nil, fmt.Errorf("failed to read response body: %w", readError) + } + + return &FetchResult{ + Body: responseBody, + StatusCode: response.StatusCode, + EntityTag: response.Header.Get("ETag"), + LastModifiedHeader: response.Header.Get("Last-Modified"), + NotModified: false, + }, nil +} diff --git a/services/worker/internal/fetcher/ssrf_protection.go b/services/worker/internal/fetcher/ssrf_protection.go new file mode 100644 index 0000000..1887e78 --- /dev/null +++ b/services/worker/internal/fetcher/ssrf_protection.go @@ -0,0 +1,77 @@ +package fetcher + +import ( + "context" + "fmt" + "net" + "net/url" + "strings" + "time" +) + +var reservedNetworks = []net.IPNet{ + {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, + {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, + {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, + {IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, + {IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, + {IP: net.IPv4(0, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, + {IP: net.ParseIP("::1"), Mask: net.CIDRMask(128, 128)}, + {IP: net.ParseIP("fc00::"), Mask: net.CIDRMask(7, 128)}, + {IP: net.ParseIP("fe80::"), Mask: net.CIDRMask(10, 128)}, +} + +func isReservedAddress(ipAddress net.IP) bool { + for _, network := range reservedNetworks { + if network.Contains(ipAddress) { + return true + } + } + + return false +} + +func ValidateFeedURL(feedURL string) error { + parsedURL, parseError := url.Parse(feedURL) + if parseError != nil { + return fmt.Errorf("invalid URL: %w", parseError) + } + + scheme := strings.ToLower(parsedURL.Scheme) + if scheme != "http" && scheme != "https" { + return fmt.Errorf("unsupported scheme %q: only http and https are allowed", parsedURL.Scheme) + } + + hostname := parsedURL.Hostname() + if hostname == "" { + return fmt.Errorf("URL has no hostname") + } + + if parsedIP := net.ParseIP(hostname); parsedIP != nil { + if isReservedAddress(parsedIP) { + return fmt.Errorf("feed URL resolves to a reserved IP address") + } + + return nil + } + + resolverContext, cancelResolver := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelResolver() + + resolvedAddresses, lookupError := net.DefaultResolver.LookupIPAddr(resolverContext, hostname) + if lookupError != nil { + return fmt.Errorf("failed to resolve hostname %q: %w", hostname, lookupError) + } + + for _, resolvedAddress := range resolvedAddresses { + if isReservedAddress(resolvedAddress.IP) { + return fmt.Errorf("feed URL resolves to a reserved IP address") + } + } + + return nil +} + +func ValidateRedirectTarget(redirectURL string) error { + return ValidateFeedURL(redirectURL) +} diff --git a/services/worker/internal/health/health.go b/services/worker/internal/health/health.go new file mode 100644 index 0000000..0a29c92 --- /dev/null +++ b/services/worker/internal/health/health.go @@ -0,0 +1,117 @@ +package health + +import ( + "context" + "encoding/json" + "fmt" + "github.com/jackc/pgx/v5/pgxpool" + "log/slog" + "net/http" + "time" +) + +type HealthStatus struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + Database string `json:"database"` +} + +type HealthServer struct { + httpServer *http.Server + databaseConnectionPool *pgxpool.Pool + logger *slog.Logger +} + +func NewHealthServer( + healthPort int, + databaseConnectionPool *pgxpool.Pool, + logger *slog.Logger, +) *HealthServer { + healthServer := &HealthServer{ + databaseConnectionPool: databaseConnectionPool, + logger: logger, + } + serveMux := http.NewServeMux() + + serveMux.HandleFunc("/health", healthServer.handleHealthCheck) + serveMux.HandleFunc("/ready", healthServer.handleReadinessCheck) + + healthServer.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", healthPort), + Handler: serveMux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 30 * time.Second, + } + + return healthServer +} + +func (healthServer *HealthServer) Start() { + go func() { + healthServer.logger.Info("health server starting", "address", healthServer.httpServer.Addr) + + listenError := healthServer.httpServer.ListenAndServe() + + if listenError != nil && listenError != http.ErrServerClosed { + healthServer.logger.Error("health server failed", "error", listenError) + } + }() +} + +func (healthServer *HealthServer) Stop(shutdownContext context.Context) error { + return healthServer.httpServer.Shutdown(shutdownContext) +} + +func (healthServer *HealthServer) handleHealthCheck(responseWriter http.ResponseWriter, request *http.Request) { + healthStatus := HealthStatus{ + Status: "healthy", + Timestamp: time.Now().UTC().Format(time.RFC3339), + Database: "unknown", + } + pingContext, cancelPing := context.WithTimeout(request.Context(), 2*time.Second) + + defer cancelPing() + + pingError := healthServer.databaseConnectionPool.Ping(pingContext) + + if pingError != nil { + healthStatus.Status = "unhealthy" + healthStatus.Database = "unreachable" + + respondWithJSON(responseWriter, http.StatusServiceUnavailable, healthStatus) + + return + } + + healthStatus.Database = "connected" + + respondWithJSON(responseWriter, http.StatusOK, healthStatus) +} + +func (healthServer *HealthServer) handleReadinessCheck(responseWriter http.ResponseWriter, request *http.Request) { + pingContext, cancelPing := context.WithTimeout(request.Context(), 2*time.Second) + + defer cancelPing() + + pingError := healthServer.databaseConnectionPool.Ping(pingContext) + + if pingError != nil { + responseWriter.WriteHeader(http.StatusServiceUnavailable) + + return + } + + responseWriter.WriteHeader(http.StatusOK) +} + +func respondWithJSON(responseWriter http.ResponseWriter, statusCode int, payload interface{}) { + responseWriter.Header().Set("Content-Type", "application/json") + responseWriter.WriteHeader(statusCode) + + encodingError := json.NewEncoder(responseWriter).Encode(payload) + + if encodingError != nil { + return + } +} diff --git a/services/worker/internal/model/feed.go b/services/worker/internal/model/feed.go new file mode 100644 index 0000000..611c820 --- /dev/null +++ b/services/worker/internal/model/feed.go @@ -0,0 +1,41 @@ +package model + +import ( + "time" +) + +type Feed struct { + Identifier string + URL string + SiteURL *string + Title *string + FeedType *string + Visibility string + EntityTag *string + LastModified *string + LastFetchedAt *time.Time + LastFetchError *string + ConsecutiveFailures int + NextFetchAt time.Time + FetchIntervalSeconds int + CreatedAt time.Time + UpdatedAt time.Time +} + +type FeedEntry struct { + FeedIdentifier string + OwnerIdentifier *string + GUID string + URL *string + Title *string + Author *string + Summary *string + ContentHTML *string + ContentText *string + ImageURL *string + PublishedAt *time.Time + WordCount *int + EnclosureURL *string + EnclosureType *string + EnclosureLength *int64 +} diff --git a/services/worker/internal/model/queue.go b/services/worker/internal/model/queue.go new file mode 100644 index 0000000..a5aaa7c --- /dev/null +++ b/services/worker/internal/model/queue.go @@ -0,0 +1,5 @@ +package model + +type RefreshRequest struct { + FeedIdentifier string `json:"feed_id"` +} diff --git a/services/worker/internal/parser/parser.go b/services/worker/internal/parser/parser.go new file mode 100644 index 0000000..1fb2f76 --- /dev/null +++ b/services/worker/internal/parser/parser.go @@ -0,0 +1,234 @@ +package parser + +import ( + "crypto/sha256" + "fmt" + "github.com/Fuwn/asa-news/internal/model" + "github.com/mmcdole/gofeed" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +type Parser struct { + gofeedParser *gofeed.Parser +} + +func NewParser() *Parser { + return &Parser{ + gofeedParser: gofeed.NewParser(), + } +} + +type ParseResult struct { + Entries []model.FeedEntry + FeedTitle string + SiteURL string + AudioEnclosureRatio float64 +} + +func (feedParser *Parser) Parse(feedIdentifier string, ownerIdentifier *string, rawFeedContent []byte) (*ParseResult, error) { + parsedFeed, parseError := feedParser.gofeedParser.ParseString(string(rawFeedContent)) + + if parseError != nil { + return nil, fmt.Errorf("failed to parse feed content: %w", parseError) + } + + feedEntries := make([]model.FeedEntry, 0, len(parsedFeed.Items)) + + audioEnclosureCount := 0 + + for _, feedItem := range parsedFeed.Items { + normalizedEntry := normalizeFeedItem(feedIdentifier, ownerIdentifier, feedItem) + if normalizedEntry.EnclosureURL != nil { + audioEnclosureCount++ + } + feedEntries = append(feedEntries, normalizedEntry) + } + + audioEnclosureRatio := 0.0 + if len(feedEntries) > 0 { + audioEnclosureRatio = float64(audioEnclosureCount) / float64(len(feedEntries)) + } + + return &ParseResult{ + Entries: feedEntries, + FeedTitle: strings.TrimSpace(parsedFeed.Title), + SiteURL: strings.TrimSpace(parsedFeed.Link), + AudioEnclosureRatio: audioEnclosureRatio, + }, nil +} + +func normalizeFeedItem(feedIdentifier string, ownerIdentifier *string, feedItem *gofeed.Item) model.FeedEntry { + globallyUniqueIdentifier := resolveGloballyUniqueIdentifier(feedItem) + entryURL := stringPointerOrNil(resolveEntryURL(feedItem)) + entryTitle := stringPointerOrNil(strings.TrimSpace(feedItem.Title)) + entrySummary := stringPointerOrNil(strings.TrimSpace(feedItem.Description)) + entryContentHTML := stringPointerOrNil(resolveContentHTML(feedItem)) + entryContentText := resolveContentText(feedItem) + authorName := stringPointerOrNil(resolveAuthorName(feedItem)) + publishedAt := resolvePublishedDate(feedItem) + entryImageURL := stringPointerOrNil(resolveImageURL(feedItem)) + wordCount := countWords(entryContentText) + enclosureURL, enclosureType, enclosureLength := resolveAudioEnclosure(feedItem) + + return model.FeedEntry{ + FeedIdentifier: feedIdentifier, + OwnerIdentifier: ownerIdentifier, + GUID: globallyUniqueIdentifier, + URL: entryURL, + Title: entryTitle, + Author: authorName, + Summary: entrySummary, + ContentHTML: entryContentHTML, + ContentText: stringPointerOrNil(entryContentText), + ImageURL: entryImageURL, + PublishedAt: publishedAt, + WordCount: wordCount, + EnclosureURL: enclosureURL, + EnclosureType: enclosureType, + EnclosureLength: enclosureLength, + } +} + +func stringPointerOrNil(value string) *string { + if value == "" { + return nil + } + + return &value +} + +func resolveGloballyUniqueIdentifier(feedItem *gofeed.Item) string { + if feedItem.GUID != "" { + return feedItem.GUID + } + + if feedItem.Link != "" { + return feedItem.Link + } + + hashInput := feedItem.Title + feedItem.Description + hashBytes := sha256.Sum256([]byte(hashInput)) + + return fmt.Sprintf("sha256:%x", hashBytes) +} + +func resolveEntryURL(feedItem *gofeed.Item) string { + if feedItem.Link != "" { + return feedItem.Link + } + + if feedItem.GUID != "" && strings.HasPrefix(feedItem.GUID, "http") { + return feedItem.GUID + } + + return "" +} + +func resolveContentHTML(feedItem *gofeed.Item) string { + if feedItem.Content != "" { + return feedItem.Content + } + + return feedItem.Description +} + +func resolveContentText(feedItem *gofeed.Item) string { + contentSource := feedItem.Content + + if contentSource == "" { + contentSource = feedItem.Description + } + + return stripHTMLTags(contentSource) +} + +func resolveAuthorName(feedItem *gofeed.Item) string { + if feedItem.Author != nil && feedItem.Author.Name != "" { + return feedItem.Author.Name + } + + if len(feedItem.Authors) > 0 && feedItem.Authors[0].Name != "" { + return feedItem.Authors[0].Name + } + + return "" +} + +func resolvePublishedDate(feedItem *gofeed.Item) *time.Time { + if feedItem.PublishedParsed != nil { + return feedItem.PublishedParsed + } + + if feedItem.UpdatedParsed != nil { + return feedItem.UpdatedParsed + } + + return nil +} + +func resolveAudioEnclosure(feedItem *gofeed.Item) (*string, *string, *int64) { + if feedItem.Enclosures == nil { + return nil, nil, nil + } + + for _, enclosure := range feedItem.Enclosures { + if strings.HasPrefix(enclosure.Type, "audio/") && enclosure.URL != "" { + enclosureURL := enclosure.URL + enclosureType := enclosure.Type + + var enclosureLength *int64 + if enclosure.Length != "" { + if parsedLength, parseError := strconv.ParseInt(enclosure.Length, 10, 64); parseError == nil { + enclosureLength = &parsedLength + } + } + + return &enclosureURL, &enclosureType, enclosureLength + } + } + + return nil, nil, nil +} + +func resolveImageURL(feedItem *gofeed.Item) string { + if feedItem.Image != nil && feedItem.Image.URL != "" { + return feedItem.Image.URL + } + + return "" +} + +func countWords(plainText string) *int { + if plainText == "" { + return nil + } + + count := len(strings.Fields(plainText)) + + return &count +} + +func stripHTMLTags(htmlContent string) string { + var resultBuilder strings.Builder + + insideTag := false + + for characterIndex := 0; characterIndex < len(htmlContent); { + currentRune, runeSize := utf8.DecodeRuneInString(htmlContent[characterIndex:]) + + if currentRune == '<' { + insideTag = true + } else if currentRune == '>' { + insideTag = false + } else if !insideTag { + resultBuilder.WriteRune(currentRune) + } + + characterIndex += runeSize + } + + return strings.TrimSpace(resultBuilder.String()) +} diff --git a/services/worker/internal/pool/pool.go b/services/worker/internal/pool/pool.go new file mode 100644 index 0000000..7df03e2 --- /dev/null +++ b/services/worker/internal/pool/pool.go @@ -0,0 +1,60 @@ +package pool + +import ( + "context" + "log/slog" + "sync" +) + +type WorkFunction func(workContext context.Context) + +type WorkerPool struct { + concurrencyLimit int + semaphoreChannel chan struct{} + waitGroup sync.WaitGroup + logger *slog.Logger +} + +func NewWorkerPool(concurrencyLimit int, logger *slog.Logger) *WorkerPool { + return &WorkerPool{ + concurrencyLimit: concurrencyLimit, + semaphoreChannel: make(chan struct{}, concurrencyLimit), + logger: logger, + } +} + +func (workerPool *WorkerPool) Submit(workContext context.Context, workFunction WorkFunction) bool { + select { + case workerPool.semaphoreChannel <- struct{}{}: + workerPool.waitGroup.Add(1) + + go func() { + defer workerPool.waitGroup.Done() + defer func() { <-workerPool.semaphoreChannel }() + defer func() { + recoveredPanic := recover() + + if recoveredPanic != nil { + workerPool.logger.Error( + "worker panic recovered", + "panic_value", recoveredPanic, + ) + } + }() + + workFunction(workContext) + }() + + return true + case <-workContext.Done(): + return false + } +} + +func (workerPool *WorkerPool) Wait() { + workerPool.waitGroup.Wait() +} + +func (workerPool *WorkerPool) ActiveWorkerCount() int { + return len(workerPool.semaphoreChannel) +} diff --git a/services/worker/internal/scheduler/refresh.go b/services/worker/internal/scheduler/refresh.go new file mode 100644 index 0000000..8271fa0 --- /dev/null +++ b/services/worker/internal/scheduler/refresh.go @@ -0,0 +1,196 @@ +package scheduler + +import ( + "context" + "github.com/Fuwn/asa-news/internal/fetcher" + "github.com/Fuwn/asa-news/internal/model" + "github.com/Fuwn/asa-news/internal/parser" + "github.com/Fuwn/asa-news/internal/webhook" + "github.com/Fuwn/asa-news/internal/writer" + "log/slog" +) + +func ProcessRefreshRequest( + refreshContext context.Context, + feed model.Feed, + feedFetcher *fetcher.Fetcher, + feedParser *parser.Parser, + feedWriter *writer.Writer, + webhookDispatcher *webhook.Dispatcher, + logger *slog.Logger, +) { + logger.Info( + "processing feed refresh", + "feed_identifier", feed.Identifier, + "feed_url", feed.URL, + ) + + var ownerIdentifier *string + + if feed.Visibility == "authenticated" { + logger.Warn( + "authenticated feed refresh not yet implemented", + "feed_identifier", feed.Identifier, + ) + + return + } + + authenticationConfig := fetcher.AuthenticationConfiguration{} + + entityTag := "" + + if feed.EntityTag != nil { + entityTag = *feed.EntityTag + } + + lastModified := "" + + if feed.LastModified != nil { + lastModified = *feed.LastModified + } + + fetchResult, fetchError := feedFetcher.Fetch( + refreshContext, + feed.URL, + entityTag, + lastModified, + authenticationConfig, + ) + + if fetchError != nil { + logger.Warn( + "feed fetch failed", + "feed_identifier", feed.Identifier, + "error", fetchError, + ) + + recordError := feedWriter.RecordFeedError(refreshContext, feed.Identifier, fetchError.Error()) + + if recordError != nil { + logger.Error( + "failed to record feed error", + "feed_identifier", feed.Identifier, + "error", recordError, + ) + } + + return + } + + if fetchResult.NotModified { + logger.Info( + "feed not modified", + "feed_identifier", feed.Identifier, + ) + + metadataError := feedWriter.UpdateFeedMetadata( + refreshContext, + feed.Identifier, + fetchResult.EntityTag, + fetchResult.LastModifiedHeader, + "", + "", + ) + + if metadataError != nil { + logger.Error( + "failed to update feed metadata after not-modified", + "feed_identifier", feed.Identifier, + "error", metadataError, + ) + } + + return + } + + parseResult, parseError := feedParser.Parse(feed.Identifier, ownerIdentifier, fetchResult.Body) + + if parseError != nil { + logger.Warn( + "feed parse failed", + "feed_identifier", feed.Identifier, + "error", parseError, + ) + + recordError := feedWriter.RecordFeedError(refreshContext, feed.Identifier, parseError.Error()) + + if recordError != nil { + logger.Error( + "failed to record parse error", + "feed_identifier", feed.Identifier, + "error", recordError, + ) + } + + return + } + + rowsAffected, writeError := feedWriter.WriteEntries(refreshContext, parseResult.Entries) + + if writeError != nil { + logger.Error( + "failed to write feed entries", + "feed_identifier", feed.Identifier, + "error", writeError, + ) + + recordError := feedWriter.RecordFeedError(refreshContext, feed.Identifier, writeError.Error()) + + if recordError != nil { + logger.Error( + "failed to record write error", + "feed_identifier", feed.Identifier, + "error", recordError, + ) + } + + return + } + + if rowsAffected > 0 && webhookDispatcher != nil { + webhookDispatcher.DispatchForFeed(refreshContext, feed.Identifier, parseResult.Entries) + } + + metadataError := feedWriter.UpdateFeedMetadata( + refreshContext, + feed.Identifier, + fetchResult.EntityTag, + fetchResult.LastModifiedHeader, + parseResult.FeedTitle, + parseResult.SiteURL, + ) + + if metadataError != nil { + logger.Error( + "failed to update feed metadata", + "feed_identifier", feed.Identifier, + "error", metadataError, + ) + } + + if parseResult.AudioEnclosureRatio > 0.5 { + if feedTypeError := feedWriter.UpdateFeedType(refreshContext, feed.Identifier, "podcast"); feedTypeError != nil { + logger.Error( + "failed to update feed type to podcast", + "feed_identifier", feed.Identifier, + "error", feedTypeError, + ) + } + } else if feed.FeedType != nil && *feed.FeedType == "podcast" { + if feedTypeError := feedWriter.UpdateFeedType(refreshContext, feed.Identifier, "rss"); feedTypeError != nil { + logger.Error( + "failed to clear podcast feed type", + "feed_identifier", feed.Identifier, + "error", feedTypeError, + ) + } + } + + logger.Info( + "feed refresh completed", + "feed_identifier", feed.Identifier, + "entries_parsed", len(parseResult.Entries), + "rows_affected", rowsAffected, + ) +} diff --git a/services/worker/internal/scheduler/scheduler.go b/services/worker/internal/scheduler/scheduler.go new file mode 100644 index 0000000..646e263 --- /dev/null +++ b/services/worker/internal/scheduler/scheduler.go @@ -0,0 +1,283 @@ +package scheduler + +import ( + "context" + "encoding/json" + "fmt" + "github.com/Fuwn/asa-news/internal/fetcher" + "github.com/Fuwn/asa-news/internal/model" + "github.com/Fuwn/asa-news/internal/parser" + "github.com/Fuwn/asa-news/internal/pool" + "github.com/Fuwn/asa-news/internal/webhook" + "github.com/Fuwn/asa-news/internal/writer" + pgmq "github.com/craigpastro/pgmq-go" + "github.com/jackc/pgx/v5/pgxpool" + "log/slog" + "time" +) + +type Scheduler struct { + databaseConnectionPool *pgxpool.Pool + feedFetcher *fetcher.Fetcher + feedParser *parser.Parser + feedWriter *writer.Writer + webhookDispatcher *webhook.Dispatcher + workerPool *pool.WorkerPool + pollInterval time.Duration + queuePollInterval time.Duration + batchSize int + logger *slog.Logger +} + +func NewScheduler( + databaseConnectionPool *pgxpool.Pool, + feedFetcher *fetcher.Fetcher, + feedParser *parser.Parser, + feedWriter *writer.Writer, + webhookDispatcher *webhook.Dispatcher, + workerPool *pool.WorkerPool, + pollInterval time.Duration, + queuePollInterval time.Duration, + batchSize int, + logger *slog.Logger, +) *Scheduler { + return &Scheduler{ + databaseConnectionPool: databaseConnectionPool, + feedFetcher: feedFetcher, + feedParser: feedParser, + feedWriter: feedWriter, + webhookDispatcher: webhookDispatcher, + workerPool: workerPool, + pollInterval: pollInterval, + queuePollInterval: queuePollInterval, + batchSize: batchSize, + logger: logger, + } +} + +func (feedScheduler *Scheduler) Run(schedulerContext context.Context) { + pollTicker := time.NewTicker(feedScheduler.pollInterval) + + defer pollTicker.Stop() + + queueTicker := time.NewTicker(feedScheduler.queuePollInterval) + + defer queueTicker.Stop() + + feedScheduler.logger.Info("scheduler started", + "poll_interval", feedScheduler.pollInterval, + "queue_poll_interval", feedScheduler.queuePollInterval, + "batch_size", feedScheduler.batchSize, + ) + feedScheduler.executePollCycle(schedulerContext) + feedScheduler.executeQueueCycle(schedulerContext) + + for { + select { + case <-schedulerContext.Done(): + feedScheduler.logger.Info("scheduler shutting down") + + return + case <-pollTicker.C: + feedScheduler.executePollCycle(schedulerContext) + case <-queueTicker.C: + feedScheduler.executeQueueCycle(schedulerContext) + } + } +} + +func (feedScheduler *Scheduler) executePollCycle(cycleContext context.Context) { + claimedFeeds, claimError := feedScheduler.claimDueFeeds(cycleContext) + + if claimError != nil { + feedScheduler.logger.Error("failed to claim due feeds", "error", claimError) + + return + } + + if len(claimedFeeds) == 0 { + return + } + + feedScheduler.logger.Info("claimed feeds for processing", "count", len(claimedFeeds)) + + for _, claimedFeed := range claimedFeeds { + capturedFeed := claimedFeed + + feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) { + ProcessRefreshRequest( + workContext, + capturedFeed, + feedScheduler.feedFetcher, + feedScheduler.feedParser, + feedScheduler.feedWriter, + feedScheduler.webhookDispatcher, + feedScheduler.logger, + ) + }) + } +} + +func (feedScheduler *Scheduler) executeQueueCycle(cycleContext context.Context) { + queueMessage, readError := pgmq.Read(cycleContext, feedScheduler.databaseConnectionPool, "feed_refresh", 30) + + if readError != nil { + return + } + + if queueMessage == nil { + return + } + + var refreshRequest model.RefreshRequest + + unmarshalError := json.Unmarshal(queueMessage.Message, &refreshRequest) + + if unmarshalError != nil { + feedScheduler.logger.Error("failed to unmarshal refresh request", "error", unmarshalError) + + return + } + + feed, lookupError := feedScheduler.lookupFeed(cycleContext, refreshRequest.FeedIdentifier) + + if lookupError != nil { + feedScheduler.logger.Error( + "failed to look up feed for queue request", + "feed_identifier", refreshRequest.FeedIdentifier, + "error", lookupError, + ) + + return + } + + capturedMessageIdentifier := queueMessage.MsgID + + feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) { + ProcessRefreshRequest( + workContext, + feed, + feedScheduler.feedFetcher, + feedScheduler.feedParser, + feedScheduler.feedWriter, + feedScheduler.webhookDispatcher, + feedScheduler.logger, + ) + + _, archiveError := pgmq.Archive(workContext, feedScheduler.databaseConnectionPool, "feed_refresh", capturedMessageIdentifier) + + if archiveError != nil { + feedScheduler.logger.Error( + "failed to archive queue message", + "message_id", capturedMessageIdentifier, + "error", archiveError, + ) + } + }) +} + +func (feedScheduler *Scheduler) claimDueFeeds(claimContext context.Context) ([]model.Feed, error) { + claimQuery := ` + UPDATE feeds + SET last_fetched_at = NOW() + WHERE id IN ( + SELECT id + FROM feeds + WHERE next_fetch_at <= NOW() + AND subscriber_count > 0 + ORDER BY next_fetch_at ASC + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + RETURNING + id, url, site_url, title, feed_type, + visibility, etag, last_modified, + last_fetched_at, last_fetch_error, + consecutive_failures, next_fetch_at, + fetch_interval_seconds, + created_at, updated_at + ` + rows, queryError := feedScheduler.databaseConnectionPool.Query(claimContext, claimQuery, feedScheduler.batchSize) + + if queryError != nil { + return nil, fmt.Errorf("failed to query due feeds: %w", queryError) + } + + defer rows.Close() + + claimedFeeds := make([]model.Feed, 0) + + for rows.Next() { + var feed model.Feed + + scanError := rows.Scan( + &feed.Identifier, + &feed.URL, + &feed.SiteURL, + &feed.Title, + &feed.FeedType, + &feed.Visibility, + &feed.EntityTag, + &feed.LastModified, + &feed.LastFetchedAt, + &feed.LastFetchError, + &feed.ConsecutiveFailures, + &feed.NextFetchAt, + &feed.FetchIntervalSeconds, + &feed.CreatedAt, + &feed.UpdatedAt, + ) + + if scanError != nil { + return nil, fmt.Errorf("failed to scan feed row: %w", scanError) + } + + claimedFeeds = append(claimedFeeds, feed) + } + + if rows.Err() != nil { + return nil, fmt.Errorf("error iterating feed rows: %w", rows.Err()) + } + + return claimedFeeds, nil +} + +func (feedScheduler *Scheduler) lookupFeed(lookupContext context.Context, feedIdentifier string) (model.Feed, error) { + lookupQuery := ` + SELECT + id, url, site_url, title, feed_type, + visibility, etag, last_modified, + last_fetched_at, last_fetch_error, + consecutive_failures, next_fetch_at, + fetch_interval_seconds, + created_at, updated_at + FROM feeds + WHERE id = $1 + ` + + var feed model.Feed + + scanError := feedScheduler.databaseConnectionPool.QueryRow(lookupContext, lookupQuery, feedIdentifier).Scan( + &feed.Identifier, + &feed.URL, + &feed.SiteURL, + &feed.Title, + &feed.FeedType, + &feed.Visibility, + &feed.EntityTag, + &feed.LastModified, + &feed.LastFetchedAt, + &feed.LastFetchError, + &feed.ConsecutiveFailures, + &feed.NextFetchAt, + &feed.FetchIntervalSeconds, + &feed.CreatedAt, + &feed.UpdatedAt, + ) + + if scanError != nil { + return model.Feed{}, fmt.Errorf("failed to look up feed %s: %w", feedIdentifier, scanError) + } + + return feed, nil +} diff --git a/services/worker/internal/webhook/webhook.go b/services/worker/internal/webhook/webhook.go new file mode 100644 index 0000000..c812820 --- /dev/null +++ b/services/worker/internal/webhook/webhook.go @@ -0,0 +1,333 @@ +package webhook + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "github.com/Fuwn/asa-news/internal/fetcher" + "github.com/Fuwn/asa-news/internal/model" + "github.com/jackc/pgx/v5/pgxpool" + "log/slog" + "net/http" + "time" +) + +type Dispatcher struct { + databaseConnectionPool *pgxpool.Pool + httpClient *http.Client + logger *slog.Logger +} + +type webhookSubscriber struct { + UserIdentifier string + WebhookURL string + WebhookSecret *string +} + +type EntryPayload struct { + EntryIdentifier string `json:"entryIdentifier"` + FeedIdentifier string `json:"feedIdentifier"` + GUID string `json:"guid"` + URL *string `json:"url"` + Title *string `json:"title"` + Author *string `json:"author"` + Summary *string `json:"summary"` + PublishedAt *string `json:"publishedAt"` + EnclosureURL *string `json:"enclosureUrl"` + EnclosureType *string `json:"enclosureType"` +} + +type WebhookPayload struct { + Event string `json:"event"` + Timestamp string `json:"timestamp"` + Entries []EntryPayload `json:"entries"` +} + +func NewDispatcher( + databaseConnectionPool *pgxpool.Pool, + logger *slog.Logger, +) *Dispatcher { + return &Dispatcher{ + databaseConnectionPool: databaseConnectionPool, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + logger: logger, + } +} + +func (webhookDispatcher *Dispatcher) DispatchForFeed( + dispatchContext context.Context, + feedIdentifier string, + entries []model.FeedEntry, +) { + subscribers, queryError := webhookDispatcher.findWebhookSubscribers(dispatchContext, feedIdentifier) + + if queryError != nil { + webhookDispatcher.logger.Error( + "failed to query webhook subscribers", + "feed_identifier", feedIdentifier, + "error", queryError, + ) + + return + } + + if len(subscribers) == 0 { + return + } + + entryPayloads := make([]EntryPayload, 0, len(entries)) + + for _, entry := range entries { + var publishedAtString *string + + if entry.PublishedAt != nil { + formattedTime := entry.PublishedAt.Format(time.RFC3339) + publishedAtString = &formattedTime + } + + entryPayloads = append(entryPayloads, EntryPayload{ + EntryIdentifier: entry.FeedIdentifier, + FeedIdentifier: entry.FeedIdentifier, + GUID: entry.GUID, + URL: entry.URL, + Title: entry.Title, + Author: entry.Author, + Summary: entry.Summary, + PublishedAt: publishedAtString, + EnclosureURL: entry.EnclosureURL, + EnclosureType: entry.EnclosureType, + }) + } + + payload := WebhookPayload{ + Event: "entries.created", + Timestamp: time.Now().UTC().Format(time.RFC3339), + Entries: entryPayloads, + } + + for _, subscriber := range subscribers { + webhookDispatcher.deliverWebhook(dispatchContext, subscriber, payload) + } +} + +func (webhookDispatcher *Dispatcher) findWebhookSubscribers( + queryContext context.Context, + feedIdentifier string, +) ([]webhookSubscriber, error) { + subscriberQuery := ` + SELECT up.id, up.webhook_url, up.webhook_secret + FROM subscriptions s + JOIN user_profiles up ON up.id = s.user_id + WHERE s.feed_id = $1 + AND up.tier = 'developer' + AND up.webhook_enabled = true + AND up.webhook_url IS NOT NULL + ` + + rows, queryError := webhookDispatcher.databaseConnectionPool.Query( + queryContext, + subscriberQuery, + feedIdentifier, + ) + + if queryError != nil { + return nil, fmt.Errorf("failed to query webhook subscribers: %w", queryError) + } + + defer rows.Close() + + subscribers := make([]webhookSubscriber, 0) + + for rows.Next() { + var subscriber webhookSubscriber + + scanError := rows.Scan( + &subscriber.UserIdentifier, + &subscriber.WebhookURL, + &subscriber.WebhookSecret, + ) + + if scanError != nil { + return nil, fmt.Errorf("failed to scan subscriber row: %w", scanError) + } + + subscribers = append(subscribers, subscriber) + } + + return subscribers, rows.Err() +} + +func (webhookDispatcher *Dispatcher) deliverWebhook( + deliveryContext context.Context, + subscriber webhookSubscriber, + payload WebhookPayload, +) { + urlValidationError := fetcher.ValidateFeedURL(subscriber.WebhookURL) + + if urlValidationError != nil { + webhookDispatcher.logger.Warn( + "webhook URL failed SSRF validation", + "user_identifier", subscriber.UserIdentifier, + "webhook_url", subscriber.WebhookURL, + "error", urlValidationError, + ) + + return + } + + payloadBytes, marshalError := json.Marshal(payload) + + if marshalError != nil { + webhookDispatcher.logger.Error( + "failed to marshal webhook payload", + "user_identifier", subscriber.UserIdentifier, + "error", marshalError, + ) + + return + } + + retryDelays := []time.Duration{0, 1 * time.Second, 4 * time.Second, 16 * time.Second} + var lastError error + + for attemptIndex, delay := range retryDelays { + if delay > 0 { + select { + case <-time.After(delay): + case <-deliveryContext.Done(): + return + } + } + + deliveryError := webhookDispatcher.sendWebhookRequest( + deliveryContext, + subscriber, + payloadBytes, + ) + + if deliveryError == nil { + if attemptIndex > 0 { + webhookDispatcher.logger.Info( + "webhook delivered after retry", + "user_identifier", subscriber.UserIdentifier, + "attempt", attemptIndex+1, + ) + } + + webhookDispatcher.resetConsecutiveFailures(deliveryContext, subscriber.UserIdentifier) + + return + } + + lastError = deliveryError + + webhookDispatcher.logger.Warn( + "webhook delivery attempt failed", + "user_identifier", subscriber.UserIdentifier, + "attempt", attemptIndex+1, + "error", deliveryError, + ) + } + + webhookDispatcher.logger.Error( + "webhook delivery failed after all retries", + "user_identifier", subscriber.UserIdentifier, + "error", lastError, + ) + + webhookDispatcher.incrementConsecutiveFailures(deliveryContext, subscriber.UserIdentifier) +} + +func (webhookDispatcher *Dispatcher) sendWebhookRequest( + requestContext context.Context, + subscriber webhookSubscriber, + payloadBytes []byte, +) error { + request, requestCreationError := http.NewRequestWithContext( + requestContext, + http.MethodPost, + subscriber.WebhookURL, + bytes.NewReader(payloadBytes), + ) + + if requestCreationError != nil { + return fmt.Errorf("failed to create webhook request: %w", requestCreationError) + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "asa.news Webhook/1.0") + + if subscriber.WebhookSecret != nil && *subscriber.WebhookSecret != "" { + mac := hmac.New(sha256.New, []byte(*subscriber.WebhookSecret)) + mac.Write(payloadBytes) + signature := hex.EncodeToString(mac.Sum(nil)) + request.Header.Set("X-Asa-Signature-256", "sha256="+signature) + } + + response, requestError := webhookDispatcher.httpClient.Do(request) + + if requestError != nil { + return fmt.Errorf("webhook request failed: %w", requestError) + } + + defer response.Body.Close() + + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + + return fmt.Errorf("webhook returned status %d", response.StatusCode) +} + +func (webhookDispatcher *Dispatcher) resetConsecutiveFailures( + updateContext context.Context, + userIdentifier string, +) { + _, updateError := webhookDispatcher.databaseConnectionPool.Exec( + updateContext, + "UPDATE user_profiles SET webhook_consecutive_failures = 0 WHERE id = $1", + userIdentifier, + ) + + if updateError != nil { + webhookDispatcher.logger.Error( + "failed to reset webhook consecutive failures", + "user_identifier", userIdentifier, + "error", updateError, + ) + } +} + +func (webhookDispatcher *Dispatcher) incrementConsecutiveFailures( + updateContext context.Context, + userIdentifier string, +) { + maximumConsecutiveFailures := 10 + + _, updateError := webhookDispatcher.databaseConnectionPool.Exec( + updateContext, + `UPDATE user_profiles + SET webhook_consecutive_failures = webhook_consecutive_failures + 1, + webhook_enabled = CASE + WHEN webhook_consecutive_failures + 1 >= $2 THEN false + ELSE webhook_enabled + END + WHERE id = $1`, + userIdentifier, + maximumConsecutiveFailures, + ) + + if updateError != nil { + webhookDispatcher.logger.Error( + "failed to increment webhook consecutive failures", + "user_identifier", userIdentifier, + "error", updateError, + ) + } +} diff --git a/services/worker/internal/writer/writer.go b/services/worker/internal/writer/writer.go new file mode 100644 index 0000000..de81bfa --- /dev/null +++ b/services/worker/internal/writer/writer.go @@ -0,0 +1,222 @@ +package writer + +import ( + "context" + "fmt" + "github.com/Fuwn/asa-news/internal/model" + "github.com/jackc/pgx/v5/pgxpool" + "strings" + "time" +) + +type Writer struct { + databaseConnectionPool *pgxpool.Pool +} + +func NewWriter(databaseConnectionPool *pgxpool.Pool) *Writer { + return &Writer{ + databaseConnectionPool: databaseConnectionPool, + } +} + +func (feedWriter *Writer) WriteEntries(writeContext context.Context, feedEntries []model.FeedEntry) (int64, error) { + if len(feedEntries) == 0 { + return 0, nil + } + + valueParameterPlaceholders := make([]string, 0, len(feedEntries)) + queryArguments := make([]interface{}, 0, len(feedEntries)*15) + + for entryIndex, entry := range feedEntries { + parameterOffset := entryIndex * 15 + valueParameterPlaceholders = append( + valueParameterPlaceholders, + fmt.Sprintf( + "($%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d)", + parameterOffset+1, parameterOffset+2, parameterOffset+3, + parameterOffset+4, parameterOffset+5, parameterOffset+6, + parameterOffset+7, parameterOffset+8, parameterOffset+9, + parameterOffset+10, parameterOffset+11, parameterOffset+12, + parameterOffset+13, parameterOffset+14, parameterOffset+15, + ), + ) + queryArguments = append( + queryArguments, + entry.FeedIdentifier, + entry.OwnerIdentifier, + entry.GUID, + entry.URL, + entry.Title, + entry.Author, + entry.Summary, + entry.ContentHTML, + entry.ContentText, + entry.ImageURL, + entry.PublishedAt, + entry.WordCount, + entry.EnclosureURL, + entry.EnclosureType, + entry.EnclosureLength, + ) + } + + isPublicEntry := feedEntries[0].OwnerIdentifier == nil + + var conflictClause string + + if isPublicEntry { + conflictClause = "ON CONFLICT (feed_id, guid) WHERE owner_id IS NULL" + } else { + conflictClause = "ON CONFLICT (feed_id, owner_id, guid) WHERE owner_id IS NOT NULL" + } + + upsertQuery := fmt.Sprintf(` + INSERT INTO entries ( + feed_id, owner_id, guid, url, title, author, + summary, content_html, content_text, image_url, + published_at, word_count, + enclosure_url, enclosure_type, enclosure_length + ) + VALUES %s + %s + DO UPDATE SET + url = EXCLUDED.url, + title = EXCLUDED.title, + author = EXCLUDED.author, + summary = CASE + WHEN EXISTS (SELECT 1 FROM user_highlights WHERE entry_id = entries.id) + THEN entries.summary + ELSE EXCLUDED.summary + END, + content_html = CASE + WHEN EXISTS (SELECT 1 FROM user_highlights WHERE entry_id = entries.id) + THEN entries.content_html + ELSE EXCLUDED.content_html + END, + content_text = CASE + WHEN EXISTS (SELECT 1 FROM user_highlights WHERE entry_id = entries.id) + THEN entries.content_text + ELSE EXCLUDED.content_text + END, + image_url = EXCLUDED.image_url, + published_at = EXCLUDED.published_at, + word_count = EXCLUDED.word_count, + enclosure_url = EXCLUDED.enclosure_url, + enclosure_type = EXCLUDED.enclosure_type, + enclosure_length = EXCLUDED.enclosure_length + WHERE + entries.title IS DISTINCT FROM EXCLUDED.title + OR entries.content_html IS DISTINCT FROM EXCLUDED.content_html + OR entries.enclosure_url IS DISTINCT FROM EXCLUDED.enclosure_url + `, strings.Join(valueParameterPlaceholders, ", "), conflictClause) + commandTag, executeError := feedWriter.databaseConnectionPool.Exec(writeContext, upsertQuery, queryArguments...) + + if executeError != nil { + return 0, fmt.Errorf("failed to upsert feed entries: %w", executeError) + } + + return commandTag.RowsAffected(), nil +} + +func (feedWriter *Writer) UpdateFeedMetadata( + updateContext context.Context, + feedIdentifier string, + entityTag string, + lastModified string, + feedTitle string, + siteURL string, +) error { + currentTime := time.Now().UTC() + + var titleParameter *string + if feedTitle != "" { + titleParameter = &feedTitle + } + + var siteURLParameter *string + if siteURL != "" { + siteURLParameter = &siteURL + } + + updateQuery := ` + UPDATE feeds + SET + last_fetched_at = $1::timestamptz, + etag = $2, + last_modified = $3, + consecutive_failures = 0, + last_fetch_error = NULL, + last_fetch_error_at = NULL, + next_fetch_at = $1::timestamptz + (fetch_interval_seconds * INTERVAL '1 second'), + title = COALESCE($5, title), + site_url = COALESCE($6, site_url) + WHERE id = $4 + ` + _, executeError := feedWriter.databaseConnectionPool.Exec( + updateContext, + updateQuery, + currentTime, + entityTag, + lastModified, + feedIdentifier, + titleParameter, + siteURLParameter, + ) + + if executeError != nil { + return fmt.Errorf("failed to update feed metadata: %w", executeError) + } + + return nil +} + +func (feedWriter *Writer) UpdateFeedType( + updateContext context.Context, + feedIdentifier string, + feedType string, +) error { + updateQuery := `UPDATE feeds SET feed_type = $1 WHERE id = $2` + _, executeError := feedWriter.databaseConnectionPool.Exec( + updateContext, + updateQuery, + feedType, + feedIdentifier, + ) + + if executeError != nil { + return fmt.Errorf("failed to update feed type: %w", executeError) + } + + return nil +} + +func (feedWriter *Writer) RecordFeedError( + updateContext context.Context, + feedIdentifier string, + errorMessage string, +) error { + currentTime := time.Now().UTC() + updateQuery := ` + UPDATE feeds + SET + last_fetched_at = $1::timestamptz, + last_fetch_error = $2, + last_fetch_error_at = $1::timestamptz, + consecutive_failures = consecutive_failures + 1, + next_fetch_at = $1::timestamptz + (LEAST(POWER(2, consecutive_failures) * 60, 21600) * INTERVAL '1 second') + WHERE id = $3 + ` + _, executeError := feedWriter.databaseConnectionPool.Exec( + updateContext, + updateQuery, + currentTime, + errorMessage, + feedIdentifier, + ) + + if executeError != nil { + return fmt.Errorf("failed to record feed error: %w", executeError) + } + + return nil +} diff --git a/supabase/email-templates.html b/supabase/email-templates.html new file mode 100644 index 0000000..a713191 --- /dev/null +++ b/supabase/email-templates.html @@ -0,0 +1,489 @@ +<!-- + asa.news — Supabase Auth Email Templates (13 total) + Copy each section into the corresponding template in: + Supabase Dashboard > Authentication > Email Templates (1-6) + Supabase Dashboard > Authentication > Threat Protection > Email Templates (7-13) + + Auth template variables: + {{ .SiteURL }} — your site URL + {{ .ConfirmationURL }} — the action link + {{ .Token }} — OTP code (6 digits) + {{ .TokenHash }} — hashed token + {{ .RedirectTo }} — redirect URL after action + {{ .Email }} — user's email address + + Security template variables: + {{ .Email }} — user's email address + {{ .OldEmail }} — previous email (email change) + {{ .NewEmail }} — new email (email change) + {{ .OldPhone }} — previous phone (phone change) + {{ .Phone }} — new phone (phone change) + {{ .Provider }} — identity provider name (link/unlink) + {{ .FactorType }} — MFA factor type (MFA add/remove) +--> + + +<!-- ============================================================ --> +<!-- 1. CONFIRM SIGNUP --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">confirm your account</h2> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + tap the link below to verify your email address and activate your account. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px"> + <a href="{{ .ConfirmationURL }}" + style="display:block;padding:12px 0;background-color:#1a1a1a;border:1px solid #363636;color:#ffffff;text-align:center;text-decoration:none;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace;font-size:13px"> + confirm email + </a> + </td> + </tr> + <tr> + <td style="padding:24px 32px 32px"> + <p style="margin:0;font-size:12px;line-height:1.6;color:#666666"> + if you didn't create an account, you can safely ignore this email. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 2. INVITE USER --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">you've been invited</h2> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + you've been invited to join asa.news. tap the link below to accept and set up your account. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px"> + <a href="{{ .ConfirmationURL }}" + style="display:block;padding:12px 0;background-color:#1a1a1a;border:1px solid #363636;color:#ffffff;text-align:center;text-decoration:none;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace;font-size:13px"> + accept invitation + </a> + </td> + </tr> + <tr> + <td style="padding:24px 32px 32px"> + <p style="margin:0;font-size:12px;line-height:1.6;color:#666666"> + if you weren't expecting this invitation, you can safely ignore this email. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 3. MAGIC LINK --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">your sign-in link</h2> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + tap the link below to sign in to your account. this link expires in 10 minutes. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px"> + <a href="{{ .ConfirmationURL }}" + style="display:block;padding:12px 0;background-color:#1a1a1a;border:1px solid #363636;color:#ffffff;text-align:center;text-decoration:none;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace;font-size:13px"> + sign in + </a> + </td> + </tr> + <tr> + <td style="padding:24px 32px 32px"> + <p style="margin:0;font-size:12px;line-height:1.6;color:#666666"> + if you didn't request this link, you can safely ignore this email. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 4. CHANGE EMAIL ADDRESS --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">confirm email change</h2> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + tap the link below to confirm changing your email address. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px"> + <a href="{{ .ConfirmationURL }}" + style="display:block;padding:12px 0;background-color:#1a1a1a;border:1px solid #363636;color:#ffffff;text-align:center;text-decoration:none;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace;font-size:13px"> + confirm new email + </a> + </td> + </tr> + <tr> + <td style="padding:24px 32px 32px"> + <p style="margin:0;font-size:12px;line-height:1.6;color:#666666"> + if you didn't request this change, please secure your account immediately. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 5. RESET PASSWORD --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">reset your password</h2> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + tap the link below to reset your password. this link expires in 1 hour. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px"> + <a href="{{ .ConfirmationURL }}" + style="display:block;padding:12px 0;background-color:#1a1a1a;border:1px solid #363636;color:#ffffff;text-align:center;text-decoration:none;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace;font-size:13px"> + reset password + </a> + </td> + </tr> + <tr> + <td style="padding:24px 32px 32px"> + <p style="margin:0;font-size:12px;line-height:1.6;color:#666666"> + if you didn't request a password reset, you can safely ignore this email. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 6. REAUTHENTICATION --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">confirm your identity</h2> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + enter the code below to verify your identity for this sensitive action. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px" align="center"> + <p style="margin:0;padding:16px 0;background-color:#1a1a1a;border:1px solid #363636;color:#ffffff;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace;font-size:28px;letter-spacing:0.5em;text-align:center"> + {{ .Token }} + </p> + </td> + </tr> + <tr> + <td style="padding:24px 32px 32px"> + <p style="margin:0;font-size:12px;line-height:1.6;color:#666666"> + this code expires in 10 minutes. if you didn't initiate this, please secure your account. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- --> +<!-- SECURITY NOTIFICATION TEMPLATES (Threat Protection) --> +<!-- Dashboard > Auth > Threat Protection > Email Templates --> +<!-- --> +<!-- ============================================================ --> + + +<!-- ============================================================ --> +<!-- 7. PASSWORD CHANGED --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">your password was changed</h2> + <p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#aaaaaa"> + the password for your account ({{ .Email }}) was recently changed. + </p> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + if this was you, no action is needed. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px 32px"> + <p style="margin:0;padding:12px 16px;background-color:#1a1a1a;border:1px solid #363636;font-size:12px;line-height:1.6;color:#c08000"> + if you didn't make this change, reset your password immediately and contact support. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 8. EMAIL ADDRESS CHANGED --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">your email address was changed</h2> + <p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#aaaaaa"> + your account email was changed from {{ .OldEmail }} to {{ .Email }}. + </p> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + if this was you, no action is needed. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px 32px"> + <p style="margin:0;padding:12px 16px;background-color:#1a1a1a;border:1px solid #363636;font-size:12px;line-height:1.6;color:#c08000"> + if you didn't make this change, your account may be compromised. contact support immediately. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 9. PHONE NUMBER CHANGED --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">your phone number was changed</h2> + <p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#aaaaaa"> + the phone number on your account ({{ .Email }}) was changed from {{ .OldPhone }} to {{ .Phone }}. + </p> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + if this was you, no action is needed. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px 32px"> + <p style="margin:0;padding:12px 16px;background-color:#1a1a1a;border:1px solid #363636;font-size:12px;line-height:1.6;color:#c08000"> + if you didn't make this change, your account may be compromised. contact support immediately. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 10. IDENTITY LINKED --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">new identity linked</h2> + <p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#aaaaaa"> + a {{ .Provider }} identity was linked to your account ({{ .Email }}). + </p> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + if this was you, no action is needed. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px 32px"> + <p style="margin:0;padding:12px 16px;background-color:#1a1a1a;border:1px solid #363636;font-size:12px;line-height:1.6;color:#c08000"> + if you didn't link this identity, your account may be compromised. contact support immediately. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 11. IDENTITY UNLINKED --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">identity unlinked</h2> + <p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#aaaaaa"> + a {{ .Provider }} identity was removed from your account ({{ .Email }}). + </p> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + if this was you, no action is needed. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px 32px"> + <p style="margin:0;padding:12px 16px;background-color:#1a1a1a;border:1px solid #363636;font-size:12px;line-height:1.6;color:#c08000"> + if you didn't remove this identity, your account may be compromised. contact support immediately. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 12. MFA METHOD ADDED --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">two-factor authentication enabled</h2> + <p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#aaaaaa"> + a {{ .FactorType }} two-factor authentication method was added to your account ({{ .Email }}). + </p> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + your account is now more secure. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px 32px"> + <p style="margin:0;padding:12px 16px;background-color:#1a1a1a;border:1px solid #363636;font-size:12px;line-height:1.6;color:#c08000"> + if you didn't set this up, your account may be compromised. contact support immediately. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> + + +<!-- ============================================================ --> +<!-- 13. MFA METHOD REMOVED --> +<!-- ============================================================ --> + +<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#070707;padding:40px 0;font-family:'JetBrains Mono',Menlo,Monaco,'Courier New',monospace"> + <tr> + <td align="center"> + <table width="480" cellpadding="0" cellspacing="0" style="background-color:#0f0f0f;border:1px solid #363636"> + <tr> + <td style="padding:32px 32px 0"> + <p style="margin:0 0 24px;font-size:14px;color:#666666">asa.news</p> + <h2 style="margin:0 0 16px;font-size:16px;font-weight:400;color:#ffffff">two-factor authentication removed</h2> + <p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#aaaaaa"> + a {{ .FactorType }} two-factor authentication method was removed from your account ({{ .Email }}). + </p> + <p style="margin:0 0 24px;font-size:13px;line-height:1.6;color:#aaaaaa"> + your account now has fewer authentication factors. + </p> + </td> + </tr> + <tr> + <td style="padding:0 32px 32px"> + <p style="margin:0;padding:12px 16px;background-color:#1a1a1a;border:1px solid #363636;font-size:12px;line-height:1.6;color:#c08000"> + if you didn't remove this factor, your account may be compromised. reset your password and contact support immediately. + </p> + </td> + </tr> + </table> + </td> + </tr> +</table> diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..434a181 --- /dev/null +++ b/turbo.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**", "dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": { + "dependsOn": ["^build"] + } + } +} |