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/web/app/reader/settings/_components/subscriptions-settings.tsx | |
| 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/web/app/reader/settings/_components/subscriptions-settings.tsx')
| -rw-r--r-- | apps/web/app/reader/settings/_components/subscriptions-settings.tsx | 281 |
1 files changed, 281 insertions, 0 deletions
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> + ) +} |