summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/settings/_components
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/reader/settings/_components
downloadasa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz
asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker. Includes three subscription tiers (free/pro/developer), API key auth, read-only REST API, webhook push notifications, Stripe billing with proration, and PWA support.
Diffstat (limited to 'apps/web/app/reader/settings/_components')
-rw-r--r--apps/web/app/reader/settings/_components/account-settings.tsx368
-rw-r--r--apps/web/app/reader/settings/_components/api-settings.tsx529
-rw-r--r--apps/web/app/reader/settings/_components/appearance-settings.tsx123
-rw-r--r--apps/web/app/reader/settings/_components/billing-settings.tsx301
-rw-r--r--apps/web/app/reader/settings/_components/custom-feeds-settings.tsx283
-rw-r--r--apps/web/app/reader/settings/_components/danger-zone-settings.tsx156
-rw-r--r--apps/web/app/reader/settings/_components/folders-settings.tsx220
-rw-r--r--apps/web/app/reader/settings/_components/import-export-settings.tsx220
-rw-r--r--apps/web/app/reader/settings/_components/muted-keywords-settings.tsx89
-rw-r--r--apps/web/app/reader/settings/_components/security-settings.tsx280
-rw-r--r--apps/web/app/reader/settings/_components/settings-shell.tsx86
-rw-r--r--apps/web/app/reader/settings/_components/subscriptions-settings.tsx281
12 files changed, 2936 insertions, 0 deletions
diff --git a/apps/web/app/reader/settings/_components/account-settings.tsx b/apps/web/app/reader/settings/_components/account-settings.tsx
new file mode 100644
index 0000000..b9ed8c3
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/account-settings.tsx
@@ -0,0 +1,368 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { useUserProfile } from "@/lib/queries/use-user-profile"
+import { queryKeys } from "@/lib/queries/query-keys"
+import { TIER_LIMITS } from "@asa-news/shared"
+import { notify } from "@/lib/notify"
+
+export function AccountSettings() {
+ const { data: userProfile, isLoading } = useUserProfile()
+ const [isEditingName, setIsEditingName] = useState(false)
+ const [editedName, setEditedName] = useState("")
+ const [isRequestingData, setIsRequestingData] = useState(false)
+ const [newEmailAddress, setNewEmailAddress] = useState("")
+ const [emailPassword, setEmailPassword] = useState("")
+ const [currentPassword, setCurrentPassword] = useState("")
+ const [newPassword, setNewPassword] = useState("")
+ const [confirmNewPassword, setConfirmNewPassword] = useState("")
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+ const router = useRouter()
+
+ const updateDisplayName = useMutation({
+ mutationFn: async (displayName: string | null) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { error } = await supabaseClient
+ .from("user_profiles")
+ .update({ display_name: displayName })
+ .eq("id", user.id)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("display name updated")
+ },
+ onError: (error: Error) => {
+ notify("failed to update display name: " + error.message)
+ },
+ })
+
+ const updateEmailAddress = useMutation({
+ mutationFn: async ({
+ emailAddress,
+ password,
+ }: {
+ emailAddress: string
+ password: string
+ }) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user?.email) throw new Error("Not authenticated")
+
+ const { error: signInError } = await supabaseClient.auth.signInWithPassword({
+ email: user.email,
+ password,
+ })
+
+ if (signInError) throw new Error("incorrect password")
+
+ const { error } = await supabaseClient.auth.updateUser({
+ email: emailAddress,
+ })
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ setNewEmailAddress("")
+ setEmailPassword("")
+ notify("confirmation email sent to your new address")
+ },
+ onError: (error: Error) => {
+ notify("failed to update email: " + error.message)
+ },
+ })
+
+ const updatePassword = useMutation({
+ mutationFn: async ({
+ currentPassword: current,
+ newPassword: updated,
+ }: {
+ currentPassword: string
+ newPassword: string
+ }) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user?.email) throw new Error("Not authenticated")
+
+ const { error: signInError } = await supabaseClient.auth.signInWithPassword({
+ email: user.email,
+ password: current,
+ })
+
+ if (signInError) throw new Error("current password is incorrect")
+
+ const { error } = await supabaseClient.auth.updateUser({
+ password: updated,
+ })
+
+ if (error) throw error
+ },
+ onSuccess: async () => {
+ setCurrentPassword("")
+ setNewPassword("")
+ setConfirmNewPassword("")
+ notify("password updated — signing out all sessions")
+ await supabaseClient.auth.signOut({ scope: "global" })
+ router.push("/sign-in")
+ },
+ onError: (error: Error) => {
+ notify("failed to update password: " + error.message)
+ },
+ })
+
+ if (isLoading) {
+ return <p className="px-4 py-6 text-text-dim">loading account ...</p>
+ }
+
+ if (!userProfile) {
+ return <p className="px-4 py-6 text-text-dim">failed to load account</p>
+ }
+
+ const tier = userProfile.tier
+ const tierLimits = TIER_LIMITS[tier]
+
+ async function handleRequestData() {
+ setIsRequestingData(true)
+ try {
+ const response = await fetch("/api/account/data")
+ if (!response.ok) throw new Error("Export failed")
+ const blob = await response.blob()
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement("a")
+ anchor.href = url
+ anchor.download = `asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json`
+ anchor.click()
+ URL.revokeObjectURL(url)
+ notify("data exported")
+ } catch {
+ notify("failed to export data")
+ } finally {
+ setIsRequestingData(false)
+ }
+ }
+
+ function handleSaveName() {
+ const trimmedName = editedName.trim()
+ updateDisplayName.mutate(trimmedName || null)
+ setIsEditingName(false)
+ }
+
+ function handleUpdateEmail(event: React.FormEvent) {
+ event.preventDefault()
+ const trimmedEmail = newEmailAddress.trim()
+ if (!trimmedEmail || !emailPassword) return
+ updateEmailAddress.mutate({ emailAddress: trimmedEmail, password: emailPassword })
+ }
+
+ function handleUpdatePassword(event: React.FormEvent) {
+ event.preventDefault()
+ if (!currentPassword) {
+ notify("current password is required")
+ return
+ }
+ if (!newPassword || newPassword !== confirmNewPassword) {
+ notify("passwords do not match")
+ return
+ }
+ if (newPassword.length < 8) {
+ notify("password must be at least 8 characters")
+ return
+ }
+ updatePassword.mutate({ currentPassword, newPassword })
+ }
+
+ return (
+ <div className="px-4 py-3">
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">display name</h3>
+ {isEditingName ? (
+ <div className="flex items-center gap-2">
+ <input
+ type="text"
+ value={editedName}
+ onChange={(event) => setEditedName(event.target.value)}
+ placeholder="display name"
+ className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ onKeyDown={(event) => {
+ if (event.key === "Enter") handleSaveName()
+ if (event.key === "Escape") setIsEditingName(false)
+ }}
+ autoFocus
+ />
+ <button
+ onClick={handleSaveName}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ save
+ </button>
+ <button
+ onClick={() => setIsEditingName(false)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ cancel
+ </button>
+ </div>
+ ) : (
+ <div className="flex items-center gap-2">
+ <span className="text-text-secondary">
+ {userProfile.displayName ?? "not set"}
+ </span>
+ <button
+ onClick={() => {
+ setEditedName(userProfile.displayName ?? "")
+ setIsEditingName(true)
+ }}
+ className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary"
+ >
+ edit
+ </button>
+ </div>
+ )}
+ </div>
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">email address</h3>
+ <p className="mb-2 text-text-dim">
+ {userProfile.email ?? "no email on file"}
+ </p>
+ <form onSubmit={handleUpdateEmail} className="space-y-2">
+ <input
+ type="email"
+ value={newEmailAddress}
+ onChange={(event) => setNewEmailAddress(event.target.value)}
+ placeholder="new email address"
+ className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <input
+ type="password"
+ value={emailPassword}
+ onChange={(event) => setEmailPassword(event.target.value)}
+ placeholder="current password"
+ className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <button
+ type="submit"
+ disabled={updateEmailAddress.isPending || !newEmailAddress.trim() || !emailPassword}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ update email
+ </button>
+ </form>
+ </div>
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">change password</h3>
+ <form onSubmit={handleUpdatePassword} className="space-y-2">
+ <input
+ type="password"
+ value={currentPassword}
+ onChange={(event) => setCurrentPassword(event.target.value)}
+ placeholder="current password"
+ className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <input
+ type="password"
+ value={newPassword}
+ onChange={(event) => setNewPassword(event.target.value)}
+ placeholder="new password"
+ className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <input
+ type="password"
+ value={confirmNewPassword}
+ onChange={(event) => setConfirmNewPassword(event.target.value)}
+ placeholder="confirm new password"
+ className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <button
+ type="submit"
+ disabled={updatePassword.isPending || !currentPassword || !newPassword || newPassword !== confirmNewPassword}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ change password
+ </button>
+ </form>
+ </div>
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">usage</h3>
+ <div className="space-y-1">
+ <UsageRow
+ label="feeds"
+ current={userProfile.feedCount}
+ maximum={tierLimits.maximumFeeds}
+ />
+ <UsageRow
+ label="folders"
+ current={userProfile.folderCount}
+ maximum={tierLimits.maximumFolders}
+ />
+ <UsageRow
+ label="muted keywords"
+ current={userProfile.mutedKeywordCount}
+ maximum={tierLimits.maximumMutedKeywords}
+ />
+ <UsageRow
+ label="custom feeds"
+ current={userProfile.customFeedCount}
+ maximum={tierLimits.maximumCustomFeeds}
+ />
+ </div>
+ </div>
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">your data</h3>
+ <p className="mb-3 text-text-dim">
+ download all your data (profile, subscriptions, folders, highlights, saved entries)
+ </p>
+ <button
+ onClick={handleRequestData}
+ disabled={isRequestingData}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ {isRequestingData ? "exporting ..." : "request all data"}
+ </button>
+ </div>
+ </div>
+ )
+}
+
+function UsageRow({
+ label,
+ current,
+ maximum,
+}: {
+ label: string
+ current: number
+ maximum: number
+}) {
+ const isNearLimit = current >= maximum * 0.8
+ const isAtLimit = current >= maximum
+
+ return (
+ <div className="flex items-center justify-between py-1">
+ <span className="text-text-secondary">{label}</span>
+ <span
+ className={
+ isAtLimit
+ ? "text-status-error"
+ : isNearLimit
+ ? "text-text-primary"
+ : "text-text-dim"
+ }
+ >
+ {current} / {maximum}
+ </span>
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/api-settings.tsx b/apps/web/app/reader/settings/_components/api-settings.tsx
new file mode 100644
index 0000000..cb69958
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/api-settings.tsx
@@ -0,0 +1,529 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { useUserProfile } from "@/lib/queries/use-user-profile"
+import { queryKeys } from "@/lib/queries/query-keys"
+import { notify } from "@/lib/notify"
+
+interface ApiKey {
+ keyIdentifier: string
+ keyPrefix: string
+ label: string | null
+ createdAt: string
+ lastUsedAt: string | null
+}
+
+interface WebhookConfiguration {
+ webhookUrl: string | null
+ webhookSecret: string | null
+ webhookEnabled: boolean
+ consecutiveFailures: number
+}
+
+function useApiKeys() {
+ return useQuery({
+ queryKey: ["apiKeys"],
+ queryFn: async () => {
+ const response = await fetch("/api/v1/keys")
+ if (!response.ok) throw new Error("Failed to load API keys")
+ const data = await response.json()
+ return data.keys as ApiKey[]
+ },
+ })
+}
+
+function useCreateApiKey() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (label: string | null) => {
+ const response = await fetch("/api/v1/keys", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ label }),
+ })
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || "Failed to create API key")
+ }
+ return response.json() as Promise<{
+ fullKey: string
+ keyPrefix: string
+ keyIdentifier: string
+ }>
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["apiKeys"] })
+ },
+ })
+}
+
+function useRevokeApiKey() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (keyIdentifier: string) => {
+ const response = await fetch(`/api/v1/keys/${keyIdentifier}`, {
+ method: "DELETE",
+ })
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || "Failed to revoke API key")
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["apiKeys"] })
+ },
+ })
+}
+
+function useWebhookConfig() {
+ return useQuery({
+ queryKey: ["webhookConfig"],
+ queryFn: async () => {
+ const response = await fetch("/api/webhook-config")
+ if (!response.ok) throw new Error("Failed to load webhook config")
+ return response.json() as Promise<WebhookConfiguration>
+ },
+ })
+}
+
+function useUpdateWebhookConfig() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (
+ updates: Partial<{
+ webhookUrl: string
+ webhookSecret: string
+ webhookEnabled: boolean
+ }>
+ ) => {
+ const response = await fetch("/api/webhook-config", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(updates),
+ })
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || "Failed to update webhook config")
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["webhookConfig"] })
+ },
+ })
+}
+
+function useTestWebhook() {
+ return useMutation({
+ mutationFn: async () => {
+ const response = await fetch("/api/webhook-config/test", {
+ method: "POST",
+ })
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || "Failed to send test webhook")
+ }
+ return response.json() as Promise<{
+ delivered: boolean
+ statusCode?: number
+ error?: string
+ }>
+ },
+ })
+}
+
+function ApiKeysSection() {
+ const { data: apiKeys, isLoading } = useApiKeys()
+ const createApiKey = useCreateApiKey()
+ const revokeApiKey = useRevokeApiKey()
+ const [newKeyLabel, setNewKeyLabel] = useState("")
+ const [revealedKey, setRevealedKey] = useState<string | null>(null)
+ const [confirmRevokeIdentifier, setConfirmRevokeIdentifier] = useState<
+ string | null
+ >(null)
+
+ function handleCreateKey() {
+ createApiKey.mutate(newKeyLabel.trim() || null, {
+ onSuccess: (data) => {
+ setRevealedKey(data.fullKey)
+ setNewKeyLabel("")
+ notify("API key created")
+ },
+ onError: (error: Error) => {
+ notify(error.message)
+ },
+ })
+ }
+
+ function handleCopyKey() {
+ if (revealedKey) {
+ navigator.clipboard.writeText(revealedKey)
+ notify("API key copied to clipboard")
+ }
+ }
+
+ function handleRevokeKey(keyIdentifier: string) {
+ revokeApiKey.mutate(keyIdentifier, {
+ onSuccess: () => {
+ notify("API key revoked")
+ setConfirmRevokeIdentifier(null)
+ },
+ onError: (error: Error) => {
+ notify(error.message)
+ },
+ })
+ }
+
+ return (
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">API keys</h3>
+ <p className="mb-3 text-text-dim">
+ use API keys to authenticate requests to the REST API
+ </p>
+
+ {revealedKey && (
+ <div className="mb-4 border border-status-warning p-3">
+ <p className="mb-2 text-text-secondary">
+ copy this key now — it will not be shown again
+ </p>
+ <div className="flex items-center gap-2">
+ <code className="min-w-0 flex-1 overflow-x-auto bg-background-tertiary px-2 py-1 text-text-primary">
+ {revealedKey}
+ </code>
+ <button
+ onClick={handleCopyKey}
+ className="shrink-0 border border-border px-3 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ copy
+ </button>
+ </div>
+ <button
+ onClick={() => setRevealedKey(null)}
+ className="mt-2 text-text-dim transition-colors hover:text-text-secondary"
+ >
+ dismiss
+ </button>
+ </div>
+ )}
+
+ {isLoading ? (
+ <p className="text-text-dim">loading keys ...</p>
+ ) : (
+ <>
+ {apiKeys && apiKeys.length > 0 && (
+ <div className="mb-4 border border-border">
+ {apiKeys.map((apiKey) => (
+ <div
+ key={apiKey.keyIdentifier}
+ className="flex items-center justify-between border-b border-border px-3 py-2 last:border-b-0"
+ >
+ <div className="min-w-0 flex-1">
+ <div className="flex items-center gap-2">
+ <code className="text-text-primary">
+ {apiKey.keyPrefix}...
+ </code>
+ {apiKey.label && (
+ <span className="text-text-dim">{apiKey.label}</span>
+ )}
+ </div>
+ <div className="text-text-dim">
+ created{" "}
+ {new Date(apiKey.createdAt).toLocaleDateString()}
+ {apiKey.lastUsedAt &&
+ ` · last used ${new Date(apiKey.lastUsedAt).toLocaleDateString()}`}
+ </div>
+ </div>
+ {confirmRevokeIdentifier === apiKey.keyIdentifier ? (
+ <div className="flex items-center gap-1">
+ <span className="text-text-dim">revoke?</span>
+ <button
+ onClick={() =>
+ handleRevokeKey(apiKey.keyIdentifier)
+ }
+ className="px-2 py-1 text-status-error transition-colors hover:text-text-primary"
+ >
+ yes
+ </button>
+ <button
+ onClick={() => setConfirmRevokeIdentifier(null)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ no
+ </button>
+ </div>
+ ) : (
+ <button
+ onClick={() =>
+ setConfirmRevokeIdentifier(apiKey.keyIdentifier)
+ }
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error"
+ >
+ revoke
+ </button>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+
+ {(!apiKeys || apiKeys.length < 5) && (
+ <div className="flex items-center gap-2">
+ <input
+ type="text"
+ value={newKeyLabel}
+ onChange={(event) => setNewKeyLabel(event.target.value)}
+ placeholder="key label (optional)"
+ className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ onKeyDown={(event) => {
+ if (event.key === "Enter") handleCreateKey()
+ }}
+ />
+ <button
+ onClick={handleCreateKey}
+ disabled={createApiKey.isPending}
+ className="shrink-0 border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ {createApiKey.isPending ? "creating ..." : "create key"}
+ </button>
+ </div>
+ )}
+ </>
+ )}
+ </div>
+ )
+}
+
+function WebhookSection() {
+ const { data: webhookConfig, isLoading } = useWebhookConfig()
+ const updateWebhookConfig = useUpdateWebhookConfig()
+ const testWebhook = useTestWebhook()
+ const [webhookUrl, setWebhookUrl] = useState("")
+ const [webhookSecret, setWebhookSecret] = useState("")
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
+
+ useEffect(() => {
+ if (webhookConfig) {
+ setWebhookUrl(webhookConfig.webhookUrl ?? "")
+ setWebhookSecret(webhookConfig.webhookSecret ?? "")
+ }
+ }, [webhookConfig])
+
+ function handleSaveWebhookConfig() {
+ updateWebhookConfig.mutate(
+ {
+ webhookUrl: webhookUrl.trim(),
+ webhookSecret: webhookSecret.trim(),
+ },
+ {
+ onSuccess: () => {
+ notify("webhook configuration saved")
+ setHasUnsavedChanges(false)
+ },
+ onError: (error: Error) => {
+ notify(error.message)
+ },
+ }
+ )
+ }
+
+ function handleToggleEnabled() {
+ if (!webhookConfig) return
+
+ updateWebhookConfig.mutate(
+ { webhookEnabled: !webhookConfig.webhookEnabled },
+ {
+ onSuccess: () => {
+ notify(
+ webhookConfig.webhookEnabled
+ ? "webhooks disabled"
+ : "webhooks enabled"
+ )
+ },
+ onError: (error: Error) => {
+ notify(error.message)
+ },
+ }
+ )
+ }
+
+ function handleTestWebhook() {
+ testWebhook.mutate(undefined, {
+ onSuccess: (data) => {
+ if (data.delivered) {
+ notify(`test webhook delivered (status ${data.statusCode})`)
+ } else {
+ notify(`test webhook failed: ${data.error}`)
+ }
+ },
+ onError: (error: Error) => {
+ notify(error.message)
+ },
+ })
+ }
+
+ function handleGenerateSecret() {
+ const array = new Uint8Array(32)
+ crypto.getRandomValues(array)
+ const generatedSecret = Array.from(array)
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("")
+ setWebhookSecret(generatedSecret)
+ setHasUnsavedChanges(true)
+ }
+
+ if (isLoading) {
+ return <p className="text-text-dim">loading webhook configuration ...</p>
+ }
+
+ return (
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">webhooks</h3>
+ <p className="mb-3 text-text-dim">
+ receive HTTP POST notifications when new entries arrive in your
+ subscribed feeds
+ </p>
+
+ <div className="mb-4">
+ <label className="mb-1 block text-text-secondary">webhook URL</label>
+ <input
+ type="url"
+ value={webhookUrl}
+ onChange={(event) => {
+ setWebhookUrl(event.target.value)
+ setHasUnsavedChanges(true)
+ }}
+ placeholder="https://example.com/webhook"
+ className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ </div>
+
+ <div className="mb-4">
+ <label className="mb-1 block text-text-secondary">
+ signing secret
+ </label>
+ <div className="flex items-center gap-2">
+ <input
+ type="text"
+ value={webhookSecret}
+ onChange={(event) => {
+ setWebhookSecret(event.target.value)
+ setHasUnsavedChanges(true)
+ }}
+ placeholder="optional HMAC-SHA256 signing secret"
+ className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <button
+ onClick={handleGenerateSecret}
+ className="shrink-0 border border-border px-3 py-1.5 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ generate
+ </button>
+ </div>
+ </div>
+
+ <div className="mb-4 flex items-center gap-4">
+ <button
+ onClick={handleSaveWebhookConfig}
+ disabled={!hasUnsavedChanges || updateWebhookConfig.isPending}
+ className="border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ {updateWebhookConfig.isPending ? "saving ..." : "save"}
+ </button>
+ <button
+ onClick={handleToggleEnabled}
+ disabled={updateWebhookConfig.isPending}
+ className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50"
+ >
+ {webhookConfig?.webhookEnabled ? "disable" : "enable"}
+ </button>
+ <button
+ onClick={handleTestWebhook}
+ disabled={
+ testWebhook.isPending ||
+ !webhookConfig?.webhookEnabled ||
+ !webhookConfig?.webhookUrl
+ }
+ className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50"
+ >
+ {testWebhook.isPending ? "sending ..." : "send test"}
+ </button>
+ </div>
+
+ {webhookConfig && (
+ <div className="flex items-center gap-3 text-text-dim">
+ <span>
+ status:{" "}
+ <span
+ className={
+ webhookConfig.webhookEnabled
+ ? "text-text-primary"
+ : "text-text-dim"
+ }
+ >
+ {webhookConfig.webhookEnabled ? "enabled" : "disabled"}
+ </span>
+ </span>
+ {webhookConfig.consecutiveFailures > 0 && (
+ <span className="text-status-warning">
+ {webhookConfig.consecutiveFailures} consecutive failure
+ {webhookConfig.consecutiveFailures !== 1 && "s"}
+ </span>
+ )}
+ </div>
+ )}
+ </div>
+ )
+}
+
+export function ApiSettings() {
+ const { data: userProfile, isLoading } = useUserProfile()
+
+ if (isLoading) {
+ return <p className="px-4 py-6 text-text-dim">loading API settings ...</p>
+ }
+
+ if (!userProfile) {
+ return (
+ <p className="px-4 py-6 text-text-dim">failed to load API settings</p>
+ )
+ }
+
+ if (userProfile.tier !== "developer") {
+ return (
+ <div className="px-4 py-3">
+ <h3 className="mb-2 text-text-primary">developer API</h3>
+ <p className="mb-3 text-text-dim">
+ the developer plan includes a read-only REST API and webhook push
+ notifications. upgrade to developer to access these features.
+ </p>
+ </div>
+ )
+ }
+
+ return (
+ <div className="px-4 py-3">
+ <ApiKeysSection />
+ <WebhookSection />
+
+ <div>
+ <h3 className="mb-2 text-text-primary">API documentation</h3>
+ <p className="mb-3 text-text-dim">
+ authenticate requests with an API key in the Authorization header:
+ </p>
+ <code className="block bg-background-tertiary px-3 py-2 text-text-secondary">
+ Authorization: Bearer asn_your_key_here
+ </code>
+ <div className="mt-3 space-y-1 text-text-dim">
+ <p>GET /api/v1/profile — your account info and limits</p>
+ <p>GET /api/v1/feeds — your subscribed feeds</p>
+ <p>GET /api/v1/folders — your folders</p>
+ <p>
+ GET /api/v1/entries — entries with ?cursor, ?limit, ?feedIdentifier,
+ ?readStatus, ?savedStatus filters
+ </p>
+ <p>GET /api/v1/entries/:id — single entry with full content</p>
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/appearance-settings.tsx b/apps/web/app/reader/settings/_components/appearance-settings.tsx
new file mode 100644
index 0000000..9c0e214
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/appearance-settings.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { useUserInterfaceStore } from "@/lib/stores/user-interface-store"
+
+export function AppearanceSettings() {
+ const { theme, setTheme } = useTheme()
+ const entryListViewMode = useUserInterfaceStore(
+ (state) => state.entryListViewMode
+ )
+ const setEntryListViewMode = useUserInterfaceStore(
+ (state) => state.setEntryListViewMode
+ )
+ const displayDensity = useUserInterfaceStore(
+ (state) => state.displayDensity
+ )
+ const setDisplayDensity = useUserInterfaceStore(
+ (state) => state.setDisplayDensity
+ )
+ const showFeedFavicons = useUserInterfaceStore(
+ (state) => state.showFeedFavicons
+ )
+ const setShowFeedFavicons = useUserInterfaceStore(
+ (state) => state.setShowFeedFavicons
+ )
+ const focusFollowsInteraction = useUserInterfaceStore(
+ (state) => state.focusFollowsInteraction
+ )
+ const setFocusFollowsInteraction = useUserInterfaceStore(
+ (state) => state.setFocusFollowsInteraction
+ )
+
+ return (
+ <div className="px-4 py-3">
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">theme</h3>
+ <p className="mb-3 text-text-dim">
+ controls the colour scheme of the application
+ </p>
+ <select
+ value={theme ?? "system"}
+ onChange={(event) => setTheme(event.target.value)}
+ className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim"
+ >
+ <option value="system">system</option>
+ <option value="light">light</option>
+ <option value="dark">dark</option>
+ </select>
+ </div>
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">display density</h3>
+ <p className="mb-3 text-text-dim">
+ controls the overall text size and spacing
+ </p>
+ <select
+ value={displayDensity}
+ onChange={(event) =>
+ setDisplayDensity(
+ event.target.value as "compact" | "default" | "spacious"
+ )
+ }
+ className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim"
+ >
+ <option value="compact">compact</option>
+ <option value="default">default</option>
+ <option value="spacious">spacious</option>
+ </select>
+ </div>
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">entry list view</h3>
+ <p className="mb-3 text-text-dim">
+ controls how entries are displayed in the list
+ </p>
+ <select
+ value={entryListViewMode}
+ onChange={(event) =>
+ setEntryListViewMode(
+ event.target.value as "compact" | "comfortable" | "expanded"
+ )
+ }
+ className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim"
+ >
+ <option value="compact">compact</option>
+ <option value="comfortable">comfortable</option>
+ <option value="expanded">expanded</option>
+ </select>
+ </div>
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">feed favicons</h3>
+ <p className="mb-3 text-text-dim">
+ show website icons next to feed names in the sidebar
+ </p>
+ <label className="flex cursor-pointer items-center gap-2 text-text-primary">
+ <input
+ type="checkbox"
+ checked={showFeedFavicons}
+ onChange={(event) => setShowFeedFavicons(event.target.checked)}
+ className="accent-text-primary"
+ />
+ <span>show favicons</span>
+ </label>
+ </div>
+ <div>
+ <h3 className="mb-2 text-text-primary">focus follows interaction</h3>
+ <p className="mb-3 text-text-dim">
+ automatically move keyboard panel focus to the last pane you
+ interacted with (clicked or scrolled)
+ </p>
+ <label className="flex cursor-pointer items-center gap-2 text-text-primary">
+ <input
+ type="checkbox"
+ checked={focusFollowsInteraction}
+ onChange={(event) =>
+ setFocusFollowsInteraction(event.target.checked)
+ }
+ className="accent-text-primary"
+ />
+ <span>enable focus follows interaction</span>
+ </label>
+ </div>
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/billing-settings.tsx b/apps/web/app/reader/settings/_components/billing-settings.tsx
new file mode 100644
index 0000000..e49720a
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/billing-settings.tsx
@@ -0,0 +1,301 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { useSearchParams } from "next/navigation"
+import { useUserProfile } from "@/lib/queries/use-user-profile"
+import { queryKeys } from "@/lib/queries/query-keys"
+import { TIER_LIMITS } from "@asa-news/shared"
+import { classNames } from "@/lib/utilities"
+import { notify } from "@/lib/notify"
+
+function useCreateCheckoutSession() {
+ return useMutation({
+ mutationFn: async ({
+ billingInterval,
+ targetTier,
+ }: {
+ billingInterval: "monthly" | "yearly"
+ targetTier: "pro" | "developer"
+ }) => {
+ const response = await fetch("/api/billing/create-checkout-session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ billingInterval, targetTier }),
+ })
+
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || "Failed to create checkout session")
+ }
+
+ const data = await response.json()
+ return data as { url?: string; upgraded?: boolean }
+ },
+ })
+}
+
+function useCreatePortalSession() {
+ return useMutation({
+ mutationFn: async () => {
+ const response = await fetch("/api/billing/create-portal-session", {
+ method: "POST",
+ })
+
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || "Failed to create portal session")
+ }
+
+ const data = await response.json()
+ return data as { url: string }
+ },
+ })
+}
+
+const PRO_FEATURES = [
+ `${TIER_LIMITS.pro.maximumFeeds} feeds`,
+ `${Number.isFinite(TIER_LIMITS.pro.historyRetentionDays) ? TIER_LIMITS.pro.historyRetentionDays.toLocaleString() + " days" : "unlimited"} history retention`,
+ `${TIER_LIMITS.pro.refreshIntervalSeconds / 60}-minute refresh interval`,
+ "authenticated feeds",
+ "OPML export",
+ "manual feed refresh",
+]
+
+const DEVELOPER_FEATURES = [
+ `${TIER_LIMITS.developer.maximumFeeds} feeds`,
+ "everything in pro",
+ "read-only REST API",
+ "webhook push notifications",
+]
+
+function UpgradeCard({
+ targetTier,
+ features,
+ monthlyPrice,
+ yearlyPrice,
+}: {
+ targetTier: "pro" | "developer"
+ features: string[]
+ monthlyPrice: string
+ yearlyPrice: string
+}) {
+ const [billingInterval, setBillingInterval] = useState<
+ "monthly" | "yearly"
+ >("yearly")
+ const queryClient = useQueryClient()
+ const createCheckoutSession = useCreateCheckoutSession()
+
+ function handleUpgrade() {
+ createCheckoutSession.mutate(
+ { billingInterval, targetTier },
+ {
+ onSuccess: (data) => {
+ if (data.upgraded) {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.userProfile.all,
+ })
+ notify(`upgraded to ${targetTier}!`)
+ } else if (data.url) {
+ window.location.href = data.url
+ }
+ },
+ onError: (error: Error) => {
+ notify("failed to start checkout: " + error.message)
+ },
+ }
+ )
+ }
+
+ return (
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">upgrade to {targetTier}</h3>
+ <ul className="mb-4 space-y-1">
+ {features.map((feature) => (
+ <li key={feature} className="text-text-secondary">
+ + {feature}
+ </li>
+ ))}
+ </ul>
+ <div className="mb-4 flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() => setBillingInterval("monthly")}
+ className={classNames(
+ "border px-3 py-1 transition-colors",
+ billingInterval === "monthly"
+ ? "border-text-primary text-text-primary"
+ : "border-border text-text-dim hover:text-text-secondary"
+ )}
+ >
+ {monthlyPrice} / month
+ </button>
+ <button
+ type="button"
+ onClick={() => setBillingInterval("yearly")}
+ className={classNames(
+ "border px-3 py-1 transition-colors",
+ billingInterval === "yearly"
+ ? "border-text-primary text-text-primary"
+ : "border-border text-text-dim hover:text-text-secondary"
+ )}
+ >
+ {yearlyPrice} / year
+ </button>
+ </div>
+ <button
+ onClick={handleUpgrade}
+ disabled={createCheckoutSession.isPending}
+ className="border border-text-primary px-4 py-2 text-text-primary transition-colors hover:bg-text-primary hover:text-background-primary disabled:opacity-50"
+ >
+ {createCheckoutSession.isPending
+ ? "redirecting ..."
+ : `upgrade to ${targetTier}`}
+ </button>
+ </div>
+ )
+}
+
+export function BillingSettings() {
+ const { data: userProfile, isLoading } = useUserProfile()
+ const queryClient = useQueryClient()
+ const searchParameters = useSearchParams()
+ const hasShownSuccessToast = useRef(false)
+
+ const createPortalSession = useCreatePortalSession()
+
+ useEffect(() => {
+ if (
+ searchParameters.get("billing") === "success" &&
+ !hasShownSuccessToast.current
+ ) {
+ hasShownSuccessToast.current = true
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("subscription activated!")
+ const url = new URL(window.location.href)
+ url.searchParams.delete("billing")
+ window.history.replaceState({}, "", url.pathname)
+ }
+ }, [searchParameters, queryClient])
+
+ if (isLoading) {
+ return <p className="px-4 py-6 text-text-dim">loading billing ...</p>
+ }
+
+ if (!userProfile) {
+ return <p className="px-4 py-6 text-text-dim">failed to load billing</p>
+ }
+
+ function handleManageSubscription() {
+ createPortalSession.mutate(undefined, {
+ onSuccess: (data) => {
+ window.location.href = data.url
+ },
+ onError: (error: Error) => {
+ notify("failed to open billing portal: " + error.message)
+ },
+ })
+ }
+
+ const isPaidTier =
+ userProfile.tier === "pro" || userProfile.tier === "developer"
+ const isCancelling =
+ userProfile.stripeSubscriptionStatus === "active" &&
+ isPaidTier &&
+ userProfile.stripeCurrentPeriodEnd !== null
+
+ return (
+ <div className="px-4 py-3">
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">current plan</h3>
+ <span className="border border-border px-2 py-1 text-text-secondary">
+ {userProfile.tier}
+ </span>
+ {userProfile.stripeSubscriptionStatus && (
+ <span className="ml-2 text-text-dim">
+ ({userProfile.stripeSubscriptionStatus})
+ </span>
+ )}
+ </div>
+
+ {userProfile.tier === "free" && (
+ <>
+ <UpgradeCard
+ targetTier="pro"
+ features={PRO_FEATURES}
+ monthlyPrice="$3"
+ yearlyPrice="$30"
+ />
+ <UpgradeCard
+ targetTier="developer"
+ features={DEVELOPER_FEATURES}
+ monthlyPrice="$6"
+ yearlyPrice="$60"
+ />
+ </>
+ )}
+
+ {userProfile.tier === "pro" && (
+ <>
+ <UpgradeCard
+ targetTier="developer"
+ features={DEVELOPER_FEATURES}
+ monthlyPrice="$6"
+ yearlyPrice="$60"
+ />
+ <div className="mb-6">
+ {isCancelling && userProfile.stripeCurrentPeriodEnd && (
+ <p className="mb-3 text-text-secondary">
+ your pro plan is active until{" "}
+ {new Date(
+ userProfile.stripeCurrentPeriodEnd
+ ).toLocaleDateString()}
+ </p>
+ )}
+ {userProfile.stripeSubscriptionStatus === "past_due" && (
+ <p className="mb-3 text-status-error">
+ payment failed — please update your payment method
+ </p>
+ )}
+ <button
+ onClick={handleManageSubscription}
+ disabled={createPortalSession.isPending}
+ className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50"
+ >
+ {createPortalSession.isPending
+ ? "redirecting ..."
+ : "manage subscription"}
+ </button>
+ </div>
+ </>
+ )}
+
+ {userProfile.tier === "developer" && (
+ <div className="mb-6">
+ {isCancelling && userProfile.stripeCurrentPeriodEnd && (
+ <p className="mb-3 text-text-secondary">
+ your developer plan is active until{" "}
+ {new Date(
+ userProfile.stripeCurrentPeriodEnd
+ ).toLocaleDateString()}
+ </p>
+ )}
+ {userProfile.stripeSubscriptionStatus === "past_due" && (
+ <p className="mb-3 text-status-error">
+ payment failed — please update your payment method
+ </p>
+ )}
+ <button
+ onClick={handleManageSubscription}
+ disabled={createPortalSession.isPending}
+ className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50"
+ >
+ {createPortalSession.isPending
+ ? "redirecting ..."
+ : "manage subscription"}
+ </button>
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx
new file mode 100644
index 0000000..b7b588b
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx
@@ -0,0 +1,283 @@
+"use client"
+
+import { useState } from "react"
+import { useCustomFeeds } from "@/lib/queries/use-custom-feeds"
+import {
+ useCreateCustomFeed,
+ useUpdateCustomFeed,
+ useDeleteCustomFeed,
+} from "@/lib/queries/use-custom-feed-mutations"
+import { useSubscriptions } from "@/lib/queries/use-subscriptions"
+import { useUserProfile } from "@/lib/queries/use-user-profile"
+import { TIER_LIMITS } from "@asa-news/shared"
+
+export function CustomFeedsSettings() {
+ const { data: customFeeds, isLoading } = useCustomFeeds()
+ const { data: subscriptionsData } = useSubscriptions()
+ const { data: userProfile } = useUserProfile()
+ const createCustomFeed = useCreateCustomFeed()
+
+ const [newName, setNewName] = useState("")
+ const [newKeywords, setNewKeywords] = useState("")
+ const [newMatchMode, setNewMatchMode] = useState<"and" | "or">("or")
+ const [newSourceFolderId, setNewSourceFolderId] = useState<string>("")
+
+ const folders = subscriptionsData?.folders ?? []
+ const tier = userProfile?.tier ?? "free"
+ const maximumCustomFeeds = TIER_LIMITS[tier].maximumCustomFeeds
+
+ function handleCreate(event: React.FormEvent) {
+ event.preventDefault()
+ const trimmedName = newName.trim()
+ const trimmedKeywords = newKeywords.trim()
+
+ if (!trimmedName || !trimmedKeywords) return
+
+ createCustomFeed.mutate({
+ name: trimmedName,
+ query: trimmedKeywords,
+ matchMode: newMatchMode,
+ sourceFolderIdentifier: newSourceFolderId || null,
+ })
+
+ setNewName("")
+ setNewKeywords("")
+ setNewMatchMode("or")
+ setNewSourceFolderId("")
+ }
+
+ if (isLoading) {
+ return <p className="px-4 py-6 text-text-dim">loading custom feeds ...</p>
+ }
+
+ const feedsList = customFeeds ?? []
+
+ return (
+ <div>
+ <div className="border-b border-border px-4 py-3">
+ <p className="mb-2 text-text-dim">
+ {feedsList.length} / {maximumCustomFeeds} custom feeds used
+ </p>
+ <form onSubmit={handleCreate} className="space-y-2">
+ <input
+ type="text"
+ value={newName}
+ onChange={(event) => setNewName(event.target.value)}
+ placeholder="feed name"
+ className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <input
+ type="text"
+ value={newKeywords}
+ onChange={(event) => setNewKeywords(event.target.value)}
+ placeholder="keywords (space-separated)"
+ className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <div className="flex gap-2">
+ <select
+ value={newMatchMode}
+ onChange={(event) =>
+ setNewMatchMode(event.target.value as "and" | "or")
+ }
+ className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none"
+ >
+ <option value="or">match any keyword</option>
+ <option value="and">match all keywords</option>
+ </select>
+ <select
+ value={newSourceFolderId}
+ onChange={(event) => setNewSourceFolderId(event.target.value)}
+ className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none"
+ >
+ <option value="">all feeds</option>
+ {folders.map((folder) => (
+ <option key={folder.folderIdentifier} value={folder.folderIdentifier}>
+ {folder.name}
+ </option>
+ ))}
+ </select>
+ <button
+ type="submit"
+ disabled={
+ createCustomFeed.isPending ||
+ !newName.trim() ||
+ !newKeywords.trim()
+ }
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ create
+ </button>
+ </div>
+ </form>
+ </div>
+ {feedsList.length === 0 ? (
+ <p className="px-4 py-6 text-text-dim">no custom feeds yet</p>
+ ) : (
+ feedsList.map((customFeed) => (
+ <CustomFeedRow
+ key={customFeed.identifier}
+ customFeed={customFeed}
+ folders={folders}
+ />
+ ))
+ )}
+ </div>
+ )
+}
+
+function CustomFeedRow({
+ customFeed,
+ folders,
+}: {
+ customFeed: {
+ identifier: string
+ name: string
+ query: string
+ matchMode: "and" | "or"
+ sourceFolderIdentifier: string | null
+ }
+ folders: { folderIdentifier: string; name: string }[]
+}) {
+ const updateCustomFeed = useUpdateCustomFeed()
+ const deleteCustomFeed = useDeleteCustomFeed()
+ const [isEditing, setIsEditing] = useState(false)
+ const [editedName, setEditedName] = useState(customFeed.name)
+ const [editedKeywords, setEditedKeywords] = useState(customFeed.query)
+ const [editedMatchMode, setEditedMatchMode] = useState(customFeed.matchMode)
+ const [editedSourceFolderId, setEditedSourceFolderId] = useState(
+ customFeed.sourceFolderIdentifier ?? ""
+ )
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+
+ function handleSave() {
+ const trimmedName = editedName.trim()
+ const trimmedKeywords = editedKeywords.trim()
+
+ if (!trimmedName || !trimmedKeywords) return
+
+ updateCustomFeed.mutate({
+ customFeedIdentifier: customFeed.identifier,
+ name: trimmedName,
+ query: trimmedKeywords,
+ matchMode: editedMatchMode,
+ sourceFolderIdentifier: editedSourceFolderId || null,
+ })
+ setIsEditing(false)
+ }
+
+ const sourceFolderName = customFeed.sourceFolderIdentifier
+ ? folders.find(
+ (folder) =>
+ folder.folderIdentifier === customFeed.sourceFolderIdentifier
+ )?.name ?? "unknown folder"
+ : "all feeds"
+
+ return (
+ <div className="border-b border-border px-4 py-3 last:border-b-0">
+ {isEditing ? (
+ <div className="space-y-2">
+ <input
+ type="text"
+ value={editedName}
+ onChange={(event) => setEditedName(event.target.value)}
+ className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim"
+ autoFocus
+ />
+ <input
+ type="text"
+ value={editedKeywords}
+ onChange={(event) => setEditedKeywords(event.target.value)}
+ className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim"
+ />
+ <div className="flex gap-2">
+ <select
+ value={editedMatchMode}
+ onChange={(event) =>
+ setEditedMatchMode(event.target.value as "and" | "or")
+ }
+ className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none"
+ >
+ <option value="or">match any</option>
+ <option value="and">match all</option>
+ </select>
+ <select
+ value={editedSourceFolderId}
+ onChange={(event) => setEditedSourceFolderId(event.target.value)}
+ className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none"
+ >
+ <option value="">all feeds</option>
+ {folders.map((folder) => (
+ <option
+ key={folder.folderIdentifier}
+ value={folder.folderIdentifier}
+ >
+ {folder.name}
+ </option>
+ ))}
+ </select>
+ </div>
+ <div className="flex gap-2">
+ <button
+ onClick={handleSave}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ save
+ </button>
+ <button
+ onClick={() => setIsEditing(false)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ cancel
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div>
+ <div className="flex items-center justify-between">
+ <span className="text-text-primary">{customFeed.name}</span>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary"
+ >
+ edit
+ </button>
+ {showDeleteConfirm ? (
+ <div className="flex items-center gap-1">
+ <button
+ onClick={() => {
+ deleteCustomFeed.mutate({
+ customFeedIdentifier: customFeed.identifier,
+ })
+ setShowDeleteConfirm(false)
+ }}
+ className="px-2 py-1 text-status-error transition-colors hover:text-text-primary"
+ >
+ yes
+ </button>
+ <button
+ onClick={() => setShowDeleteConfirm(false)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ no
+ </button>
+ </div>
+ ) : (
+ <button
+ onClick={() => setShowDeleteConfirm(true)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error"
+ >
+ delete
+ </button>
+ )}
+ </div>
+ </div>
+ <p className="text-text-dim">
+ keywords: {customFeed.query} ({customFeed.matchMode === "and" ? "all" : "any"})
+ </p>
+ <p className="text-text-dim">source: {sourceFolderName}</p>
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/danger-zone-settings.tsx b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx
new file mode 100644
index 0000000..76c48d4
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx
@@ -0,0 +1,156 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import { useMutation } from "@tanstack/react-query"
+import { useUnsubscribeAll } from "@/lib/queries/use-subscription-mutations"
+import { useDeleteAllFolders } from "@/lib/queries/use-folder-mutations"
+import { notify } from "@/lib/notify"
+
+export function DangerZoneSettings() {
+ const router = useRouter()
+ const unsubscribeAll = useUnsubscribeAll()
+ const deleteAllFolders = useDeleteAllFolders()
+ const [showDeleteSubsConfirm, setShowDeleteSubsConfirm] = useState(false)
+ const [showDeleteFoldersConfirm, setShowDeleteFoldersConfirm] = useState(false)
+ const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false)
+ const [deleteConfirmText, setDeleteConfirmText] = useState("")
+
+ const deleteAccount = useMutation({
+ mutationFn: async () => {
+ const response = await fetch("/api/account", { method: "DELETE" })
+ if (!response.ok) throw new Error("Failed to delete account")
+ },
+ onSuccess: () => {
+ router.push("/sign-in")
+ },
+ onError: (error: Error) => {
+ notify("failed to delete account: " + error.message)
+ },
+ })
+
+ return (
+ <div className="px-4 py-3">
+ <p className="mb-6 text-text-dim">
+ these actions are irreversible. proceed with caution.
+ </p>
+
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">remove all subscriptions</h3>
+ <p className="mb-2 text-text-dim">
+ unsubscribe from every feed. entries will remain but no new ones will be fetched.
+ </p>
+ {showDeleteSubsConfirm ? (
+ <div className="flex items-center gap-2">
+ <span className="text-status-error">are you sure?</span>
+ <button
+ onClick={() => {
+ unsubscribeAll.mutate()
+ setShowDeleteSubsConfirm(false)
+ }}
+ disabled={unsubscribeAll.isPending}
+ className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50"
+ >
+ yes, remove all
+ </button>
+ <button
+ onClick={() => setShowDeleteSubsConfirm(false)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ cancel
+ </button>
+ </div>
+ ) : (
+ <button
+ onClick={() => setShowDeleteSubsConfirm(true)}
+ className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error"
+ >
+ remove all subscriptions
+ </button>
+ )}
+ </div>
+
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">delete all folders</h3>
+ <p className="mb-2 text-text-dim">
+ remove all folders. feeds will be ungrouped but not unsubscribed.
+ </p>
+ {showDeleteFoldersConfirm ? (
+ <div className="flex items-center gap-2">
+ <span className="text-status-error">are you sure?</span>
+ <button
+ onClick={() => {
+ deleteAllFolders.mutate()
+ setShowDeleteFoldersConfirm(false)
+ }}
+ disabled={deleteAllFolders.isPending}
+ className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50"
+ >
+ yes, delete all
+ </button>
+ <button
+ onClick={() => setShowDeleteFoldersConfirm(false)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ cancel
+ </button>
+ </div>
+ ) : (
+ <button
+ onClick={() => setShowDeleteFoldersConfirm(true)}
+ className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error"
+ >
+ delete all folders
+ </button>
+ )}
+ </div>
+
+ <div>
+ <h3 className="mb-2 text-text-primary">delete account</h3>
+ <p className="mb-2 text-text-dim">
+ permanently delete your account and all associated data. this cannot be undone.
+ </p>
+ {showDeleteAccountConfirm ? (
+ <div>
+ <p className="mb-2 text-status-error">
+ type DELETE to confirm account deletion.
+ </p>
+ <div className="flex items-center gap-2">
+ <input
+ type="text"
+ value={deleteConfirmText}
+ onChange={(event) => setDeleteConfirmText(event.target.value)}
+ placeholder="type DELETE"
+ className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-status-error"
+ autoFocus
+ />
+ <button
+ onClick={() => deleteAccount.mutate()}
+ disabled={deleteConfirmText !== "DELETE" || deleteAccount.isPending}
+ className="border border-status-error px-4 py-2 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50"
+ >
+ {deleteAccount.isPending ? "deleting ..." : "confirm delete"}
+ </button>
+ <button
+ onClick={() => {
+ setShowDeleteAccountConfirm(false)
+ setDeleteConfirmText("")
+ }}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ cancel
+ </button>
+ </div>
+ </div>
+ ) : (
+ <button
+ onClick={() => setShowDeleteAccountConfirm(true)}
+ className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error"
+ >
+ delete account and all data
+ </button>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/folders-settings.tsx b/apps/web/app/reader/settings/_components/folders-settings.tsx
new file mode 100644
index 0000000..8a0012e
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/folders-settings.tsx
@@ -0,0 +1,220 @@
+"use client"
+
+import { useState } from "react"
+import { useSubscriptions } from "@/lib/queries/use-subscriptions"
+import {
+ useCreateFolder,
+ useRenameFolder,
+ useDeleteFolder,
+} from "@/lib/queries/use-folder-mutations"
+import { useUserProfile } from "@/lib/queries/use-user-profile"
+import { TIER_LIMITS } from "@asa-news/shared"
+
+export function FoldersSettings() {
+ const [newFolderName, setNewFolderName] = useState("")
+ const [searchQuery, setSearchQuery] = useState("")
+ const { data: subscriptionsData, isLoading } = useSubscriptions()
+ const { data: userProfile } = useUserProfile()
+ const createFolder = useCreateFolder()
+ const renameFolder = useRenameFolder()
+ const deleteFolder = useDeleteFolder()
+
+ const folders = subscriptionsData?.folders ?? []
+ const subscriptions = subscriptionsData?.subscriptions ?? []
+ const tier = userProfile?.tier ?? "free"
+ const tierLimits = TIER_LIMITS[tier]
+
+ function feedCountForFolder(folderIdentifier: string): number {
+ return subscriptions.filter(
+ (subscription) => subscription.folderIdentifier === folderIdentifier
+ ).length
+ }
+
+ function handleCreateFolder(event: React.FormEvent) {
+ event.preventDefault()
+ const trimmedName = newFolderName.trim()
+
+ if (!trimmedName) return
+
+ createFolder.mutate({ name: trimmedName })
+ setNewFolderName("")
+ }
+
+ if (isLoading) {
+ return <p className="px-4 py-6 text-text-dim">loading folders ...</p>
+ }
+
+ const normalizedQuery = searchQuery.toLowerCase().trim()
+ const filteredFolders = normalizedQuery
+ ? folders.filter((folder) => folder.name.toLowerCase().includes(normalizedQuery))
+ : folders
+
+ return (
+ <div>
+ <div className="border-b border-border px-4 py-3">
+ <p className="mb-2 text-text-dim">
+ {folders.length} / {tierLimits.maximumFolders} folders used
+ </p>
+ <form onSubmit={handleCreateFolder} className="mb-2 flex gap-2">
+ <input
+ type="text"
+ value={newFolderName}
+ onChange={(event) => setNewFolderName(event.target.value)}
+ placeholder="new folder name"
+ className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <button
+ type="submit"
+ disabled={createFolder.isPending || !newFolderName.trim()}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ create
+ </button>
+ </form>
+ {folders.length > 5 && (
+ <input
+ type="text"
+ value={searchQuery}
+ onChange={(event) => setSearchQuery(event.target.value)}
+ placeholder="search folders..."
+ className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ )}
+ </div>
+ {filteredFolders.length === 0 ? (
+ <p className="px-4 py-6 text-text-dim">
+ {folders.length === 0 ? "no folders yet" : "no folders match your search"}
+ </p>
+ ) : (
+ <div>
+ {filteredFolders.map((folder) => (
+ <FolderRow
+ key={folder.folderIdentifier}
+ folderIdentifier={folder.folderIdentifier}
+ name={folder.name}
+ feedCount={feedCountForFolder(folder.folderIdentifier)}
+ onRename={(name) =>
+ renameFolder.mutate({
+ folderIdentifier: folder.folderIdentifier,
+ name,
+ })
+ }
+ onDelete={() =>
+ deleteFolder.mutate({
+ folderIdentifier: folder.folderIdentifier,
+ })
+ }
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ )
+}
+
+function FolderRow({
+ folderIdentifier,
+ name,
+ feedCount,
+ onRename,
+ onDelete,
+}: {
+ folderIdentifier: string
+ name: string
+ feedCount: number
+ onRename: (name: string) => void
+ onDelete: () => void
+}) {
+ const [isEditing, setIsEditing] = useState(false)
+ const [editedName, setEditedName] = useState(name)
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+
+ function handleSave() {
+ const trimmedName = editedName.trim()
+
+ if (trimmedName && trimmedName !== name) {
+ onRename(trimmedName)
+ }
+
+ setIsEditing(false)
+ }
+
+ return (
+ <div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0">
+ <div className="min-w-0 flex-1">
+ {isEditing ? (
+ <div className="flex items-center gap-2">
+ <input
+ type="text"
+ value={editedName}
+ onChange={(event) => setEditedName(event.target.value)}
+ className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim"
+ onKeyDown={(event) => {
+ if (event.key === "Enter") handleSave()
+ if (event.key === "Escape") setIsEditing(false)
+ }}
+ autoFocus
+ />
+ <button
+ onClick={handleSave}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ save
+ </button>
+ <button
+ onClick={() => {
+ setEditedName(name)
+ setIsEditing(false)
+ }}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ cancel
+ </button>
+ </div>
+ ) : (
+ <div className="flex items-center gap-2">
+ <span className="text-text-primary">{name}</span>
+ <span className="text-text-dim">
+ ({feedCount} feed{feedCount !== 1 && "s"})
+ </span>
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary"
+ >
+ rename
+ </button>
+ </div>
+ )}
+ </div>
+ {showDeleteConfirm ? (
+ <div className="flex items-center gap-1">
+ <span className="text-text-dim">
+ {feedCount > 0 ? "has feeds, delete?" : "delete?"}
+ </span>
+ <button
+ onClick={() => {
+ onDelete()
+ setShowDeleteConfirm(false)
+ }}
+ className="px-2 py-1 text-status-error transition-colors hover:text-text-primary"
+ >
+ yes
+ </button>
+ <button
+ onClick={() => setShowDeleteConfirm(false)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ no
+ </button>
+ </div>
+ ) : (
+ <button
+ onClick={() => setShowDeleteConfirm(true)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error"
+ >
+ delete
+ </button>
+ )}
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/import-export-settings.tsx b/apps/web/app/reader/settings/_components/import-export-settings.tsx
new file mode 100644
index 0000000..efb3f09
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/import-export-settings.tsx
@@ -0,0 +1,220 @@
+"use client"
+
+import { useState, useRef } from "react"
+import { useSubscriptions } from "@/lib/queries/use-subscriptions"
+import { useSubscribeToFeed } from "@/lib/queries/use-subscribe-to-feed"
+import { useUserProfile } from "@/lib/queries/use-user-profile"
+import { downloadOpml, parseOpml } from "@/lib/opml"
+import type { ParsedOpmlGroup } from "@/lib/opml"
+import { notify } from "@/lib/notify"
+
+export function ImportExportSettings() {
+ const { data: subscriptionsData } = useSubscriptions()
+ const { data: userProfile } = useUserProfile()
+ const subscribeToFeed = useSubscribeToFeed()
+ const [parsedGroups, setParsedGroups] = useState<ParsedOpmlGroup[] | null>(
+ null
+ )
+ const [isImporting, setIsImporting] = useState(false)
+ const [isExportingData, setIsExportingData] = useState(false)
+ const fileInputReference = useRef<HTMLInputElement>(null)
+
+ const tier = userProfile?.tier ?? "free"
+
+ function handleExport() {
+ if (!subscriptionsData) return
+
+ downloadOpml(subscriptionsData.subscriptions, subscriptionsData.folders)
+ notify("subscriptions exported")
+ }
+
+ function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+
+ if (!file) return
+
+ const reader = new FileReader()
+ reader.onload = (loadEvent) => {
+ const xmlString = loadEvent.target?.result as string
+
+ try {
+ const groups = parseOpml(xmlString)
+ setParsedGroups(groups)
+ } catch {
+ notify("failed to parse OPML file")
+ }
+ }
+ reader.readAsText(file)
+
+ if (fileInputReference.current) {
+ fileInputReference.current.value = ""
+ }
+ }
+
+ async function handleImport() {
+ if (!parsedGroups) return
+
+ setIsImporting(true)
+ let importedCount = 0
+ let failedCount = 0
+
+ for (const group of parsedGroups) {
+ for (const feed of group.feeds) {
+ try {
+ await new Promise<void>((resolve, reject) => {
+ subscribeToFeed.mutate(
+ {
+ feedUrl: feed.url,
+ customTitle: feed.title || null,
+ },
+ {
+ onSuccess: () => {
+ importedCount++
+ resolve()
+ },
+ onError: (error) => {
+ failedCount++
+ resolve()
+ },
+ }
+ )
+ })
+ } catch {
+ failedCount++
+ }
+ }
+ }
+
+ setIsImporting(false)
+ setParsedGroups(null)
+
+ if (failedCount > 0) {
+ notify(`imported ${importedCount} feeds, ${failedCount} failed`)
+ } else {
+ notify(`imported ${importedCount} feeds`)
+ }
+ }
+
+ async function handleDataExport() {
+ setIsExportingData(true)
+ try {
+ const response = await fetch("/api/export")
+ if (!response.ok) throw new Error("Export failed")
+ const blob = await response.blob()
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement("a")
+ anchor.href = url
+ anchor.download = `asa-news-export-${new Date().toISOString().slice(0, 10)}.json`
+ anchor.click()
+ URL.revokeObjectURL(url)
+ notify("data exported")
+ } catch {
+ notify("failed to export data")
+ } finally {
+ setIsExportingData(false)
+ }
+ }
+
+ const totalFeedsInImport =
+ parsedGroups?.reduce((sum, group) => sum + group.feeds.length, 0) ?? 0
+
+ return (
+ <div className="px-4 py-3">
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">export OPML</h3>
+ <p className="mb-3 text-text-dim">
+ download your subscriptions as an OPML file
+ </p>
+ <button
+ onClick={handleExport}
+ disabled={!subscriptionsData}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ export OPML
+ </button>
+ </div>
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">export data</h3>
+ <p className="mb-3 text-text-dim">
+ {tier === "pro" || tier === "developer"
+ ? "download all your data as JSON (subscriptions, folders, saved entries)"
+ : "download your saved entries as JSON (upgrade to pro for full export)"}
+ </p>
+ <button
+ onClick={handleDataExport}
+ disabled={isExportingData}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ {isExportingData ? "exporting..." : "export data"}
+ </button>
+ </div>
+ <div>
+ <h3 className="mb-2 text-text-primary">import</h3>
+ <p className="mb-3 text-text-dim">
+ import subscriptions from an OPML file
+ </p>
+ {parsedGroups === null ? (
+ <div>
+ <input
+ ref={fileInputReference}
+ type="file"
+ accept=".opml,.xml"
+ onChange={handleFileSelect}
+ className="hidden"
+ />
+ <button
+ onClick={() => fileInputReference.current?.click()}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border"
+ >
+ select OPML file
+ </button>
+ </div>
+ ) : (
+ <div>
+ <p className="mb-3 text-text-secondary">
+ found {totalFeedsInImport} feed
+ {totalFeedsInImport !== 1 && "s"} to import:
+ </p>
+ <div className="mb-3 max-h-60 overflow-y-auto border border-border">
+ {parsedGroups.map((group, groupIndex) => (
+ <div key={groupIndex}>
+ {group.folderName && (
+ <div className="bg-background-tertiary px-3 py-1 text-text-secondary">
+ {group.folderName}
+ </div>
+ )}
+ {group.feeds.map((feed, feedIndex) => (
+ <div
+ key={feedIndex}
+ className="border-b border-border px-3 py-2 last:border-b-0"
+ >
+ <p className="truncate text-text-primary">{feed.title}</p>
+ <p className="truncate text-text-dim">{feed.url}</p>
+ </div>
+ ))}
+ </div>
+ ))}
+ </div>
+ <div className="flex gap-2">
+ <button
+ onClick={() => setParsedGroups(null)}
+ className="border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary"
+ >
+ cancel
+ </button>
+ <button
+ onClick={handleImport}
+ disabled={isImporting}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ {isImporting
+ ? "importing..."
+ : `import ${totalFeedsInImport} feeds`}
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx
new file mode 100644
index 0000000..bef4786
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import { useState } from "react"
+import { useMutedKeywords } from "@/lib/queries/use-muted-keywords"
+import {
+ useAddMutedKeyword,
+ useDeleteMutedKeyword,
+} from "@/lib/queries/use-muted-keyword-mutations"
+import { useUserProfile } from "@/lib/queries/use-user-profile"
+import { TIER_LIMITS } from "@asa-news/shared"
+
+export function MutedKeywordsSettings() {
+ const [newKeyword, setNewKeyword] = useState("")
+ const { data: keywords, isLoading } = useMutedKeywords()
+ const { data: userProfile } = useUserProfile()
+ const addKeyword = useAddMutedKeyword()
+ const deleteKeyword = useDeleteMutedKeyword()
+
+ const tier = userProfile?.tier ?? "free"
+ const tierLimits = TIER_LIMITS[tier]
+
+ function handleAddKeyword(event: React.FormEvent) {
+ event.preventDefault()
+ const trimmedKeyword = newKeyword.trim()
+
+ if (!trimmedKeyword) return
+
+ addKeyword.mutate({ keyword: trimmedKeyword })
+ setNewKeyword("")
+ }
+
+ if (isLoading) {
+ return <p className="px-4 py-6 text-text-dim">loading muted keywords...</p>
+ }
+
+ const keywordList = keywords ?? []
+
+ return (
+ <div>
+ <div className="border-b border-border px-4 py-3">
+ <p className="mb-1 text-text-dim">
+ {keywordList.length} / {tierLimits.maximumMutedKeywords} keywords used
+ </p>
+ <p className="mb-2 text-text-dim">
+ entries containing muted keywords are hidden from your timeline
+ </p>
+ <form onSubmit={handleAddKeyword} className="flex gap-2">
+ <input
+ type="text"
+ value={newKeyword}
+ onChange={(event) => setNewKeyword(event.target.value)}
+ placeholder="keyword to mute"
+ className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <button
+ type="submit"
+ disabled={addKeyword.isPending || !newKeyword.trim()}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ mute
+ </button>
+ </form>
+ </div>
+ {keywordList.length === 0 ? (
+ <p className="px-4 py-6 text-text-dim">no muted keywords</p>
+ ) : (
+ keywordList.map((keyword) => (
+ <div
+ key={keyword.identifier}
+ className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0"
+ >
+ <span className="text-text-primary">{keyword.keyword}</span>
+ <button
+ onClick={() =>
+ deleteKeyword.mutate({
+ keywordIdentifier: keyword.identifier,
+ })
+ }
+ disabled={deleteKeyword.isPending}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error disabled:opacity-50"
+ >
+ unmute
+ </button>
+ </div>
+ ))
+ )}
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/security-settings.tsx b/apps/web/app/reader/settings/_components/security-settings.tsx
new file mode 100644
index 0000000..4a00241
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/security-settings.tsx
@@ -0,0 +1,280 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { notify } from "@/lib/notify"
+import type { Factor } from "@supabase/supabase-js"
+
+type EnrollmentState =
+ | { step: "idle" }
+ | { step: "enrolling"; factorIdentifier: string; qrCodeSvg: string; otpauthUri: string }
+ | { step: "verifying"; factorIdentifier: string; challengeIdentifier: string }
+
+export function SecuritySettings() {
+ const [enrolledFactors, setEnrolledFactors] = useState<Factor[]>([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [enrollmentState, setEnrollmentState] = useState<EnrollmentState>({ step: "idle" })
+ const [factorName, setFactorName] = useState("")
+ const [verificationCode, setVerificationCode] = useState("")
+ const [isProcessing, setIsProcessing] = useState(false)
+ const [unenrollConfirmIdentifier, setUnenrollConfirmIdentifier] = useState<string | null>(null)
+ const supabaseClient = createSupabaseBrowserClient()
+
+ async function loadFactors() {
+ const { data, error } = await supabaseClient.auth.mfa.listFactors()
+
+ if (error) {
+ notify("failed to load MFA factors")
+ setIsLoading(false)
+ return
+ }
+
+ setEnrolledFactors(
+ data.totp.filter((factor) => factor.status === "verified")
+ )
+ setIsLoading(false)
+ }
+
+ useEffect(() => {
+ loadFactors()
+ }, [])
+
+ async function handleBeginEnrollment() {
+ setIsProcessing(true)
+
+ const enrollOptions: { factorType: "totp"; friendlyName?: string } = {
+ factorType: "totp",
+ }
+ if (factorName.trim()) {
+ enrollOptions.friendlyName = factorName.trim()
+ }
+
+ const { data, error } = await supabaseClient.auth.mfa.enroll(enrollOptions)
+
+ setIsProcessing(false)
+
+ if (error) {
+ notify("failed to start MFA enrolment: " + error.message)
+ return
+ }
+
+ setEnrollmentState({
+ step: "enrolling",
+ factorIdentifier: data.id,
+ qrCodeSvg: data.totp.qr_code,
+ otpauthUri: data.totp.uri,
+ })
+ setVerificationCode("")
+ }
+
+ async function handleVerifyEnrollment() {
+ if (enrollmentState.step !== "enrolling") return
+ if (verificationCode.length !== 6) return
+
+ setIsProcessing(true)
+
+ const { data: challengeData, error: challengeError } =
+ await supabaseClient.auth.mfa.challenge({
+ factorId: enrollmentState.factorIdentifier,
+ })
+
+ if (challengeError) {
+ setIsProcessing(false)
+ notify("failed to create MFA challenge: " + challengeError.message)
+ return
+ }
+
+ const { error: verifyError } = await supabaseClient.auth.mfa.verify({
+ factorId: enrollmentState.factorIdentifier,
+ challengeId: challengeData.id,
+ code: verificationCode,
+ })
+
+ setIsProcessing(false)
+
+ if (verifyError) {
+ notify("invalid code — please try again")
+ setVerificationCode("")
+ return
+ }
+
+ notify("two-factor authentication enabled")
+ setEnrollmentState({ step: "idle" })
+ setVerificationCode("")
+ setFactorName("")
+ await supabaseClient.auth.refreshSession()
+ await loadFactors()
+ }
+
+ async function handleCancelEnrollment() {
+ if (enrollmentState.step === "enrolling") {
+ await supabaseClient.auth.mfa.unenroll({
+ factorId: enrollmentState.factorIdentifier,
+ })
+ }
+
+ setEnrollmentState({ step: "idle" })
+ setVerificationCode("")
+ setFactorName("")
+ }
+
+ async function handleUnenrollFactor(factorIdentifier: string) {
+ setIsProcessing(true)
+
+ const { error } = await supabaseClient.auth.mfa.unenroll({
+ factorId: factorIdentifier,
+ })
+
+ setIsProcessing(false)
+
+ if (error) {
+ notify("failed to remove factor: " + error.message)
+ return
+ }
+
+ notify("two-factor authentication removed")
+ setUnenrollConfirmIdentifier(null)
+ await supabaseClient.auth.refreshSession()
+ await loadFactors()
+ }
+
+ if (isLoading) {
+ return <p className="px-4 py-6 text-text-dim">loading security settings ...</p>
+ }
+
+ return (
+ <div className="px-4 py-3">
+ <div className="mb-6">
+ <h3 className="mb-2 text-text-primary">two-factor authentication</h3>
+ <p className="mb-4 text-text-dim">
+ add an extra layer of security to your account with a time-based one-time password (TOTP) authenticator app
+ </p>
+
+ {enrollmentState.step === "idle" && enrolledFactors.length === 0 && (
+ <div className="flex items-center gap-2">
+ <input
+ type="text"
+ value={factorName}
+ onChange={(event) => setFactorName(event.target.value)}
+ placeholder="authenticator name (optional)"
+ className="w-64 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <button
+ onClick={handleBeginEnrollment}
+ disabled={isProcessing}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ {isProcessing ? "setting up ..." : "set up"}
+ </button>
+ </div>
+ )}
+
+ {enrollmentState.step === "enrolling" && (
+ <div className="space-y-4">
+ <p className="text-text-secondary">
+ scan this QR code with your authenticator app, then enter the 6-digit code below
+ </p>
+ <div className="inline-block bg-white p-4">
+ <img
+ src={enrollmentState.qrCodeSvg}
+ alt="TOTP QR code"
+ className="h-48 w-48"
+ />
+ </div>
+ <details className="text-text-dim">
+ <summary className="cursor-pointer transition-colors hover:text-text-secondary">
+ can&apos;t scan? copy manual entry key
+ </summary>
+ <code className="mt-2 block break-all bg-background-secondary p-2 text-text-secondary">
+ {enrollmentState.otpauthUri}
+ </code>
+ </details>
+ <div className="flex items-center gap-2">
+ <input
+ type="text"
+ inputMode="numeric"
+ pattern="[0-9]*"
+ maxLength={6}
+ value={verificationCode}
+ onChange={(event) => {
+ const filtered = event.target.value.replace(/\D/g, "")
+ setVerificationCode(filtered)
+ }}
+ placeholder="000000"
+ className="w-32 border border-border bg-background-primary px-3 py-2 text-center font-mono text-lg tracking-widest text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ autoFocus
+ onKeyDown={(event) => {
+ if (event.key === "Enter") handleVerifyEnrollment()
+ if (event.key === "Escape") handleCancelEnrollment()
+ }}
+ />
+ <button
+ onClick={handleVerifyEnrollment}
+ disabled={isProcessing || verificationCode.length !== 6}
+ className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
+ >
+ {isProcessing ? "verifying ..." : "verify"}
+ </button>
+ <button
+ onClick={handleCancelEnrollment}
+ className="px-4 py-2 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ cancel
+ </button>
+ </div>
+ </div>
+ )}
+
+ {enrolledFactors.length > 0 && enrollmentState.step === "idle" && (
+ <div className="space-y-3">
+ {enrolledFactors.map((factor) => (
+ <div
+ key={factor.id}
+ className="flex items-center justify-between border border-border px-4 py-3"
+ >
+ <div>
+ <span className="text-text-primary">
+ {factor.friendly_name || "TOTP authenticator"}
+ </span>
+ <span className="ml-2 text-text-dim">
+ added{" "}
+ {new Date(factor.created_at).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ })}
+ </span>
+ </div>
+ {unenrollConfirmIdentifier === factor.id ? (
+ <div className="flex items-center gap-2">
+ <span className="text-text-dim">remove?</span>
+ <button
+ onClick={() => handleUnenrollFactor(factor.id)}
+ disabled={isProcessing}
+ className="text-status-error transition-colors hover:text-text-primary disabled:opacity-50"
+ >
+ yes
+ </button>
+ <button
+ onClick={() => setUnenrollConfirmIdentifier(null)}
+ className="text-text-secondary transition-colors hover:text-text-primary"
+ >
+ no
+ </button>
+ </div>
+ ) : (
+ <button
+ onClick={() => setUnenrollConfirmIdentifier(factor.id)}
+ className="text-text-secondary transition-colors hover:text-status-error"
+ >
+ remove
+ </button>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/settings-shell.tsx b/apps/web/app/reader/settings/_components/settings-shell.tsx
new file mode 100644
index 0000000..ae432f3
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/settings-shell.tsx
@@ -0,0 +1,86 @@
+"use client"
+
+import { useUserInterfaceStore } from "@/lib/stores/user-interface-store"
+import { SubscriptionsSettings } from "./subscriptions-settings"
+import { FoldersSettings } from "./folders-settings"
+import { MutedKeywordsSettings } from "./muted-keywords-settings"
+import { CustomFeedsSettings } from "./custom-feeds-settings"
+import { ImportExportSettings } from "./import-export-settings"
+import { AppearanceSettings } from "./appearance-settings"
+import { AccountSettings } from "./account-settings"
+import { SecuritySettings } from "./security-settings"
+import { BillingSettings } from "./billing-settings"
+import { ApiSettings } from "./api-settings"
+import { DangerZoneSettings } from "./danger-zone-settings"
+
+const TABS = [
+ { key: "subscriptions", label: "subscriptions" },
+ { key: "folders", label: "folders" },
+ { key: "muted-keywords", label: "muted keywords" },
+ { key: "custom-feeds", label: "custom feeds" },
+ { key: "import-export", label: "import / export" },
+ { key: "appearance", label: "appearance" },
+ { key: "account", label: "account" },
+ { key: "security", label: "security" },
+ { key: "billing", label: "billing" },
+ { key: "api", label: "API" },
+ { key: "danger", label: "danger zone" },
+] as const
+
+export function SettingsShell() {
+ const activeTab = useUserInterfaceStore((state) => state.activeSettingsTab)
+ const setActiveTab = useUserInterfaceStore(
+ (state) => state.setActiveSettingsTab
+ )
+
+ return (
+ <div className="flex h-full flex-col">
+ <header className="flex items-center border-b border-border px-4 py-3">
+ <h1 className="text-text-primary">settings</h1>
+ </header>
+ <nav className="border-b border-border">
+ <select
+ value={activeTab}
+ onChange={(event) => setActiveTab(event.target.value as typeof activeTab)}
+ className="w-full border-none bg-background-primary px-4 py-2 text-text-primary outline-none md:hidden"
+ >
+ {TABS.map((tab) => (
+ <option key={tab.key} value={tab.key}>
+ {tab.label}
+ </option>
+ ))}
+ </select>
+ <div className="hidden md:flex">
+ {TABS.map((tab) => (
+ <button
+ key={tab.key}
+ onClick={() => setActiveTab(tab.key)}
+ className={`shrink-0 px-4 py-2 transition-colors ${
+ activeTab === tab.key
+ ? "border-b-2 border-text-primary text-text-primary"
+ : "text-text-dim hover:text-text-secondary"
+ }`}
+ >
+ {tab.label}
+ </button>
+ ))}
+ </div>
+ </nav>
+ <div className="flex-1 overflow-y-auto">
+ <div className="max-w-3xl">
+ {activeTab === "subscriptions" && <SubscriptionsSettings />}
+ {activeTab === "folders" && <FoldersSettings />}
+ {activeTab === "muted-keywords" && <MutedKeywordsSettings />}
+ {activeTab === "custom-feeds" && <CustomFeedsSettings />}
+ {activeTab === "import-export" && <ImportExportSettings />}
+ {activeTab === "appearance" && <AppearanceSettings />}
+ {activeTab === "account" && <AccountSettings />}
+ {activeTab === "security" && <SecuritySettings />}
+ {activeTab === "billing" && <BillingSettings />}
+ {activeTab === "api" && <ApiSettings />}
+ {activeTab === "danger" && <DangerZoneSettings />}
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/settings/_components/subscriptions-settings.tsx b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx
new file mode 100644
index 0000000..7257231
--- /dev/null
+++ b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx
@@ -0,0 +1,281 @@
+"use client"
+
+import { useState } from "react"
+import { useSubscriptions } from "@/lib/queries/use-subscriptions"
+import {
+ useUpdateSubscriptionTitle,
+ useMoveSubscriptionToFolder,
+ useUnsubscribe,
+ useRequestFeedRefresh,
+} from "@/lib/queries/use-subscription-mutations"
+import { useUserProfile } from "@/lib/queries/use-user-profile"
+import { TIER_LIMITS } from "@asa-news/shared"
+import type { Subscription } from "@/lib/types/subscription"
+
+function formatRelativeTime(isoString: string | null): string {
+ if (!isoString) return "never"
+ const date = new Date(isoString)
+ const now = new Date()
+ const differenceSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
+ if (differenceSeconds < 60) return "just now"
+ if (differenceSeconds < 3600) return `${Math.floor(differenceSeconds / 60)}m ago`
+ if (differenceSeconds < 86400) return `${Math.floor(differenceSeconds / 3600)}h ago`
+ return `${Math.floor(differenceSeconds / 86400)}d ago`
+}
+
+function formatRefreshInterval(seconds: number): string {
+ if (seconds < 3600) return `${Math.round(seconds / 60)} min`
+ return `${Math.round(seconds / 3600)} hr`
+}
+
+function SubscriptionRow({
+ subscription,
+ folderOptions,
+}: {
+ subscription: Subscription
+ folderOptions: { identifier: string; name: string }[]
+}) {
+ const [isEditingTitle, setIsEditingTitle] = useState(false)
+ const [editedTitle, setEditedTitle] = useState(
+ subscription.customTitle ?? ""
+ )
+ const [showUnsubscribeConfirm, setShowUnsubscribeConfirm] = useState(false)
+ const updateTitle = useUpdateSubscriptionTitle()
+ const moveToFolder = useMoveSubscriptionToFolder()
+ const unsubscribe = useUnsubscribe()
+ const requestRefresh = useRequestFeedRefresh()
+ const { data: userProfile } = useUserProfile()
+
+ function handleSaveTitle() {
+ const trimmedTitle = editedTitle.trim()
+ updateTitle.mutate({
+ subscriptionIdentifier: subscription.subscriptionIdentifier,
+ customTitle: trimmedTitle || null,
+ })
+ setIsEditingTitle(false)
+ }
+
+ function handleFolderChange(folderIdentifier: string) {
+ const sourceFolder = folderOptions.find(
+ (folder) => folder.identifier === subscription.folderIdentifier
+ )
+ const targetFolder = folderOptions.find(
+ (folder) => folder.identifier === folderIdentifier
+ )
+ moveToFolder.mutate({
+ subscriptionIdentifier: subscription.subscriptionIdentifier,
+ folderIdentifier: folderIdentifier || null,
+ feedTitle: subscription.customTitle ?? subscription.feedTitle ?? undefined,
+ sourceFolderName: sourceFolder?.name,
+ folderName: targetFolder?.name,
+ })
+ }
+
+ return (
+ <div className="flex flex-col gap-2 border-b border-border px-4 py-3 last:border-b-0">
+ <div className="flex items-center justify-between gap-4">
+ <div className="min-w-0 flex-1">
+ {isEditingTitle ? (
+ <div className="flex items-center gap-2">
+ <input
+ type="text"
+ value={editedTitle}
+ onChange={(event) => setEditedTitle(event.target.value)}
+ placeholder={subscription.feedTitle}
+ className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim"
+ onKeyDown={(event) => {
+ if (event.key === "Enter") handleSaveTitle()
+ if (event.key === "Escape") setIsEditingTitle(false)
+ }}
+ autoFocus
+ />
+ <button
+ onClick={handleSaveTitle}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ save
+ </button>
+ <button
+ onClick={() => setIsEditingTitle(false)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ cancel
+ </button>
+ </div>
+ ) : (
+ <div className="flex items-center gap-2">
+ <span className="truncate text-text-primary">
+ {subscription.customTitle ?? subscription.feedTitle}
+ </span>
+ <button
+ onClick={() => {
+ setEditedTitle(subscription.customTitle ?? "")
+ setIsEditingTitle(true)
+ }}
+ className="shrink-0 px-2 py-1 text-text-dim transition-colors hover:text-text-secondary"
+ >
+ rename
+ </button>
+ </div>
+ )}
+ <p className="truncate text-text-dim">{subscription.feedUrl}</p>
+ <div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-text-dim">
+ <span>last fetched: {formatRelativeTime(subscription.lastFetchedAt)}</span>
+ <span>interval: {formatRefreshInterval(subscription.fetchIntervalSeconds)}</span>
+ {subscription.consecutiveFailures > 0 && (
+ <span className="text-status-warning">
+ {subscription.consecutiveFailures} consecutive failure{subscription.consecutiveFailures !== 1 && "s"}
+ </span>
+ )}
+ </div>
+ {subscription.lastFetchError && subscription.consecutiveFailures > 0 && (
+ <p className="mt-1 truncate text-status-warning">
+ {subscription.lastFetchError}
+ </p>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <select
+ value={subscription.folderIdentifier ?? ""}
+ onChange={(event) => handleFolderChange(event.target.value)}
+ className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none"
+ >
+ <option value="">no folder</option>
+ {folderOptions.map((folder) => (
+ <option key={folder.identifier} value={folder.identifier}>
+ {folder.name}
+ </option>
+ ))}
+ </select>
+ {(userProfile?.tier === "pro" || userProfile?.tier === "developer") && (
+ <button
+ onClick={() =>
+ requestRefresh.mutate({
+ subscriptionIdentifier:
+ subscription.subscriptionIdentifier,
+ })
+ }
+ disabled={requestRefresh.isPending}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50"
+ >
+ refresh
+ </button>
+ )}
+ {showUnsubscribeConfirm ? (
+ <div className="flex items-center gap-1">
+ <span className="text-text-dim">confirm?</span>
+ <button
+ onClick={() => {
+ unsubscribe.mutate({
+ subscriptionIdentifier:
+ subscription.subscriptionIdentifier,
+ })
+ setShowUnsubscribeConfirm(false)
+ }}
+ className="px-2 py-1 text-status-error transition-colors hover:text-text-primary"
+ >
+ yes
+ </button>
+ <button
+ onClick={() => setShowUnsubscribeConfirm(false)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary"
+ >
+ no
+ </button>
+ </div>
+ ) : (
+ <button
+ onClick={() => setShowUnsubscribeConfirm(true)}
+ className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error"
+ >
+ unsubscribe
+ </button>
+ )}
+ </div>
+ </div>
+ )
+}
+
+export function SubscriptionsSettings() {
+ const { data: subscriptionsData, isLoading } = useSubscriptions()
+ const { data: userProfile } = useUserProfile()
+ const [searchQuery, setSearchQuery] = useState("")
+ const [folderFilter, setFolderFilter] = useState<string>("all")
+
+ if (isLoading) {
+ return <p className="px-4 py-6 text-text-dim">loading subscriptions ...</p>
+ }
+
+ const subscriptions = subscriptionsData?.subscriptions ?? []
+ const folders = subscriptionsData?.folders ?? []
+ const folderOptions = folders.map((folder) => ({
+ identifier: folder.folderIdentifier,
+ name: folder.name,
+ }))
+
+ if (subscriptions.length === 0) {
+ return (
+ <p className="px-4 py-6 text-text-dim">
+ no subscriptions yet — add a feed to get started
+ </p>
+ )
+ }
+
+ const normalizedQuery = searchQuery.toLowerCase().trim()
+
+ const filteredSubscriptions = subscriptions.filter((subscription) => {
+ if (folderFilter === "ungrouped" && subscription.folderIdentifier !== null) return false
+ if (folderFilter !== "all" && folderFilter !== "ungrouped" && subscription.folderIdentifier !== folderFilter) return false
+
+ if (normalizedQuery) {
+ const title = (subscription.customTitle ?? subscription.feedTitle ?? "").toLowerCase()
+ const url = (subscription.feedUrl ?? "").toLowerCase()
+ if (!title.includes(normalizedQuery) && !url.includes(normalizedQuery)) return false
+ }
+
+ return true
+ })
+
+ return (
+ <div>
+ <div className="flex flex-wrap items-center gap-2 px-4 py-3">
+ <input
+ type="text"
+ value={searchQuery}
+ onChange={(event) => setSearchQuery(event.target.value)}
+ placeholder="search subscriptions..."
+ className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
+ />
+ <select
+ value={folderFilter}
+ onChange={(event) => setFolderFilter(event.target.value)}
+ className="border border-border bg-background-primary px-2 py-1.5 text-text-secondary outline-none"
+ >
+ <option value="all">all folders</option>
+ <option value="ungrouped">ungrouped</option>
+ {folderOptions.map((folder) => (
+ <option key={folder.identifier} value={folder.identifier}>
+ {folder.name}
+ </option>
+ ))}
+ </select>
+ <span className="text-text-dim">
+ {filteredSubscriptions.length} / {TIER_LIMITS[userProfile?.tier ?? "free"].maximumFeeds}
+ </span>
+ </div>
+ <div>
+ {filteredSubscriptions.map((subscription) => (
+ <SubscriptionRow
+ key={subscription.subscriptionIdentifier}
+ subscription={subscription}
+ folderOptions={folderOptions}
+ />
+ ))}
+ {filteredSubscriptions.length === 0 && (
+ <p className="px-4 py-6 text-text-dim">no subscriptions match your filters</p>
+ )}
+ </div>
+ </div>
+ )
+}