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/shares/_components | |
| 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/shares/_components')
| -rw-r--r-- | apps/web/app/reader/shares/_components/shares-content.tsx | 504 |
1 files changed, 504 insertions, 0 deletions
diff --git a/apps/web/app/reader/shares/_components/shares-content.tsx b/apps/web/app/reader/shares/_components/shares-content.tsx new file mode 100644 index 0000000..e9ce7a4 --- /dev/null +++ b/apps/web/app/reader/shares/_components/shares-content.tsx @@ -0,0 +1,504 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Group, Panel, Separator } from "react-resizable-panels" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { useIsMobile } from "@/lib/hooks/use-is-mobile" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { EntryDetailPanel } from "@/app/reader/_components/entry-detail-panel" +import { ErrorBoundary } from "@/app/reader/_components/error-boundary" +import { classNames } from "@/lib/utilities" +import { notify } from "@/lib/notify" + +interface SharedEntry { + identifier: string + entryIdentifier: string + shareToken: string + createdAt: string + expiresAt: string | null + note: string | null + entryTitle: string | null + entryUrl: string | null +} + +function useSharedEntries() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: ["shared-entries"], + queryFn: async () => { + const { data, error } = await supabaseClient + .from("shared_entries") + .select("id, entry_id, share_token, created_at, expires_at, note, entries(title, url)") + .order("created_at", { ascending: false }) + + if (error) throw error + + return (data ?? []).map( + (row) => { + const entryData = row.entries as unknown as { + title: string | null + url: string | null + } | null + + return { + identifier: row.id, + entryIdentifier: row.entry_id, + shareToken: row.share_token, + createdAt: row.created_at, + expiresAt: row.expires_at, + note: (row as Record<string, unknown>).note as string | null, + entryTitle: entryData?.title ?? null, + entryUrl: entryData?.url ?? null, + } + } + ) + }, + }) +} + +function useRevokeShare() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (shareIdentifier: string) => { + const { error } = await supabaseClient + .from("shared_entries") + .delete() + .eq("id", shareIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + notify("share revoked") + }, + onError: () => { + notify("failed to revoke share") + }, + }) +} + +function useUpdateShareNote() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ shareToken, note }: { shareToken: string; note: string | null }) => { + const response = await fetch(`/api/share/${shareToken}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ note }), + }) + if (!response.ok) throw new Error("Failed to update note") + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + notify("note updated") + }, + onError: () => { + notify("failed to update note") + }, + }) +} + +function isExpired(expiresAt: string | null): boolean { + if (!expiresAt) return false + return new Date(expiresAt) < new Date() +} + +function ShareRow({ + share, + isSelected, + isFocused, + viewMode, + onSelect, +}: { + share: SharedEntry + isSelected: boolean + isFocused: boolean + viewMode: "compact" | "comfortable" | "expanded" + onSelect: (entryIdentifier: string) => void +}) { + const [showRevokeConfirm, setShowRevokeConfirm] = useState(false) + const [isEditingNote, setIsEditingNote] = useState(false) + const [editedNote, setEditedNote] = useState(share.note ?? "") + const revokeShare = useRevokeShare() + const updateNote = useUpdateShareNote() + const expired = isExpired(share.expiresAt) + + function handleCopyLink() { + const shareUrl = `${window.location.origin}/shared/${share.shareToken}` + navigator.clipboard.writeText(shareUrl) + notify("link copied") + } + + function handleSaveNote() { + const trimmedNote = editedNote.trim() + updateNote.mutate({ shareToken: share.shareToken, note: trimmedNote || null }) + setIsEditingNote(false) + } + + const sharedDate = new Date(share.createdAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }) + + const rowClassName = classNames( + "cursor-pointer border-b border-border px-4 transition-colors last:border-b-0", + isSelected + ? "bg-background-tertiary" + : isFocused + ? "bg-background-secondary" + : "hover:bg-background-secondary", + isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "" + ) + + if (viewMode === "compact") { + return ( + <div + data-share-list-item + onClick={() => onSelect(share.entryIdentifier)} + className={rowClassName} + > + <div className="flex items-center gap-2 py-2.5"> + <span className="min-w-0 flex-1 truncate text-text-primary"> + {share.entryTitle ?? "untitled"} + </span> + {expired && <span className="shrink-0 text-status-error">expired</span>} + <span className="shrink-0 text-text-dim">{sharedDate}</span> + </div> + </div> + ) + } + + if (viewMode === "comfortable") { + return ( + <div + data-share-list-item + onClick={() => onSelect(share.entryIdentifier)} + className={rowClassName} + > + <div className="py-2.5"> + <span className="block truncate text-text-primary"> + {share.entryTitle ?? "untitled"} + </span> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + <span>shared {sharedDate}</span> + {share.expiresAt && ( + <> + <span>·</span> + {expired ? ( + <span className="text-status-error">expired</span> + ) : ( + <span> + expires{" "} + {new Date(share.expiresAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + </span> + )} + </> + )} + {share.note && ( + <> + <span>·</span> + <span className="truncate">{share.note}</span> + </> + )} + </div> + </div> + </div> + ) + } + + return ( + <div + data-share-list-item + onClick={() => onSelect(share.entryIdentifier)} + className={classNames(rowClassName, "flex flex-col gap-1 py-3")} + > + <div className="flex items-center justify-between"> + <div className="min-w-0 flex-1"> + <span className="block truncate text-text-primary"> + {share.entryTitle ?? "untitled"} + </span> + {isEditingNote ? ( + <div className="mt-1 flex items-center gap-2"> + <input + type="text" + value={editedNote} + onChange={(event) => setEditedNote(event.target.value)} + placeholder="add a note..." + 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") handleSaveNote() + if (event.key === "Escape") setIsEditingNote(false) + }} + autoFocus + /> + <button + type="button" + onClick={handleSaveNote} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + type="button" + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : share.note ? ( + <p className="truncate text-text-secondary">{share.note}</p> + ) : null} + <p className="text-text-dim"> + shared {sharedDate} + {share.expiresAt && ( + <> + {" \u00b7 "} + {expired ? ( + <span className="text-status-error">expired</span> + ) : ( + <> + expires{" "} + {new Date(share.expiresAt).toLocaleDateString( + "en-GB", + { + day: "numeric", + month: "short", + year: "numeric", + } + )} + </> + )} + </> + )} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + {!expired && ( + <button + type="button" + onClick={handleCopyLink} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + copy link + </button> + )} + <button + type="button" + onClick={() => { + setEditedNote(share.note ?? "") + setIsEditingNote(true) + }} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + {share.note ? "edit note" : "add note"} + </button> + {showRevokeConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">revoke?</span> + <button + type="button" + onClick={() => { + revokeShare.mutate(share.identifier) + setShowRevokeConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + type="button" + onClick={() => setShowRevokeConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + type="button" + onClick={() => setShowRevokeConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + revoke + </button> + )} + </div> + </div> + ) +} + +function SharesList({ + shares, + selectedEntryIdentifier, + focusedEntryIdentifier, + viewMode, + onSelect, +}: { + shares: SharedEntry[] + selectedEntryIdentifier: string | null + focusedEntryIdentifier: string | null + viewMode: "compact" | "comfortable" | "expanded" + onSelect: (entryIdentifier: string) => void +}) { + const listReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (!focusedEntryIdentifier) return + const container = listReference.current + if (!container) return + const items = container.querySelectorAll("[data-share-list-item]") + const focusedIndex = shares.findIndex( + (share) => share.entryIdentifier === focusedEntryIdentifier + ) + items[focusedIndex]?.scrollIntoView({ block: "nearest" }) + }, [focusedEntryIdentifier, shares]) + + if (shares.length === 0) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + no shared entries yet + </div> + ) + } + + return ( + <div ref={listReference} className="h-full overflow-auto"> + {shares.map((share) => ( + <ShareRow + key={share.identifier} + share={share} + isSelected={share.entryIdentifier === selectedEntryIdentifier} + isFocused={share.entryIdentifier === focusedEntryIdentifier} + viewMode={viewMode} + onSelect={onSelect} + /> + ))} + </div> + ) +} + +export function SharesContent() { + const { data: shares, isLoading } = useSharedEntries() + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setNavigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.setNavigableEntryIdentifiers + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const isMobile = useIsMobile() + + const sharesList = shares ?? [] + + useEffect(() => { + setSelectedEntryIdentifier(null) + setNavigableEntryIdentifiers([]) + }, []) + + useEffect(() => { + setNavigableEntryIdentifiers( + sharesList.map((share) => share.entryIdentifier) + ) + }, [sharesList.length, setNavigableEntryIdentifiers]) + + if (isLoading) { + return <p className="px-6 py-8 text-text-dim">loading shares ...</p> + } + + const activeShareCount = sharesList.filter( + (share) => !isExpired(share.expiresAt) + ).length + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center justify-between border-b border-border px-4 py-3"> + <div className="flex items-center gap-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">shares</h1> + </div> + <span className="text-text-dim"> + {activeShareCount} active share{activeShareCount !== 1 ? "s" : ""} + </span> + </header> + <ErrorBoundary> + {isMobile ? ( + selectedEntryIdentifier ? ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + ) : ( + <div className="flex-1 overflow-hidden"> + <SharesList + shares={sharesList} + selectedEntryIdentifier={null} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + /> + </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" + )}> + <SharesList + shares={sharesList} + selectedEntryIdentifier={selectedEntryIdentifier} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + /> + </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> + ) +} |