summaryrefslogtreecommitdiff
path: root/apps
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 /apps
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.
Diffstat (limited to 'apps')
-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
134 files changed, 13435 insertions, 0 deletions
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"]
+}