summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/settings/_components/subscriptions-settings.tsx
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/subscriptions-settings.tsx
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/subscriptions-settings.tsx')
-rw-r--r--apps/web/app/reader/settings/_components/subscriptions-settings.tsx281
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>
+ )
+}