summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/_components/reader-shell.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/_components/reader-shell.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/_components/reader-shell.tsx')
-rw-r--r--apps/web/app/reader/_components/reader-shell.tsx208
1 files changed, 208 insertions, 0 deletions
diff --git a/apps/web/app/reader/_components/reader-shell.tsx b/apps/web/app/reader/_components/reader-shell.tsx
new file mode 100644
index 0000000..fe7e4c2
--- /dev/null
+++ b/apps/web/app/reader/_components/reader-shell.tsx
@@ -0,0 +1,208 @@
+"use client"
+
+import { Group, Panel, Separator } from "react-resizable-panels"
+import { useUserInterfaceStore } from "@/lib/stores/user-interface-store"
+import { useMarkAllAsRead } from "@/lib/queries/use-mark-all-as-read"
+import { useSubscriptions } from "@/lib/queries/use-subscriptions"
+import { useUnreadCounts } from "@/lib/queries/use-unread-counts"
+import { useIsMobile } from "@/lib/hooks/use-is-mobile"
+import { classNames } from "@/lib/utilities"
+import { EntryList } from "./entry-list"
+import { EntryDetailPanel } from "./entry-detail-panel"
+import { ErrorBoundary } from "./error-boundary"
+import { useRealtimeEntries } from "@/lib/hooks/use-realtime-entries"
+import { useCustomFeeds } from "@/lib/queries/use-custom-feeds"
+
+interface ReaderShellProperties {
+ userEmailAddress: string | null
+ feedFilter: "all" | "saved"
+ folderIdentifier?: string | null
+ feedIdentifier?: string | null
+ customFeedIdentifier?: string | null
+}
+
+export function ReaderShell({
+ userEmailAddress,
+ feedFilter,
+ folderIdentifier,
+ feedIdentifier,
+ customFeedIdentifier,
+}: ReaderShellProperties) {
+ const selectedEntryIdentifier = useUserInterfaceStore(
+ (state) => state.selectedEntryIdentifier
+ )
+ const setSelectedEntryIdentifier = useUserInterfaceStore(
+ (state) => state.setSelectedEntryIdentifier
+ )
+ const entryListViewMode = useUserInterfaceStore(
+ (state) => state.entryListViewMode
+ )
+ const setEntryListViewMode = useUserInterfaceStore(
+ (state) => state.setEntryListViewMode
+ )
+ const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen)
+ const markAllAsRead = useMarkAllAsRead()
+ const { data: subscriptionsData } = useSubscriptions()
+ const { data: unreadCounts } = useUnreadCounts()
+ const { data: customFeedsData } = useCustomFeeds()
+ const isMobile = useIsMobile()
+ const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel)
+
+ useRealtimeEntries()
+
+ let pageTitle = feedFilter === "saved" ? "saved" : "all entries"
+
+ if (feedFilter === "all" && customFeedIdentifier && customFeedsData) {
+ const matchingCustomFeed = customFeedsData.find(
+ (customFeed) => customFeed.identifier === customFeedIdentifier
+ )
+
+ if (matchingCustomFeed) {
+ pageTitle = matchingCustomFeed.name
+ }
+ }
+
+ if (feedFilter === "all" && feedIdentifier && subscriptionsData) {
+ const matchingSubscription = subscriptionsData.subscriptions.find(
+ (subscription) => subscription.feedIdentifier === feedIdentifier
+ )
+
+ if (matchingSubscription) {
+ pageTitle =
+ matchingSubscription.customTitle ||
+ matchingSubscription.feedTitle ||
+ "feed"
+ }
+ }
+
+ if (feedFilter === "all" && folderIdentifier && subscriptionsData) {
+ const matchingFolder = subscriptionsData.folders.find(
+ (folder) => folder.folderIdentifier === folderIdentifier
+ )
+
+ if (matchingFolder) {
+ pageTitle = matchingFolder.name
+ }
+ }
+
+ const totalUnreadCount = Object.values(unreadCounts ?? {}).reduce(
+ (sum, count) => sum + count,
+ 0
+ )
+ const allAreRead = totalUnreadCount === 0
+
+ return (
+ <div className="flex h-full flex-col">
+ <header className="flex items-center justify-between border-b border-border px-4 py-3">
+ {isMobile && selectedEntryIdentifier ? (
+ <button
+ type="button"
+ onClick={() => setSelectedEntryIdentifier(null)}
+ className="text-text-secondary transition-colors hover:text-text-primary"
+ >
+ &larr; back
+ </button>
+ ) : (
+ <h1 className="text-text-primary">{pageTitle}</h1>
+ )}
+ <div className="flex items-center gap-3">
+ {!(isMobile && selectedEntryIdentifier) && (
+ <>
+ <button
+ type="button"
+ onClick={() => setSearchOpen(true)}
+ className="text-text-dim transition-colors hover:text-text-secondary"
+ >
+ search
+ </button>
+ {feedFilter === "all" && (
+ <button
+ type="button"
+ onClick={() =>
+ markAllAsRead.mutate({ readState: !allAreRead })
+ }
+ disabled={markAllAsRead.isPending}
+ className="text-text-dim transition-colors hover:text-text-secondary disabled:opacity-50"
+ >
+ {allAreRead ? "mark all unread" : "mark all read"}
+ </button>
+ )}
+ <select
+ value={entryListViewMode}
+ onChange={(event) =>
+ setEntryListViewMode(
+ event.target.value as "compact" | "comfortable" | "expanded"
+ )
+ }
+ className="hidden border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none sm:block"
+ >
+ <option value="compact">compact</option>
+ <option value="comfortable">comfortable</option>
+ <option value="expanded">expanded</option>
+ </select>
+ </>
+ )}
+ </div>
+ </header>
+ <ErrorBoundary>
+ {isMobile ? (
+ selectedEntryIdentifier ? (
+ <div className="flex-1 overflow-hidden">
+ <ErrorBoundary>
+ <EntryDetailPanel
+ entryIdentifier={selectedEntryIdentifier}
+ />
+ </ErrorBoundary>
+ </div>
+ ) : (
+ <div className="flex-1 overflow-hidden">
+ <ErrorBoundary>
+ <EntryList
+ feedFilter={feedFilter}
+ folderIdentifier={folderIdentifier}
+ feedIdentifier={feedIdentifier}
+ customFeedIdentifier={customFeedIdentifier}
+ />
+ </ErrorBoundary>
+ </div>
+ )
+ ) : (
+ <Group orientation="horizontal" className="flex-1">
+ <Panel defaultSize={selectedEntryIdentifier ? 40 : 100} minSize={25}>
+ <div data-panel-zone="entryList" className={classNames(
+ "h-full",
+ focusedPanel === "entryList" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent"
+ )}>
+ <ErrorBoundary>
+ <EntryList
+ feedFilter={feedFilter}
+ folderIdentifier={folderIdentifier}
+ feedIdentifier={feedIdentifier}
+ customFeedIdentifier={customFeedIdentifier}
+ />
+ </ErrorBoundary>
+ </div>
+ </Panel>
+ {selectedEntryIdentifier && (
+ <>
+ <Separator className="w-px bg-border transition-colors hover:bg-text-dim" />
+ <Panel defaultSize={60} minSize={30}>
+ <div data-panel-zone="detailPanel" className={classNames(
+ "h-full",
+ focusedPanel === "detailPanel" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent"
+ )}>
+ <ErrorBoundary>
+ <EntryDetailPanel
+ entryIdentifier={selectedEntryIdentifier}
+ />
+ </ErrorBoundary>
+ </div>
+ </Panel>
+ </>
+ )}
+ </Group>
+ )}
+ </ErrorBoundary>
+ </div>
+ )
+}