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/_components/reader-shell.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/_components/reader-shell.tsx')
| -rw-r--r-- | apps/web/app/reader/_components/reader-shell.tsx | 208 |
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" + > + ← 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> + ) +} |