summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f
downloadasa.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.
-rw-r--r--.gitignore22
-rw-r--r--.mcp.json8
-rw-r--r--apps/web/.gitignore41
-rw-r--r--apps/web/README.md36
-rw-r--r--apps/web/app/(auth)/forgot-password/page.tsx101
-rw-r--r--apps/web/app/(auth)/layout.tsx11
-rw-r--r--apps/web/app/(auth)/reset-password/page.tsx120
-rw-r--r--apps/web/app/(auth)/sign-in/page.tsx230
-rw-r--r--apps/web/app/(auth)/sign-up/page.tsx115
-rw-r--r--apps/web/app/(marketing)/_components/feature-grid.tsx48
-rw-r--r--apps/web/app/(marketing)/_components/interactive-demo.tsx275
-rw-r--r--apps/web/app/(marketing)/_components/pricing-table.tsx186
-rw-r--r--apps/web/app/(marketing)/_components/showcase-types.ts22
-rw-r--r--apps/web/app/(marketing)/page.tsx250
-rw-r--r--apps/web/app/api/account/data/route.ts96
-rw-r--r--apps/web/app/api/account/route.ts27
-rw-r--r--apps/web/app/api/billing/create-checkout-session/route.ts153
-rw-r--r--apps/web/app/api/billing/create-portal-session/route.ts51
-rw-r--r--apps/web/app/api/billing/webhook/route.ts181
-rw-r--r--apps/web/app/api/export/route.ts67
-rw-r--r--apps/web/app/api/share/[token]/route.ts85
-rw-r--r--apps/web/app/api/share/route.ts132
-rw-r--r--apps/web/app/api/v1/entries/[entryIdentifier]/route.ts72
-rw-r--r--apps/web/app/api/v1/entries/route.ts114
-rw-r--r--apps/web/app/api/v1/feeds/route.ts55
-rw-r--r--apps/web/app/api/v1/folders/route.ts36
-rw-r--r--apps/web/app/api/v1/keys/[keyIdentifier]/route.ts36
-rw-r--r--apps/web/app/api/v1/keys/route.ts116
-rw-r--r--apps/web/app/api/v1/profile/route.ts49
-rw-r--r--apps/web/app/api/webhook-config/route.ts117
-rw-r--r--apps/web/app/api/webhook-config/test/route.ts101
-rw-r--r--apps/web/app/apple-icon.tsx28
-rw-r--r--apps/web/app/auth/callback/route.ts43
-rw-r--r--apps/web/app/favicon.icobin0 -> 25931 bytes
-rw-r--r--apps/web/app/fonts/JetBrainsMono-Regular.woff2bin0 -> 92380 bytes
-rw-r--r--apps/web/app/globals.css222
-rw-r--r--apps/web/app/icon.tsx28
-rw-r--r--apps/web/app/layout.tsx58
-rw-r--r--apps/web/app/manifest.ts26
-rw-r--r--apps/web/app/providers.tsx29
-rw-r--r--apps/web/app/reader/_components/add-feed-dialog.tsx123
-rw-r--r--apps/web/app/reader/_components/command-palette.tsx200
-rw-r--r--apps/web/app/reader/_components/entry-detail-panel.tsx470
-rw-r--r--apps/web/app/reader/_components/entry-list-item.tsx125
-rw-r--r--apps/web/app/reader/_components/entry-list.tsx217
-rw-r--r--apps/web/app/reader/_components/error-boundary.tsx55
-rw-r--r--apps/web/app/reader/_components/highlight-popover.tsx96
-rw-r--r--apps/web/app/reader/_components/highlight-selection-toolbar.tsx80
-rw-r--r--apps/web/app/reader/_components/mfa-challenge.tsx108
-rw-r--r--apps/web/app/reader/_components/notification-panel.tsx129
-rw-r--r--apps/web/app/reader/_components/reader-layout-shell.tsx204
-rw-r--r--apps/web/app/reader/_components/reader-shell.tsx208
-rw-r--r--apps/web/app/reader/_components/search-overlay.tsx180
-rw-r--r--apps/web/app/reader/_components/sidebar-content.tsx356
-rw-r--r--apps/web/app/reader/_components/sidebar-footer.tsx79
-rw-r--r--apps/web/app/reader/actions.ts10
-rw-r--r--apps/web/app/reader/highlights/_components/highlights-content.tsx452
-rw-r--r--apps/web/app/reader/highlights/page.tsx16
-rw-r--r--apps/web/app/reader/layout.tsx14
-rw-r--r--apps/web/app/reader/page.tsx25
-rw-r--r--apps/web/app/reader/saved/page.tsx16
-rw-r--r--apps/web/app/reader/settings/_components/account-settings.tsx368
-rw-r--r--apps/web/app/reader/settings/_components/api-settings.tsx529
-rw-r--r--apps/web/app/reader/settings/_components/appearance-settings.tsx123
-rw-r--r--apps/web/app/reader/settings/_components/billing-settings.tsx301
-rw-r--r--apps/web/app/reader/settings/_components/custom-feeds-settings.tsx283
-rw-r--r--apps/web/app/reader/settings/_components/danger-zone-settings.tsx156
-rw-r--r--apps/web/app/reader/settings/_components/folders-settings.tsx220
-rw-r--r--apps/web/app/reader/settings/_components/import-export-settings.tsx220
-rw-r--r--apps/web/app/reader/settings/_components/muted-keywords-settings.tsx89
-rw-r--r--apps/web/app/reader/settings/_components/security-settings.tsx280
-rw-r--r--apps/web/app/reader/settings/_components/settings-shell.tsx86
-rw-r--r--apps/web/app/reader/settings/_components/subscriptions-settings.tsx281
-rw-r--r--apps/web/app/reader/settings/page.tsx16
-rw-r--r--apps/web/app/reader/shares/_components/shares-content.tsx504
-rw-r--r--apps/web/app/reader/shares/page.tsx16
-rw-r--r--apps/web/app/shared/[token]/page.tsx165
-rw-r--r--apps/web/app/sw.ts22
-rw-r--r--apps/web/eslint.config.mjs18
-rw-r--r--apps/web/lib/api-auth.ts80
-rw-r--r--apps/web/lib/api-key.ts20
-rw-r--r--apps/web/lib/highlight-positioning.ts258
-rw-r--r--apps/web/lib/hooks/use-is-mobile.ts26
-rw-r--r--apps/web/lib/hooks/use-keyboard-navigation.ts380
-rw-r--r--apps/web/lib/hooks/use-realtime-entries.ts74
-rw-r--r--apps/web/lib/notify.ts11
-rw-r--r--apps/web/lib/opml.ts161
-rw-r--r--apps/web/lib/queries/query-keys.ts43
-rw-r--r--apps/web/lib/queries/use-all-highlights.ts85
-rw-r--r--apps/web/lib/queries/use-custom-feed-mutations.ts122
-rw-r--r--apps/web/lib/queries/use-custom-feed-timeline.ts76
-rw-r--r--apps/web/lib/queries/use-custom-feeds.ts49
-rw-r--r--apps/web/lib/queries/use-entry-highlights.ts56
-rw-r--r--apps/web/lib/queries/use-entry-search.ts58
-rw-r--r--apps/web/lib/queries/use-entry-share.ts36
-rw-r--r--apps/web/lib/queries/use-entry-state-mutations.ts133
-rw-r--r--apps/web/lib/queries/use-folder-mutations.ts137
-rw-r--r--apps/web/lib/queries/use-highlight-mutations.ts132
-rw-r--r--apps/web/lib/queries/use-mark-all-as-read.ts48
-rw-r--r--apps/web/lib/queries/use-muted-keyword-mutations.ts68
-rw-r--r--apps/web/lib/queries/use-muted-keywords.ts30
-rw-r--r--apps/web/lib/queries/use-saved-entries.ts88
-rw-r--r--apps/web/lib/queries/use-subscribe-to-feed.ts37
-rw-r--r--apps/web/lib/queries/use-subscription-mutations.ts158
-rw-r--r--apps/web/lib/queries/use-subscriptions.ts78
-rw-r--r--apps/web/lib/queries/use-timeline.ts78
-rw-r--r--apps/web/lib/queries/use-unread-counts.ts32
-rw-r--r--apps/web/lib/queries/use-user-profile.ts46
-rw-r--r--apps/web/lib/query-client.ts12
-rw-r--r--apps/web/lib/rate-limit.ts24
-rw-r--r--apps/web/lib/sanitize.ts43
-rw-r--r--apps/web/lib/stores/notification-store.ts66
-rw-r--r--apps/web/lib/stores/user-interface-store.ts135
-rw-r--r--apps/web/lib/stripe.ts11
-rw-r--r--apps/web/lib/supabase/admin.ts8
-rw-r--r--apps/web/lib/supabase/client.ts8
-rw-r--r--apps/web/lib/supabase/middleware.ts39
-rw-r--r--apps/web/lib/supabase/server.ts27
-rw-r--r--apps/web/lib/types/custom-feed.ts8
-rw-r--r--apps/web/lib/types/highlight.ts17
-rw-r--r--apps/web/lib/types/subscription.ts19
-rw-r--r--apps/web/lib/types/timeline.ts16
-rw-r--r--apps/web/lib/types/user-profile.ts18
-rw-r--r--apps/web/lib/utilities.ts6
-rw-r--r--apps/web/middleware.ts27
-rw-r--r--apps/web/next.config.ts54
-rw-r--r--apps/web/package.json47
-rw-r--r--apps/web/pnpm-workspace.yaml3
-rw-r--r--apps/web/postcss.config.mjs7
-rw-r--r--apps/web/public/file.svg1
-rw-r--r--apps/web/public/globe.svg1
-rw-r--r--apps/web/public/icons/icon.svg4
-rw-r--r--apps/web/public/next.svg1
-rw-r--r--apps/web/public/vercel.svg1
-rw-r--r--apps/web/public/window.svg1
-rw-r--r--apps/web/tsconfig.json34
-rw-r--r--package.json13
-rw-r--r--packages/shared/package.json15
-rw-r--r--packages/shared/source/index.ts45
-rw-r--r--packages/shared/tsconfig.json16
-rw-r--r--pnpm-lock.yaml5403
-rw-r--r--pnpm-workspace.yaml3
-rw-r--r--services/worker/Dockerfile18
-rw-r--r--services/worker/Taskfile.yaml57
-rw-r--r--services/worker/cmd/worker/main.go143
-rw-r--r--services/worker/go.mod81
-rw-r--r--services/worker/go.sum226
-rw-r--r--services/worker/internal/configuration/configuration.go110
-rw-r--r--services/worker/internal/database/database.go39
-rw-r--r--services/worker/internal/fetcher/authentication.go43
-rw-r--r--services/worker/internal/fetcher/errors.go145
-rw-r--r--services/worker/internal/fetcher/fetcher.go116
-rw-r--r--services/worker/internal/fetcher/ssrf_protection.go77
-rw-r--r--services/worker/internal/health/health.go117
-rw-r--r--services/worker/internal/model/feed.go41
-rw-r--r--services/worker/internal/model/queue.go5
-rw-r--r--services/worker/internal/parser/parser.go234
-rw-r--r--services/worker/internal/pool/pool.go60
-rw-r--r--services/worker/internal/scheduler/refresh.go196
-rw-r--r--services/worker/internal/scheduler/scheduler.go283
-rw-r--r--services/worker/internal/webhook/webhook.go333
-rw-r--r--services/worker/internal/writer/writer.go222
-rw-r--r--supabase/email-templates.html489
-rw-r--r--turbo.json16
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&apos;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">&#9835;</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>&#9835;</span>}
+ {entry.author && (
+ <>
+ <span>&middot;</span>
+ <span>{entry.author}</span>
+ </>
+ )}
+ <span>&middot;</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>&middot;</span>
+ <span>save</span>
+ <span>&middot;</span>
+ <span>share</span>
+ <span>&middot;</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> &middot; {entry.author}</span>}
+ {readingTime && <span> &middot; {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>&#9835;</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"
+ >
+ &larr; 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> &middot; {entry.author}</span>}
+ {readingTime && <span> &middot; {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 &mdash; 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
new file mode 100644
index 0000000..718d6fe
--- /dev/null
+++ b/apps/web/app/favicon.ico
Binary files differ
diff --git a/apps/web/app/fonts/JetBrainsMono-Regular.woff2 b/apps/web/app/fonts/JetBrainsMono-Regular.woff2
new file mode 100644
index 0000000..66c5467
--- /dev/null
+++ b/apps/web/app/fonts/JetBrainsMono-Regular.woff2
Binary files differ
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> &middot; {entryDetail.author}</span>
+ )}
+ <span> &middot; {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">&#9835;</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">&#9835;</span>
+ )}
+ {entry.author && (
+ <>
+ <span>&middot;</span>
+ <span>{entry.author}</span>
+ </>
+ )}
+ <span>&middot;</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">&#9835;</span>
+ )}
+ {entry.author && (
+ <>
+ <span>&middot;</span>
+ <span>{entry.author}</span>
+ </>
+ )}
+ <span>&middot;</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"
+ >
+ &times;
+ </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"
+ >
+ &times;
+ </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"
+ >
+ &#9776;
+ </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"
+ >
+ &larr; 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">&#9835;</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">&#9835;</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 &ldquo;highlight&rdquo; 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>&middot;</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">&nbsp;</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"
+ >
+ &larr; 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&apos;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>&middot;</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>&middot;</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"
+ >
+ &larr; 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> &middot; {entry.author}</span>}
+ {formattedDate && <span> &middot; {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, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&apos;")
+}
+
+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
+ '@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
+ date-fns:
+ specifier: ^4.1.0
+ version: 4.1.0
+ next:
+ specifier: 16.1.6
+ 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
+ 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
+ '@vercel/speed-insights':
+ specifier: ^1.3.1
+ 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
+ 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'}
+
+ resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
+ engines: {node: '>=18'}
+
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ 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]
+
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ 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]
+
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ 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]
+
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ 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==}
+
+ resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
+
+ resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==}
+
+ resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ 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'}
+
+ 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}
+
+ resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
+ cpu: [arm]
+ os: [android]
+
+ resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==}
+ cpu: [arm64]
+ os: [android]
+
+ resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==}
+ cpu: [arm]
+ os: [linux]
+
+ resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==}
+ cpu: [arm]
+ os: [linux]
+
+ resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==}
+ cpu: [arm64]
+ os: [win32]
+
+ resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==}
+ cpu: [ia32]
+ os: [win32]
+
+ 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
+
+ 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]': {}
+
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ dependencies:
+ '@emnapi/runtime': 1.8.1
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ 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]': {}
+
+ dependencies:
+ fast-glob: 3.3.1
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ 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
+
+ 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-id': 1.1.1(@types/[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])
+
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/[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
+
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/[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
+
+ dependencies:
+ '@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])
+
+ 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])
+
+ 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])
+
+ 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
+
+ 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
+ react: 19.2.3
+ semver: 7.7.3
+ zod: 4.3.6
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - webpack
+
+ 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
+ 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
+
+ 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]': {}
+
+ 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]': {}
+
+ 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
+
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.54.0([email protected]([email protected]))([email protected])
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/type-utils': 8.54.0([email protected]([email protected]))([email protected])
+ '@typescript-eslint/utils': 8.54.0([email protected]([email protected]))([email protected])
+ '@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])
+ '@typescript-eslint/utils': 8.54.0([email protected]([email protected]))([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
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ dependencies:
+ '@napi-rs/wasm-runtime': 0.2.12
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optional: true
+
+ optionalDependencies:
+ react: 19.2.3
+
+ optionalDependencies:
+ react: 19.2.3
+
+ dependencies:
+ acorn: 8.15.0
+
+
+ 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
+
+
+
+ dependencies:
+ color-convert: 2.0.1
+
+
+
+ dependencies:
+ tslib: 2.8.1
+
+
+ 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
+
+
+
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
+
+
+ dependencies:
+ '@babel/types': 7.29.0
+
+
+
+ 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
+
+
+
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+
+
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
+ '@radix-ui/react-id': 1.1.1(@types/[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
+
+
+
+
+
+
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.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
+
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+
+ dependencies:
+ ms: 2.1.3
+
+ dependencies:
+ ms: 2.1.3
+
+
+
+ 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
+
+
+
+ dependencies:
+ esutils: 2.0.3
+
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+
+
+ 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
+
+
+
+
+
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
+
+ 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
+
+
+
+ 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
+
+
+
+ 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-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:
+ transitivePeerDependencies:
+ - supports-color
+
+ dependencies:
+ debug: 3.2.7
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.54.0([email protected]([email protected]))([email protected])
+ 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
+
+ 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
+ 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:
+ '@typescript-eslint/parser': 8.54.0([email protected]([email protected]))([email protected])
+ 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
+
+
+
+ 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
+
+
+
+
+ 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
+
+
+
+ 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
+
+
+ dependencies:
+ is-callable: 1.2.7
+
+ dependencies:
+ cross-spawn: 7.0.6
+ signal-exit: 4.1.0
+
+
+ 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
+
+
+
+
+ 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
+
+
+ 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
+
+
+
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+
+
+
+
+
+ dependencies:
+ es-define-property: 1.0.1
+
+ dependencies:
+ dunder-proto: 1.0.1
+
+
+ dependencies:
+ has-symbols: 1.1.0
+
+ dependencies:
+ function-bind: 1.1.2
+
+
+ dependencies:
+ hermes-estree: 0.25.1
+
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ entities: 4.5.0
+
+
+
+
+
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+
+ 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
+
+
+ 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
+
+
+ dependencies:
+ call-bound: 1.0.4
+
+
+ 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
+
+
+
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+
+
+ dependencies:
+ call-bound: 1.0.4
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.2
+
+
+ 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
+
+
+ dependencies:
+ call-bound: 1.0.4
+
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+
+
+ 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
+
+
+
+ dependencies:
+ argparse: 2.0.1
+
+
+
+
+
+ dependencies:
+ minimist: 1.2.8
+
+
+ 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
+
+
+
+ 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
+
+
+
+ dependencies:
+ js-tokens: 4.0.0
+
+
+ dependencies:
+ yallist: 3.1.1
+
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+
+
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.1
+
+ dependencies:
+ brace-expansion: 1.1.12
+
+ dependencies:
+ brace-expansion: 2.0.2
+
+
+
+
+
+
+
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3([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
+
+
+
+
+
+ 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
+
+
+ dependencies:
+ callsites: 3.1.0
+
+
+
+
+
+ dependencies:
+ lru-cache: 10.4.3
+ minipass: 7.1.2
+
+
+
+
+
+ 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
+
+
+
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+
+
+ dependencies:
+ react: 19.2.3
+ scheduler: 0.27.0
+
+
+ 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
+
+
+ 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
+
+
+
+ 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
+
+
+ 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
+
+
+
+
+
+ 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
+
+
+ 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
+
+
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3([email protected])
+
+
+ dependencies:
+ whatwg-url: 7.1.0
+
+
+ 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
+
+
+
+ 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
+
+
+
+
+
+ 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
+
+
+ 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/parser': 8.54.0([email protected]([email protected]))([email protected])
+ '@typescript-eslint/typescript-estree': 8.54.0([email protected])
+ '@typescript-eslint/utils': 8.54.0([email protected]([email protected]))([email protected])
+ eslint: 9.39.2([email protected])
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+
+ dependencies:
+ call-bound: 1.0.4
+ has-bigints: 1.1.0
+ has-symbols: 1.1.0
+ which-boxed-primitive: 1.1.1
+
+
+ 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
+
+
+ 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
+
+
+ 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
+
+
+
+
+ dependencies:
+ zod: 4.3.6
+
+
+ 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"]
+ }
+ }
+}