diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
Diffstat (limited to 'apps')
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't have an account? sign up + </Link> + </div> + </> + ) +} diff --git a/apps/web/app/(auth)/sign-up/page.tsx b/apps/web/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..9b78d90 --- /dev/null +++ b/apps/web/app/(auth)/sign-up/page.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export default function SignUpPage() { + const [emailAddress, setEmailAddress] = useState("") + const [password, setPassword] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isComplete, setIsComplete] = useState(false) + const router = useRouter() + + async function handleSignUp(event: React.FormEvent) { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.signUp({ + email: emailAddress, + password, + }) + + if (error) { + setErrorMessage(error.message) + setIsSubmitting(false) + return + } + + setIsComplete(true) + } + + if (isComplete) { + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">check your email</h1> + <p className="text-text-secondary"> + we sent a confirmation link to {emailAddress} + </p> + </div> + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) + } + + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">sign up</h1> + <p className="text-text-secondary">create your asa.news account</p> + </div> + + <form onSubmit={handleSignUp} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="email" className="text-text-secondary"> + email + </label> + <input + id="email" + type="email" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + placeholder="[email protected]" + /> + </div> + + <div className="space-y-2"> + <label htmlFor="password" className="text-text-secondary"> + password + </label> + <input + id="password" + type="password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + minLength={8} + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "creating account..." : "sign up"} + </button> + </form> + + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + already have an account? sign in + </Link> + </> + ) +} diff --git a/apps/web/app/(marketing)/_components/feature-grid.tsx b/apps/web/app/(marketing)/_components/feature-grid.tsx new file mode 100644 index 0000000..64fd1a4 --- /dev/null +++ b/apps/web/app/(marketing)/_components/feature-grid.tsx @@ -0,0 +1,48 @@ +const FEATURES = [ + { + title: "keyboard shortcuts", + description: + "full keyboard shortcut support for power users. works just as well with a mouse or trackpad.", + }, + { + title: "podcast support", + description: + "subscribe to podcast feeds and listen to episodes with the built-in audio player.", + }, + { + title: "highlights & notes", + description: + "highlight passages in articles and attach notes. free users get up to 50 highlights.", + }, + { + title: "sharing", + description: + "share articles via public links. links last 7 days on free, 30 days on pro.", + }, + { + title: "import & export", + description: + "import your feeds from any reader via OPML. pro users can export their full data.", + }, + { + title: "real-time updates", + description: + "new entries appear automatically as feeds are refreshed. no manual reload needed.", + }, +] + +export function FeatureGrid() { + return ( + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> + {FEATURES.map((feature) => ( + <div + key={feature.title} + className="border border-border bg-background-secondary p-4" + > + <h3 className="mb-1 text-text-primary">{feature.title}</h3> + <p className="text-text-secondary">{feature.description}</p> + </div> + ))} + </div> + ) +} diff --git a/apps/web/app/(marketing)/_components/interactive-demo.tsx b/apps/web/app/(marketing)/_components/interactive-demo.tsx new file mode 100644 index 0000000..a1c3755 --- /dev/null +++ b/apps/web/app/(marketing)/_components/interactive-demo.tsx @@ -0,0 +1,275 @@ +"use client" + +import { useState } from "react" +import { formatDistanceToNow } from "date-fns" +import { classNames } from "@/lib/utilities" +import type { ShowcaseEntry, ShowcaseFeed } from "./showcase-types" + +function estimateReadingTimeMinutes(html: string): number { + const text = html.replace(/<[^>]*>/g, "").replace(/&\w+;/g, " ") + const wordCount = text.split(/\s+/).filter(Boolean).length + return Math.max(1, Math.round(wordCount / 200)) +} + +function DemoSidebar({ feeds }: { feeds: ShowcaseFeed[] }) { + return ( + <aside className="hidden w-52 shrink-0 border-r border-border bg-background-secondary lg:block"> + <div className="border-b border-border px-3 py-2 text-text-primary"> + asa.news + </div> + <nav className="space-y-0.5 p-2"> + <span className="flex items-center bg-background-tertiary px-2 py-1 text-text-primary"> + <span>all entries</span> + <span className="ml-auto text-[0.625rem] tabular-nums text-text-dim"> + {feeds.reduce((sum, feed) => sum + feed.unreadCount, 0)} + </span> + </span> + <span className="block px-2 py-1 text-text-secondary">saved</span> + <span className="block px-2 py-1 text-text-secondary">highlights</span> + <span className="block px-2 py-1 text-text-secondary">shares</span> + + <div className="mt-3 space-y-0.5"> + {feeds.map((feed) => ( + <span + key={feed.url} + className="flex items-center truncate px-2 py-1 text-[0.85em] text-text-secondary" + > + <img + src={`https://www.google.com/s2/favicons?domain=${new URL(feed.url).hostname}&sz=16`} + alt="" + width={16} + height={16} + className="shrink-0" + loading="lazy" + /> + <span className="ml-2 truncate">{feed.title}</span> + {feed.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim">♫</span> + )} + <span className="ml-auto shrink-0 text-[0.625rem] tabular-nums text-text-dim"> + {feed.unreadCount > 0 ? feed.unreadCount : ""} + </span> + </span> + ))} + </div> + </nav> + </aside> + ) +} + +function DemoEntryList({ + entries, + selectedEntryIdentifier, + onSelectEntry, +}: { + entries: ShowcaseEntry[] + selectedEntryIdentifier: string | null + onSelectEntry: (identifier: string) => void +}) { + return ( + <div className="flex-1 overflow-y-auto border-r border-border md:max-w-sm lg:max-w-md"> + {entries.map((entry, index) => { + const isSelected = entry.entryIdentifier === selectedEntryIdentifier + const isRead = index % 3 === 2 + + const relativeTimestamp = entry.publishedAt + ? formatDistanceToNow(new Date(entry.publishedAt), { + addSuffix: true, + }) + : "" + + return ( + <div + key={entry.entryIdentifier} + onClick={() => onSelectEntry(entry.entryIdentifier)} + className={classNames( + "cursor-pointer border-b border-border px-4 py-2.5 transition-colors", + isSelected + ? "bg-background-tertiary" + : "hover:bg-background-secondary", + isRead ? "opacity-60" : "" + )} + > + <div className="truncate text-text-primary"> + {entry.entryTitle} + </div> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + <span>{entry.feedTitle}</span> + {entry.enclosureUrl && <span>♫</span>} + {entry.author && ( + <> + <span>·</span> + <span>{entry.author}</span> + </> + )} + <span>·</span> + <span>{relativeTimestamp}</span> + </div> + </div> + ) + })} + </div> + ) +} + +function DemoDetailPane({ entry }: { entry: ShowcaseEntry | null }) { + if (!entry) { + return ( + <div className="hidden flex-1 items-center justify-center text-text-dim md:flex"> + select an entry to read + </div> + ) + } + + const readingTime = entry.contentHtml + ? estimateReadingTimeMinutes(entry.contentHtml) + : null + + return ( + <div className="hidden flex-1 overflow-y-auto md:block"> + <div className="border-b border-border px-4 py-2"> + <div className="flex items-center gap-3 text-text-dim"> + <span>mark read</span> + <span>·</span> + <span>save</span> + <span>·</span> + <span>share</span> + <span>·</span> + <span>open original</span> + </div> + </div> + <article className="px-6 py-4"> + <h1 className="mb-2 text-lg text-text-primary">{entry.entryTitle}</h1> + <div className="mb-4 text-text-dim"> + <span>{entry.feedTitle}</span> + {entry.author && <span> · {entry.author}</span>} + {readingTime && <span> · {readingTime} min read</span>} + </div> + {entry.enclosureUrl && ( + <div className="mb-4 border border-border p-3"> + <div className="flex items-center gap-2 text-text-secondary"> + <span>♫</span> + <span>audio available</span> + </div> + </div> + )} + {entry.contentHtml ? ( + <div + className="prose-reader text-text-secondary" + dangerouslySetInnerHTML={{ __html: entry.contentHtml }} + /> + ) : entry.summary ? ( + <p className="text-text-secondary">{entry.summary}</p> + ) : null} + </article> + </div> + ) +} + +function DemoMobileDetail({ + entry, + onClose, +}: { + entry: ShowcaseEntry + onClose: () => void +}) { + const readingTime = entry.contentHtml + ? estimateReadingTimeMinutes(entry.contentHtml) + : null + + return ( + <div className="fixed inset-0 z-50 flex flex-col bg-background-primary md:hidden"> + <div className="flex items-center border-b border-border px-4 py-2"> + <button + type="button" + onClick={onClose} + className="text-text-secondary transition-colors hover:text-text-primary" + > + ← back + </button> + </div> + <div className="flex-1 overflow-y-auto"> + <article className="px-4 py-4"> + <h1 className="mb-2 text-lg text-text-primary"> + {entry.entryTitle} + </h1> + <div className="mb-4 text-text-dim"> + <span>{entry.feedTitle}</span> + {entry.author && <span> · {entry.author}</span>} + {readingTime && <span> · {readingTime} min read</span>} + </div> + {entry.contentHtml ? ( + <div + className="prose-reader text-text-secondary" + dangerouslySetInnerHTML={{ __html: entry.contentHtml }} + /> + ) : entry.summary ? ( + <p className="text-text-secondary">{entry.summary}</p> + ) : null} + </article> + </div> + </div> + ) +} + +export function InteractiveDemo({ + showcaseEntries, +}: { + showcaseEntries: ShowcaseEntry[] +}) { + const [selectedEntryIdentifier, setSelectedEntryIdentifier] = useState< + string | null + >(showcaseEntries.length > 0 ? showcaseEntries[0].entryIdentifier : null) + const [mobileDetailEntry, setMobileDetailEntry] = + useState<ShowcaseEntry | null>(null) + + const selectedEntry = + showcaseEntries.find( + (entry) => entry.entryIdentifier === selectedEntryIdentifier + ) ?? null + + const feedMap = new Map<string, ShowcaseFeed>() + for (const entry of showcaseEntries) { + if (!feedMap.has(entry.feedUrl)) { + const seededCount = + (entry.feedUrl.charCodeAt(0) + entry.feedUrl.length) % 12 + feedMap.set(entry.feedUrl, { + title: entry.feedTitle, + url: entry.feedUrl, + feedType: entry.feedType, + unreadCount: seededCount, + }) + } + } + const feeds = Array.from(feedMap.values()) + + function handleSelectEntry(identifier: string) { + setSelectedEntryIdentifier(identifier) + const entry = showcaseEntries.find( + (showcaseEntry) => showcaseEntry.entryIdentifier === identifier + ) + if (entry && typeof window !== "undefined" && window.innerWidth < 768) { + setMobileDetailEntry(entry) + } + } + + return ( + <div className="relative border border-border bg-background-primary"> + <div className="flex h-[500px]"> + <DemoSidebar feeds={feeds} /> + <DemoEntryList + entries={showcaseEntries} + selectedEntryIdentifier={selectedEntryIdentifier} + onSelectEntry={handleSelectEntry} + /> + <DemoDetailPane entry={selectedEntry} /> + </div> + {mobileDetailEntry && ( + <DemoMobileDetail + entry={mobileDetailEntry} + onClose={() => setMobileDetailEntry(null)} + /> + )} + </div> + ) +} diff --git a/apps/web/app/(marketing)/_components/pricing-table.tsx b/apps/web/app/(marketing)/_components/pricing-table.tsx new file mode 100644 index 0000000..c06b4f9 --- /dev/null +++ b/apps/web/app/(marketing)/_components/pricing-table.tsx @@ -0,0 +1,186 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { TIER_LIMITS } from "@asa-news/shared" +import { classNames } from "@/lib/utilities" + +function formatLimit(value: number): string { + if (!Number.isFinite(value)) return "unlimited" + return value.toLocaleString() +} + +const COMPARISON_ROWS = [ + { + label: "feeds", + free: formatLimit(TIER_LIMITS.free.maximumFeeds), + pro: formatLimit(TIER_LIMITS.pro.maximumFeeds), + developer: formatLimit(TIER_LIMITS.developer.maximumFeeds), + }, + { + label: "folders", + free: formatLimit(TIER_LIMITS.free.maximumFolders), + pro: formatLimit(TIER_LIMITS.pro.maximumFolders), + developer: formatLimit(TIER_LIMITS.developer.maximumFolders), + }, + { + label: "refresh interval", + free: `${TIER_LIMITS.free.refreshIntervalSeconds / 60} min`, + pro: `${TIER_LIMITS.pro.refreshIntervalSeconds / 60} min`, + developer: `${TIER_LIMITS.developer.refreshIntervalSeconds / 60} min`, + }, + { + label: "history", + free: `${TIER_LIMITS.free.historyRetentionDays} days`, + pro: formatLimit(TIER_LIMITS.pro.historyRetentionDays), + developer: formatLimit(TIER_LIMITS.developer.historyRetentionDays), + }, + { + label: "muted keywords", + free: formatLimit(TIER_LIMITS.free.maximumMutedKeywords), + pro: formatLimit(TIER_LIMITS.pro.maximumMutedKeywords), + developer: formatLimit(TIER_LIMITS.developer.maximumMutedKeywords), + }, + { + label: "custom feeds", + free: formatLimit(TIER_LIMITS.free.maximumCustomFeeds), + pro: formatLimit(TIER_LIMITS.pro.maximumCustomFeeds), + developer: formatLimit(TIER_LIMITS.developer.maximumCustomFeeds), + }, + { + label: "authenticated feeds", + free: "no", + pro: "yes", + developer: "yes", + }, + { + label: "data export", + free: "no", + pro: "yes", + developer: "yes", + }, + { + label: "rest api", + free: "no", + pro: "no", + developer: "yes", + }, + { + label: "webhooks", + free: "no", + pro: "no", + developer: "yes", + }, +] + +export function PricingTable() { + const [billingInterval, setBillingInterval] = useState<"monthly" | "yearly">( + "yearly" + ) + + const proPrice = billingInterval === "yearly" ? "$30" : "$3" + const proPeriod = billingInterval === "yearly" ? "/ year" : "/ month" + const developerPrice = billingInterval === "yearly" ? "$60" : "$6" + const developerPeriod = billingInterval === "yearly" ? "/ year" : "/ month" + + return ( + <div> + <div className="mb-6 flex items-center justify-center gap-2"> + <button + type="button" + onClick={() => setBillingInterval("monthly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "monthly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + monthly + </button> + <button + type="button" + onClick={() => setBillingInterval("yearly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "yearly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + yearly + </button> + </div> + + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> + <div className="border border-border p-6"> + <h3 className="mb-1 text-text-primary">free</h3> + <p className="mb-4 text-text-dim">$0 / month</p> + <ul className="space-y-2"> + {COMPARISON_ROWS.map((row) => ( + <li + key={row.label} + className="flex justify-between text-text-secondary" + > + <span>{row.label}</span> + <span className="whitespace-nowrap text-text-primary">{row.free}</span> + </li> + ))} + </ul> + <Link + href="/sign-up" + className="mt-6 block border border-border px-4 py-2 text-center text-text-primary transition-colors hover:bg-background-tertiary" + > + get started + </Link> + </div> + <div className="border border-text-primary p-6"> + <h3 className="mb-1 text-text-primary">pro</h3> + <p className="mb-4 text-text-dim"> + {proPrice} {proPeriod} + </p> + <ul className="space-y-2"> + {COMPARISON_ROWS.map((row) => ( + <li + key={row.label} + className="flex justify-between text-text-secondary" + > + <span>{row.label}</span> + <span className="whitespace-nowrap text-text-primary">{row.pro}</span> + </li> + ))} + </ul> + <Link + href="/sign-up" + className="mt-6 block bg-text-primary px-4 py-2 text-center text-background-primary transition-opacity hover:opacity-90" + > + get started + </Link> + </div> + <div className="border border-border p-6"> + <h3 className="mb-1 text-text-primary">developer</h3> + <p className="mb-4 text-text-dim"> + {developerPrice} {developerPeriod} + </p> + <ul className="space-y-2"> + {COMPARISON_ROWS.map((row) => ( + <li + key={row.label} + className="flex justify-between text-text-secondary" + > + <span>{row.label}</span> + <span className="whitespace-nowrap text-text-primary">{row.developer}</span> + </li> + ))} + </ul> + <Link + href="/sign-up" + className="mt-6 block border border-border px-4 py-2 text-center text-text-primary transition-colors hover:bg-background-tertiary" + > + get started + </Link> + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/(marketing)/_components/showcase-types.ts b/apps/web/app/(marketing)/_components/showcase-types.ts new file mode 100644 index 0000000..5517223 --- /dev/null +++ b/apps/web/app/(marketing)/_components/showcase-types.ts @@ -0,0 +1,22 @@ +export interface ShowcaseEntry { + entryIdentifier: string + feedTitle: string + feedUrl: string + feedType: string | null + entryTitle: string + entryUrl: string + author: string | null + summary: string | null + contentHtml: string | null + imageUrl: string | null + publishedAt: string + enclosureUrl: string | null + enclosureType: string | null +} + +export interface ShowcaseFeed { + title: string + url: string + feedType: string | null + unreadCount: number +} diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx new file mode 100644 index 0000000..534f252 --- /dev/null +++ b/apps/web/app/(marketing)/page.tsx @@ -0,0 +1,250 @@ +import Link from "next/link" +import { redirect } from "next/navigation" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { sanitizeEntryContent } from "@/lib/sanitize" +import { InteractiveDemo } from "./_components/interactive-demo" +import { FeatureGrid } from "./_components/feature-grid" +import { PricingTable } from "./_components/pricing-table" +import type { ShowcaseEntry } from "./_components/showcase-types" + +export const revalidate = 300 + +interface ShowcaseEntryRow { + id: string + title: string | null + url: string | null + author: string | null + summary: string | null + content_html: string | null + image_url: string | null + published_at: string | null + enclosure_url: string | null + enclosure_type: string | null + feeds: { + title: string | null + url: string + feed_type: string | null + } +} + +const SHOWCASE_FEED_URLS = [ + "https://techcrunch.com/feed/", + "https://blog.cloudflare.com/rss/", + "https://hacks.mozilla.org/feed/", + "https://feeds.arstechnica.com/arstechnica/index", + "https://www.wired.com/feed/rss", +] + +async function fetchShowcaseEntries(): Promise<ShowcaseEntry[]> { + const adminClient = createSupabaseAdminClient() + + const { data: feedRows } = await adminClient + .from("feeds") + .select("id") + .in("url", SHOWCASE_FEED_URLS) + + const feedIdentifiers = (feedRows ?? []).map((row) => row.id) + + if (feedIdentifiers.length === 0) { + return FALLBACK_ENTRIES + } + + const entriesPerFeed = 5 + const perFeedResults = await Promise.all( + feedIdentifiers.map((feedIdentifier) => + adminClient + .from("entries") + .select( + "id, title, url, author, summary, content_html, image_url, published_at, enclosure_url, enclosure_type, feeds!inner(title, url, feed_type)" + ) + .is("owner_id", null) + .eq("feed_id", feedIdentifier) + .order("published_at", { ascending: false }) + .limit(entriesPerFeed) + ) + ) + + const combinedRows = perFeedResults.flatMap( + ({ data: rows }) => (rows as unknown as ShowcaseEntryRow[]) ?? [] + ) + + if (combinedRows.length === 0) { + return FALLBACK_ENTRIES + } + + combinedRows.sort( + (a, b) => + new Date(b.published_at ?? 0).getTime() - + new Date(a.published_at ?? 0).getTime() + ) + + return combinedRows.map((row) => ({ + entryIdentifier: row.id, + feedTitle: row.feeds.title ?? "untitled feed", + feedUrl: row.feeds.url, + feedType: row.feeds.feed_type, + entryTitle: row.title ?? "untitled", + entryUrl: row.url ?? "", + author: row.author, + summary: row.summary, + contentHtml: row.content_html + ? sanitizeEntryContent(row.content_html) + : null, + imageUrl: row.image_url, + publishedAt: row.published_at ?? new Date().toISOString(), + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + })) +} + +const FALLBACK_ENTRIES: ShowcaseEntry[] = [ + { + entryIdentifier: "fallback-1", + feedTitle: "TechCrunch", + feedUrl: "https://techcrunch.com/feed/", + feedType: null, + entryTitle: "The resurgence of rss in 2026", + entryUrl: "https://example.com", + author: "Sarah Chen", + summary: "RSS feeds are making a comeback as users seek alternatives to algorithmic timelines.", + contentHtml: "<p>RSS feeds are making a comeback as users seek alternatives to algorithmic timelines. More people are turning to chronological, ad-free reading experiences.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 3600000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, + { + entryIdentifier: "fallback-2", + feedTitle: "The Cloudflare Blog", + feedUrl: "https://blog.cloudflare.com/rss/", + feedType: null, + entryTitle: "How we built a global edge caching layer", + entryUrl: "https://example.com", + author: "Cloudflare Engineering", + summary: "A deep dive into the architecture behind Cloudflare's edge caching infrastructure.", + contentHtml: "<p>A deep dive into the architecture behind Cloudflare's edge caching infrastructure. Learn how requests are routed, cached, and invalidated across hundreds of data centres worldwide.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 7200000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, + { + entryIdentifier: "fallback-3", + feedTitle: "Mozilla Hacks", + feedUrl: "https://hacks.mozilla.org/feed/", + feedType: null, + entryTitle: "Exploring the future of web components", + entryUrl: "https://example.com", + author: "Mozilla", + summary: "Web components are evolving rapidly. Here's what's coming next for the open web platform.", + contentHtml: "<p>Web components are evolving rapidly. Here's what's coming next for the open web platform. From declarative shadow DOM to scoped custom element registries, the standards are maturing fast.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 10800000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, + { + entryIdentifier: "fallback-4", + feedTitle: "Ars Technica", + feedUrl: "https://feeds.arstechnica.com/arstechnica/index", + feedType: null, + entryTitle: "Building a personal information diet", + entryUrl: "https://example.com", + author: "Jordan Lee", + summary: "How curating your own feeds leads to better focus and less information overload.", + contentHtml: "<p>How curating your own feeds leads to better focus and less information overload. The key is choosing sources deliberately rather than letting algorithms decide.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 14400000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, + { + entryIdentifier: "fallback-5", + feedTitle: "Wired", + feedUrl: "https://www.wired.com/feed/rss", + feedType: null, + entryTitle: "The quiet revolution in personal information tools", + entryUrl: "https://example.com", + author: "Morgan Hayes", + summary: "A new wave of tools is helping people take control of their information diet.", + contentHtml: "<p>A new wave of tools is helping people take control of their information diet. From RSS readers to read-later apps, the focus is shifting back to user choice.</p>", + imageUrl: null, + publishedAt: new Date(Date.now() - 18000000).toISOString(), + enclosureUrl: null, + enclosureType: null, + }, +] + +export default async function LandingPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (user) redirect("/reader") + + const showcaseEntries = await fetchShowcaseEntries() + + return ( + <div className="min-h-screen"> + <header className="flex items-center justify-between border-b border-border px-6 py-3"> + <span className="text-text-primary">asa.news</span> + <Link + href="/sign-in" + className="text-text-secondary transition-colors hover:text-text-primary" + > + sign in + </Link> + </header> + + <section className="mx-auto max-w-4xl px-6 py-16 text-center"> + <h1 className="mb-3 text-xl text-text-primary">asa.news</h1> + <p className="mb-8 text-text-secondary"> + a fast, minimal rss reader for staying informed + </p> + <div className="flex items-center justify-center gap-4"> + <Link + href="/sign-up" + className="border border-border px-4 py-2 text-text-primary transition-colors hover:bg-background-tertiary" + > + sign up + </Link> + <Link + href="/sign-in" + className="px-4 py-2 text-text-secondary transition-colors hover:text-text-primary" + > + sign in + </Link> + </div> + </section> + + <section className="mx-auto max-w-6xl px-6 pb-16"> + <p className="mb-4 text-center text-text-dim"> + live preview — real entries from real feeds + </p> + <InteractiveDemo showcaseEntries={showcaseEntries} /> + </section> + + <section className="mx-auto max-w-4xl px-6 pb-16"> + <h2 className="mb-6 text-center text-text-primary">features</h2> + <FeatureGrid /> + </section> + + <section className="mx-auto max-w-4xl px-6 pb-16"> + <h2 className="mb-6 text-center text-text-primary">pricing</h2> + <PricingTable /> + </section> + + <section className="border-t border-border px-6 py-16 text-center"> + <p className="mb-6 text-text-secondary">start reading today</p> + <Link + href="/sign-up" + className="border border-border px-6 py-2 text-text-primary transition-colors hover:bg-background-tertiary" + > + sign up free + </Link> + </section> + </div> + ) +} diff --git a/apps/web/app/api/account/data/route.ts b/apps/web/app/api/account/data/route.ts new file mode 100644 index 0000000..dbee725 --- /dev/null +++ b/apps/web/app/api/account/data/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const [ + profileResult, + subscriptionsResult, + foldersResult, + mutedKeywordsResult, + customFeedsResult, + entryStatesResult, + highlightsResult, + sharedEntriesResult, + savedEntriesResult, + ] = await Promise.all([ + supabaseClient + .from("user_profiles") + .select("id, display_name, tier, created_at") + .eq("id", user.id) + .single(), + supabaseClient + .from("subscriptions") + .select("id, feed_id, folder_id, custom_title, created_at, feeds(title, url)") + .eq("user_id", user.id), + supabaseClient + .from("folders") + .select("id, name, position, created_at") + .eq("user_id", user.id), + supabaseClient + .from("muted_keywords") + .select("id, keyword, created_at") + .eq("user_id", user.id), + supabaseClient + .from("custom_feeds") + .select("id, name, query, position, created_at") + .eq("user_id", user.id), + supabaseClient + .from("user_entry_states") + .select("entry_id, read, saved, updated_at") + .eq("user_id", user.id), + supabaseClient + .from("user_highlights") + .select( + "id, entry_id, highlighted_text, note, color, text_offset, text_length, created_at, entries(title, url)" + ) + .eq("user_id", user.id), + supabaseClient + .from("shared_entries") + .select("id, entry_id, share_token, created_at, entries(title, url)") + .eq("user_id", user.id), + supabaseClient + .from("user_entry_states") + .select( + "entries(id, title, url, author, summary, published_at, feeds(title, url))" + ) + .eq("user_id", user.id) + .eq("saved", true), + ]) + + const exportData = { + exportedAt: new Date().toISOString(), + account: { + emailAddress: user.email, + ...profileResult.data, + }, + subscriptions: subscriptionsResult.data ?? [], + folders: foldersResult.data ?? [], + mutedKeywords: mutedKeywordsResult.data ?? [], + customFeeds: customFeedsResult.data ?? [], + entryStates: entryStatesResult.data ?? [], + highlights: highlightsResult.data ?? [], + sharedEntries: sharedEntriesResult.data ?? [], + savedEntries: + (savedEntriesResult.data ?? []).map( + (row) => (row as Record<string, unknown>).entries + ) ?? [], + } + + const jsonString = JSON.stringify(exportData, null, 2) + + return new Response(jsonString, { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json"`, + }, + }) +} diff --git a/apps/web/app/api/account/route.ts b/apps/web/app/api/account/route.ts new file mode 100644 index 0000000..6b1bc2d --- /dev/null +++ b/apps/web/app/api/account/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" + +export async function DELETE() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const adminClient = createSupabaseAdminClient() + + const { error } = await adminClient.auth.admin.deleteUser(user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to delete account" }, + { status: 500 } + ) + } + + return new Response(null, { status: 204 }) +} diff --git a/apps/web/app/api/billing/create-checkout-session/route.ts b/apps/web/app/api/billing/create-checkout-session/route.ts new file mode 100644 index 0000000..cfbb388 --- /dev/null +++ b/apps/web/app/api/billing/create-checkout-session/route.ts @@ -0,0 +1,153 @@ +import { NextResponse } from "next/server" +import { headers } from "next/headers" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { getStripe } from "@/lib/stripe" +import { rateLimit } from "@/lib/rate-limit" + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`checkout:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const body = await request.json().catch(() => ({})) + const billingInterval = + body.billingInterval === "yearly" ? "yearly" : "monthly" + const targetTier = + body.targetTier === "developer" ? "developer" : "pro" + + const priceIdentifierMap: Record<string, string | undefined> = { + "pro:monthly": process.env.STRIPE_PRO_MONTHLY_PRICE_IDENTIFIER, + "pro:yearly": process.env.STRIPE_PRO_YEARLY_PRICE_IDENTIFIER, + "developer:monthly": process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER, + "developer:yearly": process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER, + } + + const stripePriceIdentifier = + priceIdentifierMap[`${targetTier}:${billingInterval}`] + + if (!stripePriceIdentifier) { + return NextResponse.json( + { error: "Invalid plan configuration" }, + { status: 500 } + ) + } + + const { data: profile, error: profileError } = await supabaseClient + .from("user_profiles") + .select("tier, stripe_customer_identifier, stripe_subscription_identifier") + .eq("id", user.id) + .single() + + if (profileError || !profile) { + return NextResponse.json( + { error: "Failed to load profile" }, + { status: 500 } + ) + } + + const tierRank: Record<string, number> = { free: 0, pro: 1, developer: 2 } + const currentRank = tierRank[profile.tier] ?? 0 + const targetRank = tierRank[targetTier] ?? 0 + + if (currentRank >= targetRank) { + return NextResponse.json( + { error: `Already on ${profile.tier} plan` }, + { status: 400 } + ) + } + + if (profile.stripe_subscription_identifier && currentRank > 0) { + const subscription = await getStripe().subscriptions.retrieve( + profile.stripe_subscription_identifier + ) + + const existingItemIdentifier = subscription.items.data[0]?.id + + if (!existingItemIdentifier) { + return NextResponse.json( + { error: "Could not find existing subscription item" }, + { status: 500 } + ) + } + + await getStripe().subscriptions.update( + profile.stripe_subscription_identifier, + { + items: [ + { + id: existingItemIdentifier, + price: stripePriceIdentifier, + }, + ], + proration_behavior: "always_invoice", + metadata: { supabase_user_identifier: user.id }, + } + ) + + const adminClient = createSupabaseAdminClient() + await adminClient + .from("user_profiles") + .update({ tier: targetTier }) + .eq("id", user.id) + + return NextResponse.json({ upgraded: true }) + } + + let stripeCustomerIdentifier = profile.stripe_customer_identifier + + if (!stripeCustomerIdentifier) { + const customer = await getStripe().customers.create({ + email: user.email, + metadata: { supabase_user_identifier: user.id }, + }) + + stripeCustomerIdentifier = customer.id + + const adminClient = createSupabaseAdminClient() + const { error: updateError } = await adminClient + .from("user_profiles") + .update({ stripe_customer_identifier: stripeCustomerIdentifier }) + .eq("id", user.id) + + if (updateError) { + console.error("Admin client update error:", updateError) + return NextResponse.json( + { error: "Failed to save customer: " + updateError.message }, + { status: 500 } + ) + } + } + + const headersList = await headers() + const origin = headersList.get("origin") || "http://localhost:3000" + + const checkoutSession = await getStripe().checkout.sessions.create({ + customer: stripeCustomerIdentifier, + mode: "subscription", + line_items: [ + { + price: stripePriceIdentifier, + quantity: 1, + }, + ], + success_url: `${origin}/reader/settings?billing=success`, + cancel_url: `${origin}/reader/settings?billing=cancelled`, + subscription_data: { + metadata: { supabase_user_identifier: user.id }, + }, + client_reference_id: user.id, + }) + + return NextResponse.json({ url: checkoutSession.url }) +} diff --git a/apps/web/app/api/billing/create-portal-session/route.ts b/apps/web/app/api/billing/create-portal-session/route.ts new file mode 100644 index 0000000..3832c0d --- /dev/null +++ b/apps/web/app/api/billing/create-portal-session/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" +import { headers } from "next/headers" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { getStripe } from "@/lib/stripe" +import { rateLimit } from "@/lib/rate-limit" + +export async function POST() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`portal:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const { data: profile, error: profileError } = await supabaseClient + .from("user_profiles") + .select("stripe_customer_identifier") + .eq("id", user.id) + .single() + + if (profileError || !profile) { + return NextResponse.json( + { error: "Failed to load profile" }, + { status: 500 } + ) + } + + if (!profile.stripe_customer_identifier) { + return NextResponse.json( + { error: "No billing account found" }, + { status: 400 } + ) + } + + const headersList = await headers() + const origin = headersList.get("origin") || "http://localhost:3000" + + const portalSession = await getStripe().billingPortal.sessions.create({ + customer: profile.stripe_customer_identifier, + return_url: `${origin}/reader/settings`, + }) + + return NextResponse.json({ url: portalSession.url }) +} diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts new file mode 100644 index 0000000..8aed7d0 --- /dev/null +++ b/apps/web/app/api/billing/webhook/route.ts @@ -0,0 +1,181 @@ +import { NextResponse } from "next/server" +import type Stripe from "stripe" +import { getStripe } from "@/lib/stripe" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { rateLimit } from "@/lib/rate-limit" + +function determineTierFromSubscription( + subscription: Stripe.Subscription +): "pro" | "developer" { + const priceIdentifier = subscription.items?.data?.[0]?.price?.id + const developerPriceIdentifiers = [ + process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER, + process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER, + ] + + if (priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier)) { + return "developer" + } + + return "pro" +} + +function extractPeriodEnd(subscription: Stripe.Subscription): string | null { + const firstItem = subscription.items?.data?.[0] + if (firstItem?.current_period_end) { + return new Date(firstItem.current_period_end * 1000).toISOString() + } + + if (subscription.cancel_at) { + return new Date(subscription.cancel_at * 1000).toISOString() + } + + return null +} + +async function updateBillingState( + stripeCustomerIdentifier: string, + updates: Record<string, unknown> +) { + const adminClient = createSupabaseAdminClient() + const { error } = await adminClient + .from("user_profiles") + .update(updates) + .eq("stripe_customer_identifier", stripeCustomerIdentifier) + + if (error) { + console.error("Failed to update billing state:", error) + } +} + +async function handleCheckoutSessionCompleted( + session: Stripe.Checkout.Session +) { + if (session.mode !== "subscription" || !session.subscription) return + + const userIdentifier = session.client_reference_id + if (!userIdentifier) return + + const subscription = await getStripe().subscriptions.retrieve( + session.subscription as string, + { expand: ["items.data"] } + ) + + const adminClient = createSupabaseAdminClient() + await adminClient + .from("user_profiles") + .update({ + tier: determineTierFromSubscription(subscription), + stripe_customer_identifier: session.customer as string, + stripe_subscription_identifier: subscription.id, + stripe_subscription_status: subscription.status, + stripe_current_period_end: extractPeriodEnd(subscription), + }) + .eq("id", userIdentifier) +} + +async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { + const stripeCustomerIdentifier = subscription.customer as string + + const updates: Record<string, unknown> = { + stripe_subscription_status: subscription.status, + stripe_current_period_end: extractPeriodEnd(subscription), + } + + if (subscription.status === "active") { + updates.tier = determineTierFromSubscription(subscription) + } + + await updateBillingState(stripeCustomerIdentifier, updates) +} + +async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { + const stripeCustomerIdentifier = subscription.customer as string + + await updateBillingState(stripeCustomerIdentifier, { + tier: "free", + stripe_subscription_identifier: null, + stripe_subscription_status: "canceled", + stripe_current_period_end: null, + }) +} + +async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) { + const stripeCustomerIdentifier = invoice.customer as string + + await updateBillingState(stripeCustomerIdentifier, { + stripe_subscription_status: "past_due", + }) +} + +async function handleInvoicePaid(invoice: Stripe.Invoice) { + const stripeCustomerIdentifier = invoice.customer as string + const lineItem = invoice.lines?.data?.[0] + const priceIdentifier = (lineItem as unknown as { price?: { id?: string } } | undefined)?.price?.id + const developerPriceIdentifiers = [ + process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER, + process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER, + ] + const tier = + priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier) + ? "developer" + : "pro" + + await updateBillingState(stripeCustomerIdentifier, { + tier, + stripe_subscription_status: "active", + }) +} + +export async function POST(request: Request) { + const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown" + const rateLimitResult = rateLimit(`webhook:${clientIp}`, 60, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const body = await request.text() + const signature = request.headers.get("stripe-signature") + + if (!signature) { + return NextResponse.json({ error: "Missing signature" }, { status: 400 }) + } + + let event: Stripe.Event + + try { + event = getStripe().webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ) + } catch { + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }) + } + + switch (event.type) { + case "checkout.session.completed": + await handleCheckoutSessionCompleted( + event.data.object as Stripe.Checkout.Session + ) + break + case "customer.subscription.updated": + await handleSubscriptionUpdated( + event.data.object as Stripe.Subscription + ) + break + case "customer.subscription.deleted": + await handleSubscriptionDeleted( + event.data.object as Stripe.Subscription + ) + break + case "invoice.payment_failed": + await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice) + break + case "invoice.paid": + await handleInvoicePaid(event.data.object as Stripe.Invoice) + break + } + + return NextResponse.json({ received: true }) +} diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts new file mode 100644 index 0000000..4842f83 --- /dev/null +++ b/apps/web/app/api/export/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { data: profile } = await supabaseClient + .from("user_profiles") + .select("tier, display_name") + .eq("id", user.id) + .single() + + const tier = profile?.tier ?? "free" + + const { data: savedEntries } = await supabaseClient + .from("user_entry_states") + .select( + "entries(id, title, url, author, summary, published_at, feeds(title, url))" + ) + .eq("user_id", user.id) + .eq("saved", true) + + const exportData: Record<string, unknown> = { + exportedAt: new Date().toISOString(), + tier, + savedEntries: + (savedEntries ?? []).map((row) => (row as Record<string, unknown>).entries) ?? [], + } + + if (tier === "pro" || tier === "developer") { + const [subscriptionsResult, foldersResult, mutedKeywordsResult] = + await Promise.all([ + supabaseClient + .from("subscriptions") + .select("id, feed_id, folder_id, custom_title, feeds(title, url)") + .eq("user_id", user.id), + supabaseClient + .from("folders") + .select("id, name, position") + .eq("user_id", user.id), + supabaseClient + .from("muted_keywords") + .select("id, keyword") + .eq("user_id", user.id), + ]) + + exportData.subscriptions = subscriptionsResult.data ?? [] + exportData.folders = foldersResult.data ?? [] + exportData.mutedKeywords = mutedKeywordsResult.data ?? [] + } + + const jsonString = JSON.stringify(exportData, null, 2) + + return new Response(jsonString, { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="asa-news-export-${new Date().toISOString().slice(0, 10)}.json"`, + }, + }) +} diff --git a/apps/web/app/api/share/[token]/route.ts b/apps/web/app/api/share/[token]/route.ts new file mode 100644 index 0000000..45224aa --- /dev/null +++ b/apps/web/app/api/share/[token]/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +const MAX_NOTE_LENGTH = 1000 + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { token } = await params + + const { error } = await supabaseClient + .from("shared_entries") + .delete() + .eq("share_token", token) + .eq("user_id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to delete share" }, + { status: 500 } + ) + } + + return new Response(null, { status: 204 }) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { token } = await params + const body = await request.json() + const rawNote = body.note + + let note: string | null = null + if (rawNote !== undefined && rawNote !== null) { + if (typeof rawNote !== "string") { + return NextResponse.json( + { error: "note must be a string" }, + { status: 400 } + ) + } + if (rawNote.length > MAX_NOTE_LENGTH) { + return NextResponse.json( + { error: `note must be ${MAX_NOTE_LENGTH} characters or fewer` }, + { status: 400 } + ) + } + note = rawNote.trim() || null + } + + const { error } = await supabaseClient + .from("shared_entries") + .update({ note }) + .eq("share_token", token) + .eq("user_id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to update share" }, + { status: 500 } + ) + } + + return NextResponse.json({ note }) +} diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts new file mode 100644 index 0000000..2558560 --- /dev/null +++ b/apps/web/app/api/share/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server" +import { randomBytes } from "crypto" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +const MAX_NOTE_LENGTH = 1000 + +function buildOrigin(request: Request): string { + if (process.env.NEXT_PUBLIC_APP_URL) { + return process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/, "") + } + + return ( + request.headers.get("origin") ?? + `https://${request.headers.get("host")}` + ) +} + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { data: userProfile } = await supabaseClient + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single() + + const tier = userProfile?.tier ?? "free" + const expiryDays = tier === "pro" || tier === "developer" ? 30 : 7 + const expiresAt = new Date( + Date.now() + expiryDays * 24 * 60 * 60 * 1000 + ).toISOString() + + const body = await request.json() + const entryIdentifier = body.entryIdentifier as string + const rawNote = body.note + + if (!entryIdentifier || typeof entryIdentifier !== "string") { + return NextResponse.json( + { error: "entryIdentifier is required" }, + { status: 400 } + ) + } + + let note: string | null = null + if (rawNote !== undefined && rawNote !== null) { + if (typeof rawNote !== "string") { + return NextResponse.json( + { error: "note must be a string" }, + { status: 400 } + ) + } + if (rawNote.length > MAX_NOTE_LENGTH) { + return NextResponse.json( + { error: `note must be ${MAX_NOTE_LENGTH} characters or fewer` }, + { status: 400 } + ) + } + note = rawNote.trim() || null + } + + const { data: entryAccess } = await supabaseClient + .from("entries") + .select("id, feed_id") + .eq("id", entryIdentifier) + .maybeSingle() + + if (!entryAccess) { + return NextResponse.json( + { error: "Entry not found or not accessible" }, + { status: 404 } + ) + } + + const { data: subscriptionAccess } = await supabaseClient + .from("subscriptions") + .select("id") + .eq("feed_id", entryAccess.feed_id) + .eq("user_id", user.id) + .maybeSingle() + + if (!subscriptionAccess) { + return NextResponse.json( + { error: "You do not have access to this entry" }, + { status: 403 } + ) + } + + const origin = buildOrigin(request) + + const { data: existingShare } = await supabaseClient + .from("shared_entries") + .select("share_token") + .eq("entry_id", entryIdentifier) + .eq("user_id", user.id) + .maybeSingle() + + if (existingShare) { + const shareUrl = `${origin}/shared/${existingShare.share_token}` + return NextResponse.json({ + shareToken: existingShare.share_token, + shareUrl, + }) + } + + const shareToken = randomBytes(16).toString("base64url") + + const { error } = await supabaseClient.from("shared_entries").insert({ + user_id: user.id, + entry_id: entryIdentifier, + share_token: shareToken, + expires_at: expiresAt, + note, + }) + + if (error) { + return NextResponse.json( + { error: "Failed to create share" }, + { status: 500 } + ) + } + + const shareUrl = `${origin}/shared/${shareToken}` + + return NextResponse.json({ shareToken, shareUrl }) +} diff --git a/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts new file mode 100644 index 0000000..157366b --- /dev/null +++ b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET( + request: Request, + { params }: { params: Promise<{ entryIdentifier: string }> } +) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const { entryIdentifier } = await params + const adminClient = createSupabaseAdminClient() + + const { data: entry, error } = await adminClient + .from("entries") + .select( + "id, feed_id, guid, title, url, author, summary, content_html, image_url, published_at, enclosure_url, enclosure_type, enclosure_length, word_count" + ) + .eq("id", entryIdentifier) + .is("owner_id", null) + .single() + + if (error || !entry) { + return NextResponse.json({ error: "Entry not found" }, { status: 404 }) + } + + const { data: subscription } = await adminClient + .from("subscriptions") + .select("id") + .eq("user_id", authResult.user.userIdentifier) + .eq("feed_id", entry.feed_id) + .single() + + if (!subscription) { + return NextResponse.json({ error: "Entry not found" }, { status: 404 }) + } + + const { data: stateRow } = await adminClient + .from("user_entry_states") + .select("read, saved") + .eq("user_id", authResult.user.userIdentifier) + .eq("entry_id", entryIdentifier) + .single() + + return NextResponse.json({ + entry: { + entryIdentifier: entry.id, + feedIdentifier: entry.feed_id, + guid: entry.guid, + title: entry.title, + url: entry.url, + author: entry.author, + summary: entry.summary, + contentHtml: entry.content_html, + imageUrl: entry.image_url, + publishedAt: entry.published_at, + enclosureUrl: entry.enclosure_url, + enclosureType: entry.enclosure_type, + enclosureLength: entry.enclosure_length, + wordCount: entry.word_count, + isRead: stateRow?.read ?? false, + isSaved: stateRow?.saved ?? false, + }, + }) +} diff --git a/apps/web/app/api/v1/entries/route.ts b/apps/web/app/api/v1/entries/route.ts new file mode 100644 index 0000000..653c79b --- /dev/null +++ b/apps/web/app/api/v1/entries/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const { searchParams } = new URL(request.url) + const feedIdentifier = searchParams.get("feedIdentifier") + const isRead = searchParams.get("isRead") + const isSaved = searchParams.get("isSaved") + const cursor = searchParams.get("cursor") + const limitParameter = searchParams.get("limit") + const limit = Math.min(Math.max(Number(limitParameter) || 50, 1), 100) + + const adminClient = createSupabaseAdminClient() + + const { data: subscriptionRows } = await adminClient + .from("subscriptions") + .select("feed_id") + .eq("user_id", authResult.user.userIdentifier) + + const subscribedFeedIdentifiers = (subscriptionRows ?? []).map( + (row) => row.feed_id + ) + + if (subscribedFeedIdentifiers.length === 0) { + return NextResponse.json({ entries: [], nextCursor: null }) + } + + let query = adminClient + .from("entries") + .select( + "id, feed_id, guid, title, url, author, summary, image_url, published_at, enclosure_url, enclosure_type, user_entry_states!left(read, saved)" + ) + .in("feed_id", subscribedFeedIdentifiers) + .is("owner_id", null) + .order("published_at", { ascending: false }) + .limit(limit + 1) + + if (feedIdentifier) { + query = query.eq("feed_id", feedIdentifier) + } + + if (cursor) { + query = query.lt("published_at", cursor) + } + + const { data, error } = await query + + if (error) { + return NextResponse.json( + { error: "Failed to load entries" }, + { status: 500 } + ) + } + + interface EntryRow { + id: string + feed_id: string + guid: string | null + title: string | null + url: string | null + author: string | null + summary: string | null + image_url: string | null + published_at: string | null + enclosure_url: string | null + enclosure_type: string | null + user_entry_states: Array<{ read: boolean; saved: boolean }> | null + } + + let entries = (data as unknown as EntryRow[]).map((row) => { + const state = row.user_entry_states?.[0] + + return { + entryIdentifier: row.id, + feedIdentifier: row.feed_id, + guid: row.guid, + title: row.title, + url: row.url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + isRead: state?.read ?? false, + isSaved: state?.saved ?? false, + } + }) + + if (isRead === "true") entries = entries.filter((entry) => entry.isRead) + if (isRead === "false") entries = entries.filter((entry) => !entry.isRead) + if (isSaved === "true") entries = entries.filter((entry) => entry.isSaved) + if (isSaved === "false") entries = entries.filter((entry) => !entry.isSaved) + + const hasMore = entries.length > limit + if (hasMore) entries = entries.slice(0, limit) + + const nextCursor = + hasMore && entries.length > 0 + ? entries[entries.length - 1].publishedAt + : null + + return NextResponse.json({ entries, nextCursor }) +} diff --git a/apps/web/app/api/v1/feeds/route.ts b/apps/web/app/api/v1/feeds/route.ts new file mode 100644 index 0000000..adf5422 --- /dev/null +++ b/apps/web/app/api/v1/feeds/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const adminClient = createSupabaseAdminClient() + const { data, error } = await adminClient + .from("subscriptions") + .select( + "id, custom_title, folder_id, feeds!inner(id, url, title, feed_type, site_url)" + ) + .eq("user_id", authResult.user.userIdentifier) + + if (error) { + return NextResponse.json( + { error: "Failed to load feeds" }, + { status: 500 } + ) + } + + interface FeedRow { + id: string + custom_title: string | null + folder_id: string | null + feeds: { + id: string + url: string + title: string | null + feed_type: string | null + site_url: string | null + } + } + + return NextResponse.json({ + feeds: (data as unknown as FeedRow[]).map((row) => ({ + subscriptionIdentifier: row.id, + feedIdentifier: row.feeds.id, + feedUrl: row.feeds.url, + feedTitle: row.feeds.title, + customTitle: row.custom_title, + feedType: row.feeds.feed_type, + siteUrl: row.feeds.site_url, + folderIdentifier: row.folder_id, + })), + }) +} diff --git a/apps/web/app/api/v1/folders/route.ts b/apps/web/app/api/v1/folders/route.ts new file mode 100644 index 0000000..5fb006d --- /dev/null +++ b/apps/web/app/api/v1/folders/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const adminClient = createSupabaseAdminClient() + const { data, error } = await adminClient + .from("folders") + .select("id, name, position") + .eq("user_id", authResult.user.userIdentifier) + .order("position", { ascending: true }) + + if (error) { + return NextResponse.json( + { error: "Failed to load folders" }, + { status: 500 } + ) + } + + return NextResponse.json({ + folders: (data ?? []).map((row) => ({ + identifier: row.id, + name: row.name, + position: row.position, + })), + }) +} diff --git a/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts new file mode 100644 index 0000000..8026f27 --- /dev/null +++ b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ keyIdentifier: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { keyIdentifier } = await params + + const adminClient = createSupabaseAdminClient() + const { error } = await adminClient + .from("api_keys") + .update({ revoked_at: new Date().toISOString() }) + .eq("id", keyIdentifier) + .eq("user_id", user.id) + .is("revoked_at", null) + + if (error) { + return NextResponse.json( + { error: "Failed to revoke API key" }, + { status: 500 } + ) + } + + return NextResponse.json({ revoked: true }) +} diff --git a/apps/web/app/api/v1/keys/route.ts b/apps/web/app/api/v1/keys/route.ts new file mode 100644 index 0000000..7ac7144 --- /dev/null +++ b/apps/web/app/api/v1/keys/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { generateApiKey } from "@/lib/api-key" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" +import { rateLimit } from "@/lib/rate-limit" + +const MAXIMUM_ACTIVE_KEYS = 5 + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const adminClient = createSupabaseAdminClient() + const { data: keys, error } = await adminClient + .from("api_keys") + .select("id, key_prefix, label, created_at, last_used_at, revoked_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + + if (error) { + return NextResponse.json( + { error: "Failed to load API keys" }, + { status: 500 } + ) + } + + return NextResponse.json({ + keys: keys.map((key) => ({ + identifier: key.id, + keyPrefix: key.key_prefix, + label: key.label, + createdAt: key.created_at, + lastUsedAt: key.last_used_at, + isRevoked: key.revoked_at !== null, + })), + }) +} + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`api-keys:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const adminClient = createSupabaseAdminClient() + + const { data: userProfile } = await adminClient + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single() + + if ( + !userProfile || + !TIER_LIMITS[userProfile.tier as SubscriptionTier]?.allowsApiAccess + ) { + return NextResponse.json( + { error: "API access requires the developer plan" }, + { status: 403 } + ) + } + + const { count: activeKeyCount } = await adminClient + .from("api_keys") + .select("id", { count: "exact", head: true }) + .eq("user_id", user.id) + .is("revoked_at", null) + + if ((activeKeyCount ?? 0) >= MAXIMUM_ACTIVE_KEYS) { + return NextResponse.json( + { error: `Maximum of ${MAXIMUM_ACTIVE_KEYS} active keys allowed` }, + { status: 400 } + ) + } + + const body = await request.json().catch(() => ({})) + const label = typeof body.label === "string" ? body.label.trim() || null : null + + const { fullKey, keyHash, keyPrefix } = generateApiKey() + + const { error: insertError } = await adminClient.from("api_keys").insert({ + user_id: user.id, + key_hash: keyHash, + key_prefix: keyPrefix, + label, + }) + + if (insertError) { + return NextResponse.json( + { error: "Failed to create API key" }, + { status: 500 } + ) + } + + return NextResponse.json({ + key: fullKey, + keyPrefix, + label, + }) +} diff --git a/apps/web/app/api/v1/profile/route.ts b/apps/web/app/api/v1/profile/route.ts new file mode 100644 index 0000000..f7ec308 --- /dev/null +++ b/apps/web/app/api/v1/profile/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const adminClient = createSupabaseAdminClient() + const { data: profile, error } = await adminClient + .from("user_profiles") + .select( + "tier, feed_count, folder_count, muted_keyword_count, custom_feed_count" + ) + .eq("id", authResult.user.userIdentifier) + .single() + + if (error || !profile) { + return NextResponse.json( + { error: "Failed to load profile" }, + { status: 500 } + ) + } + + const tierLimits = TIER_LIMITS[profile.tier as SubscriptionTier] + + return NextResponse.json({ + profile: { + tier: profile.tier, + feedCount: profile.feed_count, + folderCount: profile.folder_count, + mutedKeywordCount: profile.muted_keyword_count, + customFeedCount: profile.custom_feed_count, + limits: { + maximumFeeds: tierLimits.maximumFeeds, + maximumFolders: tierLimits.maximumFolders, + maximumMutedKeywords: tierLimits.maximumMutedKeywords, + maximumCustomFeeds: tierLimits.maximumCustomFeeds, + }, + }, + }) +} diff --git a/apps/web/app/api/webhook-config/route.ts b/apps/web/app/api/webhook-config/route.ts new file mode 100644 index 0000000..1ce9a30 --- /dev/null +++ b/apps/web/app/api/webhook-config/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" +import { rateLimit } from "@/lib/rate-limit" + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const adminClient = createSupabaseAdminClient() + const { data: profile, error } = await adminClient + .from("user_profiles") + .select( + "tier, webhook_url, webhook_secret, webhook_enabled, webhook_consecutive_failures" + ) + .eq("id", user.id) + .single() + + if (error || !profile) { + return NextResponse.json( + { error: "Failed to load webhook config" }, + { status: 500 } + ) + } + + return NextResponse.json({ + webhookUrl: profile.webhook_url, + webhookSecret: profile.webhook_secret, + webhookEnabled: profile.webhook_enabled, + consecutiveFailures: profile.webhook_consecutive_failures, + }) +} + +export async function PUT(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`webhook-config:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const adminClient = createSupabaseAdminClient() + + const { data: profile } = await adminClient + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single() + + if ( + !profile || + !TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks + ) { + return NextResponse.json( + { error: "Webhooks require the developer plan" }, + { status: 403 } + ) + } + + const body = await request.json().catch(() => ({})) + + const updates: Record<string, unknown> = {} + + if (typeof body.webhookUrl === "string") { + const trimmedUrl = body.webhookUrl.trim() + if (trimmedUrl && !trimmedUrl.startsWith("https://")) { + return NextResponse.json( + { error: "Webhook URL must use HTTPS" }, + { status: 400 } + ) + } + updates.webhook_url = trimmedUrl || null + } + + if (typeof body.webhookSecret === "string") { + updates.webhook_secret = body.webhookSecret.trim() || null + } + + if (typeof body.webhookEnabled === "boolean") { + updates.webhook_enabled = body.webhookEnabled + if (body.webhookEnabled) { + updates.webhook_consecutive_failures = 0 + } + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No updates provided" }, { status: 400 }) + } + + const { error } = await adminClient + .from("user_profiles") + .update(updates) + .eq("id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to update webhook config" }, + { status: 500 } + ) + } + + return NextResponse.json({ updated: true }) +} diff --git a/apps/web/app/api/webhook-config/test/route.ts b/apps/web/app/api/webhook-config/test/route.ts new file mode 100644 index 0000000..684ec0c --- /dev/null +++ b/apps/web/app/api/webhook-config/test/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server" +import { createHmac } from "crypto" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" +import { rateLimit } from "@/lib/rate-limit" + +export async function POST() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`webhook-test:${user.id}`, 5, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const adminClient = createSupabaseAdminClient() + const { data: profile } = await adminClient + .from("user_profiles") + .select( + "tier, webhook_url, webhook_secret, webhook_enabled" + ) + .eq("id", user.id) + .single() + + if ( + !profile || + !TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks + ) { + return NextResponse.json( + { error: "Webhooks require the developer plan" }, + { status: 403 } + ) + } + + if (!profile.webhook_url) { + return NextResponse.json( + { error: "No webhook URL configured" }, + { status: 400 } + ) + } + + const testPayload = { + event: "test", + timestamp: new Date().toISOString(), + entries: [ + { + entryIdentifier: "test-entry-000", + feedIdentifier: "test-feed-000", + title: "Test webhook delivery", + url: "https://asa.news", + author: "asa.news", + summary: "This is a test webhook payload to verify your endpoint.", + publishedAt: new Date().toISOString(), + }, + ], + } + + const payloadString = JSON.stringify(testPayload) + const headers: Record<string, string> = { + "Content-Type": "application/json", + "User-Agent": "asa.news Webhook/1.0", + } + + if (profile.webhook_secret) { + const signature = createHmac("sha256", profile.webhook_secret) + .update(payloadString) + .digest("hex") + headers["X-Asa-Signature-256"] = `sha256=${signature}` + } + + try { + const response = await fetch(profile.webhook_url, { + method: "POST", + headers, + body: payloadString, + signal: AbortSignal.timeout(10_000), + }) + + return NextResponse.json({ + delivered: true, + statusCode: response.status, + }) + } catch (deliveryError) { + const errorMessage = + deliveryError instanceof Error + ? deliveryError.message + : "Unknown error" + + return NextResponse.json({ + delivered: false, + error: errorMessage, + }) + } +} diff --git a/apps/web/app/apple-icon.tsx b/apps/web/app/apple-icon.tsx new file mode 100644 index 0000000..f9e2d0b --- /dev/null +++ b/apps/web/app/apple-icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from "next/og" + +export const size = { width: 180, height: 180 } +export const contentType = "image/png" + +export default function AppleIcon() { + return new ImageResponse( + ( + <div + style={{ + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "#0a0a0a", + color: "#e5e5e5", + fontFamily: "monospace", + fontWeight: 700, + fontSize: 64, + }} + > + asa + </div> + ), + { ...size } + ) +} diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts new file mode 100644 index 0000000..a912da3 --- /dev/null +++ b/apps/web/app/auth/callback/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import type { EmailOtpType } from "@supabase/supabase-js" + +function sanitizeRedirectPath(rawPath: string | null): string { + if (!rawPath) return "/reader" + if (!rawPath.startsWith("/")) return "/reader" + if (rawPath.startsWith("//")) return "/reader" + if (rawPath.includes("\\")) return "/reader" + + return rawPath +} + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get("code") + const tokenHash = searchParams.get("token_hash") + const type = searchParams.get("type") as EmailOtpType | null + const next = sanitizeRedirectPath(searchParams.get("next")) + + const supabaseClient = await createSupabaseServerClient() + + if (tokenHash && type) { + const { error } = await supabaseClient.auth.verifyOtp({ + token_hash: tokenHash, + type, + }) + + if (!error) { + return NextResponse.redirect(`${origin}${next}`) + } + } + + if (code) { + const { error } = await supabaseClient.auth.exchangeCodeForSession(code) + + if (!error) { + return NextResponse.redirect(`${origin}${next}`) + } + } + + return NextResponse.redirect(`${origin}/sign-in?error=auth`) +} diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico Binary files differnew file mode 100644 index 0000000..718d6fe --- /dev/null +++ b/apps/web/app/favicon.ico diff --git a/apps/web/app/fonts/JetBrainsMono-Regular.woff2 b/apps/web/app/fonts/JetBrainsMono-Regular.woff2 Binary files differnew file mode 100644 index 0000000..66c5467 --- /dev/null +++ b/apps/web/app/fonts/JetBrainsMono-Regular.woff2 diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..c97ddb2 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,222 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --color-background-primary: #070707; + --color-background-secondary: #0f0f0f; + --color-background-tertiary: #1a1a1a; + --color-border: #363636; + --color-text-primary: #ffffff; + --color-text-secondary: #aaaaaa; + --color-text-tertiary: #808080; + --color-text-dim: #666666; + --color-status-operational: #d0d0d0; + --color-status-warning: #c08000; + --color-status-error: #c06060; + --color-status-unknown: #707070; + + --font-mono: "JetBrains Mono", Menlo, Monaco, "Courier New", monospace; + + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; + --radius-xl: 0px; + --radius-2xl: 0px; + --radius-3xl: 0px; + --radius-4xl: 0px; + --radius-full: 0px; +} + +:root { + --background: var(--color-background-primary); + --foreground: var(--color-text-primary); + --card: var(--color-background-secondary); + --card-foreground: var(--color-text-primary); + --popover: var(--color-background-secondary); + --popover-foreground: var(--color-text-primary); + --primary: var(--color-text-primary); + --primary-foreground: var(--color-background-primary); + --secondary: var(--color-background-tertiary); + --secondary-foreground: var(--color-text-primary); + --muted: var(--color-background-tertiary); + --muted-foreground: var(--color-text-secondary); + --accent: var(--color-background-tertiary); + --accent-foreground: var(--color-text-primary); + --destructive: var(--color-status-error); + --border: var(--color-border); + --input: var(--color-border); + --ring: var(--color-text-dim); + --radius: 0px; +} + +.light { + --color-background-primary: #ffffff; + --color-background-secondary: #f8f8f8; + --color-background-tertiary: #f0f0f0; + --color-border: #d0d0d0; + --color-text-primary: #000000; + --color-text-secondary: #555555; + --color-text-tertiary: #666666; + --color-text-dim: #767676; + --color-status-operational: #333333; + --color-status-warning: #8a6200; + --color-status-error: #c03030; + --color-status-unknown: #767676; + + --background: var(--color-background-primary); + --foreground: var(--color-text-primary); + --card: var(--color-background-secondary); + --card-foreground: var(--color-text-primary); + --popover: var(--color-background-secondary); + --popover-foreground: var(--color-text-primary); + --primary: var(--color-text-primary); + --primary-foreground: var(--color-background-primary); + --secondary: var(--color-background-tertiary); + --secondary-foreground: var(--color-text-primary); + --muted: var(--color-background-tertiary); + --muted-foreground: var(--color-text-secondary); + --accent: var(--color-background-tertiary); + --accent-foreground: var(--color-text-primary); + --destructive: var(--color-status-error); + --border: var(--color-border); + --input: var(--color-border); + --ring: var(--color-text-dim); +} + +* { + font-weight: 400 !important; +} + +*, +*::before, +*::after { + box-shadow: none !important; +} + +body { + font-family: var(--font-mono); + font-size: var(--base-font-size, 1rem); + line-height: 1.5; + background: var(--background); + color: var(--foreground); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.transition-colors { + transition-property: color, background-color, border-color; + transition-timing-function: ease; + transition-duration: 100ms; +} + +@keyframes pulse-status { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.prose-reader a { + color: var(--color-text-primary); + text-decoration: underline; + text-decoration-color: var(--color-text-dim); + text-underline-offset: 2px; +} + +.prose-reader a:hover { + text-decoration-color: var(--color-text-primary); +} + +.prose-reader h1, +.prose-reader h2, +.prose-reader h3, +.prose-reader h4, +.prose-reader h5, +.prose-reader h6 { + color: var(--color-text-primary); + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +.prose-reader p { + margin-bottom: 1em; +} + +.prose-reader img { + max-width: 100%; + height: auto; +} + +.prose-reader pre { + background: var(--color-background-tertiary); + border: 1px solid var(--color-border); + padding: 1em; + overflow-x: auto; + margin-bottom: 1em; +} + +.prose-reader code { + background: var(--color-background-tertiary); + padding: 0.15em 0.3em; +} + +.prose-reader pre code { + background: transparent; + padding: 0; +} + +.prose-reader blockquote { + border-left: 2px solid var(--color-border); + padding-left: 1em; + color: var(--color-text-secondary); + margin-bottom: 1em; +} + +.prose-reader ul, +.prose-reader ol { + padding-left: 1.5em; + margin-bottom: 1em; +} + +.prose-reader li { + margin-bottom: 0.25em; +} + +.prose-reader hr { + border: none; + border-top: 1px solid var(--color-border); + margin: 2em 0; +} + +.prose-reader table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1em; +} + +.prose-reader th, +.prose-reader td { + border: 1px solid var(--color-border); + padding: 0.5em; + text-align: left; +} + +.prose-reader mark[data-highlight-color] { + background-color: rgba(234, 179, 8, 0.18); + color: var(--color-text-primary); + border-bottom: 1px solid rgba(234, 179, 8, 0.4); + cursor: pointer; +} + +.prose-reader mark[data-has-note] { + border-bottom-style: dashed; +} + +.light .prose-reader mark[data-highlight-color] { + background-color: rgba(234, 179, 8, 0.15); + border-bottom-color: rgba(234, 179, 8, 0.35); +} diff --git a/apps/web/app/icon.tsx b/apps/web/app/icon.tsx new file mode 100644 index 0000000..a017579 --- /dev/null +++ b/apps/web/app/icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from "next/og" + +export const size = { width: 32, height: 32 } +export const contentType = "image/png" + +export default function Icon() { + return new ImageResponse( + ( + <div + style={{ + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "#0a0a0a", + color: "#e5e5e5", + fontFamily: "monospace", + fontWeight: 700, + fontSize: 14, + }} + > + asa + </div> + ), + { ...size } + ) +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..a3e3b8b --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,58 @@ +import type { Metadata, Viewport } from "next" +import localFont from "next/font/local" +import { ThemeProvider } from "next-themes" +import { SpeedInsights } from "@vercel/speed-insights/next" +import { Analytics } from "@vercel/analytics/next" +import { Providers } from "./providers" +import "./globals.css" + +const jetBrainsMono = localFont({ + src: "./fonts/JetBrainsMono-Regular.woff2", + variable: "--font-mono", + display: "swap", +}) + +export const metadata: Metadata = { + title: "asa.news", + description: "A fast, minimal RSS reader for staying informed", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "asa.news", + }, + formatDetection: { + telephone: false, + }, +} + +export const viewport: Viewport = { + themeColor: "#0a0a0a", + width: "device-width", + initialScale: 1, + maximumScale: 1, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + <html lang="en" suppressHydrationWarning> + <body className={`${jetBrainsMono.variable} antialiased`}> + <ThemeProvider + attribute="class" + defaultTheme="dark" + enableSystem + disableTransitionOnChange + > + <Providers> + {children} + </Providers> + </ThemeProvider> + <SpeedInsights /> + <Analytics /> + </body> + </html> + ) +} diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts new file mode 100644 index 0000000..0bef8b1 --- /dev/null +++ b/apps/web/app/manifest.ts @@ -0,0 +1,26 @@ +import type { MetadataRoute } from "next" + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "asa.news", + short_name: "asa.news", + description: "A dense, keyboard-first RSS reader", + start_url: "/reader", + display: "standalone", + background_color: "#0a0a0a", + theme_color: "#0a0a0a", + icons: [ + { + src: "/icons/icon.svg", + sizes: "any", + type: "image/svg+xml", + }, + { + src: "/icons/icon.svg", + sizes: "512x512", + type: "image/svg+xml", + purpose: "maskable", + }, + ], + } +} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx new file mode 100644 index 0000000..03c2320 --- /dev/null +++ b/apps/web/app/providers.tsx @@ -0,0 +1,29 @@ +"use client" + +import { useState } from "react" +import { QueryClientProvider } from "@tanstack/react-query" +import { Toaster } from "sonner" +import { createQueryClient } from "@/lib/query-client" + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(createQueryClient) + + return ( + <QueryClientProvider client={queryClient}> + {children} + <Toaster + position="bottom-right" + toastOptions={{ + style: { + background: "var(--color-background-secondary)", + border: "1px solid var(--color-border)", + color: "var(--color-text-primary)", + borderRadius: "0px", + fontFamily: "var(--font-mono)", + fontSize: "0.75rem", + }, + }} + /> + </QueryClientProvider> + ) +} diff --git a/apps/web/app/reader/_components/add-feed-dialog.tsx b/apps/web/app/reader/_components/add-feed-dialog.tsx new file mode 100644 index 0000000..4eb119c --- /dev/null +++ b/apps/web/app/reader/_components/add-feed-dialog.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useState } from "react" +import { useSubscribeToFeed } from "@/lib/queries/use-subscribe-to-feed" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +export function AddFeedDialog() { + const isOpen = useUserInterfaceStore((state) => state.isAddFeedDialogOpen) + const setOpen = useUserInterfaceStore((state) => state.setAddFeedDialogOpen) + const [feedUrl, setFeedUrl] = useState("") + const [customTitle, setCustomTitle] = useState("") + const [selectedFolderIdentifier, setSelectedFolderIdentifier] = useState< + string | null + >(null) + const subscribeToFeed = useSubscribeToFeed() + const { data: subscriptionsData } = useSubscriptions() + + function handleClose() { + setFeedUrl("") + setCustomTitle("") + setSelectedFolderIdentifier(null) + setOpen(false) + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + subscribeToFeed.mutate( + { + feedUrl, + folderIdentifier: selectedFolderIdentifier, + customTitle: customTitle || null, + }, + { + onSuccess: () => { + handleClose() + }, + } + ) + } + + if (!isOpen) return null + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center"> + <div + className="fixed inset-0 bg-background-primary/80" + onClick={handleClose} + /> + <div className="relative w-full max-w-md border border-border bg-background-secondary p-6"> + <h2 className="mb-4 text-text-primary">add feed</h2> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="feed-url" className="text-text-secondary"> + feed url + </label> + <input + id="feed-url" + type="url" + value={feedUrl} + onChange={(event) => setFeedUrl(event.target.value)} + required + placeholder="https://example.com/feed.xml" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + <div className="space-y-2"> + <label htmlFor="custom-title" className="text-text-secondary"> + custom title (optional) + </label> + <input + id="custom-title" + type="text" + value={customTitle} + onChange={(event) => setCustomTitle(event.target.value)} + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + <div className="space-y-2"> + <label htmlFor="folder-select" className="text-text-secondary"> + folder (optional) + </label> + <select + id="folder-select" + value={selectedFolderIdentifier ?? ""} + onChange={(event) => + setSelectedFolderIdentifier(event.target.value || null) + } + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none" + > + <option value="">no folder</option> + {subscriptionsData?.folders.map((folder) => ( + <option + key={folder.folderIdentifier} + value={folder.folderIdentifier} + > + {folder.name} + </option> + ))} + </select> + </div> + <div className="flex gap-2"> + <button + type="button" + onClick={handleClose} + className="flex-1 border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + cancel + </button> + <button + type="submit" + disabled={subscribeToFeed.isPending} + className="flex-1 border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {subscribeToFeed.isPending ? "adding..." : "add feed"} + </button> + </div> + </form> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/command-palette.tsx b/apps/web/app/reader/_components/command-palette.tsx new file mode 100644 index 0000000..f3ff992 --- /dev/null +++ b/apps/web/app/reader/_components/command-palette.tsx @@ -0,0 +1,200 @@ +"use client" + +import { Command } from "cmdk" +import { useEffect, useRef, useState } from "react" +import { useRouter } from "next/navigation" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" + +export function CommandPalette() { + const isOpen = useUserInterfaceStore((state) => state.isCommandPaletteOpen) + const setOpen = useUserInterfaceStore((state) => state.setCommandPaletteOpen) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const setAddFeedDialogOpen = useUserInterfaceStore( + (state) => state.setAddFeedDialogOpen + ) + const router = useRouter() + const { data: subscriptionsData } = useSubscriptions() + const listReference = useRef<HTMLDivElement>(null) + const [inputValue, setInputValue] = useState("") + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + setOpen(!isOpen) + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, setOpen]) + + useEffect(() => { + if (!isOpen) return + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false) + return + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + setTimeout(() => { + const list = listReference.current + if (!list) return + const selected = list.querySelector('[aria-selected="true"]') as HTMLElement + if (!selected) return + const listRect = list.getBoundingClientRect() + const selectedRect = selected.getBoundingClientRect() + if (selectedRect.bottom > listRect.bottom) { + list.scrollTop += selectedRect.bottom - listRect.bottom + } else if (selectedRect.top < listRect.top) { + list.scrollTop -= listRect.top - selectedRect.top + } + }, 0) + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, setOpen]) + + if (!isOpen) return null + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "Backspace" && inputValue === "") { + event.preventDefault() + setOpen(false) + } + } + + function navigateAndClose(path: string) { + router.push(path) + setOpen(false) + } + + function actionAndClose(action: () => void) { + action() + setOpen(false) + } + + return ( + <div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"> + <div + className="fixed inset-0 bg-background-primary/80" + onClick={() => setOpen(false)} + /> + <Command className="relative w-full max-w-lg border border-border bg-background-secondary"> + <Command.Input + placeholder="type a command..." + className="w-full border-b border-border bg-transparent px-4 py-3 text-text-primary outline-none placeholder:text-text-dim" + autoFocus + value={inputValue} + onValueChange={setInputValue} + onKeyDown={handleInputKeyDown} + /> + <Command.List ref={listReference} className="max-h-80 overflow-auto p-2"> + <Command.Empty className="p-4 text-center text-text-dim"> + no results found + </Command.Empty> + + <Command.Group + heading="navigation" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + <Command.Item + onSelect={() => navigateAndClose("/reader")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to all entries + </Command.Item> + <Command.Item + onSelect={() => navigateAndClose("/reader/saved")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to saved + </Command.Item> + <Command.Item + onSelect={() => navigateAndClose("/reader/settings")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to settings + </Command.Item> + </Command.Group> + + {subscriptionsData && + subscriptionsData.subscriptions.length > 0 && ( + <Command.Group + heading="feeds" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + {subscriptionsData.subscriptions.map((subscription) => ( + <Command.Item + key={subscription.subscriptionIdentifier} + value={`feed-${subscription.subscriptionIdentifier}-${subscription.customTitle ?? subscription.feedTitle}`} + onSelect={() => + navigateAndClose( + `/reader?feed=${subscription.feedIdentifier}` + ) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + {subscription.customTitle ?? subscription.feedTitle} + </Command.Item> + ))} + </Command.Group> + )} + + <Command.Group + heading="actions" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + <Command.Item + onSelect={() => + actionAndClose(() => setAddFeedDialogOpen(true)) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + add feed + </Command.Item> + <Command.Item + onSelect={() => actionAndClose(toggleSidebar)} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + toggle sidebar + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("compact")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + compact view + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("comfortable")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + comfortable view + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("expanded")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + expanded view + </Command.Item> + </Command.Group> + </Command.List> + </Command> + </div> + ) +} diff --git a/apps/web/app/reader/_components/entry-detail-panel.tsx b/apps/web/app/reader/_components/entry-detail-panel.tsx new file mode 100644 index 0000000..2e8e19c --- /dev/null +++ b/apps/web/app/reader/_components/entry-detail-panel.tsx @@ -0,0 +1,470 @@ +"use client" + +import { useEffect, useRef, useState, useCallback } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { sanitizeEntryContent } from "@/lib/sanitize" +import { + useToggleEntryReadState, + useToggleEntrySavedState, +} from "@/lib/queries/use-entry-state-mutations" +import { queryKeys } from "@/lib/queries/query-keys" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useTimeline } from "@/lib/queries/use-timeline" +import { useEntryShare } from "@/lib/queries/use-entry-share" +import { useEntryHighlights } from "@/lib/queries/use-entry-highlights" +import { + useCreateHighlight, + useUpdateHighlightNote, + useDeleteHighlight, +} from "@/lib/queries/use-highlight-mutations" +import { + serializeSelectionRange, + deserializeHighlightRange, + applyHighlightToRange, + removeHighlightFromDom, +} from "@/lib/highlight-positioning" +import { HighlightSelectionToolbar } from "./highlight-selection-toolbar" +import { HighlightPopover } from "./highlight-popover" +import { notify } from "@/lib/notify" +import type { Highlight } from "@/lib/types/highlight" + +interface EntryDetailRow { + id: string + title: string | null + url: string | null + author: string | null + content_html: string | null + summary: string | null + published_at: string | null + enclosure_url: string | null + feeds: { + title: string | null + } +} + +function estimateReadingTimeMinutes(html: string): number { + const text = html.replace(/<[^>]*>/g, "").replace(/&\w+;/g, " ") + const wordCount = text.split(/\s+/).filter(Boolean).length + return Math.max(1, Math.round(wordCount / 200)) +} + +export function EntryDetailPanel({ + entryIdentifier, +}: { + entryIdentifier: string +}) { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + const toggleReadState = useToggleEntryReadState() + const toggleSavedState = useToggleEntrySavedState() + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + + const proseContainerReference = useRef<HTMLDivElement>(null) + const [selectionToolbarState, setSelectionToolbarState] = useState<{ + selectionRect: DOMRect + containerRect: DOMRect + range: Range + } | null>(null) + const [highlightPopoverState, setHighlightPopoverState] = useState<{ + highlightIdentifier: string + note: string | null + anchorRect: DOMRect + containerRect: DOMRect + } | null>(null) + const [unpositionedHighlights, setUnpositionedHighlights] = useState<Highlight[]>([]) + + const { data: timelineData } = useTimeline() + const currentEntry = timelineData?.pages + .flatMap((page) => page) + .find((entry) => entry.entryIdentifier === entryIdentifier) + + const { data: entryDetail, isLoading } = useQuery({ + queryKey: queryKeys.entryDetail.single(entryIdentifier), + queryFn: async () => { + const { data, error } = await supabaseClient + .from("entries") + .select( + "id, title, url, author, content_html, summary, published_at, enclosure_url, feeds!inner(title)" + ) + .eq("id", entryIdentifier) + .single() + + if (error) throw error + + return data as unknown as EntryDetailRow + }, + }) + + const { data: shareData } = useEntryShare(entryIdentifier) + const { data: highlightsData } = useEntryHighlights(entryIdentifier) + + const createHighlight = useCreateHighlight() + const updateHighlightNote = useUpdateHighlightNote() + const deleteHighlight = useDeleteHighlight() + + const shareMutation = useMutation({ + mutationFn: async (note?: string | null) => { + const response = await fetch("/api/share", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entryIdentifier, note: note ?? null }), + }) + if (!response.ok) throw new Error("Failed to create share") + return response.json() as Promise<{ + shareToken: string + shareUrl: string + }> + }, + onSuccess: async (data) => { + await navigator.clipboard.writeText(data.shareUrl) + notify("link copied") + queryClient.invalidateQueries({ + queryKey: queryKeys.entryShare.single(entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + }, + }) + + const unshareMutation = useMutation({ + mutationFn: async (shareToken: string) => { + const response = await fetch(`/api/share/${shareToken}`, { + method: "DELETE", + }) + if (!response.ok) throw new Error("Failed to delete share") + }, + onSuccess: () => { + notify("share link removed") + queryClient.invalidateQueries({ + queryKey: queryKeys.entryShare.single(entryIdentifier), + }) + }, + }) + + useEffect(() => { + if (!currentEntry || currentEntry.isRead) return + + const autoReadTimeout = setTimeout(() => { + toggleReadState.mutate({ + entryIdentifier, + isRead: true, + }) + }, 1500) + + return () => clearTimeout(autoReadTimeout) + }, [entryIdentifier, currentEntry?.isRead]) + + const contentHtml = + entryDetail?.content_html || entryDetail?.summary || "" + const sanitisedContent = sanitizeEntryContent(contentHtml) + + useEffect(() => { + const container = proseContainerReference.current + if (!container || !sanitisedContent) return + + container.textContent = "" + const template = document.createElement("template") + template.innerHTML = sanitisedContent + container.appendChild(template.content.cloneNode(true)) + + const failedHighlights: Highlight[] = [] + + if (highlightsData && highlightsData.length > 0) { + const sortedHighlights = [...highlightsData].sort( + (a, b) => b.textOffset - a.textOffset + ) + for (const highlight of sortedHighlights) { + const range = deserializeHighlightRange(container, highlight) + if (range) { + applyHighlightToRange( + range, + highlight.identifier, + highlight.color, + !!highlight.note + ) + } else { + failedHighlights.push(highlight) + } + } + } + + setUnpositionedHighlights(failedHighlights) + }, [sanitisedContent, highlightsData]) + + const handleTextSelection = useCallback(() => { + const container = proseContainerReference.current + if (!container) return + + const selection = window.getSelection() + if (!selection || selection.isCollapsed || !selection.rangeCount) { + setSelectionToolbarState(null) + return + } + + const range = selection.getRangeAt(0) + if (!container.contains(range.commonAncestorContainer)) { + setSelectionToolbarState(null) + return + } + + const selectionRect = range.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + + setSelectionToolbarState({ + selectionRect, + containerRect, + range: range.cloneRange(), + }) + setHighlightPopoverState(null) + }, []) + + useEffect(() => { + document.addEventListener("mouseup", handleTextSelection) + document.addEventListener("touchend", handleTextSelection) + return () => { + document.removeEventListener("mouseup", handleTextSelection) + document.removeEventListener("touchend", handleTextSelection) + } + }, [handleTextSelection]) + + useEffect(() => { + const container = proseContainerReference.current + if (!container) return + + const currentContainer = container + + function handleMarkClick(event: MouseEvent) { + const target = event.target as HTMLElement + const markElement = target.closest("mark[data-highlight-identifier]") + if (!markElement) return + + const highlightIdentifier = markElement.getAttribute("data-highlight-identifier") + if (!highlightIdentifier) return + + const matchingHighlight = highlightsData?.find( + (h) => h.identifier === highlightIdentifier + ) + + const anchorRect = markElement.getBoundingClientRect() + const containerRect = currentContainer.getBoundingClientRect() + + setHighlightPopoverState({ + highlightIdentifier, + note: matchingHighlight?.note ?? null, + anchorRect, + containerRect, + }) + setSelectionToolbarState(null) + } + + container.addEventListener("click", handleMarkClick) + return () => container.removeEventListener("click", handleMarkClick) + }, [highlightsData]) + + function handleCreateHighlight(note: string | null) { + const container = proseContainerReference.current + if (!container || !selectionToolbarState) return + + const serialized = serializeSelectionRange( + container, + selectionToolbarState.range + ) + if (!serialized) return + + createHighlight.mutate({ + entryIdentifier, + highlightedText: serialized.highlightedText, + note, + textOffset: serialized.textOffset, + textLength: serialized.textLength, + textPrefix: serialized.textPrefix, + textSuffix: serialized.textSuffix, + color: "yellow", + }) + + window.getSelection()?.removeAllRanges() + setSelectionToolbarState(null) + } + + function handleUpdateHighlightNote(note: string | null) { + if (!highlightPopoverState) return + updateHighlightNote.mutate({ + highlightIdentifier: highlightPopoverState.highlightIdentifier, + note, + entryIdentifier, + }) + setHighlightPopoverState(null) + } + + function handleDeleteHighlight() { + if (!highlightPopoverState) return + const container = proseContainerReference.current + if (container) { + removeHighlightFromDom( + container, + highlightPopoverState.highlightIdentifier + ) + } + deleteHighlight.mutate({ + highlightIdentifier: highlightPopoverState.highlightIdentifier, + entryIdentifier, + }) + setHighlightPopoverState(null) + } + + if (isLoading || !entryDetail) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + loading ... + </div> + ) + } + + const readingTimeMinutes = estimateReadingTimeMinutes(contentHtml) + const isRead = currentEntry?.isRead ?? false + const isSaved = currentEntry?.isSaved ?? false + + return ( + <div data-detail-panel className="flex h-full flex-col"> + <div className="flex items-center gap-2 overflow-x-auto border-b border-border px-4 py-2"> + <button + type="button" + onClick={() => + toggleReadState.mutate({ + entryIdentifier, + isRead: !isRead, + }) + } + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {isRead ? "mark unread" : "mark read"} + </button> + <button + type="button" + onClick={() => + toggleSavedState.mutate({ + entryIdentifier, + isSaved: !isSaved, + }) + } + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {isSaved ? "unsave" : "save"} + </button> + {entryDetail.url && ( + <a + href={entryDetail.url} + target="_blank" + rel="noopener noreferrer" + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + open original + </a> + )} + {shareData?.isShared ? ( + <button + type="button" + onClick={() => unshareMutation.mutate(shareData.shareToken!)} + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + unshare + </button> + ) : ( + <button + type="button" + onClick={() => { + const note = window.prompt("add a note (optional):") + shareMutation.mutate(note || null) + }} + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + share + </button> + )} + <div className="flex-1" /> + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="hidden px-2 py-1 text-text-dim transition-colors hover:text-text-secondary md:block" + > + close + </button> + </div> + <article className="flex-1 overflow-auto px-6 py-4"> + <h2 className="mb-1 text-base text-text-primary"> + {entryDetail.title} + </h2> + <div className="mb-4 text-text-dim"> + {entryDetail.feeds?.title && ( + <span>{entryDetail.feeds.title}</span> + )} + {entryDetail.author && ( + <span> · {entryDetail.author}</span> + )} + <span> · {readingTimeMinutes} min read</span> + </div> + {entryDetail.enclosure_url && ( + <div className="mb-4 border border-border p-3"> + <audio + controls + preload="none" + src={entryDetail.enclosure_url} + className="w-full" + /> + </div> + )} + {unpositionedHighlights.length > 0 && ( + <div className="mb-4 border border-border px-3 py-2"> + <p className="mb-2 text-text-dim"> + {unpositionedHighlights.length} highlight + {unpositionedHighlights.length !== 1 && "s"} could not be positioned + (the article content may have changed) + </p> + {unpositionedHighlights.map((highlight) => ( + <div + key={highlight.identifier} + className="mb-1 border-l-2 border-text-dim pl-2 text-text-secondary last:mb-0" + > + <span className="bg-background-tertiary text-text-primary"> + {highlight.highlightedText} + </span> + {highlight.note && ( + <span className="ml-2 text-text-dim"> + — {highlight.note} + </span> + )} + </div> + ))} + </div> + )} + <div className="relative"> + <div + ref={proseContainerReference} + className="prose-reader text-text-secondary" + /> + {selectionToolbarState && ( + <HighlightSelectionToolbar + selectionRect={selectionToolbarState.selectionRect} + containerRect={selectionToolbarState.containerRect} + onHighlight={handleCreateHighlight} + onDismiss={() => setSelectionToolbarState(null)} + /> + )} + {highlightPopoverState && ( + <HighlightPopover + highlightIdentifier={highlightPopoverState.highlightIdentifier} + note={highlightPopoverState.note} + anchorRect={highlightPopoverState.anchorRect} + containerRect={highlightPopoverState.containerRect} + onUpdateNote={handleUpdateHighlightNote} + onDelete={handleDeleteHighlight} + onDismiss={() => setHighlightPopoverState(null)} + /> + )} + </div> + </article> + </div> + ) +} diff --git a/apps/web/app/reader/_components/entry-list-item.tsx b/apps/web/app/reader/_components/entry-list-item.tsx new file mode 100644 index 0000000..375b0f5 --- /dev/null +++ b/apps/web/app/reader/_components/entry-list-item.tsx @@ -0,0 +1,125 @@ +"use client" + +import { formatDistanceToNow } from "date-fns" +import { classNames } from "@/lib/utilities" +import type { TimelineEntry } from "@/lib/types/timeline" +import type { VirtualItem } from "@tanstack/react-virtual" + +interface EntryListItemProperties { + entry: TimelineEntry + isSelected: boolean + isFocused: boolean + viewMode: "compact" | "comfortable" | "expanded" + onSelect: () => void + measureReference: (element: HTMLElement | null) => void + virtualItem: VirtualItem +} + +function stripHtmlTags(html: string): string { + return html.replace(/<[^>]*>/g, "").replace(/&\w+;/g, " ").trim() +} + +export function EntryListItem({ + entry, + isSelected, + isFocused, + viewMode, + onSelect, + measureReference, + virtualItem, +}: EntryListItemProperties) { + const relativeTimestamp = entry.publishedAt + ? formatDistanceToNow(new Date(entry.publishedAt), { addSuffix: true }) + : "" + + const displayTitle = entry.customTitle ?? entry.feedTitle + + return ( + <div + ref={measureReference} + data-index={virtualItem.index} + onClick={onSelect} + className={classNames( + "absolute left-0 top-0 w-full cursor-pointer border-b border-border px-4 transition-colors", + isSelected + ? "bg-background-tertiary" + : isFocused + ? "bg-background-secondary" + : "hover:bg-background-secondary", + isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "", + entry.isRead ? "opacity-60" : "" + )} + style={{ transform: `translateY(${virtualItem.start}px)` }} + > + {viewMode === "compact" && ( + <div className="flex items-center gap-2 py-2.5"> + <span className="shrink-0 text-text-dim">{displayTitle}</span> + {entry.enclosureUrl && ( + <span className="shrink-0 text-text-dim" title="podcast episode">♫</span> + )} + <span className="min-w-0 flex-1 truncate text-text-primary"> + {entry.entryTitle} + </span> + <span className="shrink-0 text-text-dim">{relativeTimestamp}</span> + </div> + )} + + {viewMode === "comfortable" && ( + <div className="py-2.5"> + <div className="truncate text-text-primary">{entry.entryTitle}</div> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + <span>{displayTitle}</span> + {entry.enclosureUrl && ( + <span title="podcast episode">♫</span> + )} + {entry.author && ( + <> + <span>·</span> + <span>{entry.author}</span> + </> + )} + <span>·</span> + <span>{relativeTimestamp}</span> + </div> + </div> + )} + + {viewMode === "expanded" && ( + <div className="flex gap-3 py-3"> + <div className="min-w-0 flex-1"> + <div className="truncate text-text-primary"> + {entry.entryTitle} + </div> + {entry.summary && ( + <p className="mt-1 line-clamp-2 text-text-secondary"> + {stripHtmlTags(entry.summary)} + </p> + )} + <div className="mt-1 flex items-center gap-2 text-text-dim"> + <span>{displayTitle}</span> + {entry.enclosureUrl && ( + <span title="podcast episode">♫</span> + )} + {entry.author && ( + <> + <span>·</span> + <span>{entry.author}</span> + </> + )} + <span>·</span> + <span>{relativeTimestamp}</span> + </div> + </div> + {entry.imageUrl && ( + <img + src={entry.imageUrl} + alt="" + className="hidden h-16 w-16 shrink-0 object-cover sm:block" + loading="lazy" + /> + )} + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/_components/entry-list.tsx b/apps/web/app/reader/_components/entry-list.tsx new file mode 100644 index 0000000..6d4bcf3 --- /dev/null +++ b/apps/web/app/reader/_components/entry-list.tsx @@ -0,0 +1,217 @@ +"use client" + +import { useRef, useEffect } from "react" +import { useVirtualizer } from "@tanstack/react-virtual" +import { useTimeline } from "@/lib/queries/use-timeline" +import { useSavedEntries } from "@/lib/queries/use-saved-entries" +import { useCustomFeedTimeline } from "@/lib/queries/use-custom-feed-timeline" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { EntryListItem } from "./entry-list-item" + +interface EntryListProperties { + feedFilter: "all" | "saved" + folderIdentifier?: string | null + feedIdentifier?: string | null + customFeedIdentifier?: string | null +} + +function useEntryData( + feedFilter: "all" | "saved", + folderIdentifier?: string | null, + feedIdentifier?: string | null, + customFeedIdentifier?: string | null +) { + const timelineQuery = useTimeline( + feedFilter === "all" && !customFeedIdentifier ? folderIdentifier : undefined, + feedFilter === "all" && !customFeedIdentifier ? feedIdentifier : undefined, + false + ) + const savedQuery = useSavedEntries() + const customFeedQuery = useCustomFeedTimeline( + feedFilter === "all" ? (customFeedIdentifier ?? null) : null + ) + + if (feedFilter === "saved") { + return savedQuery + } + + if (customFeedIdentifier) { + return customFeedQuery + } + + return timelineQuery +} + +export function EntryList({ + feedFilter, + folderIdentifier, + feedIdentifier, + customFeedIdentifier, +}: EntryListProperties) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useEntryData(feedFilter, folderIdentifier, feedIdentifier, customFeedIdentifier) + + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setFocusedEntryIdentifier = useUserInterfaceStore( + (state) => state.setFocusedEntryIdentifier + ) + + const setNavigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.setNavigableEntryIdentifiers + ) + + const allEntries = data?.pages.flatMap((page) => page) ?? [] + const scrollContainerReference = useRef<HTMLDivElement>(null) + + const firstEntryIdentifier = allEntries[0]?.entryIdentifier + const lastEntryIdentifier = allEntries[allEntries.length - 1]?.entryIdentifier + + useEffect(() => { + setNavigableEntryIdentifiers( + allEntries.map((entry) => entry.entryIdentifier) + ) + }, [firstEntryIdentifier, lastEntryIdentifier, allEntries.length, setNavigableEntryIdentifiers]) + + function getEstimatedItemSize() { + switch (entryListViewMode) { + case "compact": + return 40 + case "comfortable": + return 60 + case "expanded": + return 108 + } + } + + const virtualizer = useVirtualizer({ + count: hasNextPage ? allEntries.length + 1 : allEntries.length, + getScrollElement: () => scrollContainerReference.current, + estimateSize: getEstimatedItemSize, + overscan: 10, + }) + + const virtualItems = virtualizer.getVirtualItems() + + useEffect(() => { + const lastItem = virtualItems[virtualItems.length - 1] + + if (!lastItem) return + + if ( + lastItem.index >= allEntries.length - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage() + } + }, [ + virtualItems, + allEntries.length, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + ]) + + const allEntriesReference = useRef(allEntries) + allEntriesReference.current = allEntries + + useEffect(() => { + if (!focusedEntryIdentifier) return + + const focusedIndex = allEntriesReference.current.findIndex( + (entry) => entry.entryIdentifier === focusedEntryIdentifier + ) + + if (focusedIndex !== -1) { + virtualizer.scrollToIndex(focusedIndex, { align: "auto" }) + } + }, [focusedEntryIdentifier]) + + if (isLoading) { + return ( + <div className="space-y-2 p-4"> + {Array.from({ length: 8 }).map((_, skeletonIndex) => ( + <div + key={skeletonIndex} + className="h-10 animate-[skeleton-shimmer_1.5s_ease-in-out_infinite] bg-background-tertiary" + /> + ))} + </div> + ) + } + + if (allEntries.length === 0) { + return ( + <div className="flex h-full items-center justify-center"> + <p className="text-text-tertiary"> + {feedFilter === "saved" + ? "no saved entries yet" + : "no entries yet \u2014 add a feed to get started"} + </p> + </div> + ) + } + + return ( + <div ref={scrollContainerReference} className="h-full overflow-auto"> + <div + style={{ + height: `${virtualizer.getTotalSize()}px`, + width: "100%", + position: "relative", + }} + > + {virtualItems.map((virtualItem) => { + const entry = allEntries[virtualItem.index] + + if (!entry) { + return ( + <div + key="loader" + data-index={virtualItem.index} + ref={virtualizer.measureElement} + className="absolute left-0 top-0 w-full" + style={{ + transform: `translateY(${virtualItem.start}px)`, + }} + > + <p className="p-4 text-center text-text-dim">loading ...</p> + </div> + ) + } + + return ( + <EntryListItem + key={entry.entryIdentifier} + entry={entry} + isSelected={ + entry.entryIdentifier === selectedEntryIdentifier + } + isFocused={ + entry.entryIdentifier === focusedEntryIdentifier + } + viewMode={entryListViewMode} + onSelect={() => { + setFocusedEntryIdentifier(entry.entryIdentifier) + setSelectedEntryIdentifier(entry.entryIdentifier) + }} + measureReference={virtualizer.measureElement} + virtualItem={virtualItem} + /> + ) + })} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/error-boundary.tsx b/apps/web/app/reader/_components/error-boundary.tsx new file mode 100644 index 0000000..6696e66 --- /dev/null +++ b/apps/web/app/reader/_components/error-boundary.tsx @@ -0,0 +1,55 @@ +"use client" + +import { Component, type ReactNode } from "react" + +interface ErrorBoundaryProperties { + fallback?: ReactNode + children: ReactNode +} + +interface ErrorBoundaryState { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component< + ErrorBoundaryProperties, + ErrorBoundaryState +> { + constructor(properties: ErrorBoundaryProperties) { + super(properties) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( + <div className="flex h-full items-center justify-center p-4"> + <div className="max-w-sm text-center"> + <p className="mb-2 text-text-primary">something went wrong</p> + <p className="mb-4 text-text-dim"> + {this.state.error?.message ?? "an unexpected error occurred"} + </p> + <button + type="button" + onClick={() => this.setState({ hasError: false, error: null })} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + try again + </button> + </div> + </div> + ) + } + + return this.props.children + } +} diff --git a/apps/web/app/reader/_components/highlight-popover.tsx b/apps/web/app/reader/_components/highlight-popover.tsx new file mode 100644 index 0000000..301c174 --- /dev/null +++ b/apps/web/app/reader/_components/highlight-popover.tsx @@ -0,0 +1,96 @@ +"use client" + +import { useState } from "react" + +interface HighlightPopoverProperties { + highlightIdentifier: string + note: string | null + anchorRect: DOMRect + containerRect: DOMRect + onUpdateNote: (note: string | null) => void + onDelete: () => void + onDismiss: () => void +} + +export function HighlightPopover({ + note, + anchorRect, + onUpdateNote, + onDelete, + onDismiss, +}: HighlightPopoverProperties) { + const [isEditingNote, setIsEditingNote] = useState(false) + const [editedNoteText, setEditedNoteText] = useState(note ?? "") + + const popoverLeft = anchorRect.left + anchorRect.width / 2 + const popoverTop = anchorRect.bottom + 4 + + function handleSaveNote() { + onUpdateNote(editedNoteText.trim() || null) + setIsEditingNote(false) + } + + return ( + <div + className="fixed z-[100] -translate-x-1/2" + style={{ left: popoverLeft, top: popoverTop }} + > + <div className="min-w-48 border border-border bg-background-secondary p-2"> + {isEditingNote ? ( + <div className="space-y-1"> + <input + type="text" + value={editedNoteText} + onChange={(event) => setEditedNoteText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveNote() + if (event.key === "Escape") onDismiss() + }} + placeholder="add a note..." + className="w-full border border-border bg-background-primary px-2 py-1 text-xs text-text-primary outline-none" + autoFocus + /> + <div className="flex gap-1"> + <button + type="button" + onClick={handleSaveNote} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + save + </button> + <button + type="button" + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-xs text-text-dim transition-colors hover:text-text-secondary" + > + cancel + </button> + </div> + </div> + ) : ( + <div className="space-y-1"> + {note && ( + <p className="text-xs text-text-secondary">{note}</p> + )} + <div className="flex gap-1"> + <button + type="button" + onClick={() => setIsEditingNote(true)} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {note ? "edit note" : "add note"} + </button> + <button + type="button" + onClick={onDelete} + className="px-2 py-1 text-xs text-status-error transition-colors hover:bg-background-tertiary" + > + remove + </button> + </div> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/highlight-selection-toolbar.tsx b/apps/web/app/reader/_components/highlight-selection-toolbar.tsx new file mode 100644 index 0000000..42522bf --- /dev/null +++ b/apps/web/app/reader/_components/highlight-selection-toolbar.tsx @@ -0,0 +1,80 @@ +"use client" + +import { useState } from "react" + +interface HighlightSelectionToolbarProperties { + selectionRect: DOMRect + containerRect: DOMRect + onHighlight: (note: string | null) => void + onDismiss: () => void +} + +export function HighlightSelectionToolbar({ + selectionRect, + onHighlight, + onDismiss, +}: HighlightSelectionToolbarProperties) { + const [showNoteInput, setShowNoteInput] = useState(false) + const [noteText, setNoteText] = useState("") + + const toolbarLeft = selectionRect.left + selectionRect.width / 2 + const toolbarTop = selectionRect.top - 8 + + function handleHighlightClick() { + if (showNoteInput) { + onHighlight(noteText.trim() || null) + } else { + onHighlight(null) + } + } + + return ( + <div + className="fixed z-[100] -translate-x-1/2 -translate-y-full" + style={{ left: toolbarLeft, top: toolbarTop }} + > + <div className="border border-border bg-background-secondary p-1"> + {showNoteInput ? ( + <div className="flex items-center gap-1"> + <input + type="text" + value={noteText} + onChange={(event) => setNoteText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleHighlightClick() + if (event.key === "Escape") onDismiss() + }} + placeholder="add a note..." + className="border border-border bg-background-primary px-2 py-1 text-xs text-text-primary outline-none" + autoFocus + /> + <button + type="button" + onClick={handleHighlightClick} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + save + </button> + </div> + ) : ( + <div className="flex items-center gap-1"> + <button + type="button" + onClick={handleHighlightClick} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + highlight + </button> + <button + type="button" + onClick={() => setShowNoteInput(true)} + className="px-2 py-1 text-xs text-text-dim transition-colors hover:bg-background-tertiary hover:text-text-secondary" + > + + note + </button> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/mfa-challenge.tsx b/apps/web/app/reader/_components/mfa-challenge.tsx new file mode 100644 index 0000000..347d8b4 --- /dev/null +++ b/apps/web/app/reader/_components/mfa-challenge.tsx @@ -0,0 +1,108 @@ +"use client" + +import { useState, useEffect } from "react" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export function MfaChallenge({ onVerified }: { onVerified: () => void }) { + const [verificationCode, setVerificationCode] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isVerifying, setIsVerifying] = useState(false) + const [factorIdentifier, setFactorIdentifier] = useState<string | null>(null) + const supabaseClient = createSupabaseBrowserClient() + + useEffect(() => { + async function loadFactor() { + const { data } = await supabaseClient.auth.mfa.listFactors() + + if (data?.totp && data.totp.length > 0) { + const verifiedFactor = data.totp.find( + (factor) => factor.status === "verified" + ) + + if (verifiedFactor) { + setFactorIdentifier(verifiedFactor.id) + } + } + } + + loadFactor() + }, []) + + async function handleVerify(event?: React.FormEvent) { + event?.preventDefault() + + if (!factorIdentifier || verificationCode.length !== 6) return + + setIsVerifying(true) + setErrorMessage(null) + + const { data: challengeData, error: challengeError } = + await supabaseClient.auth.mfa.challenge({ + factorId: factorIdentifier, + }) + + if (challengeError) { + setIsVerifying(false) + setErrorMessage("failed to create challenge — please try again") + return + } + + const { error: verifyError } = await supabaseClient.auth.mfa.verify({ + factorId: factorIdentifier, + challengeId: challengeData.id, + code: verificationCode, + }) + + setIsVerifying(false) + + if (verifyError) { + setErrorMessage("invalid code — please try again") + setVerificationCode("") + return + } + + onVerified() + } + + return ( + <div className="flex h-screen items-center justify-center bg-background-primary"> + <div className="w-full max-w-sm space-y-6 px-4"> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">two-factor authentication</h1> + <p className="text-text-secondary"> + enter the 6-digit code from your authenticator app + </p> + </div> + + <form onSubmit={handleVerify} className="space-y-4"> + <input + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={6} + value={verificationCode} + onChange={(event) => { + const filtered = event.target.value.replace(/\D/g, "") + setVerificationCode(filtered) + }} + placeholder="000000" + className="w-full border border-border bg-background-secondary px-3 py-3 text-center font-mono text-2xl tracking-[0.5em] text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + autoFocus + /> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isVerifying || verificationCode.length !== 6 || !factorIdentifier} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isVerifying ? "verifying ..." : "verify"} + </button> + </form> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/notification-panel.tsx b/apps/web/app/reader/_components/notification-panel.tsx new file mode 100644 index 0000000..216741f --- /dev/null +++ b/apps/web/app/reader/_components/notification-panel.tsx @@ -0,0 +1,129 @@ +"use client" + +import { useEffect, useRef } from "react" +import { formatDistanceToNow } from "date-fns" +import { toast } from "sonner" +import { + useNotificationStore, + type StoredNotification, +} from "@/lib/stores/notification-store" + +export function NotificationPanel({ onClose }: { onClose: () => void }) { + const panelReference = useRef<HTMLDivElement>(null) + const notifications = useNotificationStore((state) => state.notifications) + const dismissNotification = useNotificationStore( + (state) => state.dismissNotification + ) + const clearAllNotifications = useNotificationStore( + (state) => state.clearAllNotifications + ) + const markAllAsViewed = useNotificationStore( + (state) => state.markAllAsViewed + ) + + useEffect(() => { + markAllAsViewed() + }, [markAllAsViewed]) + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + panelReference.current && + !panelReference.current.contains(event.target as Node) + ) { + onClose() + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [onClose]) + + function handleNotificationClick(notification: StoredNotification) { + if (notification.actionUrl) { + navigator.clipboard.writeText(notification.actionUrl) + toast("link copied to clipboard") + } + } + + return ( + <div + ref={panelReference} + className="fixed bottom-16 left-2 z-50 w-80 max-w-[calc(100vw-1rem)] border border-border bg-background-secondary shadow-lg md:absolute md:bottom-full md:left-0 md:mb-1" + > + <div className="flex items-center justify-between border-b border-border px-3 py-2"> + <span className="text-text-primary">notifications</span> + {notifications.length > 0 && ( + <button + type="button" + onClick={() => { + clearAllNotifications() + onClose() + }} + className="text-text-dim transition-colors hover:text-text-secondary" + > + clear all + </button> + )} + </div> + <div className="max-h-64 overflow-auto"> + {notifications.length === 0 ? ( + <p className="px-3 py-4 text-center text-text-dim"> + no notifications + </p> + ) : ( + notifications.map((notification: StoredNotification) => ( + <div + key={notification.identifier} + className={`flex items-start gap-2 border-b border-border px-3 py-2 last:border-b-0 ${ + notification.actionUrl + ? "cursor-pointer transition-colors hover:bg-background-tertiary" + : "" + }`} + onClick={ + notification.actionUrl + ? () => handleNotificationClick(notification) + : undefined + } + > + <div className="min-w-0 flex-1"> + <p className="text-text-secondary">{notification.message}</p> + {notification.actionUrl && ( + <p className="mt-0.5 text-text-dim"> + tap to copy link + </p> + )} + <p className="mt-0.5 text-text-dim"> + {formatDistanceToNow(new Date(notification.timestamp), { + addSuffix: true, + })} + </p> + </div> + <button + type="button" + onClick={(event) => { + event.stopPropagation() + dismissNotification(notification.identifier) + }} + className="shrink-0 px-1 text-text-dim transition-colors hover:text-text-secondary" + > + × + </button> + </div> + )) + )} + </div> + </div> + ) +} + +export function useUnviewedNotificationCount(): number { + const notifications = useNotificationStore((state) => state.notifications) + const lastViewedAt = useNotificationStore((state) => state.lastViewedAt) + + if (!lastViewedAt) return notifications.length + + return notifications.filter( + (notification) => notification.timestamp > lastViewedAt + ).length +} diff --git a/apps/web/app/reader/_components/reader-layout-shell.tsx b/apps/web/app/reader/_components/reader-layout-shell.tsx new file mode 100644 index 0000000..7e0e80b --- /dev/null +++ b/apps/web/app/reader/_components/reader-layout-shell.tsx @@ -0,0 +1,204 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { classNames } from "@/lib/utilities" +import { ErrorBoundary } from "./error-boundary" +import { SidebarContent } from "./sidebar-content" +import { CommandPalette } from "./command-palette" +import { AddFeedDialog } from "./add-feed-dialog" +import { SearchOverlay } from "./search-overlay" +import { MfaChallenge } from "./mfa-challenge" +import { useKeyboardNavigation } from "@/lib/hooks/use-keyboard-navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +const DENSITY_FONT_SIZE_MAP: Record<string, string> = { + compact: "0.875rem", + default: "1rem", + spacious: "1.125rem", +} + +export function ReaderLayoutShell({ + sidebarFooter, + children, +}: { + sidebarFooter: React.ReactNode + children: React.ReactNode +}) { + const [requiresMfaVerification, setRequiresMfaVerification] = useState(false) + const [isMfaCheckComplete, setIsMfaCheckComplete] = useState(false) + + const isSidebarCollapsed = useUserInterfaceStore( + (state) => state.isSidebarCollapsed + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setSidebarCollapsed = useUserInterfaceStore( + (state) => state.setSidebarCollapsed + ) + const displayDensity = useUserInterfaceStore( + (state) => state.displayDensity + ) + const isSearchOpen = useUserInterfaceStore((state) => state.isSearchOpen) + const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const setFocusedPanel = useUserInterfaceStore((state) => state.setFocusedPanel) + const focusFollowsInteraction = useUserInterfaceStore( + (state) => state.focusFollowsInteraction + ) + + useKeyboardNavigation() + + useEffect(() => { + async function checkAssuranceLevel() { + const supabaseClient = createSupabaseBrowserClient() + const { data } = await supabaseClient.auth.mfa.getAuthenticatorAssuranceLevel() + + if ( + data && + data.currentLevel === "aal1" && + data.nextLevel === "aal2" + ) { + setRequiresMfaVerification(true) + } + + setIsMfaCheckComplete(true) + } + + checkAssuranceLevel() + }, []) + + useEffect(() => { + if (window.innerWidth < 768) { + setSidebarCollapsed(true) + } + }, [setSidebarCollapsed]) + + useEffect(() => { + document.body.style.setProperty( + "--base-font-size", + DENSITY_FONT_SIZE_MAP[displayDensity] ?? "0.8125rem" + ) + }, [displayDensity]) + + useEffect(() => { + if (!focusFollowsInteraction) return + + function handlePointerDown(event: PointerEvent) { + const target = event.target as HTMLElement + const zone = target.closest("[data-panel-zone]") + if (!zone) return + const panelZone = zone.getAttribute("data-panel-zone") + if ( + panelZone === "sidebar" || + panelZone === "entryList" || + panelZone === "detailPanel" + ) { + useUserInterfaceStore.getState().setFocusedPanel(panelZone) + } + } + + function handleScroll(event: Event) { + const target = event.target as HTMLElement + if (!target || !target.closest) return + const zone = target.closest("[data-panel-zone]") + if (!zone) return + const panelZone = zone.getAttribute("data-panel-zone") + if ( + panelZone === "sidebar" || + panelZone === "entryList" || + panelZone === "detailPanel" + ) { + const currentPanel = useUserInterfaceStore.getState().focusedPanel + if (currentPanel !== panelZone) { + useUserInterfaceStore.getState().setFocusedPanel(panelZone) + } + } + } + + document.addEventListener("pointerdown", handlePointerDown) + document.addEventListener("scroll", handleScroll, true) + return () => { + document.removeEventListener("pointerdown", handlePointerDown) + document.removeEventListener("scroll", handleScroll, true) + } + }, [focusFollowsInteraction]) + + if (!isMfaCheckComplete) { + return ( + <div className="flex h-screen items-center justify-center bg-background-primary"> + <span className="text-text-dim">loading ...</span> + </div> + ) + } + + if (requiresMfaVerification) { + return <MfaChallenge onVerified={() => setRequiresMfaVerification(false)} /> + } + + return ( + <div className="flex h-screen"> + <div + className={classNames( + "fixed inset-0 z-30 bg-black/50 transition-opacity md:hidden", + !isSidebarCollapsed + ? "pointer-events-auto opacity-100" + : "pointer-events-none opacity-0" + )} + onClick={toggleSidebar} + /> + + <aside + data-panel-zone="sidebar" + className={classNames( + "fixed z-40 flex h-full shrink-0 flex-col border-r border-border bg-background-secondary transition-transform duration-200 md:relative md:z-10 md:transition-[width]", + "w-64", + isSidebarCollapsed + ? "-translate-x-full md:w-0 md:translate-x-0 md:overflow-hidden" + : "translate-x-0", + focusedPanel === "sidebar" && !isSidebarCollapsed + ? "border-r-text-dim" + : "" + )} + > + <div className="flex items-center justify-between p-4"> + <h2 className="text-text-primary">asa.news</h2> + <button + type="button" + onClick={toggleSidebar} + className="px-1 py-0.5 text-lg leading-none text-text-dim transition-colors hover:text-text-secondary" + > + × + </button> + </div> + <ErrorBoundary> + <Suspense> + <SidebarContent /> + </Suspense> + </ErrorBoundary> + {sidebarFooter} + </aside> + + <main className="flex-1 overflow-hidden"> + <div className="flex h-full flex-col"> + {isSidebarCollapsed && ( + <div className="flex items-center border-b border-border px-2 py-1"> + <button + type="button" + onClick={toggleSidebar} + className="px-2 py-1 text-lg leading-none text-text-secondary transition-colors hover:text-text-primary" + > + ☰ + </button> + </div> + )} + <div className="flex-1 overflow-hidden">{children}</div> + </div> + </main> + <CommandPalette /> + <AddFeedDialog /> + {isSearchOpen && ( + <SearchOverlay onClose={() => setSearchOpen(false)} /> + )} + </div> + ) +} diff --git a/apps/web/app/reader/_components/reader-shell.tsx b/apps/web/app/reader/_components/reader-shell.tsx new file mode 100644 index 0000000..fe7e4c2 --- /dev/null +++ b/apps/web/app/reader/_components/reader-shell.tsx @@ -0,0 +1,208 @@ +"use client" + +import { Group, Panel, Separator } from "react-resizable-panels" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useMarkAllAsRead } from "@/lib/queries/use-mark-all-as-read" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUnreadCounts } from "@/lib/queries/use-unread-counts" +import { useIsMobile } from "@/lib/hooks/use-is-mobile" +import { classNames } from "@/lib/utilities" +import { EntryList } from "./entry-list" +import { EntryDetailPanel } from "./entry-detail-panel" +import { ErrorBoundary } from "./error-boundary" +import { useRealtimeEntries } from "@/lib/hooks/use-realtime-entries" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" + +interface ReaderShellProperties { + userEmailAddress: string | null + feedFilter: "all" | "saved" + folderIdentifier?: string | null + feedIdentifier?: string | null + customFeedIdentifier?: string | null +} + +export function ReaderShell({ + userEmailAddress, + feedFilter, + folderIdentifier, + feedIdentifier, + customFeedIdentifier, +}: ReaderShellProperties) { + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) + const markAllAsRead = useMarkAllAsRead() + const { data: subscriptionsData } = useSubscriptions() + const { data: unreadCounts } = useUnreadCounts() + const { data: customFeedsData } = useCustomFeeds() + const isMobile = useIsMobile() + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + + useRealtimeEntries() + + let pageTitle = feedFilter === "saved" ? "saved" : "all entries" + + if (feedFilter === "all" && customFeedIdentifier && customFeedsData) { + const matchingCustomFeed = customFeedsData.find( + (customFeed) => customFeed.identifier === customFeedIdentifier + ) + + if (matchingCustomFeed) { + pageTitle = matchingCustomFeed.name + } + } + + if (feedFilter === "all" && feedIdentifier && subscriptionsData) { + const matchingSubscription = subscriptionsData.subscriptions.find( + (subscription) => subscription.feedIdentifier === feedIdentifier + ) + + if (matchingSubscription) { + pageTitle = + matchingSubscription.customTitle || + matchingSubscription.feedTitle || + "feed" + } + } + + if (feedFilter === "all" && folderIdentifier && subscriptionsData) { + const matchingFolder = subscriptionsData.folders.find( + (folder) => folder.folderIdentifier === folderIdentifier + ) + + if (matchingFolder) { + pageTitle = matchingFolder.name + } + } + + const totalUnreadCount = Object.values(unreadCounts ?? {}).reduce( + (sum, count) => sum + count, + 0 + ) + const allAreRead = totalUnreadCount === 0 + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center justify-between border-b border-border px-4 py-3"> + {isMobile && selectedEntryIdentifier ? ( + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + ← back + </button> + ) : ( + <h1 className="text-text-primary">{pageTitle}</h1> + )} + <div className="flex items-center gap-3"> + {!(isMobile && selectedEntryIdentifier) && ( + <> + <button + type="button" + onClick={() => setSearchOpen(true)} + className="text-text-dim transition-colors hover:text-text-secondary" + > + search + </button> + {feedFilter === "all" && ( + <button + type="button" + onClick={() => + markAllAsRead.mutate({ readState: !allAreRead }) + } + disabled={markAllAsRead.isPending} + className="text-text-dim transition-colors hover:text-text-secondary disabled:opacity-50" + > + {allAreRead ? "mark all unread" : "mark all read"} + </button> + )} + <select + value={entryListViewMode} + onChange={(event) => + setEntryListViewMode( + event.target.value as "compact" | "comfortable" | "expanded" + ) + } + className="hidden border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none sm:block" + > + <option value="compact">compact</option> + <option value="comfortable">comfortable</option> + <option value="expanded">expanded</option> + </select> + </> + )} + </div> + </header> + <ErrorBoundary> + {isMobile ? ( + selectedEntryIdentifier ? ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryDetailPanel + entryIdentifier={selectedEntryIdentifier} + /> + </ErrorBoundary> + </div> + ) : ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryList + feedFilter={feedFilter} + folderIdentifier={folderIdentifier} + feedIdentifier={feedIdentifier} + customFeedIdentifier={customFeedIdentifier} + /> + </ErrorBoundary> + </div> + ) + ) : ( + <Group orientation="horizontal" className="flex-1"> + <Panel defaultSize={selectedEntryIdentifier ? 40 : 100} minSize={25}> + <div data-panel-zone="entryList" className={classNames( + "h-full", + focusedPanel === "entryList" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <ErrorBoundary> + <EntryList + feedFilter={feedFilter} + folderIdentifier={folderIdentifier} + feedIdentifier={feedIdentifier} + customFeedIdentifier={customFeedIdentifier} + /> + </ErrorBoundary> + </div> + </Panel> + {selectedEntryIdentifier && ( + <> + <Separator className="w-px bg-border transition-colors hover:bg-text-dim" /> + <Panel defaultSize={60} minSize={30}> + <div data-panel-zone="detailPanel" className={classNames( + "h-full", + focusedPanel === "detailPanel" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <ErrorBoundary> + <EntryDetailPanel + entryIdentifier={selectedEntryIdentifier} + /> + </ErrorBoundary> + </div> + </Panel> + </> + )} + </Group> + )} + </ErrorBoundary> + </div> + ) +} diff --git a/apps/web/app/reader/_components/search-overlay.tsx b/apps/web/app/reader/_components/search-overlay.tsx new file mode 100644 index 0000000..5cfdb57 --- /dev/null +++ b/apps/web/app/reader/_components/search-overlay.tsx @@ -0,0 +1,180 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useEntrySearch } from "@/lib/queries/use-entry-search" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +function getMatchSnippet(text: string, query: string): string | null { + const stripped = text.replace(/<[^>]*>/g, "") + const lowerStripped = stripped.toLowerCase() + const matchIndex = lowerStripped.indexOf(query) + if (matchIndex === -1) return null + const start = Math.max(0, matchIndex - 40) + const end = Math.min(stripped.length, matchIndex + query.length + 80) + const prefix = start > 0 ? "\u2026" : "" + const suffix = end < stripped.length ? "\u2026" : "" + return prefix + stripped.slice(start, end) + suffix +} + +function highlightText(text: string, query: string): React.ReactNode { + if (!query) return text + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const parts = text.split(new RegExp(`(${escapedQuery})`, "gi")) + return parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + <mark key={index} className="bg-[rgba(234,179,8,0.18)] text-text-primary"> + {part} + </mark> + ) : ( + part + ) + ) +} + +interface SearchOverlayProperties { + onClose: () => void +} + +export function SearchOverlay({ onClose }: SearchOverlayProperties) { + const [searchQuery, setSearchQuery] = useState("") + const [selectedResultIndex, setSelectedResultIndex] = useState(-1) + const inputReference = useRef<HTMLInputElement>(null) + const resultListReference = useRef<HTMLDivElement>(null) + const { data: results, isLoading } = useEntrySearch(searchQuery) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + + useEffect(() => { + inputReference.current?.focus() + }, []) + + useEffect(() => { + setSelectedResultIndex(-1) + }, [searchQuery]) + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + onClose() + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + function handleSelectEntry(entryIdentifier: string) { + setSelectedEntryIdentifier(entryIdentifier) + onClose() + } + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "Backspace" && searchQuery === "") { + onClose() + return + } + + if (!results || results.length === 0) return + + if (event.key === "ArrowDown") { + event.preventDefault() + setSelectedResultIndex((previous) => { + const nextIndex = previous < results.length - 1 ? previous + 1 : 0 + scrollResultIntoView(nextIndex) + return nextIndex + }) + } else if (event.key === "ArrowUp") { + event.preventDefault() + setSelectedResultIndex((previous) => { + const nextIndex = previous > 0 ? previous - 1 : results.length - 1 + scrollResultIntoView(nextIndex) + return nextIndex + }) + } else if (event.key === "Enter" && selectedResultIndex >= 0) { + event.preventDefault() + handleSelectEntry(results[selectedResultIndex].entryIdentifier) + } + } + + function scrollResultIntoView(index: number) { + const container = resultListReference.current + if (!container) return + const items = container.querySelectorAll("[data-result-item]") + items[index]?.scrollIntoView({ block: "nearest" }) + } + + function handleBackdropClick(event: React.MouseEvent) { + if (event.target === event.currentTarget) { + onClose() + } + } + + return ( + <div + className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh]" + onClick={handleBackdropClick} + > + <div className="w-full max-w-lg border border-border bg-background-primary shadow-lg"> + <div className="border-b border-border px-4 py-3"> + <input + ref={inputReference} + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + onKeyDown={handleInputKeyDown} + placeholder="search entries..." + className="w-full bg-transparent text-text-primary outline-none placeholder:text-text-dim" + /> + </div> + <div ref={resultListReference} className="max-h-80 overflow-auto"> + {isLoading && searchQuery.trim().length >= 2 && ( + <p className="px-4 py-3 text-text-dim">searching...</p> + )} + {!isLoading && + searchQuery.trim().length >= 2 && + results?.length === 0 && ( + <p className="px-4 py-3 text-text-dim">no results</p> + )} + {results?.map((entry, index) => { + const query = searchQuery.trim().toLowerCase() + const titleMatches = (entry.entryTitle ?? "").toLowerCase().includes(query) + const summarySnippet = !titleMatches && entry.summary + ? getMatchSnippet(entry.summary, query) + : null + + return ( + <button + key={entry.entryIdentifier} + type="button" + data-result-item + onClick={() => handleSelectEntry(entry.entryIdentifier)} + className={`block w-full px-4 py-2 text-left transition-colors hover:bg-background-tertiary ${ + index === selectedResultIndex + ? "bg-background-tertiary" + : "" + }`} + > + <p className="truncate text-text-primary"> + {titleMatches + ? highlightText(entry.entryTitle ?? "", query) + : entry.entryTitle} + </p> + <p className="truncate text-[0.6875rem] text-text-dim"> + {entry.customTitle ?? entry.feedTitle} + {entry.author && ` \u00b7 ${entry.author}`} + </p> + {summarySnippet && ( + <p className="mt-0.5 line-clamp-2 text-[0.6875rem] text-text-secondary"> + {highlightText(summarySnippet, query)} + </p> + )} + </button> + ) + })} + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/sidebar-content.tsx b/apps/web/app/reader/_components/sidebar-content.tsx new file mode 100644 index 0000000..ee5c873 --- /dev/null +++ b/apps/web/app/reader/_components/sidebar-content.tsx @@ -0,0 +1,356 @@ +"use client" + +import Link from "next/link" +import { usePathname, useSearchParams } from "next/navigation" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUnreadCounts } from "@/lib/queries/use-unread-counts" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { classNames } from "@/lib/utilities" + +const NAVIGATION_LINK_CLASS = + "block px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + +const ACTIVE_LINK_CLASS = "bg-background-tertiary text-text-primary" + +function getFaviconUrl(feedUrl: string): string | null { + try { + const hostname = new URL(feedUrl).hostname + return `https://www.google.com/s2/favicons?domain=${hostname}&sz=16` + } catch { + return null + } +} + +function FeedFavicon({ feedUrl }: { feedUrl: string }) { + const faviconUrl = getFaviconUrl(feedUrl) + if (!faviconUrl) return null + + return ( + <img + src={faviconUrl} + alt="" + width={16} + height={16} + className="shrink-0" + loading="lazy" + /> + ) +} + +function displayNameForSubscription(subscription: { + customTitle: string | null + feedTitle: string + feedUrl: string +}): string { + if (subscription.customTitle) return subscription.customTitle + if (subscription.feedTitle) return subscription.feedTitle + + try { + return new URL(subscription.feedUrl).hostname + } catch { + return subscription.feedUrl || "untitled feed" + } +} + +function UnreadBadge({ count }: { count: number }) { + if (count === 0) return null + + return ( + <span className="ml-auto shrink-0 text-[0.625rem] tabular-nums text-text-dim"> + {count > 999 ? "999+" : count} + </span> + ) +} + +function sidebarFocusClass( + focusedPanel: string, + focusedSidebarIndex: number, + navIndex: number +): string { + return focusedPanel === "sidebar" && focusedSidebarIndex === navIndex + ? "bg-background-tertiary text-text-primary" + : "" +} + +export function SidebarContent() { + const pathname = usePathname() + const searchParameters = useSearchParams() + const { data } = useSubscriptions() + const { data: unreadCounts } = useUnreadCounts() + const { data: customFeedsData } = useCustomFeeds() + const setAddFeedDialogOpen = useUserInterfaceStore( + (state) => state.setAddFeedDialogOpen + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const showFeedFavicons = useUserInterfaceStore( + (state) => state.showFeedFavicons + ) + const expandedFolderIdentifiers = useUserInterfaceStore( + (state) => state.expandedFolderIdentifiers + ) + const toggleFolderExpansion = useUserInterfaceStore( + (state) => state.toggleFolderExpansion + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const focusedSidebarIndex = useUserInterfaceStore( + (state) => state.focusedSidebarIndex + ) + + function closeSidebarOnMobile() { + if (typeof window !== "undefined" && window.innerWidth < 768) { + toggleSidebar() + } + } + + const folders = data?.folders ?? [] + const subscriptions = data?.subscriptions ?? [] + const ungroupedSubscriptions = subscriptions.filter( + (subscription) => !subscription.folderIdentifier + ) + + const totalUnreadCount = Object.values(unreadCounts ?? {}).reduce( + (sum, count) => sum + count, + 0 + ) + + function getFolderUnreadCount(folderIdentifier: string): number { + return subscriptions + .filter( + (subscription) => + subscription.folderIdentifier === folderIdentifier + ) + .reduce( + (sum, subscription) => + sum + (unreadCounts?.[subscription.feedIdentifier] ?? 0), + 0 + ) + } + + const activeFeedIdentifier = searchParameters.get("feed") + const activeFolderIdentifier = searchParameters.get("folder") + const activeCustomFeedIdentifier = searchParameters.get("custom_feed") + + let navIndex = 0 + + return ( + <nav className="flex-1 space-y-1 overflow-auto px-2"> + <Link + href="/reader" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center", + pathname === "/reader" && + !activeFeedIdentifier && + !activeFolderIdentifier && + !activeCustomFeedIdentifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + <span>all entries</span> + <UnreadBadge count={totalUnreadCount} /> + </Link> + <Link + href="/reader/saved" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/saved" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + saved + </Link> + <Link + href="/reader/highlights" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/highlights" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + highlights + </Link> + <Link + href="/reader/shares" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/shares" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + shares + </Link> + + {customFeedsData && customFeedsData.length > 0 && ( + <div className="mt-3 space-y-0.5"> + {customFeedsData.map((customFeed) => ( + <Link + key={customFeed.identifier} + href={`/reader?custom_feed=${customFeed.identifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "truncate pl-4 text-[0.85em]", + activeCustomFeedIdentifier === customFeed.identifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {customFeed.name} + </Link> + ))} + </div> + )} + + {ungroupedSubscriptions.length > 0 && ( + <div className="mt-3 space-y-0.5"> + {ungroupedSubscriptions.map((subscription) => ( + <Link + key={subscription.subscriptionIdentifier} + href={`/reader?feed=${subscription.feedIdentifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center truncate pl-4 text-[0.85em]", + activeFeedIdentifier === subscription.feedIdentifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {showFeedFavicons && ( + <FeedFavicon feedUrl={subscription.feedUrl} /> + )} + <span className={classNames("truncate", showFeedFavicons && "ml-2")}> + {displayNameForSubscription(subscription)} + </span> + {subscription.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> + )} + {subscription.consecutiveFailures > 0 && ( + <span className="ml-1 shrink-0 text-status-warning" title={subscription.lastFetchError ?? "feed error"}> + [!] + </span> + )} + <UnreadBadge + count={unreadCounts?.[subscription.feedIdentifier] ?? 0} + /> + </Link> + ))} + </div> + )} + + {folders.map((folder) => { + const isExpanded = expandedFolderIdentifiers.includes( + folder.folderIdentifier + ) + const folderSubscriptions = subscriptions.filter( + (subscription) => + subscription.folderIdentifier === folder.folderIdentifier + ) + const folderUnreadCount = getFolderUnreadCount( + folder.folderIdentifier + ) + + const folderNavIndex = navIndex++ + + return ( + <div key={folder.folderIdentifier} className="mt-2"> + <div + data-sidebar-nav-item + className={classNames( + "flex w-full items-center gap-1 px-2 py-1", + sidebarFocusClass(focusedPanel, focusedSidebarIndex, folderNavIndex) + )} + > + <button + type="button" + onClick={() => + toggleFolderExpansion(folder.folderIdentifier) + } + className="shrink-0 px-0.5 text-text-secondary transition-colors hover:text-text-primary" + > + {isExpanded ? "\u25BE" : "\u25B8"} + </button> + <Link + href={`/reader?folder=${folder.folderIdentifier}`} + onClick={closeSidebarOnMobile} + className={classNames( + "flex-1 truncate text-text-secondary transition-colors hover:text-text-primary", + activeFolderIdentifier === folder.folderIdentifier && + "text-text-primary" + )} + > + {folder.name} + </Link> + <UnreadBadge count={folderUnreadCount} /> + </div> + {isExpanded && ( + <div className="space-y-0.5"> + {folderSubscriptions.map((subscription) => ( + <Link + key={subscription.subscriptionIdentifier} + href={`/reader?feed=${subscription.feedIdentifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center truncate pl-6 text-[0.85em]", + activeFeedIdentifier === + subscription.feedIdentifier && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {showFeedFavicons && ( + <FeedFavicon feedUrl={subscription.feedUrl} /> + )} + <span className={classNames("truncate", showFeedFavicons && "ml-2")}> + {displayNameForSubscription(subscription)} + </span> + {subscription.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> + )} + {subscription.consecutiveFailures > 0 && ( + <span className="ml-1 shrink-0 text-status-warning" title={subscription.lastFetchError ?? "feed error"}> + [!] + </span> + )} + <UnreadBadge + count={ + unreadCounts?.[subscription.feedIdentifier] ?? 0 + } + /> + </Link> + ))} + </div> + )} + </div> + ) + })} + + <div className="mt-3"> + <button + type="button" + data-sidebar-nav-item + onClick={() => setAddFeedDialogOpen(true)} + className={classNames( + "w-full px-2 py-1 text-left text-text-dim transition-colors hover:bg-background-tertiary hover:text-text-secondary", + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + + add feed + </button> + </div> + </nav> + ) +} diff --git a/apps/web/app/reader/_components/sidebar-footer.tsx b/apps/web/app/reader/_components/sidebar-footer.tsx new file mode 100644 index 0000000..8c520c3 --- /dev/null +++ b/apps/web/app/reader/_components/sidebar-footer.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { signOut } from "../actions" +import { + NotificationPanel, + useUnviewedNotificationCount, +} from "./notification-panel" + +export function SidebarFooter() { + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setActiveSettingsTab = useUserInterfaceStore( + (state) => state.setActiveSettingsTab + ) + const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false) + const unviewedNotificationCount = useUnviewedNotificationCount() + const { data: userProfile } = useUserProfile() + + const displayName = userProfile?.displayName ?? "account" + + function closeSidebarOnMobile() { + if (typeof window !== "undefined" && window.innerWidth < 768) { + toggleSidebar() + } + } + + return ( + <div className="border-t border-border p-2"> + <Link + href="/reader/settings" + onClick={() => { + setActiveSettingsTab("account") + closeSidebarOnMobile() + }} + className="block truncate px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {displayName} + </Link> + <Link + href="/reader/settings" + onClick={closeSidebarOnMobile} + className="block px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + settings + </Link> + <div className="relative"> + <button + type="button" + onClick={() => setIsNotificationPanelOpen(!isNotificationPanelOpen)} + className="w-full px-2 py-1 text-left text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + notifications + {unviewedNotificationCount > 0 && ( + <span className="ml-1 inline-flex h-4 min-w-4 items-center justify-center bg-accent-primary px-1 text-[0.6875rem] text-background-primary"> + {unviewedNotificationCount} + </span> + )} + </button> + {isNotificationPanelOpen && ( + <NotificationPanel + onClose={() => setIsNotificationPanelOpen(false)} + /> + )} + </div> + <form action={signOut}> + <button + type="submit" + onClick={closeSidebarOnMobile} + className="w-full px-2 py-1 text-left text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + sign out + </button> + </form> + </div> + ) +} diff --git a/apps/web/app/reader/actions.ts b/apps/web/app/reader/actions.ts new file mode 100644 index 0000000..efcc1ec --- /dev/null +++ b/apps/web/app/reader/actions.ts @@ -0,0 +1,10 @@ +"use server" + +import { redirect } from "next/navigation" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +export async function signOut() { + const supabaseClient = await createSupabaseServerClient() + await supabaseClient.auth.signOut() + redirect("/sign-in") +} diff --git a/apps/web/app/reader/highlights/_components/highlights-content.tsx b/apps/web/app/reader/highlights/_components/highlights-content.tsx new file mode 100644 index 0000000..4034210 --- /dev/null +++ b/apps/web/app/reader/highlights/_components/highlights-content.tsx @@ -0,0 +1,452 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { formatDistanceToNow } from "date-fns" +import { Group, Panel, Separator } from "react-resizable-panels" +import { useAllHighlights } from "@/lib/queries/use-all-highlights" +import { + useDeleteHighlight, + useUpdateHighlightNote, +} from "@/lib/queries/use-highlight-mutations" +import { useIsMobile } from "@/lib/hooks/use-is-mobile" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { EntryDetailPanel } from "@/app/reader/_components/entry-detail-panel" +import { ErrorBoundary } from "@/app/reader/_components/error-boundary" +import { classNames } from "@/lib/utilities" +import type { HighlightWithEntryContext } from "@/lib/types/highlight" + +function groupHighlightsByEntry( + highlights: HighlightWithEntryContext[] +): Map<string, HighlightWithEntryContext[]> { + const grouped = new Map<string, HighlightWithEntryContext[]>() + + for (const highlight of highlights) { + const existing = grouped.get(highlight.entryIdentifier) + if (existing) { + existing.push(highlight) + } else { + grouped.set(highlight.entryIdentifier, [highlight]) + } + } + + return grouped +} + +function HighlightItem({ + highlight, + entryIdentifier, +}: { + highlight: HighlightWithEntryContext + entryIdentifier: string +}) { + const [showRemoveConfirm, setShowRemoveConfirm] = useState(false) + const [isEditingNote, setIsEditingNote] = useState(false) + const [editedNote, setEditedNote] = useState(highlight.note ?? "") + const deleteHighlight = useDeleteHighlight() + const updateNote = useUpdateHighlightNote() + + function handleSaveNote() { + const trimmedNote = editedNote.trim() + updateNote.mutate({ + highlightIdentifier: highlight.identifier, + note: trimmedNote || null, + entryIdentifier, + }) + setIsEditingNote(false) + } + + return ( + <div className="border-l-2 border-text-dim pl-3"> + <p className="text-text-secondary"> + {highlight.highlightedText} + </p> + {isEditingNote ? ( + <div className="mt-1 flex items-center gap-2"> + <input + type="text" + value={editedNote} + onChange={(event) => setEditedNote(event.target.value)} + placeholder="add a note..." + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveNote() + if (event.key === "Escape") setIsEditingNote(false) + }} + autoFocus + /> + <button + onClick={handleSaveNote} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : highlight.note ? ( + <p className="mt-1 text-text-dim"> + {highlight.note} + </p> + ) : null} + <div className="mt-1 flex items-center gap-2 text-text-dim"> + <span> + {formatDistanceToNow( + new Date(highlight.createdAt), + { addSuffix: true } + )} + </span> + <button + type="button" + onClick={() => { + setEditedNote(highlight.note ?? "") + setIsEditingNote(true) + }} + className="text-text-secondary transition-colors hover:text-text-primary" + > + {highlight.note ? "edit note" : "add note"} + </button> + {showRemoveConfirm ? ( + <div className="flex items-center gap-1"> + <span>remove?</span> + <button + type="button" + onClick={() => { + deleteHighlight.mutate({ + highlightIdentifier: highlight.identifier, + entryIdentifier, + }) + setShowRemoveConfirm(false) + }} + className="text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + type="button" + onClick={() => setShowRemoveConfirm(false)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + type="button" + onClick={() => setShowRemoveConfirm(true)} + className="text-text-secondary transition-colors hover:text-status-error" + > + remove + </button> + )} + </div> + </div> + ) +} + +function HighlightsList({ + groupedByEntry, + entryIdentifiers, + selectedEntryIdentifier, + focusedEntryIdentifier, + viewMode, + onSelect, + lastElementReference, + hasNextPage, + isFetchingNextPage, +}: { + groupedByEntry: Map<string, HighlightWithEntryContext[]> + entryIdentifiers: string[] + selectedEntryIdentifier: string | null + focusedEntryIdentifier: string | null + viewMode: "compact" | "comfortable" | "expanded" + onSelect: (entryIdentifier: string) => void + lastElementReference: (node: HTMLDivElement | null) => (() => void) | undefined + hasNextPage: boolean + isFetchingNextPage: boolean +}) { + const listReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (!focusedEntryIdentifier) return + const container = listReference.current + if (!container) return + const items = container.querySelectorAll("[data-highlight-group-item]") + const focusedIndex = entryIdentifiers.indexOf(focusedEntryIdentifier) + items[focusedIndex]?.scrollIntoView({ block: "nearest" }) + }, [focusedEntryIdentifier, entryIdentifiers]) + + if (groupedByEntry.size === 0) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + <div className="text-center"> + <p>no highlights yet</p> + <p className="mt-1 text-xs"> + select text in an entry and click “highlight” to get started + </p> + </div> + </div> + ) + } + + return ( + <div ref={listReference} className="h-full overflow-auto"> + {entryIdentifiers.map((entryIdentifier) => { + const highlights = groupedByEntry.get(entryIdentifier)! + const firstHighlight = highlights[0]! + const isSelected = entryIdentifier === selectedEntryIdentifier + const isFocused = entryIdentifier === focusedEntryIdentifier + + const rowClassName = classNames( + "cursor-pointer border-b border-border px-4 transition-colors last:border-b-0", + isSelected + ? "bg-background-tertiary" + : isFocused + ? "bg-background-secondary" + : "hover:bg-background-secondary", + isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "" + ) + + if (viewMode === "compact") { + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={rowClassName} + > + <div className="flex items-center gap-2 py-2.5"> + <span className="min-w-0 flex-1 truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + <span className="shrink-0 text-text-dim"> + {highlights.length} highlight{highlights.length !== 1 && "s"} + </span> + {firstHighlight.feedTitle && ( + <span className="shrink-0 text-text-dim"> + {firstHighlight.feedTitle} + </span> + )} + </div> + </div> + ) + } + + if (viewMode === "comfortable") { + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={rowClassName} + > + <div className="py-2.5"> + <span className="block truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + {firstHighlight.feedTitle && ( + <span>{firstHighlight.feedTitle}</span> + )} + <span> + {highlights.length} highlight{highlights.length !== 1 && "s"} + </span> + <span>·</span> + <span className="truncate"> + {firstHighlight.highlightedText} + </span> + </div> + </div> + </div> + ) + } + + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={classNames(rowClassName, "py-3")} + > + <div className="mb-2 flex items-center gap-2"> + <span className="truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + {firstHighlight.feedTitle && ( + <span className="shrink-0 text-text-dim"> + {firstHighlight.feedTitle} + </span> + )} + </div> + <div className="space-y-2"> + {highlights.map((highlight) => ( + <HighlightItem + key={highlight.identifier} + highlight={highlight} + entryIdentifier={entryIdentifier} + /> + ))} + </div> + </div> + ) + })} + {hasNextPage && ( + <div ref={lastElementReference} className="py-4 text-center"> + {isFetchingNextPage ? ( + <span className="text-text-dim">loading more ...</span> + ) : ( + <span className="text-text-dim"> </span> + )} + </div> + )} + </div> + ) +} + +export function HighlightsContent() { + const { + data, + isLoading, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useAllHighlights() + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setNavigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.setNavigableEntryIdentifiers + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const isMobile = useIsMobile() + + const lastElementReference = useCallback( + (node: HTMLDivElement | null) => { + if (!node || !hasNextPage || isFetchingNextPage) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + fetchNextPage() + } + }, + { threshold: 0.1 } + ) + + observer.observe(node) + return () => observer.disconnect() + }, + [hasNextPage, isFetchingNextPage, fetchNextPage] + ) + + const allHighlights = data?.pages.flat() ?? [] + const groupedByEntry = groupHighlightsByEntry(allHighlights) + const entryIdentifiers = Array.from(groupedByEntry.keys()) + + useEffect(() => { + setSelectedEntryIdentifier(null) + setNavigableEntryIdentifiers([]) + }, []) + + useEffect(() => { + setNavigableEntryIdentifiers(entryIdentifiers) + }, [entryIdentifiers.length, setNavigableEntryIdentifiers]) + + if (isLoading) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + loading ... + </div> + ) + } + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center justify-between border-b border-border px-4 py-3"> + <div className="flex items-center gap-3"> + {isMobile && selectedEntryIdentifier && ( + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + ← back + </button> + )} + <h1 className="text-text-primary">highlights</h1> + </div> + <span className="text-text-dim">{allHighlights.length} highlight{allHighlights.length !== 1 && "s"}</span> + </header> + <ErrorBoundary> + {isMobile ? ( + selectedEntryIdentifier ? ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + ) : ( + <div className="flex-1 overflow-hidden"> + <HighlightsList + groupedByEntry={groupedByEntry} + entryIdentifiers={entryIdentifiers} + selectedEntryIdentifier={null} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + lastElementReference={lastElementReference} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + /> + </div> + ) + ) : ( + <Group orientation="horizontal" className="flex-1"> + <Panel defaultSize={selectedEntryIdentifier ? 40 : 100} minSize={25}> + <div data-panel-zone="entryList" className={classNames( + "h-full", + focusedPanel === "entryList" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <HighlightsList + groupedByEntry={groupedByEntry} + entryIdentifiers={entryIdentifiers} + selectedEntryIdentifier={selectedEntryIdentifier} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + lastElementReference={lastElementReference} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + /> + </div> + </Panel> + {selectedEntryIdentifier && ( + <> + <Separator className="w-px bg-border transition-colors hover:bg-text-dim" /> + <Panel defaultSize={60} minSize={30}> + <div data-panel-zone="detailPanel" className={classNames( + "h-full", + focusedPanel === "detailPanel" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + </Panel> + </> + )} + </Group> + )} + </ErrorBoundary> + </div> + ) +} diff --git a/apps/web/app/reader/highlights/page.tsx b/apps/web/app/reader/highlights/page.tsx new file mode 100644 index 0000000..c73c032 --- /dev/null +++ b/apps/web/app/reader/highlights/page.tsx @@ -0,0 +1,16 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { redirect } from "next/navigation" +import { HighlightsContent } from "./_components/highlights-content" + +export default async function HighlightsPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + redirect("/") + } + + return <HighlightsContent /> +} diff --git a/apps/web/app/reader/layout.tsx b/apps/web/app/reader/layout.tsx new file mode 100644 index 0000000..8efedbe --- /dev/null +++ b/apps/web/app/reader/layout.tsx @@ -0,0 +1,14 @@ +import { ReaderLayoutShell } from "./_components/reader-layout-shell" +import { SidebarFooter } from "./_components/sidebar-footer" + +export default function ReaderLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <ReaderLayoutShell sidebarFooter={<SidebarFooter />}> + {children} + </ReaderLayoutShell> + ) +} diff --git a/apps/web/app/reader/page.tsx b/apps/web/app/reader/page.tsx new file mode 100644 index 0000000..4773fd8 --- /dev/null +++ b/apps/web/app/reader/page.tsx @@ -0,0 +1,25 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { ReaderShell } from "./_components/reader-shell" + +export default async function ReaderPage({ + searchParams, +}: { + searchParams: Promise<{ folder?: string; feed?: string; custom_feed?: string }> +}) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + const resolvedSearchParams = await searchParams + + return ( + <ReaderShell + userEmailAddress={user?.email ?? null} + feedFilter="all" + folderIdentifier={resolvedSearchParams.folder} + feedIdentifier={resolvedSearchParams.feed} + customFeedIdentifier={resolvedSearchParams.custom_feed} + /> + ) +} diff --git a/apps/web/app/reader/saved/page.tsx b/apps/web/app/reader/saved/page.tsx new file mode 100644 index 0000000..0ad5ba3 --- /dev/null +++ b/apps/web/app/reader/saved/page.tsx @@ -0,0 +1,16 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { ReaderShell } from "../_components/reader-shell" + +export default async function SavedPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + return ( + <ReaderShell + userEmailAddress={user?.email ?? null} + feedFilter="saved" + /> + ) +} diff --git a/apps/web/app/reader/settings/_components/account-settings.tsx b/apps/web/app/reader/settings/_components/account-settings.tsx new file mode 100644 index 0000000..b9ed8c3 --- /dev/null +++ b/apps/web/app/reader/settings/_components/account-settings.tsx @@ -0,0 +1,368 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { TIER_LIMITS } from "@asa-news/shared" +import { notify } from "@/lib/notify" + +export function AccountSettings() { + const { data: userProfile, isLoading } = useUserProfile() + const [isEditingName, setIsEditingName] = useState(false) + const [editedName, setEditedName] = useState("") + const [isRequestingData, setIsRequestingData] = useState(false) + const [newEmailAddress, setNewEmailAddress] = useState("") + const [emailPassword, setEmailPassword] = useState("") + const [currentPassword, setCurrentPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmNewPassword, setConfirmNewPassword] = useState("") + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + const router = useRouter() + + const updateDisplayName = useMutation({ + mutationFn: async (displayName: string | null) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_profiles") + .update({ display_name: displayName }) + .eq("id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("display name updated") + }, + onError: (error: Error) => { + notify("failed to update display name: " + error.message) + }, + }) + + const updateEmailAddress = useMutation({ + mutationFn: async ({ + emailAddress, + password, + }: { + emailAddress: string + password: string + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user?.email) throw new Error("Not authenticated") + + const { error: signInError } = await supabaseClient.auth.signInWithPassword({ + email: user.email, + password, + }) + + if (signInError) throw new Error("incorrect password") + + const { error } = await supabaseClient.auth.updateUser({ + email: emailAddress, + }) + + if (error) throw error + }, + onSuccess: () => { + setNewEmailAddress("") + setEmailPassword("") + notify("confirmation email sent to your new address") + }, + onError: (error: Error) => { + notify("failed to update email: " + error.message) + }, + }) + + const updatePassword = useMutation({ + mutationFn: async ({ + currentPassword: current, + newPassword: updated, + }: { + currentPassword: string + newPassword: string + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user?.email) throw new Error("Not authenticated") + + const { error: signInError } = await supabaseClient.auth.signInWithPassword({ + email: user.email, + password: current, + }) + + if (signInError) throw new Error("current password is incorrect") + + const { error } = await supabaseClient.auth.updateUser({ + password: updated, + }) + + if (error) throw error + }, + onSuccess: async () => { + setCurrentPassword("") + setNewPassword("") + setConfirmNewPassword("") + notify("password updated — signing out all sessions") + await supabaseClient.auth.signOut({ scope: "global" }) + router.push("/sign-in") + }, + onError: (error: Error) => { + notify("failed to update password: " + error.message) + }, + }) + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading account ...</p> + } + + if (!userProfile) { + return <p className="px-4 py-6 text-text-dim">failed to load account</p> + } + + const tier = userProfile.tier + const tierLimits = TIER_LIMITS[tier] + + async function handleRequestData() { + setIsRequestingData(true) + try { + const response = await fetch("/api/account/data") + if (!response.ok) throw new Error("Export failed") + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json` + anchor.click() + URL.revokeObjectURL(url) + notify("data exported") + } catch { + notify("failed to export data") + } finally { + setIsRequestingData(false) + } + } + + function handleSaveName() { + const trimmedName = editedName.trim() + updateDisplayName.mutate(trimmedName || null) + setIsEditingName(false) + } + + function handleUpdateEmail(event: React.FormEvent) { + event.preventDefault() + const trimmedEmail = newEmailAddress.trim() + if (!trimmedEmail || !emailPassword) return + updateEmailAddress.mutate({ emailAddress: trimmedEmail, password: emailPassword }) + } + + function handleUpdatePassword(event: React.FormEvent) { + event.preventDefault() + if (!currentPassword) { + notify("current password is required") + return + } + if (!newPassword || newPassword !== confirmNewPassword) { + notify("passwords do not match") + return + } + if (newPassword.length < 8) { + notify("password must be at least 8 characters") + return + } + updatePassword.mutate({ currentPassword, newPassword }) + } + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">display name</h3> + {isEditingName ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + placeholder="display name" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveName() + if (event.key === "Escape") setIsEditingName(false) + }} + autoFocus + /> + <button + onClick={handleSaveName} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingName(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <span className="text-text-secondary"> + {userProfile.displayName ?? "not set"} + </span> + <button + onClick={() => { + setEditedName(userProfile.displayName ?? "") + setIsEditingName(true) + }} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + edit + </button> + </div> + )} + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">email address</h3> + <p className="mb-2 text-text-dim"> + {userProfile.email ?? "no email on file"} + </p> + <form onSubmit={handleUpdateEmail} className="space-y-2"> + <input + type="email" + value={newEmailAddress} + onChange={(event) => setNewEmailAddress(event.target.value)} + placeholder="new email address" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={emailPassword} + onChange={(event) => setEmailPassword(event.target.value)} + placeholder="current password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={updateEmailAddress.isPending || !newEmailAddress.trim() || !emailPassword} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + update email + </button> + </form> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">change password</h3> + <form onSubmit={handleUpdatePassword} className="space-y-2"> + <input + type="password" + value={currentPassword} + onChange={(event) => setCurrentPassword(event.target.value)} + placeholder="current password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={newPassword} + onChange={(event) => setNewPassword(event.target.value)} + placeholder="new password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={confirmNewPassword} + onChange={(event) => setConfirmNewPassword(event.target.value)} + placeholder="confirm new password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={updatePassword.isPending || !currentPassword || !newPassword || newPassword !== confirmNewPassword} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + change password + </button> + </form> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">usage</h3> + <div className="space-y-1"> + <UsageRow + label="feeds" + current={userProfile.feedCount} + maximum={tierLimits.maximumFeeds} + /> + <UsageRow + label="folders" + current={userProfile.folderCount} + maximum={tierLimits.maximumFolders} + /> + <UsageRow + label="muted keywords" + current={userProfile.mutedKeywordCount} + maximum={tierLimits.maximumMutedKeywords} + /> + <UsageRow + label="custom feeds" + current={userProfile.customFeedCount} + maximum={tierLimits.maximumCustomFeeds} + /> + </div> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">your data</h3> + <p className="mb-3 text-text-dim"> + download all your data (profile, subscriptions, folders, highlights, saved entries) + </p> + <button + onClick={handleRequestData} + disabled={isRequestingData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isRequestingData ? "exporting ..." : "request all data"} + </button> + </div> + </div> + ) +} + +function UsageRow({ + label, + current, + maximum, +}: { + label: string + current: number + maximum: number +}) { + const isNearLimit = current >= maximum * 0.8 + const isAtLimit = current >= maximum + + return ( + <div className="flex items-center justify-between py-1"> + <span className="text-text-secondary">{label}</span> + <span + className={ + isAtLimit + ? "text-status-error" + : isNearLimit + ? "text-text-primary" + : "text-text-dim" + } + > + {current} / {maximum} + </span> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/api-settings.tsx b/apps/web/app/reader/settings/_components/api-settings.tsx new file mode 100644 index 0000000..cb69958 --- /dev/null +++ b/apps/web/app/reader/settings/_components/api-settings.tsx @@ -0,0 +1,529 @@ +"use client" + +import { useState, useEffect } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { notify } from "@/lib/notify" + +interface ApiKey { + keyIdentifier: string + keyPrefix: string + label: string | null + createdAt: string + lastUsedAt: string | null +} + +interface WebhookConfiguration { + webhookUrl: string | null + webhookSecret: string | null + webhookEnabled: boolean + consecutiveFailures: number +} + +function useApiKeys() { + return useQuery({ + queryKey: ["apiKeys"], + queryFn: async () => { + const response = await fetch("/api/v1/keys") + if (!response.ok) throw new Error("Failed to load API keys") + const data = await response.json() + return data.keys as ApiKey[] + }, + }) +} + +function useCreateApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (label: string | null) => { + const response = await fetch("/api/v1/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create API key") + } + return response.json() as Promise<{ + fullKey: string + keyPrefix: string + keyIdentifier: string + }> + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useRevokeApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (keyIdentifier: string) => { + const response = await fetch(`/api/v1/keys/${keyIdentifier}`, { + method: "DELETE", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to revoke API key") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useWebhookConfig() { + return useQuery({ + queryKey: ["webhookConfig"], + queryFn: async () => { + const response = await fetch("/api/webhook-config") + if (!response.ok) throw new Error("Failed to load webhook config") + return response.json() as Promise<WebhookConfiguration> + }, + }) +} + +function useUpdateWebhookConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ( + updates: Partial<{ + webhookUrl: string + webhookSecret: string + webhookEnabled: boolean + }> + ) => { + const response = await fetch("/api/webhook-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to update webhook config") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["webhookConfig"] }) + }, + }) +} + +function useTestWebhook() { + return useMutation({ + mutationFn: async () => { + const response = await fetch("/api/webhook-config/test", { + method: "POST", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to send test webhook") + } + return response.json() as Promise<{ + delivered: boolean + statusCode?: number + error?: string + }> + }, + }) +} + +function ApiKeysSection() { + const { data: apiKeys, isLoading } = useApiKeys() + const createApiKey = useCreateApiKey() + const revokeApiKey = useRevokeApiKey() + const [newKeyLabel, setNewKeyLabel] = useState("") + const [revealedKey, setRevealedKey] = useState<string | null>(null) + const [confirmRevokeIdentifier, setConfirmRevokeIdentifier] = useState< + string | null + >(null) + + function handleCreateKey() { + createApiKey.mutate(newKeyLabel.trim() || null, { + onSuccess: (data) => { + setRevealedKey(data.fullKey) + setNewKeyLabel("") + notify("API key created") + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleCopyKey() { + if (revealedKey) { + navigator.clipboard.writeText(revealedKey) + notify("API key copied to clipboard") + } + } + + function handleRevokeKey(keyIdentifier: string) { + revokeApiKey.mutate(keyIdentifier, { + onSuccess: () => { + notify("API key revoked") + setConfirmRevokeIdentifier(null) + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">API keys</h3> + <p className="mb-3 text-text-dim"> + use API keys to authenticate requests to the REST API + </p> + + {revealedKey && ( + <div className="mb-4 border border-status-warning p-3"> + <p className="mb-2 text-text-secondary"> + copy this key now — it will not be shown again + </p> + <div className="flex items-center gap-2"> + <code className="min-w-0 flex-1 overflow-x-auto bg-background-tertiary px-2 py-1 text-text-primary"> + {revealedKey} + </code> + <button + onClick={handleCopyKey} + className="shrink-0 border border-border px-3 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + copy + </button> + </div> + <button + onClick={() => setRevealedKey(null)} + className="mt-2 text-text-dim transition-colors hover:text-text-secondary" + > + dismiss + </button> + </div> + )} + + {isLoading ? ( + <p className="text-text-dim">loading keys ...</p> + ) : ( + <> + {apiKeys && apiKeys.length > 0 && ( + <div className="mb-4 border border-border"> + {apiKeys.map((apiKey) => ( + <div + key={apiKey.keyIdentifier} + className="flex items-center justify-between border-b border-border px-3 py-2 last:border-b-0" + > + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <code className="text-text-primary"> + {apiKey.keyPrefix}... + </code> + {apiKey.label && ( + <span className="text-text-dim">{apiKey.label}</span> + )} + </div> + <div className="text-text-dim"> + created{" "} + {new Date(apiKey.createdAt).toLocaleDateString()} + {apiKey.lastUsedAt && + ` · last used ${new Date(apiKey.lastUsedAt).toLocaleDateString()}`} + </div> + </div> + {confirmRevokeIdentifier === apiKey.keyIdentifier ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">revoke?</span> + <button + onClick={() => + handleRevokeKey(apiKey.keyIdentifier) + } + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setConfirmRevokeIdentifier(null)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => + setConfirmRevokeIdentifier(apiKey.keyIdentifier) + } + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + revoke + </button> + )} + </div> + ))} + </div> + )} + + {(!apiKeys || apiKeys.length < 5) && ( + <div className="flex items-center gap-2"> + <input + type="text" + value={newKeyLabel} + onChange={(event) => setNewKeyLabel(event.target.value)} + placeholder="key label (optional)" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleCreateKey() + }} + /> + <button + onClick={handleCreateKey} + disabled={createApiKey.isPending} + className="shrink-0 border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {createApiKey.isPending ? "creating ..." : "create key"} + </button> + </div> + )} + </> + )} + </div> + ) +} + +function WebhookSection() { + const { data: webhookConfig, isLoading } = useWebhookConfig() + const updateWebhookConfig = useUpdateWebhookConfig() + const testWebhook = useTestWebhook() + const [webhookUrl, setWebhookUrl] = useState("") + const [webhookSecret, setWebhookSecret] = useState("") + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + + useEffect(() => { + if (webhookConfig) { + setWebhookUrl(webhookConfig.webhookUrl ?? "") + setWebhookSecret(webhookConfig.webhookSecret ?? "") + } + }, [webhookConfig]) + + function handleSaveWebhookConfig() { + updateWebhookConfig.mutate( + { + webhookUrl: webhookUrl.trim(), + webhookSecret: webhookSecret.trim(), + }, + { + onSuccess: () => { + notify("webhook configuration saved") + setHasUnsavedChanges(false) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleToggleEnabled() { + if (!webhookConfig) return + + updateWebhookConfig.mutate( + { webhookEnabled: !webhookConfig.webhookEnabled }, + { + onSuccess: () => { + notify( + webhookConfig.webhookEnabled + ? "webhooks disabled" + : "webhooks enabled" + ) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleTestWebhook() { + testWebhook.mutate(undefined, { + onSuccess: (data) => { + if (data.delivered) { + notify(`test webhook delivered (status ${data.statusCode})`) + } else { + notify(`test webhook failed: ${data.error}`) + } + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleGenerateSecret() { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + const generatedSecret = Array.from(array) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + setWebhookSecret(generatedSecret) + setHasUnsavedChanges(true) + } + + if (isLoading) { + return <p className="text-text-dim">loading webhook configuration ...</p> + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">webhooks</h3> + <p className="mb-3 text-text-dim"> + receive HTTP POST notifications when new entries arrive in your + subscribed feeds + </p> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary">webhook URL</label> + <input + type="url" + value={webhookUrl} + onChange={(event) => { + setWebhookUrl(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="https://example.com/webhook" + className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary"> + signing secret + </label> + <div className="flex items-center gap-2"> + <input + type="text" + value={webhookSecret} + onChange={(event) => { + setWebhookSecret(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="optional HMAC-SHA256 signing secret" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + onClick={handleGenerateSecret} + className="shrink-0 border border-border px-3 py-1.5 text-text-secondary transition-colors hover:text-text-primary" + > + generate + </button> + </div> + </div> + + <div className="mb-4 flex items-center gap-4"> + <button + onClick={handleSaveWebhookConfig} + disabled={!hasUnsavedChanges || updateWebhookConfig.isPending} + className="border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {updateWebhookConfig.isPending ? "saving ..." : "save"} + </button> + <button + onClick={handleToggleEnabled} + disabled={updateWebhookConfig.isPending} + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {webhookConfig?.webhookEnabled ? "disable" : "enable"} + </button> + <button + onClick={handleTestWebhook} + disabled={ + testWebhook.isPending || + !webhookConfig?.webhookEnabled || + !webhookConfig?.webhookUrl + } + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {testWebhook.isPending ? "sending ..." : "send test"} + </button> + </div> + + {webhookConfig && ( + <div className="flex items-center gap-3 text-text-dim"> + <span> + status:{" "} + <span + className={ + webhookConfig.webhookEnabled + ? "text-text-primary" + : "text-text-dim" + } + > + {webhookConfig.webhookEnabled ? "enabled" : "disabled"} + </span> + </span> + {webhookConfig.consecutiveFailures > 0 && ( + <span className="text-status-warning"> + {webhookConfig.consecutiveFailures} consecutive failure + {webhookConfig.consecutiveFailures !== 1 && "s"} + </span> + )} + </div> + )} + </div> + ) +} + +export function ApiSettings() { + const { data: userProfile, isLoading } = useUserProfile() + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading API settings ...</p> + } + + if (!userProfile) { + return ( + <p className="px-4 py-6 text-text-dim">failed to load API settings</p> + ) + } + + if (userProfile.tier !== "developer") { + return ( + <div className="px-4 py-3"> + <h3 className="mb-2 text-text-primary">developer API</h3> + <p className="mb-3 text-text-dim"> + the developer plan includes a read-only REST API and webhook push + notifications. upgrade to developer to access these features. + </p> + </div> + ) + } + + return ( + <div className="px-4 py-3"> + <ApiKeysSection /> + <WebhookSection /> + + <div> + <h3 className="mb-2 text-text-primary">API documentation</h3> + <p className="mb-3 text-text-dim"> + authenticate requests with an API key in the Authorization header: + </p> + <code className="block bg-background-tertiary px-3 py-2 text-text-secondary"> + Authorization: Bearer asn_your_key_here + </code> + <div className="mt-3 space-y-1 text-text-dim"> + <p>GET /api/v1/profile — your account info and limits</p> + <p>GET /api/v1/feeds — your subscribed feeds</p> + <p>GET /api/v1/folders — your folders</p> + <p> + GET /api/v1/entries — entries with ?cursor, ?limit, ?feedIdentifier, + ?readStatus, ?savedStatus filters + </p> + <p>GET /api/v1/entries/:id — single entry with full content</p> + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/appearance-settings.tsx b/apps/web/app/reader/settings/_components/appearance-settings.tsx new file mode 100644 index 0000000..9c0e214 --- /dev/null +++ b/apps/web/app/reader/settings/_components/appearance-settings.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useTheme } from "next-themes" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +export function AppearanceSettings() { + const { theme, setTheme } = useTheme() + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const displayDensity = useUserInterfaceStore( + (state) => state.displayDensity + ) + const setDisplayDensity = useUserInterfaceStore( + (state) => state.setDisplayDensity + ) + const showFeedFavicons = useUserInterfaceStore( + (state) => state.showFeedFavicons + ) + const setShowFeedFavicons = useUserInterfaceStore( + (state) => state.setShowFeedFavicons + ) + const focusFollowsInteraction = useUserInterfaceStore( + (state) => state.focusFollowsInteraction + ) + const setFocusFollowsInteraction = useUserInterfaceStore( + (state) => state.setFocusFollowsInteraction + ) + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">theme</h3> + <p className="mb-3 text-text-dim"> + controls the colour scheme of the application + </p> + <select + value={theme ?? "system"} + onChange={(event) => setTheme(event.target.value)} + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="system">system</option> + <option value="light">light</option> + <option value="dark">dark</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">display density</h3> + <p className="mb-3 text-text-dim"> + controls the overall text size and spacing + </p> + <select + value={displayDensity} + onChange={(event) => + setDisplayDensity( + event.target.value as "compact" | "default" | "spacious" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="compact">compact</option> + <option value="default">default</option> + <option value="spacious">spacious</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">entry list view</h3> + <p className="mb-3 text-text-dim"> + controls how entries are displayed in the list + </p> + <select + value={entryListViewMode} + onChange={(event) => + setEntryListViewMode( + event.target.value as "compact" | "comfortable" | "expanded" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="compact">compact</option> + <option value="comfortable">comfortable</option> + <option value="expanded">expanded</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">feed favicons</h3> + <p className="mb-3 text-text-dim"> + show website icons next to feed names in the sidebar + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={showFeedFavicons} + onChange={(event) => setShowFeedFavicons(event.target.checked)} + className="accent-text-primary" + /> + <span>show favicons</span> + </label> + </div> + <div> + <h3 className="mb-2 text-text-primary">focus follows interaction</h3> + <p className="mb-3 text-text-dim"> + automatically move keyboard panel focus to the last pane you + interacted with (clicked or scrolled) + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={focusFollowsInteraction} + onChange={(event) => + setFocusFollowsInteraction(event.target.checked) + } + className="accent-text-primary" + /> + <span>enable focus follows interaction</span> + </label> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/billing-settings.tsx b/apps/web/app/reader/settings/_components/billing-settings.tsx new file mode 100644 index 0000000..e49720a --- /dev/null +++ b/apps/web/app/reader/settings/_components/billing-settings.tsx @@ -0,0 +1,301 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useSearchParams } from "next/navigation" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { TIER_LIMITS } from "@asa-news/shared" +import { classNames } from "@/lib/utilities" +import { notify } from "@/lib/notify" + +function useCreateCheckoutSession() { + return useMutation({ + mutationFn: async ({ + billingInterval, + targetTier, + }: { + billingInterval: "monthly" | "yearly" + targetTier: "pro" | "developer" + }) => { + const response = await fetch("/api/billing/create-checkout-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ billingInterval, targetTier }), + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create checkout session") + } + + const data = await response.json() + return data as { url?: string; upgraded?: boolean } + }, + }) +} + +function useCreatePortalSession() { + return useMutation({ + mutationFn: async () => { + const response = await fetch("/api/billing/create-portal-session", { + method: "POST", + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create portal session") + } + + const data = await response.json() + return data as { url: string } + }, + }) +} + +const PRO_FEATURES = [ + `${TIER_LIMITS.pro.maximumFeeds} feeds`, + `${Number.isFinite(TIER_LIMITS.pro.historyRetentionDays) ? TIER_LIMITS.pro.historyRetentionDays.toLocaleString() + " days" : "unlimited"} history retention`, + `${TIER_LIMITS.pro.refreshIntervalSeconds / 60}-minute refresh interval`, + "authenticated feeds", + "OPML export", + "manual feed refresh", +] + +const DEVELOPER_FEATURES = [ + `${TIER_LIMITS.developer.maximumFeeds} feeds`, + "everything in pro", + "read-only REST API", + "webhook push notifications", +] + +function UpgradeCard({ + targetTier, + features, + monthlyPrice, + yearlyPrice, +}: { + targetTier: "pro" | "developer" + features: string[] + monthlyPrice: string + yearlyPrice: string +}) { + const [billingInterval, setBillingInterval] = useState< + "monthly" | "yearly" + >("yearly") + const queryClient = useQueryClient() + const createCheckoutSession = useCreateCheckoutSession() + + function handleUpgrade() { + createCheckoutSession.mutate( + { billingInterval, targetTier }, + { + onSuccess: (data) => { + if (data.upgraded) { + queryClient.invalidateQueries({ + queryKey: queryKeys.userProfile.all, + }) + notify(`upgraded to ${targetTier}!`) + } else if (data.url) { + window.location.href = data.url + } + }, + onError: (error: Error) => { + notify("failed to start checkout: " + error.message) + }, + } + ) + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">upgrade to {targetTier}</h3> + <ul className="mb-4 space-y-1"> + {features.map((feature) => ( + <li key={feature} className="text-text-secondary"> + + {feature} + </li> + ))} + </ul> + <div className="mb-4 flex items-center gap-2"> + <button + type="button" + onClick={() => setBillingInterval("monthly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "monthly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + {monthlyPrice} / month + </button> + <button + type="button" + onClick={() => setBillingInterval("yearly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "yearly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + {yearlyPrice} / year + </button> + </div> + <button + onClick={handleUpgrade} + disabled={createCheckoutSession.isPending} + className="border border-text-primary px-4 py-2 text-text-primary transition-colors hover:bg-text-primary hover:text-background-primary disabled:opacity-50" + > + {createCheckoutSession.isPending + ? "redirecting ..." + : `upgrade to ${targetTier}`} + </button> + </div> + ) +} + +export function BillingSettings() { + const { data: userProfile, isLoading } = useUserProfile() + const queryClient = useQueryClient() + const searchParameters = useSearchParams() + const hasShownSuccessToast = useRef(false) + + const createPortalSession = useCreatePortalSession() + + useEffect(() => { + if ( + searchParameters.get("billing") === "success" && + !hasShownSuccessToast.current + ) { + hasShownSuccessToast.current = true + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("subscription activated!") + const url = new URL(window.location.href) + url.searchParams.delete("billing") + window.history.replaceState({}, "", url.pathname) + } + }, [searchParameters, queryClient]) + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading billing ...</p> + } + + if (!userProfile) { + return <p className="px-4 py-6 text-text-dim">failed to load billing</p> + } + + function handleManageSubscription() { + createPortalSession.mutate(undefined, { + onSuccess: (data) => { + window.location.href = data.url + }, + onError: (error: Error) => { + notify("failed to open billing portal: " + error.message) + }, + }) + } + + const isPaidTier = + userProfile.tier === "pro" || userProfile.tier === "developer" + const isCancelling = + userProfile.stripeSubscriptionStatus === "active" && + isPaidTier && + userProfile.stripeCurrentPeriodEnd !== null + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">current plan</h3> + <span className="border border-border px-2 py-1 text-text-secondary"> + {userProfile.tier} + </span> + {userProfile.stripeSubscriptionStatus && ( + <span className="ml-2 text-text-dim"> + ({userProfile.stripeSubscriptionStatus}) + </span> + )} + </div> + + {userProfile.tier === "free" && ( + <> + <UpgradeCard + targetTier="pro" + features={PRO_FEATURES} + monthlyPrice="$3" + yearlyPrice="$30" + /> + <UpgradeCard + targetTier="developer" + features={DEVELOPER_FEATURES} + monthlyPrice="$6" + yearlyPrice="$60" + /> + </> + )} + + {userProfile.tier === "pro" && ( + <> + <UpgradeCard + targetTier="developer" + features={DEVELOPER_FEATURES} + monthlyPrice="$6" + yearlyPrice="$60" + /> + <div className="mb-6"> + {isCancelling && userProfile.stripeCurrentPeriodEnd && ( + <p className="mb-3 text-text-secondary"> + your pro plan is active until{" "} + {new Date( + userProfile.stripeCurrentPeriodEnd + ).toLocaleDateString()} + </p> + )} + {userProfile.stripeSubscriptionStatus === "past_due" && ( + <p className="mb-3 text-status-error"> + payment failed — please update your payment method + </p> + )} + <button + onClick={handleManageSubscription} + disabled={createPortalSession.isPending} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50" + > + {createPortalSession.isPending + ? "redirecting ..." + : "manage subscription"} + </button> + </div> + </> + )} + + {userProfile.tier === "developer" && ( + <div className="mb-6"> + {isCancelling && userProfile.stripeCurrentPeriodEnd && ( + <p className="mb-3 text-text-secondary"> + your developer plan is active until{" "} + {new Date( + userProfile.stripeCurrentPeriodEnd + ).toLocaleDateString()} + </p> + )} + {userProfile.stripeSubscriptionStatus === "past_due" && ( + <p className="mb-3 text-status-error"> + payment failed — please update your payment method + </p> + )} + <button + onClick={handleManageSubscription} + disabled={createPortalSession.isPending} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50" + > + {createPortalSession.isPending + ? "redirecting ..." + : "manage subscription"} + </button> + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx new file mode 100644 index 0000000..b7b588b --- /dev/null +++ b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx @@ -0,0 +1,283 @@ +"use client" + +import { useState } from "react" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" +import { + useCreateCustomFeed, + useUpdateCustomFeed, + useDeleteCustomFeed, +} from "@/lib/queries/use-custom-feed-mutations" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function CustomFeedsSettings() { + const { data: customFeeds, isLoading } = useCustomFeeds() + const { data: subscriptionsData } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const createCustomFeed = useCreateCustomFeed() + + const [newName, setNewName] = useState("") + const [newKeywords, setNewKeywords] = useState("") + const [newMatchMode, setNewMatchMode] = useState<"and" | "or">("or") + const [newSourceFolderId, setNewSourceFolderId] = useState<string>("") + + const folders = subscriptionsData?.folders ?? [] + const tier = userProfile?.tier ?? "free" + const maximumCustomFeeds = TIER_LIMITS[tier].maximumCustomFeeds + + function handleCreate(event: React.FormEvent) { + event.preventDefault() + const trimmedName = newName.trim() + const trimmedKeywords = newKeywords.trim() + + if (!trimmedName || !trimmedKeywords) return + + createCustomFeed.mutate({ + name: trimmedName, + query: trimmedKeywords, + matchMode: newMatchMode, + sourceFolderIdentifier: newSourceFolderId || null, + }) + + setNewName("") + setNewKeywords("") + setNewMatchMode("or") + setNewSourceFolderId("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading custom feeds ...</p> + } + + const feedsList = customFeeds ?? [] + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-2 text-text-dim"> + {feedsList.length} / {maximumCustomFeeds} custom feeds used + </p> + <form onSubmit={handleCreate} className="space-y-2"> + <input + type="text" + value={newName} + onChange={(event) => setNewName(event.target.value)} + placeholder="feed name" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="text" + value={newKeywords} + onChange={(event) => setNewKeywords(event.target.value)} + placeholder="keywords (space-separated)" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <div className="flex gap-2"> + <select + value={newMatchMode} + onChange={(event) => + setNewMatchMode(event.target.value as "and" | "or") + } + className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none" + > + <option value="or">match any keyword</option> + <option value="and">match all keywords</option> + </select> + <select + value={newSourceFolderId} + onChange={(event) => setNewSourceFolderId(event.target.value)} + className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none" + > + <option value="">all feeds</option> + {folders.map((folder) => ( + <option key={folder.folderIdentifier} value={folder.folderIdentifier}> + {folder.name} + </option> + ))} + </select> + <button + type="submit" + disabled={ + createCustomFeed.isPending || + !newName.trim() || + !newKeywords.trim() + } + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + create + </button> + </div> + </form> + </div> + {feedsList.length === 0 ? ( + <p className="px-4 py-6 text-text-dim">no custom feeds yet</p> + ) : ( + feedsList.map((customFeed) => ( + <CustomFeedRow + key={customFeed.identifier} + customFeed={customFeed} + folders={folders} + /> + )) + )} + </div> + ) +} + +function CustomFeedRow({ + customFeed, + folders, +}: { + customFeed: { + identifier: string + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + } + folders: { folderIdentifier: string; name: string }[] +}) { + const updateCustomFeed = useUpdateCustomFeed() + const deleteCustomFeed = useDeleteCustomFeed() + const [isEditing, setIsEditing] = useState(false) + const [editedName, setEditedName] = useState(customFeed.name) + const [editedKeywords, setEditedKeywords] = useState(customFeed.query) + const [editedMatchMode, setEditedMatchMode] = useState(customFeed.matchMode) + const [editedSourceFolderId, setEditedSourceFolderId] = useState( + customFeed.sourceFolderIdentifier ?? "" + ) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + function handleSave() { + const trimmedName = editedName.trim() + const trimmedKeywords = editedKeywords.trim() + + if (!trimmedName || !trimmedKeywords) return + + updateCustomFeed.mutate({ + customFeedIdentifier: customFeed.identifier, + name: trimmedName, + query: trimmedKeywords, + matchMode: editedMatchMode, + sourceFolderIdentifier: editedSourceFolderId || null, + }) + setIsEditing(false) + } + + const sourceFolderName = customFeed.sourceFolderIdentifier + ? folders.find( + (folder) => + folder.folderIdentifier === customFeed.sourceFolderIdentifier + )?.name ?? "unknown folder" + : "all feeds" + + return ( + <div className="border-b border-border px-4 py-3 last:border-b-0"> + {isEditing ? ( + <div className="space-y-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + autoFocus + /> + <input + type="text" + value={editedKeywords} + onChange={(event) => setEditedKeywords(event.target.value)} + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + /> + <div className="flex gap-2"> + <select + value={editedMatchMode} + onChange={(event) => + setEditedMatchMode(event.target.value as "and" | "or") + } + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="or">match any</option> + <option value="and">match all</option> + </select> + <select + value={editedSourceFolderId} + onChange={(event) => setEditedSourceFolderId(event.target.value)} + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="">all feeds</option> + {folders.map((folder) => ( + <option + key={folder.folderIdentifier} + value={folder.folderIdentifier} + > + {folder.name} + </option> + ))} + </select> + </div> + <div className="flex gap-2"> + <button + onClick={handleSave} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditing(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + ) : ( + <div> + <div className="flex items-center justify-between"> + <span className="text-text-primary">{customFeed.name}</span> + <div className="flex items-center gap-2"> + <button + onClick={() => setIsEditing(true)} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + edit + </button> + {showDeleteConfirm ? ( + <div className="flex items-center gap-1"> + <button + onClick={() => { + deleteCustomFeed.mutate({ + customFeedIdentifier: customFeed.identifier, + }) + setShowDeleteConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowDeleteConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + delete + </button> + )} + </div> + </div> + <p className="text-text-dim"> + keywords: {customFeed.query} ({customFeed.matchMode === "and" ? "all" : "any"}) + </p> + <p className="text-text-dim">source: {sourceFolderName}</p> + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/danger-zone-settings.tsx b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx new file mode 100644 index 0000000..76c48d4 --- /dev/null +++ b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx @@ -0,0 +1,156 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation } from "@tanstack/react-query" +import { useUnsubscribeAll } from "@/lib/queries/use-subscription-mutations" +import { useDeleteAllFolders } from "@/lib/queries/use-folder-mutations" +import { notify } from "@/lib/notify" + +export function DangerZoneSettings() { + const router = useRouter() + const unsubscribeAll = useUnsubscribeAll() + const deleteAllFolders = useDeleteAllFolders() + const [showDeleteSubsConfirm, setShowDeleteSubsConfirm] = useState(false) + const [showDeleteFoldersConfirm, setShowDeleteFoldersConfirm] = useState(false) + const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false) + const [deleteConfirmText, setDeleteConfirmText] = useState("") + + const deleteAccount = useMutation({ + mutationFn: async () => { + const response = await fetch("/api/account", { method: "DELETE" }) + if (!response.ok) throw new Error("Failed to delete account") + }, + onSuccess: () => { + router.push("/sign-in") + }, + onError: (error: Error) => { + notify("failed to delete account: " + error.message) + }, + }) + + return ( + <div className="px-4 py-3"> + <p className="mb-6 text-text-dim"> + these actions are irreversible. proceed with caution. + </p> + + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">remove all subscriptions</h3> + <p className="mb-2 text-text-dim"> + unsubscribe from every feed. entries will remain but no new ones will be fetched. + </p> + {showDeleteSubsConfirm ? ( + <div className="flex items-center gap-2"> + <span className="text-status-error">are you sure?</span> + <button + onClick={() => { + unsubscribeAll.mutate() + setShowDeleteSubsConfirm(false) + }} + disabled={unsubscribeAll.isPending} + className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + yes, remove all + </button> + <button + onClick={() => setShowDeleteSubsConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteSubsConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + remove all subscriptions + </button> + )} + </div> + + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">delete all folders</h3> + <p className="mb-2 text-text-dim"> + remove all folders. feeds will be ungrouped but not unsubscribed. + </p> + {showDeleteFoldersConfirm ? ( + <div className="flex items-center gap-2"> + <span className="text-status-error">are you sure?</span> + <button + onClick={() => { + deleteAllFolders.mutate() + setShowDeleteFoldersConfirm(false) + }} + disabled={deleteAllFolders.isPending} + className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + yes, delete all + </button> + <button + onClick={() => setShowDeleteFoldersConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteFoldersConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + delete all folders + </button> + )} + </div> + + <div> + <h3 className="mb-2 text-text-primary">delete account</h3> + <p className="mb-2 text-text-dim"> + permanently delete your account and all associated data. this cannot be undone. + </p> + {showDeleteAccountConfirm ? ( + <div> + <p className="mb-2 text-status-error"> + type DELETE to confirm account deletion. + </p> + <div className="flex items-center gap-2"> + <input + type="text" + value={deleteConfirmText} + onChange={(event) => setDeleteConfirmText(event.target.value)} + placeholder="type DELETE" + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-status-error" + autoFocus + /> + <button + onClick={() => deleteAccount.mutate()} + disabled={deleteConfirmText !== "DELETE" || deleteAccount.isPending} + className="border border-status-error px-4 py-2 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + {deleteAccount.isPending ? "deleting ..." : "confirm delete"} + </button> + <button + onClick={() => { + setShowDeleteAccountConfirm(false) + setDeleteConfirmText("") + }} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + ) : ( + <button + onClick={() => setShowDeleteAccountConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + delete account and all data + </button> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/folders-settings.tsx b/apps/web/app/reader/settings/_components/folders-settings.tsx new file mode 100644 index 0000000..8a0012e --- /dev/null +++ b/apps/web/app/reader/settings/_components/folders-settings.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { + useCreateFolder, + useRenameFolder, + useDeleteFolder, +} from "@/lib/queries/use-folder-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function FoldersSettings() { + const [newFolderName, setNewFolderName] = useState("") + const [searchQuery, setSearchQuery] = useState("") + const { data: subscriptionsData, isLoading } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const createFolder = useCreateFolder() + const renameFolder = useRenameFolder() + const deleteFolder = useDeleteFolder() + + const folders = subscriptionsData?.folders ?? [] + const subscriptions = subscriptionsData?.subscriptions ?? [] + const tier = userProfile?.tier ?? "free" + const tierLimits = TIER_LIMITS[tier] + + function feedCountForFolder(folderIdentifier: string): number { + return subscriptions.filter( + (subscription) => subscription.folderIdentifier === folderIdentifier + ).length + } + + function handleCreateFolder(event: React.FormEvent) { + event.preventDefault() + const trimmedName = newFolderName.trim() + + if (!trimmedName) return + + createFolder.mutate({ name: trimmedName }) + setNewFolderName("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading folders ...</p> + } + + const normalizedQuery = searchQuery.toLowerCase().trim() + const filteredFolders = normalizedQuery + ? folders.filter((folder) => folder.name.toLowerCase().includes(normalizedQuery)) + : folders + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-2 text-text-dim"> + {folders.length} / {tierLimits.maximumFolders} folders used + </p> + <form onSubmit={handleCreateFolder} className="mb-2 flex gap-2"> + <input + type="text" + value={newFolderName} + onChange={(event) => setNewFolderName(event.target.value)} + placeholder="new folder name" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={createFolder.isPending || !newFolderName.trim()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + create + </button> + </form> + {folders.length > 5 && ( + <input + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="search folders..." + className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + )} + </div> + {filteredFolders.length === 0 ? ( + <p className="px-4 py-6 text-text-dim"> + {folders.length === 0 ? "no folders yet" : "no folders match your search"} + </p> + ) : ( + <div> + {filteredFolders.map((folder) => ( + <FolderRow + key={folder.folderIdentifier} + folderIdentifier={folder.folderIdentifier} + name={folder.name} + feedCount={feedCountForFolder(folder.folderIdentifier)} + onRename={(name) => + renameFolder.mutate({ + folderIdentifier: folder.folderIdentifier, + name, + }) + } + onDelete={() => + deleteFolder.mutate({ + folderIdentifier: folder.folderIdentifier, + }) + } + /> + ))} + </div> + )} + </div> + ) +} + +function FolderRow({ + folderIdentifier, + name, + feedCount, + onRename, + onDelete, +}: { + folderIdentifier: string + name: string + feedCount: number + onRename: (name: string) => void + onDelete: () => void +}) { + const [isEditing, setIsEditing] = useState(false) + const [editedName, setEditedName] = useState(name) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + function handleSave() { + const trimmedName = editedName.trim() + + if (trimmedName && trimmedName !== name) { + onRename(trimmedName) + } + + setIsEditing(false) + } + + return ( + <div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0"> + <div className="min-w-0 flex-1"> + {isEditing ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSave() + if (event.key === "Escape") setIsEditing(false) + }} + autoFocus + /> + <button + onClick={handleSave} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => { + setEditedName(name) + setIsEditing(false) + }} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <span className="text-text-primary">{name}</span> + <span className="text-text-dim"> + ({feedCount} feed{feedCount !== 1 && "s"}) + </span> + <button + onClick={() => setIsEditing(true)} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + rename + </button> + </div> + )} + </div> + {showDeleteConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim"> + {feedCount > 0 ? "has feeds, delete?" : "delete?"} + </span> + <button + onClick={() => { + onDelete() + setShowDeleteConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowDeleteConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + delete + </button> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/import-export-settings.tsx b/apps/web/app/reader/settings/_components/import-export-settings.tsx new file mode 100644 index 0000000..efb3f09 --- /dev/null +++ b/apps/web/app/reader/settings/_components/import-export-settings.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState, useRef } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useSubscribeToFeed } from "@/lib/queries/use-subscribe-to-feed" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { downloadOpml, parseOpml } from "@/lib/opml" +import type { ParsedOpmlGroup } from "@/lib/opml" +import { notify } from "@/lib/notify" + +export function ImportExportSettings() { + const { data: subscriptionsData } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const subscribeToFeed = useSubscribeToFeed() + const [parsedGroups, setParsedGroups] = useState<ParsedOpmlGroup[] | null>( + null + ) + const [isImporting, setIsImporting] = useState(false) + const [isExportingData, setIsExportingData] = useState(false) + const fileInputReference = useRef<HTMLInputElement>(null) + + const tier = userProfile?.tier ?? "free" + + function handleExport() { + if (!subscriptionsData) return + + downloadOpml(subscriptionsData.subscriptions, subscriptionsData.folders) + notify("subscriptions exported") + } + + function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + + if (!file) return + + const reader = new FileReader() + reader.onload = (loadEvent) => { + const xmlString = loadEvent.target?.result as string + + try { + const groups = parseOpml(xmlString) + setParsedGroups(groups) + } catch { + notify("failed to parse OPML file") + } + } + reader.readAsText(file) + + if (fileInputReference.current) { + fileInputReference.current.value = "" + } + } + + async function handleImport() { + if (!parsedGroups) return + + setIsImporting(true) + let importedCount = 0 + let failedCount = 0 + + for (const group of parsedGroups) { + for (const feed of group.feeds) { + try { + await new Promise<void>((resolve, reject) => { + subscribeToFeed.mutate( + { + feedUrl: feed.url, + customTitle: feed.title || null, + }, + { + onSuccess: () => { + importedCount++ + resolve() + }, + onError: (error) => { + failedCount++ + resolve() + }, + } + ) + }) + } catch { + failedCount++ + } + } + } + + setIsImporting(false) + setParsedGroups(null) + + if (failedCount > 0) { + notify(`imported ${importedCount} feeds, ${failedCount} failed`) + } else { + notify(`imported ${importedCount} feeds`) + } + } + + async function handleDataExport() { + setIsExportingData(true) + try { + const response = await fetch("/api/export") + if (!response.ok) throw new Error("Export failed") + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `asa-news-export-${new Date().toISOString().slice(0, 10)}.json` + anchor.click() + URL.revokeObjectURL(url) + notify("data exported") + } catch { + notify("failed to export data") + } finally { + setIsExportingData(false) + } + } + + const totalFeedsInImport = + parsedGroups?.reduce((sum, group) => sum + group.feeds.length, 0) ?? 0 + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">export OPML</h3> + <p className="mb-3 text-text-dim"> + download your subscriptions as an OPML file + </p> + <button + onClick={handleExport} + disabled={!subscriptionsData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + export OPML + </button> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">export data</h3> + <p className="mb-3 text-text-dim"> + {tier === "pro" || tier === "developer" + ? "download all your data as JSON (subscriptions, folders, saved entries)" + : "download your saved entries as JSON (upgrade to pro for full export)"} + </p> + <button + onClick={handleDataExport} + disabled={isExportingData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isExportingData ? "exporting..." : "export data"} + </button> + </div> + <div> + <h3 className="mb-2 text-text-primary">import</h3> + <p className="mb-3 text-text-dim"> + import subscriptions from an OPML file + </p> + {parsedGroups === null ? ( + <div> + <input + ref={fileInputReference} + type="file" + accept=".opml,.xml" + onChange={handleFileSelect} + className="hidden" + /> + <button + onClick={() => fileInputReference.current?.click()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border" + > + select OPML file + </button> + </div> + ) : ( + <div> + <p className="mb-3 text-text-secondary"> + found {totalFeedsInImport} feed + {totalFeedsInImport !== 1 && "s"} to import: + </p> + <div className="mb-3 max-h-60 overflow-y-auto border border-border"> + {parsedGroups.map((group, groupIndex) => ( + <div key={groupIndex}> + {group.folderName && ( + <div className="bg-background-tertiary px-3 py-1 text-text-secondary"> + {group.folderName} + </div> + )} + {group.feeds.map((feed, feedIndex) => ( + <div + key={feedIndex} + className="border-b border-border px-3 py-2 last:border-b-0" + > + <p className="truncate text-text-primary">{feed.title}</p> + <p className="truncate text-text-dim">{feed.url}</p> + </div> + ))} + </div> + ))} + </div> + <div className="flex gap-2"> + <button + onClick={() => setParsedGroups(null)} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + cancel + </button> + <button + onClick={handleImport} + disabled={isImporting} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isImporting + ? "importing..." + : `import ${totalFeedsInImport} feeds`} + </button> + </div> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx new file mode 100644 index 0000000..bef4786 --- /dev/null +++ b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx @@ -0,0 +1,89 @@ +"use client" + +import { useState } from "react" +import { useMutedKeywords } from "@/lib/queries/use-muted-keywords" +import { + useAddMutedKeyword, + useDeleteMutedKeyword, +} from "@/lib/queries/use-muted-keyword-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function MutedKeywordsSettings() { + const [newKeyword, setNewKeyword] = useState("") + const { data: keywords, isLoading } = useMutedKeywords() + const { data: userProfile } = useUserProfile() + const addKeyword = useAddMutedKeyword() + const deleteKeyword = useDeleteMutedKeyword() + + const tier = userProfile?.tier ?? "free" + const tierLimits = TIER_LIMITS[tier] + + function handleAddKeyword(event: React.FormEvent) { + event.preventDefault() + const trimmedKeyword = newKeyword.trim() + + if (!trimmedKeyword) return + + addKeyword.mutate({ keyword: trimmedKeyword }) + setNewKeyword("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading muted keywords...</p> + } + + const keywordList = keywords ?? [] + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-1 text-text-dim"> + {keywordList.length} / {tierLimits.maximumMutedKeywords} keywords used + </p> + <p className="mb-2 text-text-dim"> + entries containing muted keywords are hidden from your timeline + </p> + <form onSubmit={handleAddKeyword} className="flex gap-2"> + <input + type="text" + value={newKeyword} + onChange={(event) => setNewKeyword(event.target.value)} + placeholder="keyword to mute" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={addKeyword.isPending || !newKeyword.trim()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + mute + </button> + </form> + </div> + {keywordList.length === 0 ? ( + <p className="px-4 py-6 text-text-dim">no muted keywords</p> + ) : ( + keywordList.map((keyword) => ( + <div + key={keyword.identifier} + className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0" + > + <span className="text-text-primary">{keyword.keyword}</span> + <button + onClick={() => + deleteKeyword.mutate({ + keywordIdentifier: keyword.identifier, + }) + } + disabled={deleteKeyword.isPending} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error disabled:opacity-50" + > + unmute + </button> + </div> + )) + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/security-settings.tsx b/apps/web/app/reader/settings/_components/security-settings.tsx new file mode 100644 index 0000000..4a00241 --- /dev/null +++ b/apps/web/app/reader/settings/_components/security-settings.tsx @@ -0,0 +1,280 @@ +"use client" + +import { useState, useEffect } from "react" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { notify } from "@/lib/notify" +import type { Factor } from "@supabase/supabase-js" + +type EnrollmentState = + | { step: "idle" } + | { step: "enrolling"; factorIdentifier: string; qrCodeSvg: string; otpauthUri: string } + | { step: "verifying"; factorIdentifier: string; challengeIdentifier: string } + +export function SecuritySettings() { + const [enrolledFactors, setEnrolledFactors] = useState<Factor[]>([]) + const [isLoading, setIsLoading] = useState(true) + const [enrollmentState, setEnrollmentState] = useState<EnrollmentState>({ step: "idle" }) + const [factorName, setFactorName] = useState("") + const [verificationCode, setVerificationCode] = useState("") + const [isProcessing, setIsProcessing] = useState(false) + const [unenrollConfirmIdentifier, setUnenrollConfirmIdentifier] = useState<string | null>(null) + const supabaseClient = createSupabaseBrowserClient() + + async function loadFactors() { + const { data, error } = await supabaseClient.auth.mfa.listFactors() + + if (error) { + notify("failed to load MFA factors") + setIsLoading(false) + return + } + + setEnrolledFactors( + data.totp.filter((factor) => factor.status === "verified") + ) + setIsLoading(false) + } + + useEffect(() => { + loadFactors() + }, []) + + async function handleBeginEnrollment() { + setIsProcessing(true) + + const enrollOptions: { factorType: "totp"; friendlyName?: string } = { + factorType: "totp", + } + if (factorName.trim()) { + enrollOptions.friendlyName = factorName.trim() + } + + const { data, error } = await supabaseClient.auth.mfa.enroll(enrollOptions) + + setIsProcessing(false) + + if (error) { + notify("failed to start MFA enrolment: " + error.message) + return + } + + setEnrollmentState({ + step: "enrolling", + factorIdentifier: data.id, + qrCodeSvg: data.totp.qr_code, + otpauthUri: data.totp.uri, + }) + setVerificationCode("") + } + + async function handleVerifyEnrollment() { + if (enrollmentState.step !== "enrolling") return + if (verificationCode.length !== 6) return + + setIsProcessing(true) + + const { data: challengeData, error: challengeError } = + await supabaseClient.auth.mfa.challenge({ + factorId: enrollmentState.factorIdentifier, + }) + + if (challengeError) { + setIsProcessing(false) + notify("failed to create MFA challenge: " + challengeError.message) + return + } + + const { error: verifyError } = await supabaseClient.auth.mfa.verify({ + factorId: enrollmentState.factorIdentifier, + challengeId: challengeData.id, + code: verificationCode, + }) + + setIsProcessing(false) + + if (verifyError) { + notify("invalid code — please try again") + setVerificationCode("") + return + } + + notify("two-factor authentication enabled") + setEnrollmentState({ step: "idle" }) + setVerificationCode("") + setFactorName("") + await supabaseClient.auth.refreshSession() + await loadFactors() + } + + async function handleCancelEnrollment() { + if (enrollmentState.step === "enrolling") { + await supabaseClient.auth.mfa.unenroll({ + factorId: enrollmentState.factorIdentifier, + }) + } + + setEnrollmentState({ step: "idle" }) + setVerificationCode("") + setFactorName("") + } + + async function handleUnenrollFactor(factorIdentifier: string) { + setIsProcessing(true) + + const { error } = await supabaseClient.auth.mfa.unenroll({ + factorId: factorIdentifier, + }) + + setIsProcessing(false) + + if (error) { + notify("failed to remove factor: " + error.message) + return + } + + notify("two-factor authentication removed") + setUnenrollConfirmIdentifier(null) + await supabaseClient.auth.refreshSession() + await loadFactors() + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading security settings ...</p> + } + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">two-factor authentication</h3> + <p className="mb-4 text-text-dim"> + add an extra layer of security to your account with a time-based one-time password (TOTP) authenticator app + </p> + + {enrollmentState.step === "idle" && enrolledFactors.length === 0 && ( + <div className="flex items-center gap-2"> + <input + type="text" + value={factorName} + onChange={(event) => setFactorName(event.target.value)} + placeholder="authenticator name (optional)" + className="w-64 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + onClick={handleBeginEnrollment} + disabled={isProcessing} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isProcessing ? "setting up ..." : "set up"} + </button> + </div> + )} + + {enrollmentState.step === "enrolling" && ( + <div className="space-y-4"> + <p className="text-text-secondary"> + scan this QR code with your authenticator app, then enter the 6-digit code below + </p> + <div className="inline-block bg-white p-4"> + <img + src={enrollmentState.qrCodeSvg} + alt="TOTP QR code" + className="h-48 w-48" + /> + </div> + <details className="text-text-dim"> + <summary className="cursor-pointer transition-colors hover:text-text-secondary"> + can't scan? copy manual entry key + </summary> + <code className="mt-2 block break-all bg-background-secondary p-2 text-text-secondary"> + {enrollmentState.otpauthUri} + </code> + </details> + <div className="flex items-center gap-2"> + <input + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={6} + value={verificationCode} + onChange={(event) => { + const filtered = event.target.value.replace(/\D/g, "") + setVerificationCode(filtered) + }} + placeholder="000000" + className="w-32 border border-border bg-background-primary px-3 py-2 text-center font-mono text-lg tracking-widest text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + autoFocus + onKeyDown={(event) => { + if (event.key === "Enter") handleVerifyEnrollment() + if (event.key === "Escape") handleCancelEnrollment() + }} + /> + <button + onClick={handleVerifyEnrollment} + disabled={isProcessing || verificationCode.length !== 6} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isProcessing ? "verifying ..." : "verify"} + </button> + <button + onClick={handleCancelEnrollment} + className="px-4 py-2 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + )} + + {enrolledFactors.length > 0 && enrollmentState.step === "idle" && ( + <div className="space-y-3"> + {enrolledFactors.map((factor) => ( + <div + key={factor.id} + className="flex items-center justify-between border border-border px-4 py-3" + > + <div> + <span className="text-text-primary"> + {factor.friendly_name || "TOTP authenticator"} + </span> + <span className="ml-2 text-text-dim"> + added{" "} + {new Date(factor.created_at).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + </span> + </div> + {unenrollConfirmIdentifier === factor.id ? ( + <div className="flex items-center gap-2"> + <span className="text-text-dim">remove?</span> + <button + onClick={() => handleUnenrollFactor(factor.id)} + disabled={isProcessing} + className="text-status-error transition-colors hover:text-text-primary disabled:opacity-50" + > + yes + </button> + <button + onClick={() => setUnenrollConfirmIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setUnenrollConfirmIdentifier(factor.id)} + className="text-text-secondary transition-colors hover:text-status-error" + > + remove + </button> + )} + </div> + ))} + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/settings-shell.tsx b/apps/web/app/reader/settings/_components/settings-shell.tsx new file mode 100644 index 0000000..ae432f3 --- /dev/null +++ b/apps/web/app/reader/settings/_components/settings-shell.tsx @@ -0,0 +1,86 @@ +"use client" + +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { SubscriptionsSettings } from "./subscriptions-settings" +import { FoldersSettings } from "./folders-settings" +import { MutedKeywordsSettings } from "./muted-keywords-settings" +import { CustomFeedsSettings } from "./custom-feeds-settings" +import { ImportExportSettings } from "./import-export-settings" +import { AppearanceSettings } from "./appearance-settings" +import { AccountSettings } from "./account-settings" +import { SecuritySettings } from "./security-settings" +import { BillingSettings } from "./billing-settings" +import { ApiSettings } from "./api-settings" +import { DangerZoneSettings } from "./danger-zone-settings" + +const TABS = [ + { key: "subscriptions", label: "subscriptions" }, + { key: "folders", label: "folders" }, + { key: "muted-keywords", label: "muted keywords" }, + { key: "custom-feeds", label: "custom feeds" }, + { key: "import-export", label: "import / export" }, + { key: "appearance", label: "appearance" }, + { key: "account", label: "account" }, + { key: "security", label: "security" }, + { key: "billing", label: "billing" }, + { key: "api", label: "API" }, + { key: "danger", label: "danger zone" }, +] as const + +export function SettingsShell() { + const activeTab = useUserInterfaceStore((state) => state.activeSettingsTab) + const setActiveTab = useUserInterfaceStore( + (state) => state.setActiveSettingsTab + ) + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center border-b border-border px-4 py-3"> + <h1 className="text-text-primary">settings</h1> + </header> + <nav className="border-b border-border"> + <select + value={activeTab} + onChange={(event) => setActiveTab(event.target.value as typeof activeTab)} + className="w-full border-none bg-background-primary px-4 py-2 text-text-primary outline-none md:hidden" + > + {TABS.map((tab) => ( + <option key={tab.key} value={tab.key}> + {tab.label} + </option> + ))} + </select> + <div className="hidden md:flex"> + {TABS.map((tab) => ( + <button + key={tab.key} + onClick={() => setActiveTab(tab.key)} + className={`shrink-0 px-4 py-2 transition-colors ${ + activeTab === tab.key + ? "border-b-2 border-text-primary text-text-primary" + : "text-text-dim hover:text-text-secondary" + }`} + > + {tab.label} + </button> + ))} + </div> + </nav> + <div className="flex-1 overflow-y-auto"> + <div className="max-w-3xl"> + {activeTab === "subscriptions" && <SubscriptionsSettings />} + {activeTab === "folders" && <FoldersSettings />} + {activeTab === "muted-keywords" && <MutedKeywordsSettings />} + {activeTab === "custom-feeds" && <CustomFeedsSettings />} + {activeTab === "import-export" && <ImportExportSettings />} + {activeTab === "appearance" && <AppearanceSettings />} + {activeTab === "account" && <AccountSettings />} + {activeTab === "security" && <SecuritySettings />} + {activeTab === "billing" && <BillingSettings />} + {activeTab === "api" && <ApiSettings />} + {activeTab === "danger" && <DangerZoneSettings />} + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/subscriptions-settings.tsx b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx new file mode 100644 index 0000000..7257231 --- /dev/null +++ b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx @@ -0,0 +1,281 @@ +"use client" + +import { useState } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { + useUpdateSubscriptionTitle, + useMoveSubscriptionToFolder, + useUnsubscribe, + useRequestFeedRefresh, +} from "@/lib/queries/use-subscription-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" +import type { Subscription } from "@/lib/types/subscription" + +function formatRelativeTime(isoString: string | null): string { + if (!isoString) return "never" + const date = new Date(isoString) + const now = new Date() + const differenceSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) + if (differenceSeconds < 60) return "just now" + if (differenceSeconds < 3600) return `${Math.floor(differenceSeconds / 60)}m ago` + if (differenceSeconds < 86400) return `${Math.floor(differenceSeconds / 3600)}h ago` + return `${Math.floor(differenceSeconds / 86400)}d ago` +} + +function formatRefreshInterval(seconds: number): string { + if (seconds < 3600) return `${Math.round(seconds / 60)} min` + return `${Math.round(seconds / 3600)} hr` +} + +function SubscriptionRow({ + subscription, + folderOptions, +}: { + subscription: Subscription + folderOptions: { identifier: string; name: string }[] +}) { + const [isEditingTitle, setIsEditingTitle] = useState(false) + const [editedTitle, setEditedTitle] = useState( + subscription.customTitle ?? "" + ) + const [showUnsubscribeConfirm, setShowUnsubscribeConfirm] = useState(false) + const updateTitle = useUpdateSubscriptionTitle() + const moveToFolder = useMoveSubscriptionToFolder() + const unsubscribe = useUnsubscribe() + const requestRefresh = useRequestFeedRefresh() + const { data: userProfile } = useUserProfile() + + function handleSaveTitle() { + const trimmedTitle = editedTitle.trim() + updateTitle.mutate({ + subscriptionIdentifier: subscription.subscriptionIdentifier, + customTitle: trimmedTitle || null, + }) + setIsEditingTitle(false) + } + + function handleFolderChange(folderIdentifier: string) { + const sourceFolder = folderOptions.find( + (folder) => folder.identifier === subscription.folderIdentifier + ) + const targetFolder = folderOptions.find( + (folder) => folder.identifier === folderIdentifier + ) + moveToFolder.mutate({ + subscriptionIdentifier: subscription.subscriptionIdentifier, + folderIdentifier: folderIdentifier || null, + feedTitle: subscription.customTitle ?? subscription.feedTitle ?? undefined, + sourceFolderName: sourceFolder?.name, + folderName: targetFolder?.name, + }) + } + + return ( + <div className="flex flex-col gap-2 border-b border-border px-4 py-3 last:border-b-0"> + <div className="flex items-center justify-between gap-4"> + <div className="min-w-0 flex-1"> + {isEditingTitle ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedTitle} + onChange={(event) => setEditedTitle(event.target.value)} + placeholder={subscription.feedTitle} + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveTitle() + if (event.key === "Escape") setIsEditingTitle(false) + }} + autoFocus + /> + <button + onClick={handleSaveTitle} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingTitle(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <span className="truncate text-text-primary"> + {subscription.customTitle ?? subscription.feedTitle} + </span> + <button + onClick={() => { + setEditedTitle(subscription.customTitle ?? "") + setIsEditingTitle(true) + }} + className="shrink-0 px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + rename + </button> + </div> + )} + <p className="truncate text-text-dim">{subscription.feedUrl}</p> + <div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-text-dim"> + <span>last fetched: {formatRelativeTime(subscription.lastFetchedAt)}</span> + <span>interval: {formatRefreshInterval(subscription.fetchIntervalSeconds)}</span> + {subscription.consecutiveFailures > 0 && ( + <span className="text-status-warning"> + {subscription.consecutiveFailures} consecutive failure{subscription.consecutiveFailures !== 1 && "s"} + </span> + )} + </div> + {subscription.lastFetchError && subscription.consecutiveFailures > 0 && ( + <p className="mt-1 truncate text-status-warning"> + {subscription.lastFetchError} + </p> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <select + value={subscription.folderIdentifier ?? ""} + onChange={(event) => handleFolderChange(event.target.value)} + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="">no folder</option> + {folderOptions.map((folder) => ( + <option key={folder.identifier} value={folder.identifier}> + {folder.name} + </option> + ))} + </select> + {(userProfile?.tier === "pro" || userProfile?.tier === "developer") && ( + <button + onClick={() => + requestRefresh.mutate({ + subscriptionIdentifier: + subscription.subscriptionIdentifier, + }) + } + disabled={requestRefresh.isPending} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + refresh + </button> + )} + {showUnsubscribeConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">confirm?</span> + <button + onClick={() => { + unsubscribe.mutate({ + subscriptionIdentifier: + subscription.subscriptionIdentifier, + }) + setShowUnsubscribeConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowUnsubscribeConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowUnsubscribeConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + unsubscribe + </button> + )} + </div> + </div> + ) +} + +export function SubscriptionsSettings() { + const { data: subscriptionsData, isLoading } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const [searchQuery, setSearchQuery] = useState("") + const [folderFilter, setFolderFilter] = useState<string>("all") + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading subscriptions ...</p> + } + + const subscriptions = subscriptionsData?.subscriptions ?? [] + const folders = subscriptionsData?.folders ?? [] + const folderOptions = folders.map((folder) => ({ + identifier: folder.folderIdentifier, + name: folder.name, + })) + + if (subscriptions.length === 0) { + return ( + <p className="px-4 py-6 text-text-dim"> + no subscriptions yet — add a feed to get started + </p> + ) + } + + const normalizedQuery = searchQuery.toLowerCase().trim() + + const filteredSubscriptions = subscriptions.filter((subscription) => { + if (folderFilter === "ungrouped" && subscription.folderIdentifier !== null) return false + if (folderFilter !== "all" && folderFilter !== "ungrouped" && subscription.folderIdentifier !== folderFilter) return false + + if (normalizedQuery) { + const title = (subscription.customTitle ?? subscription.feedTitle ?? "").toLowerCase() + const url = (subscription.feedUrl ?? "").toLowerCase() + if (!title.includes(normalizedQuery) && !url.includes(normalizedQuery)) return false + } + + return true + }) + + return ( + <div> + <div className="flex flex-wrap items-center gap-2 px-4 py-3"> + <input + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="search subscriptions..." + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <select + value={folderFilter} + onChange={(event) => setFolderFilter(event.target.value)} + className="border border-border bg-background-primary px-2 py-1.5 text-text-secondary outline-none" + > + <option value="all">all folders</option> + <option value="ungrouped">ungrouped</option> + {folderOptions.map((folder) => ( + <option key={folder.identifier} value={folder.identifier}> + {folder.name} + </option> + ))} + </select> + <span className="text-text-dim"> + {filteredSubscriptions.length} / {TIER_LIMITS[userProfile?.tier ?? "free"].maximumFeeds} + </span> + </div> + <div> + {filteredSubscriptions.map((subscription) => ( + <SubscriptionRow + key={subscription.subscriptionIdentifier} + subscription={subscription} + folderOptions={folderOptions} + /> + ))} + {filteredSubscriptions.length === 0 && ( + <p className="px-4 py-6 text-text-dim">no subscriptions match your filters</p> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/page.tsx b/apps/web/app/reader/settings/page.tsx new file mode 100644 index 0000000..3a49bd7 --- /dev/null +++ b/apps/web/app/reader/settings/page.tsx @@ -0,0 +1,16 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { redirect } from "next/navigation" +import { SettingsShell } from "./_components/settings-shell" + +export default async function SettingsPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + redirect("/sign-in") + } + + return <SettingsShell /> +} diff --git a/apps/web/app/reader/shares/_components/shares-content.tsx b/apps/web/app/reader/shares/_components/shares-content.tsx new file mode 100644 index 0000000..e9ce7a4 --- /dev/null +++ b/apps/web/app/reader/shares/_components/shares-content.tsx @@ -0,0 +1,504 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Group, Panel, Separator } from "react-resizable-panels" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { useIsMobile } from "@/lib/hooks/use-is-mobile" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { EntryDetailPanel } from "@/app/reader/_components/entry-detail-panel" +import { ErrorBoundary } from "@/app/reader/_components/error-boundary" +import { classNames } from "@/lib/utilities" +import { notify } from "@/lib/notify" + +interface SharedEntry { + identifier: string + entryIdentifier: string + shareToken: string + createdAt: string + expiresAt: string | null + note: string | null + entryTitle: string | null + entryUrl: string | null +} + +function useSharedEntries() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: ["shared-entries"], + queryFn: async () => { + const { data, error } = await supabaseClient + .from("shared_entries") + .select("id, entry_id, share_token, created_at, expires_at, note, entries(title, url)") + .order("created_at", { ascending: false }) + + if (error) throw error + + return (data ?? []).map( + (row) => { + const entryData = row.entries as unknown as { + title: string | null + url: string | null + } | null + + return { + identifier: row.id, + entryIdentifier: row.entry_id, + shareToken: row.share_token, + createdAt: row.created_at, + expiresAt: row.expires_at, + note: (row as Record<string, unknown>).note as string | null, + entryTitle: entryData?.title ?? null, + entryUrl: entryData?.url ?? null, + } + } + ) + }, + }) +} + +function useRevokeShare() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (shareIdentifier: string) => { + const { error } = await supabaseClient + .from("shared_entries") + .delete() + .eq("id", shareIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + notify("share revoked") + }, + onError: () => { + notify("failed to revoke share") + }, + }) +} + +function useUpdateShareNote() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ shareToken, note }: { shareToken: string; note: string | null }) => { + const response = await fetch(`/api/share/${shareToken}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ note }), + }) + if (!response.ok) throw new Error("Failed to update note") + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + notify("note updated") + }, + onError: () => { + notify("failed to update note") + }, + }) +} + +function isExpired(expiresAt: string | null): boolean { + if (!expiresAt) return false + return new Date(expiresAt) < new Date() +} + +function ShareRow({ + share, + isSelected, + isFocused, + viewMode, + onSelect, +}: { + share: SharedEntry + isSelected: boolean + isFocused: boolean + viewMode: "compact" | "comfortable" | "expanded" + onSelect: (entryIdentifier: string) => void +}) { + const [showRevokeConfirm, setShowRevokeConfirm] = useState(false) + const [isEditingNote, setIsEditingNote] = useState(false) + const [editedNote, setEditedNote] = useState(share.note ?? "") + const revokeShare = useRevokeShare() + const updateNote = useUpdateShareNote() + const expired = isExpired(share.expiresAt) + + function handleCopyLink() { + const shareUrl = `${window.location.origin}/shared/${share.shareToken}` + navigator.clipboard.writeText(shareUrl) + notify("link copied") + } + + function handleSaveNote() { + const trimmedNote = editedNote.trim() + updateNote.mutate({ shareToken: share.shareToken, note: trimmedNote || null }) + setIsEditingNote(false) + } + + const sharedDate = new Date(share.createdAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }) + + const rowClassName = classNames( + "cursor-pointer border-b border-border px-4 transition-colors last:border-b-0", + isSelected + ? "bg-background-tertiary" + : isFocused + ? "bg-background-secondary" + : "hover:bg-background-secondary", + isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "" + ) + + if (viewMode === "compact") { + return ( + <div + data-share-list-item + onClick={() => onSelect(share.entryIdentifier)} + className={rowClassName} + > + <div className="flex items-center gap-2 py-2.5"> + <span className="min-w-0 flex-1 truncate text-text-primary"> + {share.entryTitle ?? "untitled"} + </span> + {expired && <span className="shrink-0 text-status-error">expired</span>} + <span className="shrink-0 text-text-dim">{sharedDate}</span> + </div> + </div> + ) + } + + if (viewMode === "comfortable") { + return ( + <div + data-share-list-item + onClick={() => onSelect(share.entryIdentifier)} + className={rowClassName} + > + <div className="py-2.5"> + <span className="block truncate text-text-primary"> + {share.entryTitle ?? "untitled"} + </span> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + <span>shared {sharedDate}</span> + {share.expiresAt && ( + <> + <span>·</span> + {expired ? ( + <span className="text-status-error">expired</span> + ) : ( + <span> + expires{" "} + {new Date(share.expiresAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + </span> + )} + </> + )} + {share.note && ( + <> + <span>·</span> + <span className="truncate">{share.note}</span> + </> + )} + </div> + </div> + </div> + ) + } + + return ( + <div + data-share-list-item + onClick={() => onSelect(share.entryIdentifier)} + className={classNames(rowClassName, "flex flex-col gap-1 py-3")} + > + <div className="flex items-center justify-between"> + <div className="min-w-0 flex-1"> + <span className="block truncate text-text-primary"> + {share.entryTitle ?? "untitled"} + </span> + {isEditingNote ? ( + <div className="mt-1 flex items-center gap-2"> + <input + type="text" + value={editedNote} + onChange={(event) => setEditedNote(event.target.value)} + placeholder="add a note..." + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveNote() + if (event.key === "Escape") setIsEditingNote(false) + }} + autoFocus + /> + <button + type="button" + onClick={handleSaveNote} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + type="button" + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : share.note ? ( + <p className="truncate text-text-secondary">{share.note}</p> + ) : null} + <p className="text-text-dim"> + shared {sharedDate} + {share.expiresAt && ( + <> + {" \u00b7 "} + {expired ? ( + <span className="text-status-error">expired</span> + ) : ( + <> + expires{" "} + {new Date(share.expiresAt).toLocaleDateString( + "en-GB", + { + day: "numeric", + month: "short", + year: "numeric", + } + )} + </> + )} + </> + )} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + {!expired && ( + <button + type="button" + onClick={handleCopyLink} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + copy link + </button> + )} + <button + type="button" + onClick={() => { + setEditedNote(share.note ?? "") + setIsEditingNote(true) + }} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + {share.note ? "edit note" : "add note"} + </button> + {showRevokeConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">revoke?</span> + <button + type="button" + onClick={() => { + revokeShare.mutate(share.identifier) + setShowRevokeConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + type="button" + onClick={() => setShowRevokeConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + type="button" + onClick={() => setShowRevokeConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + revoke + </button> + )} + </div> + </div> + ) +} + +function SharesList({ + shares, + selectedEntryIdentifier, + focusedEntryIdentifier, + viewMode, + onSelect, +}: { + shares: SharedEntry[] + selectedEntryIdentifier: string | null + focusedEntryIdentifier: string | null + viewMode: "compact" | "comfortable" | "expanded" + onSelect: (entryIdentifier: string) => void +}) { + const listReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (!focusedEntryIdentifier) return + const container = listReference.current + if (!container) return + const items = container.querySelectorAll("[data-share-list-item]") + const focusedIndex = shares.findIndex( + (share) => share.entryIdentifier === focusedEntryIdentifier + ) + items[focusedIndex]?.scrollIntoView({ block: "nearest" }) + }, [focusedEntryIdentifier, shares]) + + if (shares.length === 0) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + no shared entries yet + </div> + ) + } + + return ( + <div ref={listReference} className="h-full overflow-auto"> + {shares.map((share) => ( + <ShareRow + key={share.identifier} + share={share} + isSelected={share.entryIdentifier === selectedEntryIdentifier} + isFocused={share.entryIdentifier === focusedEntryIdentifier} + viewMode={viewMode} + onSelect={onSelect} + /> + ))} + </div> + ) +} + +export function SharesContent() { + const { data: shares, isLoading } = useSharedEntries() + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setNavigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.setNavigableEntryIdentifiers + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const isMobile = useIsMobile() + + const sharesList = shares ?? [] + + useEffect(() => { + setSelectedEntryIdentifier(null) + setNavigableEntryIdentifiers([]) + }, []) + + useEffect(() => { + setNavigableEntryIdentifiers( + sharesList.map((share) => share.entryIdentifier) + ) + }, [sharesList.length, setNavigableEntryIdentifiers]) + + if (isLoading) { + return <p className="px-6 py-8 text-text-dim">loading shares ...</p> + } + + const activeShareCount = sharesList.filter( + (share) => !isExpired(share.expiresAt) + ).length + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center justify-between border-b border-border px-4 py-3"> + <div className="flex items-center gap-3"> + {isMobile && selectedEntryIdentifier && ( + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + ← back + </button> + )} + <h1 className="text-text-primary">shares</h1> + </div> + <span className="text-text-dim"> + {activeShareCount} active share{activeShareCount !== 1 ? "s" : ""} + </span> + </header> + <ErrorBoundary> + {isMobile ? ( + selectedEntryIdentifier ? ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + ) : ( + <div className="flex-1 overflow-hidden"> + <SharesList + shares={sharesList} + selectedEntryIdentifier={null} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + /> + </div> + ) + ) : ( + <Group orientation="horizontal" className="flex-1"> + <Panel defaultSize={selectedEntryIdentifier ? 40 : 100} minSize={25}> + <div data-panel-zone="entryList" className={classNames( + "h-full", + focusedPanel === "entryList" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <SharesList + shares={sharesList} + selectedEntryIdentifier={selectedEntryIdentifier} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + /> + </div> + </Panel> + {selectedEntryIdentifier && ( + <> + <Separator className="w-px bg-border transition-colors hover:bg-text-dim" /> + <Panel defaultSize={60} minSize={30}> + <div data-panel-zone="detailPanel" className={classNames( + "h-full", + focusedPanel === "detailPanel" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + </Panel> + </> + )} + </Group> + )} + </ErrorBoundary> + </div> + ) +} diff --git a/apps/web/app/reader/shares/page.tsx b/apps/web/app/reader/shares/page.tsx new file mode 100644 index 0000000..d912b9e --- /dev/null +++ b/apps/web/app/reader/shares/page.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { SharesContent } from "./_components/shares-content" + +export default async function SharesPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + redirect("/sign-in") + } + + return <SharesContent /> +} diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx new file mode 100644 index 0000000..222c1c8 --- /dev/null +++ b/apps/web/app/shared/[token]/page.tsx @@ -0,0 +1,165 @@ +import type { Metadata } from "next" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { sanitizeEntryContent } from "@/lib/sanitize" + +interface SharedPageProperties { + params: Promise<{ token: string }> +} + +interface SharedEntryRow { + entry_id: string + expires_at: string | null + entries: { + id: string + title: string | null + url: string | null + author: string | null + summary: string | null + content_html: string | null + published_at: string | null + enclosure_url: string | null + feeds: { + title: string | null + } + } +} + +async function fetchSharedEntry(token: string) { + const adminClient = createSupabaseAdminClient() + + const { data, error } = await adminClient + .from("shared_entries") + .select( + "entry_id, expires_at, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))" + ) + .eq("share_token", token) + .maybeSingle() + + if (error || !data) return null + + const row = data as unknown as SharedEntryRow + + if (row.expires_at && new Date(row.expires_at) < new Date()) { + return { expired: true as const } + } + + return { expired: false as const, entry: row.entries } +} + +export async function generateMetadata({ + params, +}: SharedPageProperties): Promise<Metadata> { + const { token } = await params + const result = await fetchSharedEntry(token) + + if (!result || result.expired) { + return { title: "shared entry — asa.news" } + } + + return { + title: `${result.entry.title ?? "untitled"} — asa.news`, + description: result.entry.summary?.slice(0, 200) ?? undefined, + openGraph: { + title: result.entry.title ?? "shared entry", + description: result.entry.summary?.slice(0, 200) ?? undefined, + siteName: "asa.news", + }, + } +} + +function SanitisedContent({ htmlContent }: { htmlContent: string }) { + // Content is sanitised via sanitize-html before rendering + const sanitisedHtml = sanitizeEntryContent(htmlContent) + return ( + <div + className="prose-reader text-text-secondary" + // eslint-disable-next-line react/no-danger -- content sanitised by sanitize-html + dangerouslySetInnerHTML={{ __html: sanitisedHtml }} + /> + ) +} + +export default async function SharedPage({ params }: SharedPageProperties) { + const { token } = await params + const result = await fetchSharedEntry(token) + + if (!result) { + return ( + <div className="mx-auto max-w-2xl px-6 py-16 text-center"> + <h1 className="mb-4 text-text-primary">shared entry not found</h1> + <p className="text-text-secondary"> + this shared link is no longer available or has been removed. + </p> + </div> + ) + } + + if (result.expired) { + return ( + <div className="mx-auto max-w-2xl px-6 py-16 text-center"> + <h1 className="mb-4 text-text-primary">this share has expired</h1> + <p className="text-text-secondary"> + shared links expire after a set period. the owner may share it again if needed. + </p> + </div> + ) + } + + const entry = result.entry + const formattedDate = entry.published_at + ? new Date(entry.published_at).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }) + : null + + return ( + <div className="mx-auto max-w-2xl px-6 py-8"> + <article> + <h1 className="mb-2 text-lg text-text-primary">{entry.title}</h1> + <div className="mb-6 text-text-dim"> + {entry.feeds?.title && <span>{entry.feeds.title}</span>} + {entry.author && <span> · {entry.author}</span>} + {formattedDate && <span> · {formattedDate}</span>} + </div> + {entry.enclosure_url && ( + <div className="mb-4 border border-border p-3"> + <audio + controls + preload="none" + src={entry.enclosure_url} + className="w-full" + /> + </div> + )} + <SanitisedContent + htmlContent={entry.content_html || entry.summary || ""} + /> + </article> + <footer className="mt-12 border-t border-border pt-4 text-text-dim"> + <p> + shared from{" "} + <a + href="/" + className="text-text-secondary transition-colors hover:text-text-primary" + > + asa.news + </a> + </p> + {entry.url && ( + <p className="mt-1"> + <a + href={entry.url} + target="_blank" + rel="noopener noreferrer" + className="text-text-secondary transition-colors hover:text-text-primary" + > + view original + </a> + </p> + )} + </footer> + </div> + ) +} diff --git a/apps/web/app/sw.ts b/apps/web/app/sw.ts new file mode 100644 index 0000000..894c9ae --- /dev/null +++ b/apps/web/app/sw.ts @@ -0,0 +1,22 @@ +/// <reference lib="webworker" /> +import { defaultCache } from "@serwist/next/worker" +import type { PrecacheEntry, SerwistGlobalConfig } from "serwist" +import { Serwist } from "serwist" + +declare global { + interface WorkerGlobalScope extends SerwistGlobalConfig { + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined + } +} + +declare const self: ServiceWorkerGlobalScope + +const serwist = new Serwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: defaultCache, +}) + +serwist.addEventListeners() diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts new file mode 100644 index 0000000..309fbe9 --- /dev/null +++ b/apps/web/lib/api-auth.ts @@ -0,0 +1,80 @@ +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { hashApiKey } from "@/lib/api-key" +import { rateLimit } from "@/lib/rate-limit" + +interface AuthenticatedApiUser { + userIdentifier: string + tier: string +} + +export async function authenticateApiRequest( + request: Request +): Promise< + | { authenticated: true; user: AuthenticatedApiUser } + | { authenticated: false; status: number; error: string } +> { + const authorizationHeader = request.headers.get("authorization") + + if (!authorizationHeader?.startsWith("Bearer ")) { + return { + authenticated: false, + status: 401, + error: "Missing or invalid Authorization header", + } + } + + const apiKey = authorizationHeader.slice(7) + + if (!apiKey.startsWith("asn_")) { + return { authenticated: false, status: 401, error: "Invalid API key format" } + } + + const keyHash = hashApiKey(apiKey) + const adminClient = createSupabaseAdminClient() + + const { data: keyRow } = await adminClient + .from("api_keys") + .select("user_id") + .eq("key_hash", keyHash) + .is("revoked_at", null) + .single() + + if (!keyRow) { + return { authenticated: false, status: 401, error: "Invalid or revoked API key" } + } + + const { data: userProfile } = await adminClient + .from("user_profiles") + .select("tier") + .eq("id", keyRow.user_id) + .single() + + if (!userProfile || userProfile.tier !== "developer") { + return { + authenticated: false, + status: 403, + error: "API access requires the developer plan", + } + } + + const rateLimitResult = rateLimit(`api:${keyRow.user_id}`, 100, 60_000) + + if (!rateLimitResult.success) { + return { + authenticated: false, + status: 429, + error: `Rate limit exceeded. ${rateLimitResult.remaining} requests remaining.`, + } + } + + adminClient + .from("api_keys") + .update({ last_used_at: new Date().toISOString() }) + .eq("key_hash", keyHash) + .then(() => {}) + + return { + authenticated: true, + user: { userIdentifier: keyRow.user_id, tier: userProfile.tier }, + } +} diff --git a/apps/web/lib/api-key.ts b/apps/web/lib/api-key.ts new file mode 100644 index 0000000..ce59f89 --- /dev/null +++ b/apps/web/lib/api-key.ts @@ -0,0 +1,20 @@ +import { randomBytes, createHash } from "crypto" + +const API_KEY_PREFIX = "asn_" + +export function generateApiKey(): { + fullKey: string + keyHash: string + keyPrefix: string +} { + const randomPart = randomBytes(20).toString("hex") + const fullKey = `${API_KEY_PREFIX}${randomPart}` + const keyHash = hashApiKey(fullKey) + const keyPrefix = fullKey.slice(0, 8) + + return { fullKey, keyHash, keyPrefix } +} + +export function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex") +} diff --git a/apps/web/lib/highlight-positioning.ts b/apps/web/lib/highlight-positioning.ts new file mode 100644 index 0000000..4c4c068 --- /dev/null +++ b/apps/web/lib/highlight-positioning.ts @@ -0,0 +1,258 @@ +interface SerializedHighlightRange { + highlightedText: string + textOffset: number + textLength: number + textPrefix: string + textSuffix: string +} + +function collectTextContent(containerElement: HTMLElement): string { + const treeWalker = document.createTreeWalker( + containerElement, + NodeFilter.SHOW_TEXT + ) + let fullText = "" + while (treeWalker.nextNode()) { + fullText += treeWalker.currentNode.textContent ?? "" + } + return fullText +} + +function computeAbsoluteTextOffset( + containerElement: HTMLElement, + targetNode: Node, + targetOffset: number +): number { + const treeWalker = document.createTreeWalker( + containerElement, + NodeFilter.SHOW_TEXT + ) + let absoluteOffset = 0 + while (treeWalker.nextNode()) { + if (treeWalker.currentNode === targetNode) { + return absoluteOffset + targetOffset + } + absoluteOffset += (treeWalker.currentNode.textContent ?? "").length + } + return absoluteOffset + targetOffset +} + +function findTextNodeAtOffset( + containerElement: HTMLElement, + targetOffset: number +): { node: Text; offset: number } | null { + const treeWalker = document.createTreeWalker( + containerElement, + NodeFilter.SHOW_TEXT + ) + let currentOffset = 0 + while (treeWalker.nextNode()) { + const textNode = treeWalker.currentNode as Text + const nodeLength = (textNode.textContent ?? "").length + if (currentOffset + nodeLength >= targetOffset) { + return { node: textNode, offset: targetOffset - currentOffset } + } + currentOffset += nodeLength + } + return null +} + +export function serializeSelectionRange( + containerElement: HTMLElement, + selectionRange: Range +): SerializedHighlightRange | null { + const selectedText = selectionRange.toString() + if (!selectedText.trim()) return null + + if (!containerElement.contains(selectionRange.startContainer)) return null + + const fullText = collectTextContent(containerElement) + const textOffset = computeAbsoluteTextOffset( + containerElement, + selectionRange.startContainer, + selectionRange.startOffset + ) + const textLength = selectedText.length + + const prefixStart = Math.max(0, textOffset - 50) + const textPrefix = fullText.slice(prefixStart, textOffset) + const textSuffix = fullText.slice( + textOffset + textLength, + textOffset + textLength + 50 + ) + + return { + highlightedText: selectedText, + textOffset, + textLength, + textPrefix, + textSuffix, + } +} + +export function deserializeHighlightRange( + containerElement: HTMLElement, + highlight: SerializedHighlightRange +): Range | null { + const fullText = collectTextContent(containerElement) + + let matchOffset = -1 + + const candidateText = fullText.slice( + highlight.textOffset, + highlight.textOffset + highlight.textLength + ) + if (candidateText === highlight.highlightedText) { + matchOffset = highlight.textOffset + } + + if (matchOffset === -1) { + const searchStart = Math.max(0, highlight.textOffset - 100) + const searchEnd = Math.min( + fullText.length, + highlight.textOffset + highlight.textLength + 100 + ) + const searchWindow = fullText.slice(searchStart, searchEnd) + const foundIndex = searchWindow.indexOf(highlight.highlightedText) + if (foundIndex !== -1) { + matchOffset = searchStart + foundIndex + } + } + + if (matchOffset === -1) { + const globalIndex = fullText.indexOf(highlight.highlightedText) + if (globalIndex !== -1) { + matchOffset = globalIndex + } + } + + if (matchOffset === -1) return null + + const startPosition = findTextNodeAtOffset(containerElement, matchOffset) + const endPosition = findTextNodeAtOffset( + containerElement, + matchOffset + highlight.textLength + ) + + if (!startPosition || !endPosition) return null + + const highlightRange = document.createRange() + highlightRange.setStart(startPosition.node, startPosition.offset) + highlightRange.setEnd(endPosition.node, endPosition.offset) + + return highlightRange +} + +interface TextNodeSegment { + node: Text + startOffset: number + endOffset: number +} + +function collectTextNodesInRange(range: Range): TextNodeSegment[] { + const segments: TextNodeSegment[] = [] + + if ( + range.startContainer === range.endContainer && + range.startContainer.nodeType === Node.TEXT_NODE + ) { + segments.push({ + node: range.startContainer as Text, + startOffset: range.startOffset, + endOffset: range.endOffset, + }) + return segments + } + + const ancestor = range.commonAncestorContainer + const walkRoot = + ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode! : ancestor + const treeWalker = document.createTreeWalker( + walkRoot, + NodeFilter.SHOW_TEXT + ) + + let foundStart = false + + while (treeWalker.nextNode()) { + const textNode = treeWalker.currentNode as Text + + if (textNode === range.startContainer) { + foundStart = true + segments.push({ + node: textNode, + startOffset: range.startOffset, + endOffset: (textNode.textContent ?? "").length, + }) + } else if (textNode === range.endContainer) { + segments.push({ + node: textNode, + startOffset: 0, + endOffset: range.endOffset, + }) + break + } else if (foundStart) { + segments.push({ + node: textNode, + startOffset: 0, + endOffset: (textNode.textContent ?? "").length, + }) + } + } + + return segments +} + +export function applyHighlightToRange( + highlightRange: Range, + highlightIdentifier: string, + color: string, + hasNote: boolean +): void { + const segments = collectTextNodesInRange(highlightRange) + + if (segments.length === 0) return + + for (const segment of segments) { + let targetNode = segment.node + + if (segment.endOffset < (targetNode.textContent ?? "").length) { + targetNode.splitText(segment.endOffset) + } + + if (segment.startOffset > 0) { + targetNode = targetNode.splitText(segment.startOffset) + } + + const markElement = document.createElement("mark") + markElement.setAttribute("data-highlight-identifier", highlightIdentifier) + markElement.setAttribute("data-highlight-color", color) + if (hasNote) { + markElement.setAttribute("data-has-note", "true") + } + + targetNode.parentNode!.insertBefore(markElement, targetNode) + markElement.appendChild(targetNode) + } +} + +export function removeHighlightFromDom( + containerElement: HTMLElement, + highlightIdentifier: string +): void { + const markElements = containerElement.querySelectorAll( + `mark[data-highlight-identifier="${highlightIdentifier}"]` + ) + + for (const markElement of markElements) { + const parentNode = markElement.parentNode + if (!parentNode) continue + + while (markElement.firstChild) { + parentNode.insertBefore(markElement.firstChild, markElement) + } + + parentNode.removeChild(markElement) + parentNode.normalize() + } +} diff --git a/apps/web/lib/hooks/use-is-mobile.ts b/apps/web/lib/hooks/use-is-mobile.ts new file mode 100644 index 0000000..a56e36c --- /dev/null +++ b/apps/web/lib/hooks/use-is-mobile.ts @@ -0,0 +1,26 @@ +"use client" + +import { useState, useEffect } from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(false) + + useEffect(() => { + const mediaQuery = window.matchMedia( + `(max-width: ${MOBILE_BREAKPOINT - 1}px)` + ) + + setIsMobile(mediaQuery.matches) + + function handleChange(event: MediaQueryListEvent) { + setIsMobile(event.matches) + } + + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, []) + + return isMobile +} diff --git a/apps/web/lib/hooks/use-keyboard-navigation.ts b/apps/web/lib/hooks/use-keyboard-navigation.ts new file mode 100644 index 0000000..c4b3f5f --- /dev/null +++ b/apps/web/lib/hooks/use-keyboard-navigation.ts @@ -0,0 +1,380 @@ +"use client" + +import { useEffect } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { + useToggleEntryReadState, + useToggleEntrySavedState, +} from "@/lib/queries/use-entry-state-mutations" +import { useMarkAllAsRead } from "@/lib/queries/use-mark-all-as-read" +import { queryKeys } from "@/lib/queries/query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" +import type { InfiniteData } from "@tanstack/react-query" + +function findEntryInCache( + queryClient: ReturnType<typeof useQueryClient>, + entryIdentifier: string +): TimelineEntry | undefined { + const allQueries = [ + ...queryClient.getQueriesData<InfiniteData<TimelineEntry[]>>({ + queryKey: queryKeys.timeline.all, + }), + ...queryClient.getQueriesData<InfiniteData<TimelineEntry[]>>({ + queryKey: ["custom-feed-timeline"], + }), + ...queryClient.getQueriesData<InfiniteData<TimelineEntry[]>>({ + queryKey: queryKeys.savedEntries.all, + }), + ] + + for (const [, data] of allQueries) { + if (!data) continue + for (const page of data.pages) { + const match = page.find( + (entry) => entry.entryIdentifier === entryIdentifier + ) + if (match) return match + } + } + + return undefined +} + +const PANEL_ORDER = ["sidebar", "entryList", "detailPanel"] as const + +export function useKeyboardNavigation() { + const queryClient = useQueryClient() + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setFocusedEntryIdentifier = useUserInterfaceStore( + (state) => state.setFocusedEntryIdentifier + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const setFocusedPanel = useUserInterfaceStore( + (state) => state.setFocusedPanel + ) + const focusedSidebarIndex = useUserInterfaceStore( + (state) => state.focusedSidebarIndex + ) + const setFocusedSidebarIndex = useUserInterfaceStore( + (state) => state.setFocusedSidebarIndex + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setCommandPaletteOpen = useUserInterfaceStore( + (state) => state.setCommandPaletteOpen + ) + const isCommandPaletteOpen = useUserInterfaceStore( + (state) => state.isCommandPaletteOpen + ) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const isSearchOpen = useUserInterfaceStore((state) => state.isSearchOpen) + const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) + const setSidebarCollapsed = useUserInterfaceStore( + (state) => state.setSidebarCollapsed + ) + const isSidebarCollapsed = useUserInterfaceStore( + (state) => state.isSidebarCollapsed + ) + const navigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.navigableEntryIdentifiers + ) + const toggleReadState = useToggleEntryReadState() + const toggleSavedState = useToggleEntrySavedState() + const markAllAsRead = useMarkAllAsRead() + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement + + if ( + event.key !== "Escape" && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return + } + + if ((isCommandPaletteOpen || isSearchOpen) && event.key !== "Escape") return + + if (event.ctrlKey) { + switch (event.key) { + case "h": { + event.preventDefault() + const currentPanelIndex = PANEL_ORDER.indexOf(focusedPanel) + if (currentPanelIndex > 0) { + const targetPanel = PANEL_ORDER[currentPanelIndex - 1] + setFocusedPanel(targetPanel) + if (targetPanel === "sidebar") { + setSidebarCollapsed(false) + } + } else { + setSidebarCollapsed(false) + setFocusedPanel("sidebar") + } + return + } + case "l": { + event.preventDefault() + const currentPanelIndex = PANEL_ORDER.indexOf(focusedPanel) + if (currentPanelIndex < PANEL_ORDER.length - 1) { + const targetPanel = PANEL_ORDER[currentPanelIndex + 1] + if (targetPanel === "detailPanel" && !selectedEntryIdentifier) { + return + } + setFocusedPanel(targetPanel) + } + return + } + } + + return + } + + if (focusedPanel === "sidebar") { + handleSidebarKeyDown(event) + return + } + + if (focusedPanel === "detailPanel") { + handleDetailPanelKeyDown(event) + return + } + + const activeIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + const currentIndex = activeIdentifier + ? navigableEntryIdentifiers.indexOf(activeIdentifier) + : -1 + + switch (event.key) { + case "j": + case "ArrowDown": { + event.preventDefault() + + if (navigableEntryIdentifiers.length === 0) break + + const nextIndex = + currentIndex === -1 + ? 0 + : Math.min(currentIndex + 1, navigableEntryIdentifiers.length - 1) + + setFocusedEntryIdentifier(navigableEntryIdentifiers[nextIndex]) + + break + } + case "k": + case "ArrowUp": { + event.preventDefault() + + if (navigableEntryIdentifiers.length === 0) break + + if (currentIndex === -1) { + setFocusedEntryIdentifier(navigableEntryIdentifiers[0]) + } else { + const previousIndex = Math.max(currentIndex - 1, 0) + setFocusedEntryIdentifier(navigableEntryIdentifiers[previousIndex]) + } + + break + } + case "Enter": { + if (focusedEntryIdentifier) { + event.preventDefault() + setSelectedEntryIdentifier(focusedEntryIdentifier) + } + + break + } + case "Escape": { + if (isCommandPaletteOpen) { + setCommandPaletteOpen(false) + } else if (isSearchOpen) { + setSearchOpen(false) + } else if (selectedEntryIdentifier) { + setSelectedEntryIdentifier(null) + } else if (focusedEntryIdentifier) { + setFocusedEntryIdentifier(null) + } + + break + } + case "r": { + const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + if (targetIdentifier) { + const entry = findEntryInCache(queryClient, targetIdentifier) + if (entry) { + toggleReadState.mutate({ + entryIdentifier: entry.entryIdentifier, + isRead: !entry.isRead, + }) + } + } + + break + } + case "s": { + const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + if (targetIdentifier) { + const entry = findEntryInCache(queryClient, targetIdentifier) + if (entry) { + toggleSavedState.mutate({ + entryIdentifier: entry.entryIdentifier, + isSaved: !entry.isSaved, + }) + } + } + + break + } + case "o": { + const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + if (targetIdentifier) { + const entry = findEntryInCache(queryClient, targetIdentifier) + if (entry?.entryUrl) { + window.open(entry.entryUrl, "_blank", "noopener,noreferrer") + } + } + + break + } + case "A": { + if (event.shiftKey) { + event.preventDefault() + markAllAsRead.mutate({}) + } + + break + } + case "/": { + event.preventDefault() + setSearchOpen(true) + + break + } + case "b": { + toggleSidebar() + + break + } + case "1": { + setEntryListViewMode("compact") + + break + } + case "2": { + setEntryListViewMode("comfortable") + + break + } + case "3": { + setEntryListViewMode("expanded") + + break + } + } + } + + function handleDetailPanelKeyDown(event: KeyboardEvent) { + const SCROLL_AMOUNT = 100 + + switch (event.key) { + case "j": + case "ArrowDown": { + event.preventDefault() + const detailArticle = document.querySelector( + "[data-detail-panel] article" + ) + detailArticle?.scrollBy({ top: SCROLL_AMOUNT, behavior: "smooth" }) + break + } + case "k": + case "ArrowUp": { + event.preventDefault() + const detailArticle = document.querySelector( + "[data-detail-panel] article" + ) + detailArticle?.scrollBy({ top: -SCROLL_AMOUNT, behavior: "smooth" }) + break + } + case "Escape": { + setFocusedPanel("entryList") + break + } + } + } + + function handleSidebarKeyDown(event: KeyboardEvent) { + const sidebarLinks = document.querySelectorAll<HTMLElement>( + "[data-sidebar-nav-item]" + ) + const itemCount = sidebarLinks.length + + if (itemCount === 0) return + + switch (event.key) { + case "j": + case "ArrowDown": { + event.preventDefault() + const nextIndex = Math.min(focusedSidebarIndex + 1, itemCount - 1) + setFocusedSidebarIndex(nextIndex) + sidebarLinks[nextIndex]?.scrollIntoView({ block: "nearest" }) + break + } + case "k": + case "ArrowUp": { + event.preventDefault() + const previousIndex = Math.max(focusedSidebarIndex - 1, 0) + setFocusedSidebarIndex(previousIndex) + sidebarLinks[previousIndex]?.scrollIntoView({ block: "nearest" }) + break + } + case "Enter": { + event.preventDefault() + sidebarLinks[focusedSidebarIndex]?.click() + setFocusedPanel("entryList") + break + } + case "Escape": { + setFocusedPanel("entryList") + break + } + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [ + selectedEntryIdentifier, + focusedEntryIdentifier, + focusedPanel, + focusedSidebarIndex, + isCommandPaletteOpen, + isSearchOpen, + isSidebarCollapsed, + navigableEntryIdentifiers, + queryClient, + setSelectedEntryIdentifier, + setFocusedEntryIdentifier, + setFocusedPanel, + setFocusedSidebarIndex, + setCommandPaletteOpen, + setSidebarCollapsed, + toggleSidebar, + setEntryListViewMode, + setSearchOpen, + toggleReadState, + toggleSavedState, + markAllAsRead, + ]) +} diff --git a/apps/web/lib/hooks/use-realtime-entries.ts b/apps/web/lib/hooks/use-realtime-entries.ts new file mode 100644 index 0000000..0eaba77 --- /dev/null +++ b/apps/web/lib/hooks/use-realtime-entries.ts @@ -0,0 +1,74 @@ +"use client" + +import { useEffect, useRef } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "@/lib/queries/query-keys" +import { toast } from "sonner" +import { useNotificationStore } from "@/lib/stores/notification-store" + +const DEBOUNCE_MILLISECONDS = 3000 + +export function useRealtimeEntries() { + const queryClient = useQueryClient() + const supabaseClientReference = useRef(createSupabaseBrowserClient()) + const pendingCountReference = useRef(0) + const debounceTimerReference = useRef<ReturnType<typeof setTimeout> | null>(null) + + useEffect(() => { + function flushPendingNotifications() { + const count = pendingCountReference.current + if (count === 0) return + + pendingCountReference.current = 0 + debounceTimerReference.current = null + + const message = + count === 1 ? "1 new entry" : `${count} new entries` + + useNotificationStore.getState().addNotification(message) + toast(message, { + action: { + label: "refresh", + onClick: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.timeline.all, + }) + }, + }, + }) + } + + const channel = supabaseClientReference.current + .channel("entries-realtime") + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "entries", + }, + () => { + pendingCountReference.current++ + + if (debounceTimerReference.current) { + clearTimeout(debounceTimerReference.current) + } + + debounceTimerReference.current = setTimeout( + flushPendingNotifications, + DEBOUNCE_MILLISECONDS + ) + } + ) + .subscribe() + + return () => { + if (debounceTimerReference.current) { + clearTimeout(debounceTimerReference.current) + } + + supabaseClientReference.current.removeChannel(channel) + } + }, [queryClient]) +} diff --git a/apps/web/lib/notify.ts b/apps/web/lib/notify.ts new file mode 100644 index 0000000..911364f --- /dev/null +++ b/apps/web/lib/notify.ts @@ -0,0 +1,11 @@ +import { toast } from "sonner" +import { useNotificationStore } from "./stores/notification-store" + +export function notify( + message: string, + type: "info" | "success" | "error" = "info", + actionUrl?: string +) { + toast(message) + useNotificationStore.getState().addNotification(message, type, actionUrl) +} diff --git a/apps/web/lib/opml.ts b/apps/web/lib/opml.ts new file mode 100644 index 0000000..bd0c3a7 --- /dev/null +++ b/apps/web/lib/opml.ts @@ -0,0 +1,161 @@ +import type { Folder, Subscription } from "@/lib/types/subscription" + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +export function generateOpml( + subscriptions: Subscription[], + folders: Folder[] +): string { + const lines: string[] = [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<opml version="2.0">', + " <head>", + " <title>asa.news subscriptions</title>", + ` <dateCreated>${new Date().toUTCString()}</dateCreated>`, + " </head>", + " <body>", + ] + + const folderMap = new Map<string, Folder>() + + for (const folder of folders) { + folderMap.set(folder.folderIdentifier, folder) + } + + const subscriptionsByFolder = new Map<string | null, Subscription[]>() + + for (const subscription of subscriptions) { + const key = subscription.folderIdentifier + const existing = subscriptionsByFolder.get(key) ?? [] + existing.push(subscription) + subscriptionsByFolder.set(key, existing) + } + + const ungrouped = subscriptionsByFolder.get(null) ?? [] + + for (const subscription of ungrouped) { + const title = escapeXml(subscription.customTitle ?? subscription.feedTitle) + const xmlUrl = escapeXml(subscription.feedUrl) + lines.push( + ` <outline type="rss" text="${title}" title="${title}" xmlUrl="${xmlUrl}" />` + ) + } + + for (const folder of folders) { + const folderSubscriptions = + subscriptionsByFolder.get(folder.folderIdentifier) ?? [] + const folderName = escapeXml(folder.name) + lines.push(` <outline text="${folderName}" title="${folderName}">`) + + for (const subscription of folderSubscriptions) { + const title = escapeXml(subscription.customTitle ?? subscription.feedTitle) + const xmlUrl = escapeXml(subscription.feedUrl) + lines.push( + ` <outline type="rss" text="${title}" title="${title}" xmlUrl="${xmlUrl}" />` + ) + } + + lines.push(" </outline>") + } + + lines.push(" </body>") + lines.push("</opml>") + + return lines.join("\n") +} + +export interface ParsedOpmlFeed { + url: string + title: string +} + +export interface ParsedOpmlGroup { + folderName: string | null + feeds: ParsedOpmlFeed[] +} + +export function parseOpml(xmlString: string): ParsedOpmlGroup[] { + const parser = new DOMParser() + const document = parser.parseFromString(xmlString, "application/xml") + const parseError = document.querySelector("parsererror") + + if (parseError) { + throw new Error("Invalid OPML file") + } + + const body = document.querySelector("body") + + if (!body) { + throw new Error("Invalid OPML: no body element") + } + + const groups: ParsedOpmlGroup[] = [] + const ungroupedFeeds: ParsedOpmlFeed[] = [] + const topLevelOutlines = body.querySelectorAll(":scope > outline") + + for (const outline of topLevelOutlines) { + const xmlUrl = outline.getAttribute("xmlUrl") + + if (xmlUrl) { + ungroupedFeeds.push({ + url: xmlUrl, + title: + outline.getAttribute("title") ?? + outline.getAttribute("text") ?? + xmlUrl, + }) + } else { + const folderName = + outline.getAttribute("title") ?? outline.getAttribute("text") + const feeds: ParsedOpmlFeed[] = [] + const childOutlines = outline.querySelectorAll(":scope > outline") + + for (const child of childOutlines) { + const childXmlUrl = child.getAttribute("xmlUrl") + + if (childXmlUrl) { + feeds.push({ + url: childXmlUrl, + title: + child.getAttribute("title") ?? + child.getAttribute("text") ?? + childXmlUrl, + }) + } + } + + if (feeds.length > 0) { + groups.push({ folderName: folderName, feeds }) + } + } + } + + if (ungroupedFeeds.length > 0) { + groups.unshift({ folderName: null, feeds: ungroupedFeeds }) + } + + return groups +} + +export function downloadOpml( + subscriptions: Subscription[], + folders: Folder[] +): void { + const opmlContent = generateOpml(subscriptions, folders) + const blob = new Blob([opmlContent], { type: "application/xml" }) + const url = URL.createObjectURL(blob) + const anchor = window.document.createElement("a") + anchor.href = url + anchor.download = "asa-news-subscriptions.opml" + window.document.body.appendChild(anchor) + anchor.click() + window.document.body.removeChild(anchor) + URL.revokeObjectURL(url) +} diff --git a/apps/web/lib/queries/query-keys.ts b/apps/web/lib/queries/query-keys.ts new file mode 100644 index 0000000..69e3407 --- /dev/null +++ b/apps/web/lib/queries/query-keys.ts @@ -0,0 +1,43 @@ +export const queryKeys = { + timeline: { + all: ["timeline"] as const, + list: (folderIdentifier?: string | null, feedIdentifier?: string | null, unreadOnly?: boolean) => + ["timeline", { folderIdentifier, feedIdentifier, unreadOnly }] as const, + }, + savedEntries: { + all: ["saved-entries"] as const, + }, + subscriptions: { + all: ["subscriptions"] as const, + }, + entryDetail: { + single: (entryIdentifier: string) => + ["entry-detail", entryIdentifier] as const, + }, + userProfile: { + all: ["user-profile"] as const, + }, + mutedKeywords: { + all: ["muted-keywords"] as const, + }, + unreadCounts: { + all: ["unread-counts"] as const, + }, + entrySearch: { + query: (searchQuery: string) => ["entry-search", searchQuery] as const, + }, + entryShare: { + single: (entryIdentifier: string) => + ["entry-share", entryIdentifier] as const, + }, + highlights: { + forEntry: (entryIdentifier: string) => + ["highlights", entryIdentifier] as const, + all: ["highlights"] as const, + }, + customFeeds: { + all: ["custom-feeds"] as const, + timeline: (customFeedIdentifier: string) => + ["custom-feed-timeline", customFeedIdentifier] as const, + }, +} diff --git a/apps/web/lib/queries/use-all-highlights.ts b/apps/web/lib/queries/use-all-highlights.ts new file mode 100644 index 0000000..39988de --- /dev/null +++ b/apps/web/lib/queries/use-all-highlights.ts @@ -0,0 +1,85 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { HighlightWithEntryContext } from "@/lib/types/highlight" + +const HIGHLIGHTS_PAGE_SIZE = 50 + +interface HighlightWithContextRow { + id: string + entry_id: string + highlighted_text: string + note: string | null + text_offset: number + text_length: number + text_prefix: string + text_suffix: string + color: string + created_at: string + entries: { + id: string + title: string | null + feeds: { + title: string | null + } + } +} + +function mapRowToHighlightWithContext( + row: HighlightWithContextRow +): HighlightWithEntryContext { + return { + identifier: row.id, + entryIdentifier: row.entry_id, + highlightedText: row.highlighted_text, + note: row.note, + textOffset: row.text_offset, + textLength: row.text_length, + textPrefix: row.text_prefix, + textSuffix: row.text_suffix, + color: row.color, + createdAt: row.created_at, + entryTitle: row.entries?.title ?? null, + feedTitle: row.entries?.feeds?.title ?? null, + } +} + +export function useAllHighlights() { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.highlights.all, + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + let query = supabaseClient + .from("user_highlights") + .select( + "id, entry_id, highlighted_text, note, text_offset, text_length, text_prefix, text_suffix, color, created_at, entries!inner(id, title, feeds!inner(title))" + ) + .order("created_at", { ascending: false }) + .limit(HIGHLIGHTS_PAGE_SIZE) + + if (pageParam) { + query = query.lt("created_at", pageParam) + } + + const { data, error } = await query + + if (error) throw error + + return ( + (data as unknown as HighlightWithContextRow[]) ?? [] + ).map(mapRowToHighlightWithContext) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: HighlightWithEntryContext[]) => { + if (lastPage.length < HIGHLIGHTS_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].createdAt + }, + }) +} diff --git a/apps/web/lib/queries/use-custom-feed-mutations.ts b/apps/web/lib/queries/use-custom-feed-mutations.ts new file mode 100644 index 0000000..f0751db --- /dev/null +++ b/apps/web/lib/queries/use-custom-feed-mutations.ts @@ -0,0 +1,122 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useCreateCustomFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + name, + query, + matchMode, + sourceFolderIdentifier, + }: { + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient.from("custom_feeds").insert({ + user_id: user.id, + name, + query, + match_mode: matchMode, + source_folder_id: sourceFolderIdentifier, + position: 0, + }) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("custom feed created") + }, + onError: (error: Error) => { + notify( + error.message.includes("limit") + ? "custom feed limit reached for your plan" + : "failed to create custom feed: " + error.message + ) + }, + }) +} + +export function useUpdateCustomFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + customFeedIdentifier, + name, + query, + matchMode, + sourceFolderIdentifier, + }: { + customFeedIdentifier: string + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + }) => { + const { error } = await supabaseClient + .from("custom_feeds") + .update({ + name, + query, + match_mode: matchMode, + source_folder_id: sourceFolderIdentifier, + }) + .eq("id", customFeedIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + notify("custom feed updated") + }, + onError: (error: Error) => { + notify("failed to update custom feed: " + error.message) + }, + }) +} + +export function useDeleteCustomFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + customFeedIdentifier, + }: { + customFeedIdentifier: string + }) => { + const { error } = await supabaseClient + .from("custom_feeds") + .delete() + .eq("id", customFeedIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("custom feed deleted") + }, + onError: (error: Error) => { + notify("failed to delete custom feed: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-custom-feed-timeline.ts b/apps/web/lib/queries/use-custom-feed-timeline.ts new file mode 100644 index 0000000..4224123 --- /dev/null +++ b/apps/web/lib/queries/use-custom-feed-timeline.ts @@ -0,0 +1,76 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +const TIMELINE_PAGE_SIZE = 50 + +interface TimelineRow { + entry_id: string + feed_id: string + feed_title: string + custom_title: string | null + entry_title: string + entry_url: string + author: string | null + summary: string | null + image_url: string | null + published_at: string + is_read: boolean + is_saved: boolean + enclosure_url: string | null + enclosure_type: string | null +} + +function mapRowToTimelineEntry(row: TimelineRow): TimelineEntry { + return { + entryIdentifier: row.entry_id, + feedIdentifier: row.feed_id, + feedTitle: row.feed_title, + customTitle: row.custom_title, + entryTitle: row.entry_title, + entryUrl: row.entry_url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + isRead: row.is_read, + isSaved: row.is_saved, + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + } +} + +export function useCustomFeedTimeline(customFeedIdentifier: string | null) { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.customFeeds.timeline(customFeedIdentifier ?? ""), + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + const { data, error } = await supabaseClient.rpc( + "get_custom_feed_timeline", + { + p_custom_feed_id: customFeedIdentifier!, + p_result_limit: TIMELINE_PAGE_SIZE, + p_pagination_cursor: pageParam ?? undefined, + } + ) + + if (error) throw error + + return ((data as TimelineRow[]) ?? []).map(mapRowToTimelineEntry) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: TimelineEntry[]) => { + if (lastPage.length < TIMELINE_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].publishedAt + }, + enabled: !!customFeedIdentifier, + }) +} diff --git a/apps/web/lib/queries/use-custom-feeds.ts b/apps/web/lib/queries/use-custom-feeds.ts new file mode 100644 index 0000000..5c11721 --- /dev/null +++ b/apps/web/lib/queries/use-custom-feeds.ts @@ -0,0 +1,49 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { CustomFeed } from "@/lib/types/custom-feed" + +interface CustomFeedRow { + id: string + name: string + query: string + match_mode: string + source_folder_id: string | null + position: number +} + +export function useCustomFeeds() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.customFeeds.all, + queryFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { data, error } = await supabaseClient + .from("custom_feeds") + .select("id, name, query, match_mode, source_folder_id, position") + .eq("user_id", user.id) + .order("position") + + if (error) throw error + + return ((data as CustomFeedRow[]) ?? []).map( + (row): CustomFeed => ({ + identifier: row.id, + name: row.name, + query: row.query, + matchMode: row.match_mode as "and" | "or", + sourceFolderIdentifier: row.source_folder_id, + position: row.position, + }) + ) + }, + }) +} diff --git a/apps/web/lib/queries/use-entry-highlights.ts b/apps/web/lib/queries/use-entry-highlights.ts new file mode 100644 index 0000000..3fdada5 --- /dev/null +++ b/apps/web/lib/queries/use-entry-highlights.ts @@ -0,0 +1,56 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { Highlight } from "@/lib/types/highlight" + +interface HighlightRow { + id: string + entry_id: string + highlighted_text: string + note: string | null + text_offset: number + text_length: number + text_prefix: string + text_suffix: string + color: string + created_at: string +} + +function mapRowToHighlight(row: HighlightRow): Highlight { + return { + identifier: row.id, + entryIdentifier: row.entry_id, + highlightedText: row.highlighted_text, + note: row.note, + textOffset: row.text_offset, + textLength: row.text_length, + textPrefix: row.text_prefix, + textSuffix: row.text_suffix, + color: row.color, + createdAt: row.created_at, + } +} + +export function useEntryHighlights(entryIdentifier: string | null) { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.highlights.forEntry(entryIdentifier ?? ""), + enabled: !!entryIdentifier, + queryFn: async () => { + const { data, error } = await supabaseClient + .from("user_highlights") + .select( + "id, entry_id, highlighted_text, note, text_offset, text_length, text_prefix, text_suffix, color, created_at" + ) + .eq("entry_id", entryIdentifier!) + .order("text_offset", { ascending: true }) + + if (error) throw error + + return ((data as unknown as HighlightRow[]) ?? []).map(mapRowToHighlight) + }, + }) +} diff --git a/apps/web/lib/queries/use-entry-search.ts b/apps/web/lib/queries/use-entry-search.ts new file mode 100644 index 0000000..9e05ac8 --- /dev/null +++ b/apps/web/lib/queries/use-entry-search.ts @@ -0,0 +1,58 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +interface SearchResultRow { + entry_id: string + feed_id: string + feed_title: string + custom_title: string | null + entry_title: string + entry_url: string + author: string | null + summary: string | null + image_url: string | null + published_at: string + is_read: boolean + is_saved: boolean +} + +export function useEntrySearch(searchQuery: string) { + const supabaseClient = createSupabaseBrowserClient() + const trimmedQuery = searchQuery.trim() + + return useQuery({ + queryKey: queryKeys.entrySearch.query(trimmedQuery), + queryFn: async () => { + const { data, error } = await supabaseClient.rpc("search_entries", { + p_query: trimmedQuery, + p_result_limit: 30, + }) + + if (error) throw error + + return ((data as SearchResultRow[]) ?? []).map( + (row): TimelineEntry => ({ + entryIdentifier: row.entry_id, + feedIdentifier: row.feed_id, + feedTitle: row.feed_title, + customTitle: row.custom_title, + entryTitle: row.entry_title, + entryUrl: row.entry_url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + isRead: row.is_read, + isSaved: row.is_saved, + enclosureUrl: null, + enclosureType: null, + }) + ) + }, + enabled: trimmedQuery.length >= 2, + }) +} diff --git a/apps/web/lib/queries/use-entry-share.ts b/apps/web/lib/queries/use-entry-share.ts new file mode 100644 index 0000000..bba7aa3 --- /dev/null +++ b/apps/web/lib/queries/use-entry-share.ts @@ -0,0 +1,36 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" + +interface EntryShareResult { + shareToken: string | null + isShared: boolean +} + +export function useEntryShare(entryIdentifier: string | null): { + data: EntryShareResult | undefined + isLoading: boolean +} { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.entryShare.single(entryIdentifier ?? ""), + enabled: !!entryIdentifier, + queryFn: async (): Promise<EntryShareResult> => { + const { data, error } = await supabaseClient + .from("shared_entries") + .select("share_token") + .eq("entry_id", entryIdentifier!) + .maybeSingle() + + if (error) throw error + + return { + shareToken: data?.share_token ?? null, + isShared: !!data, + } + }, + }) +} diff --git a/apps/web/lib/queries/use-entry-state-mutations.ts b/apps/web/lib/queries/use-entry-state-mutations.ts new file mode 100644 index 0000000..5f79fc0 --- /dev/null +++ b/apps/web/lib/queries/use-entry-state-mutations.ts @@ -0,0 +1,133 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" +import type { InfiniteData } from "@tanstack/react-query" + +export function useToggleEntryReadState() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + entryIdentifier, + isRead, + }: { + entryIdentifier: string + isRead: boolean + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_entry_states") + .upsert( + { + user_id: user.id, + entry_id: entryIdentifier, + read: isRead, + read_at: isRead ? new Date().toISOString() : null, + }, + { onConflict: "user_id,entry_id" } + ) + + if (error) throw error + }, + onMutate: async ({ entryIdentifier, isRead }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.timeline.all }) + + const previousTimeline = queryClient.getQueriesData< + InfiniteData<TimelineEntry[]> + >({ queryKey: queryKeys.timeline.all }) + + queryClient.setQueriesData<InfiniteData<TimelineEntry[]>>( + { queryKey: queryKeys.timeline.all }, + (existingData) => { + if (!existingData) return existingData + + return { + ...existingData, + pages: existingData.pages.map((page) => + page.map((entry) => + entry.entryIdentifier === entryIdentifier + ? { ...entry, isRead } + : entry + ) + ), + } + } + ) + + return { previousTimeline } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + }, + }) +} + +export function useToggleEntrySavedState() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + entryIdentifier, + isSaved, + }: { + entryIdentifier: string + isSaved: boolean + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_entry_states") + .upsert( + { + user_id: user.id, + entry_id: entryIdentifier, + saved: isSaved, + saved_at: isSaved ? new Date().toISOString() : null, + }, + { onConflict: "user_id,entry_id" } + ) + + if (error) throw error + }, + onMutate: async ({ entryIdentifier, isSaved }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.timeline.all }) + + queryClient.setQueriesData<InfiniteData<TimelineEntry[]>>( + { queryKey: queryKeys.timeline.all }, + (existingData) => { + if (!existingData) return existingData + + return { + ...existingData, + pages: existingData.pages.map((page) => + page.map((entry) => + entry.entryIdentifier === entryIdentifier + ? { ...entry, isSaved } + : entry + ) + ), + } + } + ) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + }, + }) +} diff --git a/apps/web/lib/queries/use-folder-mutations.ts b/apps/web/lib/queries/use-folder-mutations.ts new file mode 100644 index 0000000..8595a60 --- /dev/null +++ b/apps/web/lib/queries/use-folder-mutations.ts @@ -0,0 +1,137 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useCreateFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ name }: { name: string }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient.from("folders").insert({ + user_id: user.id, + name, + position: 0, + }) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("folder created") + }, + onError: (error: Error) => { + notify(error.message.includes("limit") + ? "folder limit reached for your plan" + : "failed to create folder: " + error.message) + }, + }) +} + +export function useDeleteAllFolders() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + await supabaseClient + .from("subscriptions") + .update({ folder_id: null }) + .eq("user_id", user.id) + .not("folder_id", "is", null) + + const { error } = await supabaseClient + .from("folders") + .delete() + .eq("user_id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("all folders deleted") + }, + onError: (error: Error) => { + notify("failed to delete all folders: " + error.message) + }, + }) +} + +export function useRenameFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + folderIdentifier, + name, + }: { + folderIdentifier: string + name: string + }) => { + const { error } = await supabaseClient + .from("folders") + .update({ name }) + .eq("id", folderIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + notify("folder renamed") + }, + onError: (error: Error) => { + notify("failed to rename folder: " + error.message) + }, + }) +} + +export function useDeleteFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + folderIdentifier, + }: { + folderIdentifier: string + }) => { + await supabaseClient + .from("subscriptions") + .update({ folder_id: null }) + .eq("folder_id", folderIdentifier) + + const { error } = await supabaseClient + .from("folders") + .delete() + .eq("id", folderIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("folder deleted") + }, + onError: (error: Error) => { + notify("failed to delete folder: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-highlight-mutations.ts b/apps/web/lib/queries/use-highlight-mutations.ts new file mode 100644 index 0000000..0e228c8 --- /dev/null +++ b/apps/web/lib/queries/use-highlight-mutations.ts @@ -0,0 +1,132 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +interface CreateHighlightParameters { + entryIdentifier: string + highlightedText: string + textOffset: number + textLength: number + textPrefix: string + textSuffix: string + note?: string | null + color?: string +} + +export function useCreateHighlight() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (parameters: CreateHighlightParameters) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { data, error } = await supabaseClient + .from("user_highlights") + .insert({ + user_id: user.id, + entry_id: parameters.entryIdentifier, + highlighted_text: parameters.highlightedText, + text_offset: parameters.textOffset, + text_length: parameters.textLength, + text_prefix: parameters.textPrefix, + text_suffix: parameters.textSuffix, + note: parameters.note ?? null, + color: parameters.color ?? "yellow", + }) + .select("id") + .single() + + if (error) throw error + + return data.id as string + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("text highlighted") + }, + onError: (error: Error) => { + notify( + error.message.includes("limit") + ? "highlight limit reached for your plan" + : "failed to create highlight" + ) + }, + }) +} + +export function useUpdateHighlightNote() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + highlightIdentifier, + note, + }: { + highlightIdentifier: string + note: string | null + entryIdentifier: string + }) => { + const { error } = await supabaseClient + .from("user_highlights") + .update({ note }) + .eq("id", highlightIdentifier) + + if (error) throw error + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all }) + notify("note updated") + }, + onError: () => { + notify("failed to update note") + }, + }) +} + +export function useDeleteHighlight() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + highlightIdentifier, + }: { + highlightIdentifier: string + entryIdentifier: string + }) => { + const { error } = await supabaseClient + .from("user_highlights") + .delete() + .eq("id", highlightIdentifier) + + if (error) throw error + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("highlight removed") + }, + onError: () => { + notify("failed to remove highlight") + }, + }) +} diff --git a/apps/web/lib/queries/use-mark-all-as-read.ts b/apps/web/lib/queries/use-mark-all-as-read.ts new file mode 100644 index 0000000..fdda661 --- /dev/null +++ b/apps/web/lib/queries/use-mark-all-as-read.ts @@ -0,0 +1,48 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useMarkAllAsRead() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + feedIdentifier, + folderIdentifier, + readState = true, + }: { + feedIdentifier?: string | null + folderIdentifier?: string | null + readState?: boolean + } = {}) => { + const { data, error } = await supabaseClient.rpc("mark_all_as_read", { + p_feed_id: feedIdentifier ?? null, + p_folder_id: folderIdentifier ?? null, + p_read_state: readState, + }) + + if (error) throw error + + return data as number + }, + onSuccess: (affectedCount, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.unreadCounts.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + + const action = variables?.readState === false ? "unread" : "read" + + if (affectedCount > 0) { + notify(`marked ${affectedCount} entries as ${action}`) + } + }, + onError: (_, variables) => { + const action = variables?.readState === false ? "unread" : "read" + notify(`failed to mark entries as ${action}`) + }, + }) +} diff --git a/apps/web/lib/queries/use-muted-keyword-mutations.ts b/apps/web/lib/queries/use-muted-keyword-mutations.ts new file mode 100644 index 0000000..67bcf33 --- /dev/null +++ b/apps/web/lib/queries/use-muted-keyword-mutations.ts @@ -0,0 +1,68 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useAddMutedKeyword() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ keyword }: { keyword: string }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient.from("muted_keywords").insert({ + user_id: user.id, + keyword: keyword.toLowerCase().trim(), + }) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("keyword muted") + }, + onError: (error: Error) => { + notify(error.message.includes("limit") + ? "muted keyword limit reached for your plan" + : "failed to mute keyword: " + error.message) + }, + }) +} + +export function useDeleteMutedKeyword() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + keywordIdentifier, + }: { + keywordIdentifier: string + }) => { + const { error } = await supabaseClient + .from("muted_keywords") + .delete() + .eq("id", keywordIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("keyword unmuted") + }, + onError: (error: Error) => { + notify("failed to unmute keyword: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-muted-keywords.ts b/apps/web/lib/queries/use-muted-keywords.ts new file mode 100644 index 0000000..ce1b53e --- /dev/null +++ b/apps/web/lib/queries/use-muted-keywords.ts @@ -0,0 +1,30 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { MutedKeyword } from "@/lib/types/user-profile" + +export function useMutedKeywords() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.mutedKeywords.all, + queryFn: async () => { + const { data, error } = await supabaseClient + .from("muted_keywords") + .select("id, keyword, created_at") + .order("created_at", { ascending: false }) + + if (error) throw error + + const keywords: MutedKeyword[] = (data ?? []).map((row) => ({ + identifier: row.id, + keyword: row.keyword, + createdAt: row.created_at, + })) + + return keywords + }, + }) +} diff --git a/apps/web/lib/queries/use-saved-entries.ts b/apps/web/lib/queries/use-saved-entries.ts new file mode 100644 index 0000000..bdfcec9 --- /dev/null +++ b/apps/web/lib/queries/use-saved-entries.ts @@ -0,0 +1,88 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +const SAVED_PAGE_SIZE = 50 + +interface SavedEntryRow { + entry_id: string + read: boolean + saved: boolean + saved_at: string + entries: { + id: string + feed_id: string + title: string | null + url: string | null + author: string | null + summary: string | null + image_url: string | null + published_at: string | null + enclosure_url: string | null + enclosure_type: string | null + feeds: { + title: string | null + } + } +} + +function mapSavedRowToTimelineEntry(row: SavedEntryRow): TimelineEntry { + return { + entryIdentifier: row.entries.id, + feedIdentifier: row.entries.feed_id, + feedTitle: row.entries.feeds?.title ?? "", + customTitle: null, + entryTitle: row.entries.title ?? "", + entryUrl: row.entries.url ?? "", + author: row.entries.author, + summary: row.entries.summary, + imageUrl: row.entries.image_url, + publishedAt: row.entries.published_at ?? "", + isRead: row.read, + isSaved: row.saved, + enclosureUrl: row.entries.enclosure_url, + enclosureType: row.entries.enclosure_type, + } +} + +export function useSavedEntries() { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.savedEntries.all, + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + let query = supabaseClient + .from("user_entry_states") + .select( + "entry_id, read, saved, saved_at, entries!inner(id, feed_id, title, url, author, summary, image_url, published_at, enclosure_url, enclosure_type, feeds!inner(title))" + ) + .eq("saved", true) + .order("saved_at", { ascending: false }) + .limit(SAVED_PAGE_SIZE) + + if (pageParam) { + query = query.lt("saved_at", pageParam) + } + + const { data, error } = await query + + if (error) throw error + + return ((data as unknown as SavedEntryRow[]) ?? []).map( + mapSavedRowToTimelineEntry + ) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: TimelineEntry[]) => { + if (lastPage.length < SAVED_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].publishedAt + }, + }) +} diff --git a/apps/web/lib/queries/use-subscribe-to-feed.ts b/apps/web/lib/queries/use-subscribe-to-feed.ts new file mode 100644 index 0000000..5e585a9 --- /dev/null +++ b/apps/web/lib/queries/use-subscribe-to-feed.ts @@ -0,0 +1,37 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useSubscribeToFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (parameters: { + feedUrl: string + folderIdentifier?: string | null + customTitle?: string | null + }) => { + const { data, error } = await supabaseClient.rpc("subscribe_to_feed", { + feed_url: parameters.feedUrl, + target_folder_id: parameters.folderIdentifier ?? undefined, + feed_custom_title: parameters.customTitle ?? undefined, + }) + + if (error) throw error + + return data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + notify("feed added successfully") + }, + onError: (error: Error) => { + notify("failed to add feed: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-subscription-mutations.ts b/apps/web/lib/queries/use-subscription-mutations.ts new file mode 100644 index 0000000..3b4b3ba --- /dev/null +++ b/apps/web/lib/queries/use-subscription-mutations.ts @@ -0,0 +1,158 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useUpdateSubscriptionTitle() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + customTitle, + }: { + subscriptionIdentifier: string + customTitle: string | null + }) => { + const { error } = await supabaseClient + .from("subscriptions") + .update({ custom_title: customTitle }) + .eq("id", subscriptionIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + notify("title updated") + }, + onError: (error: Error) => { + notify("failed to update title: " + error.message) + }, + }) +} + +export function useMoveSubscriptionToFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + folderIdentifier, + }: { + subscriptionIdentifier: string + folderIdentifier: string | null + feedTitle?: string + sourceFolderName?: string + folderName?: string + }) => { + const { error } = await supabaseClient + .from("subscriptions") + .update({ folder_id: folderIdentifier }) + .eq("id", subscriptionIdentifier) + + if (error) throw error + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + const source = variables.sourceFolderName ?? "no folder" + const destination = variables.folderName ?? "no folder" + const feedLabel = variables.feedTitle ?? "feed" + notify(`moved "${feedLabel}" from ${source} to ${destination}`) + }, + onError: (error: Error) => { + notify("failed to move feed: " + error.message) + }, + }) +} + +export function useUnsubscribe() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + }: { + subscriptionIdentifier: string + }) => { + const { error } = await supabaseClient + .from("subscriptions") + .delete() + .eq("id", subscriptionIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("unsubscribed") + }, + onError: (error: Error) => { + notify("failed to unsubscribe: " + error.message) + }, + }) +} + +export function useUnsubscribeAll() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("subscriptions") + .delete() + .eq("user_id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.unreadCounts.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("all feeds removed") + }, + onError: (error: Error) => { + notify("failed to remove all feeds: " + error.message) + }, + }) +} + +export function useRequestFeedRefresh() { + const supabaseClient = createSupabaseBrowserClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + }: { + subscriptionIdentifier: string + }) => { + const { error } = await supabaseClient.rpc("request_feed_refresh", { + target_subscription_id: subscriptionIdentifier, + }) + + if (error) throw error + }, + onSuccess: () => { + notify("refresh requested") + }, + onError: (error: Error) => { + notify(error.message.includes("Pro") + ? "manual refresh requires a pro subscription" + : "failed to request refresh: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-subscriptions.ts b/apps/web/lib/queries/use-subscriptions.ts new file mode 100644 index 0000000..ebf099d --- /dev/null +++ b/apps/web/lib/queries/use-subscriptions.ts @@ -0,0 +1,78 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { Folder, Subscription } from "@/lib/types/subscription" + +interface SubscriptionRow { + id: string + feed_id: string + folder_id: string | null + custom_title: string | null + position: number + feeds: { + title: string | null + url: string + consecutive_failures: number + last_fetch_error: string | null + last_fetched_at: string | null + fetch_interval_seconds: number + feed_type: string | null + } +} + +interface FolderRow { + id: string + name: string + position: number +} + +export function useSubscriptions() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.subscriptions.all, + queryFn: async () => { + const [subscriptionsResult, foldersResult] = await Promise.all([ + supabaseClient + .from("subscriptions") + .select("id, feed_id, folder_id, custom_title, position, feeds(title, url, consecutive_failures, last_fetch_error, last_fetched_at, fetch_interval_seconds, feed_type)") + .order("position", { ascending: true }), + supabaseClient + .from("folders") + .select("id, name, position") + .order("position", { ascending: true }), + ]) + + if (subscriptionsResult.error) throw subscriptionsResult.error + if (foldersResult.error) throw foldersResult.error + + const subscriptions: Subscription[] = ( + (subscriptionsResult.data as unknown as SubscriptionRow[]) ?? [] + ).map((row) => ({ + subscriptionIdentifier: row.id, + feedIdentifier: row.feed_id, + folderIdentifier: row.folder_id, + customTitle: row.custom_title, + feedTitle: row.feeds?.title ?? "", + feedUrl: row.feeds?.url ?? "", + consecutiveFailures: row.feeds?.consecutive_failures ?? 0, + lastFetchError: row.feeds?.last_fetch_error ?? null, + lastFetchedAt: row.feeds?.last_fetched_at ?? null, + fetchIntervalSeconds: row.feeds?.fetch_interval_seconds ?? 3600, + feedType: row.feeds?.feed_type ?? null, + })) + + const folders: Folder[] = ( + (foldersResult.data as unknown as FolderRow[]) ?? [] + ).map((row) => ({ + folderIdentifier: row.id, + name: row.name, + position: row.position, + })) + + return { subscriptions, folders } + }, + }) +} diff --git a/apps/web/lib/queries/use-timeline.ts b/apps/web/lib/queries/use-timeline.ts new file mode 100644 index 0000000..5a38aba --- /dev/null +++ b/apps/web/lib/queries/use-timeline.ts @@ -0,0 +1,78 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +const TIMELINE_PAGE_SIZE = 50 + +interface TimelineRow { + entry_id: string + feed_id: string + feed_title: string + custom_title: string | null + entry_title: string + entry_url: string + author: string | null + summary: string | null + image_url: string | null + published_at: string + is_read: boolean + is_saved: boolean + enclosure_url: string | null + enclosure_type: string | null +} + +function mapRowToTimelineEntry(row: TimelineRow): TimelineEntry { + return { + entryIdentifier: row.entry_id, + feedIdentifier: row.feed_id, + feedTitle: row.feed_title, + customTitle: row.custom_title, + entryTitle: row.entry_title, + entryUrl: row.entry_url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + isRead: row.is_read, + isSaved: row.is_saved, + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + } +} + +export function useTimeline( + folderIdentifier?: string | null, + feedIdentifier?: string | null, + unreadOnly?: boolean +) { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.timeline.list(folderIdentifier, feedIdentifier, unreadOnly), + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + const { data, error } = await supabaseClient.rpc("get_timeline", { + target_folder_id: folderIdentifier ?? undefined, + target_feed_id: feedIdentifier ?? undefined, + result_limit: TIMELINE_PAGE_SIZE, + pagination_cursor: pageParam ?? undefined, + unread_only: unreadOnly ?? false, + }) + + if (error) throw error + + return ((data as TimelineRow[]) ?? []).map(mapRowToTimelineEntry) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: TimelineEntry[]) => { + if (lastPage.length < TIMELINE_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].publishedAt + }, + }) +} diff --git a/apps/web/lib/queries/use-unread-counts.ts b/apps/web/lib/queries/use-unread-counts.ts new file mode 100644 index 0000000..75deccb --- /dev/null +++ b/apps/web/lib/queries/use-unread-counts.ts @@ -0,0 +1,32 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" + +interface UnreadCountRow { + feed_id: string + unread_count: number +} + +export function useUnreadCounts() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.unreadCounts.all, + queryFn: async () => { + const { data, error } = await supabaseClient.rpc("get_unread_counts") + + if (error) throw error + + const countsByFeedIdentifier: Record<string, number> = {} + + for (const row of (data as UnreadCountRow[]) ?? []) { + countsByFeedIdentifier[row.feed_id] = row.unread_count + } + + return countsByFeedIdentifier + }, + refetchInterval: 60_000, + }) +} diff --git a/apps/web/lib/queries/use-user-profile.ts b/apps/web/lib/queries/use-user-profile.ts new file mode 100644 index 0000000..760f970 --- /dev/null +++ b/apps/web/lib/queries/use-user-profile.ts @@ -0,0 +1,46 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { UserProfile } from "@/lib/types/user-profile" + +export function useUserProfile() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.userProfile.all, + queryFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { data, error } = await supabaseClient + .from("user_profiles") + .select( + "id, display_name, tier, feed_count, folder_count, muted_keyword_count, custom_feed_count, stripe_subscription_status, stripe_current_period_end" + ) + .eq("id", user.id) + .single() + + if (error) throw error + + const profile: UserProfile = { + identifier: data.id, + email: user.email ?? null, + displayName: data.display_name, + tier: data.tier, + feedCount: data.feed_count, + folderCount: data.folder_count, + mutedKeywordCount: data.muted_keyword_count, + customFeedCount: data.custom_feed_count, + stripeSubscriptionStatus: data.stripe_subscription_status, + stripeCurrentPeriodEnd: data.stripe_current_period_end, + } + + return profile + }, + }) +} diff --git a/apps/web/lib/query-client.ts b/apps/web/lib/query-client.ts new file mode 100644 index 0000000..82be2df --- /dev/null +++ b/apps/web/lib/query-client.ts @@ -0,0 +1,12 @@ +import { QueryClient } from "@tanstack/react-query" + +export function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + refetchOnWindowFocus: false, + }, + }, + }) +} diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts new file mode 100644 index 0000000..4016781 --- /dev/null +++ b/apps/web/lib/rate-limit.ts @@ -0,0 +1,24 @@ +const requestTimestamps = new Map<string, number[]>() + +export function rateLimit( + identifier: string, + limit: number, + windowMilliseconds: number +): { success: boolean; remaining: number } { + const now = Date.now() + const timestamps = requestTimestamps.get(identifier) ?? [] + const windowStart = now - windowMilliseconds + const recentTimestamps = timestamps.filter( + (timestamp) => timestamp > windowStart + ) + + if (recentTimestamps.length >= limit) { + requestTimestamps.set(identifier, recentTimestamps) + return { success: false, remaining: 0 } + } + + recentTimestamps.push(now) + requestTimestamps.set(identifier, recentTimestamps) + + return { success: true, remaining: limit - recentTimestamps.length } +} diff --git a/apps/web/lib/sanitize.ts b/apps/web/lib/sanitize.ts new file mode 100644 index 0000000..b63cee1 --- /dev/null +++ b/apps/web/lib/sanitize.ts @@ -0,0 +1,43 @@ +import sanitizeHtml from "sanitize-html" + +const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { + allowedTags: [ + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "p", + "a", + "ul", + "ol", + "li", + "blockquote", + "pre", + "code", + "em", + "strong", + "del", + "br", + "hr", + "img", + "figure", + "figcaption", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + ], + allowedAttributes: { + a: ["href", "title", "rel"], + img: ["src", "alt", "title", "width", "height"], + }, + allowedSchemes: ["http", "https"], +} + +export function sanitizeEntryContent(htmlContent: string): string { + return sanitizeHtml(htmlContent, SANITIZE_OPTIONS) +} diff --git a/apps/web/lib/stores/notification-store.ts b/apps/web/lib/stores/notification-store.ts new file mode 100644 index 0000000..d7eee57 --- /dev/null +++ b/apps/web/lib/stores/notification-store.ts @@ -0,0 +1,66 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" + +const MAXIMUM_NOTIFICATIONS = 50 + +export interface StoredNotification { + identifier: string + message: string + timestamp: string + type: "info" | "success" | "error" + actionUrl?: string +} + +interface NotificationState { + notifications: StoredNotification[] + lastViewedAt: string | null + + addNotification: ( + message: string, + type?: "info" | "success" | "error", + actionUrl?: string + ) => void + dismissNotification: (identifier: string) => void + clearAllNotifications: () => void + markAllAsViewed: () => void +} + +export const useNotificationStore = create<NotificationState>()( + persist( + (set) => ({ + notifications: [], + lastViewedAt: null, + + addNotification: (message, type = "info", actionUrl) => + set((state) => { + const newNotification: StoredNotification = { + identifier: crypto.randomUUID(), + message, + timestamp: new Date().toISOString(), + type, + ...(actionUrl ? { actionUrl } : {}), + } + const updated = [newNotification, ...state.notifications].slice( + 0, + MAXIMUM_NOTIFICATIONS + ) + return { notifications: updated } + }), + + dismissNotification: (identifier) => + set((state) => ({ + notifications: state.notifications.filter( + (notification) => notification.identifier !== identifier + ), + })), + + clearAllNotifications: () => set({ notifications: [] }), + + markAllAsViewed: () => + set({ lastViewedAt: new Date().toISOString() }), + }), + { + name: "asa-news-notifications", + } + ) +) diff --git a/apps/web/lib/stores/user-interface-store.ts b/apps/web/lib/stores/user-interface-store.ts new file mode 100644 index 0000000..468542d --- /dev/null +++ b/apps/web/lib/stores/user-interface-store.ts @@ -0,0 +1,135 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" + +type EntryListViewMode = "compact" | "comfortable" | "expanded" + +type DisplayDensity = "compact" | "default" | "spacious" + +type FocusedPanel = "sidebar" | "entryList" | "detailPanel" + +type SettingsTab = + | "subscriptions" + | "folders" + | "muted-keywords" + | "custom-feeds" + | "import-export" + | "appearance" + | "account" + | "security" + | "billing" + | "api" + | "danger" + +interface UserInterfaceState { + isSidebarCollapsed: boolean + isCommandPaletteOpen: boolean + isAddFeedDialogOpen: boolean + isSearchOpen: boolean + selectedEntryIdentifier: string | null + focusedEntryIdentifier: string | null + focusedPanel: FocusedPanel + focusedSidebarIndex: number + entryListViewMode: EntryListViewMode + displayDensity: DisplayDensity + activeSettingsTab: SettingsTab + showFeedFavicons: boolean + focusFollowsInteraction: boolean + expandedFolderIdentifiers: string[] + navigableEntryIdentifiers: string[] + + toggleSidebar: () => void + setSidebarCollapsed: (isCollapsed: boolean) => void + setCommandPaletteOpen: (isOpen: boolean) => void + setAddFeedDialogOpen: (isOpen: boolean) => void + setSearchOpen: (isOpen: boolean) => void + setSelectedEntryIdentifier: (identifier: string | null) => void + setFocusedEntryIdentifier: (identifier: string | null) => void + setFocusedPanel: (panel: FocusedPanel) => void + setFocusedSidebarIndex: (index: number) => void + setEntryListViewMode: (mode: EntryListViewMode) => void + setDisplayDensity: (density: DisplayDensity) => void + setActiveSettingsTab: (tab: SettingsTab) => void + setShowFeedFavicons: (show: boolean) => void + setFocusFollowsInteraction: (enabled: boolean) => void + toggleFolderExpansion: (folderIdentifier: string) => void + setNavigableEntryIdentifiers: (identifiers: string[]) => void +} + +export const useUserInterfaceStore = create<UserInterfaceState>()( + persist( + (set) => ({ + isSidebarCollapsed: false, + isCommandPaletteOpen: false, + isAddFeedDialogOpen: false, + isSearchOpen: false, + selectedEntryIdentifier: null, + focusedEntryIdentifier: null, + focusedPanel: "entryList", + focusedSidebarIndex: 0, + entryListViewMode: "comfortable", + displayDensity: "default", + activeSettingsTab: "subscriptions", + showFeedFavicons: true, + focusFollowsInteraction: false, + expandedFolderIdentifiers: [], + navigableEntryIdentifiers: [], + + toggleSidebar: () => + set((state) => ({ isSidebarCollapsed: !state.isSidebarCollapsed })), + + setSidebarCollapsed: (isCollapsed) => + set({ isSidebarCollapsed: isCollapsed }), + + setCommandPaletteOpen: (isOpen) => set({ isCommandPaletteOpen: isOpen }), + + setAddFeedDialogOpen: (isOpen) => set({ isAddFeedDialogOpen: isOpen }), + + setSearchOpen: (isOpen) => set({ isSearchOpen: isOpen }), + + setSelectedEntryIdentifier: (identifier) => + set({ selectedEntryIdentifier: identifier }), + + setFocusedEntryIdentifier: (identifier) => + set({ focusedEntryIdentifier: identifier }), + + setFocusedPanel: (panel) => set({ focusedPanel: panel }), + + setFocusedSidebarIndex: (index) => set({ focusedSidebarIndex: index }), + + setEntryListViewMode: (mode) => set({ entryListViewMode: mode }), + + setDisplayDensity: (density) => set({ displayDensity: density }), + + setActiveSettingsTab: (tab) => set({ activeSettingsTab: tab }), + + setShowFeedFavicons: (show) => set({ showFeedFavicons: show }), + + setFocusFollowsInteraction: (enabled) => + set({ focusFollowsInteraction: enabled }), + + toggleFolderExpansion: (folderIdentifier) => + set((state) => { + const current = state.expandedFolderIdentifiers + const isExpanded = current.includes(folderIdentifier) + return { + expandedFolderIdentifiers: isExpanded + ? current.filter((id) => id !== folderIdentifier) + : [...current, folderIdentifier], + } + }), + + setNavigableEntryIdentifiers: (identifiers) => + set({ navigableEntryIdentifiers: identifiers }), + }), + { + name: "asa-news-ui-preferences", + partialize: (state) => ({ + entryListViewMode: state.entryListViewMode, + displayDensity: state.displayDensity, + showFeedFavicons: state.showFeedFavicons, + focusFollowsInteraction: state.focusFollowsInteraction, + expandedFolderIdentifiers: state.expandedFolderIdentifiers, + }), + } + ) +) diff --git a/apps/web/lib/stripe.ts b/apps/web/lib/stripe.ts new file mode 100644 index 0000000..1955c02 --- /dev/null +++ b/apps/web/lib/stripe.ts @@ -0,0 +1,11 @@ +import Stripe from "stripe" + +let stripeInstance: Stripe | null = null + +export function getStripe(): Stripe { + if (!stripeInstance) { + stripeInstance = new Stripe(process.env.STRIPE_SECRET_KEY!) + } + + return stripeInstance +} diff --git a/apps/web/lib/supabase/admin.ts b/apps/web/lib/supabase/admin.ts new file mode 100644 index 0000000..5f5684d --- /dev/null +++ b/apps/web/lib/supabase/admin.ts @@ -0,0 +1,8 @@ +import { createClient } from "@supabase/supabase-js" + +export function createSupabaseAdminClient() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) +} diff --git a/apps/web/lib/supabase/client.ts b/apps/web/lib/supabase/client.ts new file mode 100644 index 0000000..c6747fb --- /dev/null +++ b/apps/web/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from "@supabase/ssr" + +export function createSupabaseBrowserClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} diff --git a/apps/web/lib/supabase/middleware.ts b/apps/web/lib/supabase/middleware.ts new file mode 100644 index 0000000..038e7c0 --- /dev/null +++ b/apps/web/lib/supabase/middleware.ts @@ -0,0 +1,39 @@ +import { createServerClient } from "@supabase/ssr" +import { NextResponse, type NextRequest } from "next/server" + +export async function updateSupabaseSession(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }) + + const supabaseClient = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ) + + supabaseResponse = NextResponse.next({ + request, + }) + + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + return { user, supabaseResponse } +} diff --git a/apps/web/lib/supabase/server.ts b/apps/web/lib/supabase/server.ts new file mode 100644 index 0000000..f781393 --- /dev/null +++ b/apps/web/lib/supabase/server.ts @@ -0,0 +1,27 @@ +import { createServerClient } from "@supabase/ssr" +import { cookies } from "next/headers" + +export async function createSupabaseServerClient() { + const cookieStore = await cookies() + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll() + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ) + } catch { + // no-op + } + }, + }, + } + ) +} diff --git a/apps/web/lib/types/custom-feed.ts b/apps/web/lib/types/custom-feed.ts new file mode 100644 index 0000000..d729a12 --- /dev/null +++ b/apps/web/lib/types/custom-feed.ts @@ -0,0 +1,8 @@ +export interface CustomFeed { + identifier: string + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + position: number +} diff --git a/apps/web/lib/types/highlight.ts b/apps/web/lib/types/highlight.ts new file mode 100644 index 0000000..60ec53c --- /dev/null +++ b/apps/web/lib/types/highlight.ts @@ -0,0 +1,17 @@ +export interface Highlight { + identifier: string + entryIdentifier: string + highlightedText: string + note: string | null + textOffset: number + textLength: number + textPrefix: string + textSuffix: string + color: string + createdAt: string +} + +export interface HighlightWithEntryContext extends Highlight { + entryTitle: string | null + feedTitle: string | null +} diff --git a/apps/web/lib/types/subscription.ts b/apps/web/lib/types/subscription.ts new file mode 100644 index 0000000..36d16d4 --- /dev/null +++ b/apps/web/lib/types/subscription.ts @@ -0,0 +1,19 @@ +export interface Folder { + folderIdentifier: string + name: string + position: number +} + +export interface Subscription { + subscriptionIdentifier: string + feedIdentifier: string + folderIdentifier: string | null + customTitle: string | null + feedTitle: string + feedUrl: string + consecutiveFailures: number + lastFetchError: string | null + lastFetchedAt: string | null + fetchIntervalSeconds: number + feedType: string | null +} diff --git a/apps/web/lib/types/timeline.ts b/apps/web/lib/types/timeline.ts new file mode 100644 index 0000000..888e428 --- /dev/null +++ b/apps/web/lib/types/timeline.ts @@ -0,0 +1,16 @@ +export interface TimelineEntry { + entryIdentifier: string + feedIdentifier: string + feedTitle: string + customTitle: string | null + entryTitle: string + entryUrl: string + author: string | null + summary: string | null + imageUrl: string | null + publishedAt: string + isRead: boolean + isSaved: boolean + enclosureUrl: string | null + enclosureType: string | null +} diff --git a/apps/web/lib/types/user-profile.ts b/apps/web/lib/types/user-profile.ts new file mode 100644 index 0000000..68eeb75 --- /dev/null +++ b/apps/web/lib/types/user-profile.ts @@ -0,0 +1,18 @@ +export interface UserProfile { + identifier: string + email: string | null + displayName: string | null + tier: "free" | "pro" | "developer" + feedCount: number + folderCount: number + mutedKeywordCount: number + customFeedCount: number + stripeSubscriptionStatus: string | null + stripeCurrentPeriodEnd: string | null +} + +export interface MutedKeyword { + identifier: string + keyword: string + createdAt: string +} diff --git a/apps/web/lib/utilities.ts b/apps/web/lib/utilities.ts new file mode 100644 index 0000000..c4b84f2 --- /dev/null +++ b/apps/web/lib/utilities.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function classNames(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..e54008a --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,27 @@ +import { type NextRequest, NextResponse } from "next/server" +import { updateSupabaseSession } from "@/lib/supabase/middleware" + +export async function middleware(request: NextRequest) { + const { user, supabaseResponse } = await updateSupabaseSession(request) + + const isReaderRoute = request.nextUrl.pathname.startsWith("/reader") + const isAuthRoute = request.nextUrl.pathname.startsWith("/sign-") + + if (!user && isReaderRoute) { + const signInUrl = new URL("/sign-in", request.url) + return NextResponse.redirect(signInUrl) + } + + if (user && isAuthRoute) { + const readerUrl = new URL("/reader", request.url) + return NextResponse.redirect(readerUrl) + } + + return supabaseResponse +} + +export const config = { + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|manifest.json|sw.js|icons).*)", + ], +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..f580efd --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,54 @@ +import withSerwistInit from "@serwist/next" +import type { NextConfig } from "next" + +const withSerwist = withSerwistInit({ + swSrc: "app/sw.ts", + swDest: "public/sw.js", + disable: process.env.NODE_ENV === "development", +}) + +const securityHeaders = [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https: http:", + "font-src 'self'", + "connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com", + "frame-src https://js.stripe.com https://hooks.stripe.com", + "media-src 'self' https: http:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ].join("; "), + }, +] + +const nextConfig: NextConfig = { + reactCompiler: true, + turbopack: {}, + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ] + }, +} + +export default withSerwist(nextConfig) diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..31a0a1f --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,47 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build --webpack", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@asa-news/shared": "workspace:*", + "@serwist/next": "^9.5.4", + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.95.2", + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-virtual": "^3.13.18", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "next": "16.1.6", + "next-themes": "^0.4.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-resizable-panels": "^4.6.0", + "sanitize-html": "^2.17.0", + "serwist": "^9.5.4", + "sonner": "^2.0.7", + "stripe": "^20.3.1", + "tailwind-merge": "^3.4.0", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/sanitize-html": "^2.16.0", + "@vercel/analytics": "^1.6.1", + "@vercel/speed-insights": "^1.3.1", + "babel-plugin-react-compiler": "1.0.0", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/apps/web/pnpm-workspace.yaml b/apps/web/pnpm-workspace.yaml new file mode 100644 index 0000000..581a9d5 --- /dev/null +++ b/apps/web/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - sharp + - unrs-resolver diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/apps/web/public/file.svg b/apps/web/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/apps/web/public/file.svg @@ -0,0 +1 @@ +<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file diff --git a/apps/web/public/globe.svg b/apps/web/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/apps/web/public/globe.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file diff --git a/apps/web/public/icons/icon.svg b/apps/web/public/icons/icon.svg new file mode 100644 index 0000000..0733979 --- /dev/null +++ b/apps/web/public/icons/icon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> + <rect width="512" height="512" fill="#0a0a0a"/> + <text x="256" y="272" text-anchor="middle" font-family="monospace" font-weight="bold" font-size="180" fill="#e5e5e5">asa</text> +</svg> diff --git a/apps/web/public/next.svg b/apps/web/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/apps/web/public/next.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file diff --git a/apps/web/public/vercel.svg b/apps/web/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/apps/web/public/vercel.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file diff --git a/apps/web/public/window.svg b/apps/web/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/apps/web/public/window.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..3a13f90 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} |