summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/shares/_components
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/shares/_components
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/shares/_components')
-rw-r--r--apps/web/app/reader/shares/_components/shares-content.tsx504
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>&middot;</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>&middot;</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"
+ >
+ &larr; 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>
+ )
+}