diff options
Diffstat (limited to 'apps/web/app/reader')
36 files changed, 6635 insertions, 0 deletions
diff --git a/apps/web/app/reader/_components/add-feed-dialog.tsx b/apps/web/app/reader/_components/add-feed-dialog.tsx new file mode 100644 index 0000000..4eb119c --- /dev/null +++ b/apps/web/app/reader/_components/add-feed-dialog.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useState } from "react" +import { useSubscribeToFeed } from "@/lib/queries/use-subscribe-to-feed" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +export function AddFeedDialog() { + const isOpen = useUserInterfaceStore((state) => state.isAddFeedDialogOpen) + const setOpen = useUserInterfaceStore((state) => state.setAddFeedDialogOpen) + const [feedUrl, setFeedUrl] = useState("") + const [customTitle, setCustomTitle] = useState("") + const [selectedFolderIdentifier, setSelectedFolderIdentifier] = useState< + string | null + >(null) + const subscribeToFeed = useSubscribeToFeed() + const { data: subscriptionsData } = useSubscriptions() + + function handleClose() { + setFeedUrl("") + setCustomTitle("") + setSelectedFolderIdentifier(null) + setOpen(false) + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + subscribeToFeed.mutate( + { + feedUrl, + folderIdentifier: selectedFolderIdentifier, + customTitle: customTitle || null, + }, + { + onSuccess: () => { + handleClose() + }, + } + ) + } + + if (!isOpen) return null + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center"> + <div + className="fixed inset-0 bg-background-primary/80" + onClick={handleClose} + /> + <div className="relative w-full max-w-md border border-border bg-background-secondary p-6"> + <h2 className="mb-4 text-text-primary">add feed</h2> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="feed-url" className="text-text-secondary"> + feed url + </label> + <input + id="feed-url" + type="url" + value={feedUrl} + onChange={(event) => setFeedUrl(event.target.value)} + required + placeholder="https://example.com/feed.xml" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + <div className="space-y-2"> + <label htmlFor="custom-title" className="text-text-secondary"> + custom title (optional) + </label> + <input + id="custom-title" + type="text" + value={customTitle} + onChange={(event) => setCustomTitle(event.target.value)} + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + <div className="space-y-2"> + <label htmlFor="folder-select" className="text-text-secondary"> + folder (optional) + </label> + <select + id="folder-select" + value={selectedFolderIdentifier ?? ""} + onChange={(event) => + setSelectedFolderIdentifier(event.target.value || null) + } + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none" + > + <option value="">no folder</option> + {subscriptionsData?.folders.map((folder) => ( + <option + key={folder.folderIdentifier} + value={folder.folderIdentifier} + > + {folder.name} + </option> + ))} + </select> + </div> + <div className="flex gap-2"> + <button + type="button" + onClick={handleClose} + className="flex-1 border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + cancel + </button> + <button + type="submit" + disabled={subscribeToFeed.isPending} + className="flex-1 border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {subscribeToFeed.isPending ? "adding..." : "add feed"} + </button> + </div> + </form> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/command-palette.tsx b/apps/web/app/reader/_components/command-palette.tsx new file mode 100644 index 0000000..f3ff992 --- /dev/null +++ b/apps/web/app/reader/_components/command-palette.tsx @@ -0,0 +1,200 @@ +"use client" + +import { Command } from "cmdk" +import { useEffect, useRef, useState } from "react" +import { useRouter } from "next/navigation" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" + +export function CommandPalette() { + const isOpen = useUserInterfaceStore((state) => state.isCommandPaletteOpen) + const setOpen = useUserInterfaceStore((state) => state.setCommandPaletteOpen) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const setAddFeedDialogOpen = useUserInterfaceStore( + (state) => state.setAddFeedDialogOpen + ) + const router = useRouter() + const { data: subscriptionsData } = useSubscriptions() + const listReference = useRef<HTMLDivElement>(null) + const [inputValue, setInputValue] = useState("") + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + setOpen(!isOpen) + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, setOpen]) + + useEffect(() => { + if (!isOpen) return + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false) + return + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + setTimeout(() => { + const list = listReference.current + if (!list) return + const selected = list.querySelector('[aria-selected="true"]') as HTMLElement + if (!selected) return + const listRect = list.getBoundingClientRect() + const selectedRect = selected.getBoundingClientRect() + if (selectedRect.bottom > listRect.bottom) { + list.scrollTop += selectedRect.bottom - listRect.bottom + } else if (selectedRect.top < listRect.top) { + list.scrollTop -= listRect.top - selectedRect.top + } + }, 0) + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, setOpen]) + + if (!isOpen) return null + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "Backspace" && inputValue === "") { + event.preventDefault() + setOpen(false) + } + } + + function navigateAndClose(path: string) { + router.push(path) + setOpen(false) + } + + function actionAndClose(action: () => void) { + action() + setOpen(false) + } + + return ( + <div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"> + <div + className="fixed inset-0 bg-background-primary/80" + onClick={() => setOpen(false)} + /> + <Command className="relative w-full max-w-lg border border-border bg-background-secondary"> + <Command.Input + placeholder="type a command..." + className="w-full border-b border-border bg-transparent px-4 py-3 text-text-primary outline-none placeholder:text-text-dim" + autoFocus + value={inputValue} + onValueChange={setInputValue} + onKeyDown={handleInputKeyDown} + /> + <Command.List ref={listReference} className="max-h-80 overflow-auto p-2"> + <Command.Empty className="p-4 text-center text-text-dim"> + no results found + </Command.Empty> + + <Command.Group + heading="navigation" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + <Command.Item + onSelect={() => navigateAndClose("/reader")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to all entries + </Command.Item> + <Command.Item + onSelect={() => navigateAndClose("/reader/saved")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to saved + </Command.Item> + <Command.Item + onSelect={() => navigateAndClose("/reader/settings")} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + go to settings + </Command.Item> + </Command.Group> + + {subscriptionsData && + subscriptionsData.subscriptions.length > 0 && ( + <Command.Group + heading="feeds" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + {subscriptionsData.subscriptions.map((subscription) => ( + <Command.Item + key={subscription.subscriptionIdentifier} + value={`feed-${subscription.subscriptionIdentifier}-${subscription.customTitle ?? subscription.feedTitle}`} + onSelect={() => + navigateAndClose( + `/reader?feed=${subscription.feedIdentifier}` + ) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + {subscription.customTitle ?? subscription.feedTitle} + </Command.Item> + ))} + </Command.Group> + )} + + <Command.Group + heading="actions" + className="mb-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-text-dim" + > + <Command.Item + onSelect={() => + actionAndClose(() => setAddFeedDialogOpen(true)) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + add feed + </Command.Item> + <Command.Item + onSelect={() => actionAndClose(toggleSidebar)} + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + toggle sidebar + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("compact")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + compact view + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("comfortable")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + comfortable view + </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => setEntryListViewMode("expanded")) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + expanded view + </Command.Item> + </Command.Group> + </Command.List> + </Command> + </div> + ) +} diff --git a/apps/web/app/reader/_components/entry-detail-panel.tsx b/apps/web/app/reader/_components/entry-detail-panel.tsx new file mode 100644 index 0000000..2e8e19c --- /dev/null +++ b/apps/web/app/reader/_components/entry-detail-panel.tsx @@ -0,0 +1,470 @@ +"use client" + +import { useEffect, useRef, useState, useCallback } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { sanitizeEntryContent } from "@/lib/sanitize" +import { + useToggleEntryReadState, + useToggleEntrySavedState, +} from "@/lib/queries/use-entry-state-mutations" +import { queryKeys } from "@/lib/queries/query-keys" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useTimeline } from "@/lib/queries/use-timeline" +import { useEntryShare } from "@/lib/queries/use-entry-share" +import { useEntryHighlights } from "@/lib/queries/use-entry-highlights" +import { + useCreateHighlight, + useUpdateHighlightNote, + useDeleteHighlight, +} from "@/lib/queries/use-highlight-mutations" +import { + serializeSelectionRange, + deserializeHighlightRange, + applyHighlightToRange, + removeHighlightFromDom, +} from "@/lib/highlight-positioning" +import { HighlightSelectionToolbar } from "./highlight-selection-toolbar" +import { HighlightPopover } from "./highlight-popover" +import { notify } from "@/lib/notify" +import type { Highlight } from "@/lib/types/highlight" + +interface EntryDetailRow { + id: string + title: string | null + url: string | null + author: string | null + content_html: string | null + summary: string | null + published_at: string | null + enclosure_url: string | null + feeds: { + title: string | null + } +} + +function estimateReadingTimeMinutes(html: string): number { + const text = html.replace(/<[^>]*>/g, "").replace(/&\w+;/g, " ") + const wordCount = text.split(/\s+/).filter(Boolean).length + return Math.max(1, Math.round(wordCount / 200)) +} + +export function EntryDetailPanel({ + entryIdentifier, +}: { + entryIdentifier: string +}) { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + const toggleReadState = useToggleEntryReadState() + const toggleSavedState = useToggleEntrySavedState() + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + + const proseContainerReference = useRef<HTMLDivElement>(null) + const [selectionToolbarState, setSelectionToolbarState] = useState<{ + selectionRect: DOMRect + containerRect: DOMRect + range: Range + } | null>(null) + const [highlightPopoverState, setHighlightPopoverState] = useState<{ + highlightIdentifier: string + note: string | null + anchorRect: DOMRect + containerRect: DOMRect + } | null>(null) + const [unpositionedHighlights, setUnpositionedHighlights] = useState<Highlight[]>([]) + + const { data: timelineData } = useTimeline() + const currentEntry = timelineData?.pages + .flatMap((page) => page) + .find((entry) => entry.entryIdentifier === entryIdentifier) + + const { data: entryDetail, isLoading } = useQuery({ + queryKey: queryKeys.entryDetail.single(entryIdentifier), + queryFn: async () => { + const { data, error } = await supabaseClient + .from("entries") + .select( + "id, title, url, author, content_html, summary, published_at, enclosure_url, feeds!inner(title)" + ) + .eq("id", entryIdentifier) + .single() + + if (error) throw error + + return data as unknown as EntryDetailRow + }, + }) + + const { data: shareData } = useEntryShare(entryIdentifier) + const { data: highlightsData } = useEntryHighlights(entryIdentifier) + + const createHighlight = useCreateHighlight() + const updateHighlightNote = useUpdateHighlightNote() + const deleteHighlight = useDeleteHighlight() + + const shareMutation = useMutation({ + mutationFn: async (note?: string | null) => { + const response = await fetch("/api/share", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entryIdentifier, note: note ?? null }), + }) + if (!response.ok) throw new Error("Failed to create share") + return response.json() as Promise<{ + shareToken: string + shareUrl: string + }> + }, + onSuccess: async (data) => { + await navigator.clipboard.writeText(data.shareUrl) + notify("link copied") + queryClient.invalidateQueries({ + queryKey: queryKeys.entryShare.single(entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + }, + }) + + const unshareMutation = useMutation({ + mutationFn: async (shareToken: string) => { + const response = await fetch(`/api/share/${shareToken}`, { + method: "DELETE", + }) + if (!response.ok) throw new Error("Failed to delete share") + }, + onSuccess: () => { + notify("share link removed") + queryClient.invalidateQueries({ + queryKey: queryKeys.entryShare.single(entryIdentifier), + }) + }, + }) + + useEffect(() => { + if (!currentEntry || currentEntry.isRead) return + + const autoReadTimeout = setTimeout(() => { + toggleReadState.mutate({ + entryIdentifier, + isRead: true, + }) + }, 1500) + + return () => clearTimeout(autoReadTimeout) + }, [entryIdentifier, currentEntry?.isRead]) + + const contentHtml = + entryDetail?.content_html || entryDetail?.summary || "" + const sanitisedContent = sanitizeEntryContent(contentHtml) + + useEffect(() => { + const container = proseContainerReference.current + if (!container || !sanitisedContent) return + + container.textContent = "" + const template = document.createElement("template") + template.innerHTML = sanitisedContent + container.appendChild(template.content.cloneNode(true)) + + const failedHighlights: Highlight[] = [] + + if (highlightsData && highlightsData.length > 0) { + const sortedHighlights = [...highlightsData].sort( + (a, b) => b.textOffset - a.textOffset + ) + for (const highlight of sortedHighlights) { + const range = deserializeHighlightRange(container, highlight) + if (range) { + applyHighlightToRange( + range, + highlight.identifier, + highlight.color, + !!highlight.note + ) + } else { + failedHighlights.push(highlight) + } + } + } + + setUnpositionedHighlights(failedHighlights) + }, [sanitisedContent, highlightsData]) + + const handleTextSelection = useCallback(() => { + const container = proseContainerReference.current + if (!container) return + + const selection = window.getSelection() + if (!selection || selection.isCollapsed || !selection.rangeCount) { + setSelectionToolbarState(null) + return + } + + const range = selection.getRangeAt(0) + if (!container.contains(range.commonAncestorContainer)) { + setSelectionToolbarState(null) + return + } + + const selectionRect = range.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + + setSelectionToolbarState({ + selectionRect, + containerRect, + range: range.cloneRange(), + }) + setHighlightPopoverState(null) + }, []) + + useEffect(() => { + document.addEventListener("mouseup", handleTextSelection) + document.addEventListener("touchend", handleTextSelection) + return () => { + document.removeEventListener("mouseup", handleTextSelection) + document.removeEventListener("touchend", handleTextSelection) + } + }, [handleTextSelection]) + + useEffect(() => { + const container = proseContainerReference.current + if (!container) return + + const currentContainer = container + + function handleMarkClick(event: MouseEvent) { + const target = event.target as HTMLElement + const markElement = target.closest("mark[data-highlight-identifier]") + if (!markElement) return + + const highlightIdentifier = markElement.getAttribute("data-highlight-identifier") + if (!highlightIdentifier) return + + const matchingHighlight = highlightsData?.find( + (h) => h.identifier === highlightIdentifier + ) + + const anchorRect = markElement.getBoundingClientRect() + const containerRect = currentContainer.getBoundingClientRect() + + setHighlightPopoverState({ + highlightIdentifier, + note: matchingHighlight?.note ?? null, + anchorRect, + containerRect, + }) + setSelectionToolbarState(null) + } + + container.addEventListener("click", handleMarkClick) + return () => container.removeEventListener("click", handleMarkClick) + }, [highlightsData]) + + function handleCreateHighlight(note: string | null) { + const container = proseContainerReference.current + if (!container || !selectionToolbarState) return + + const serialized = serializeSelectionRange( + container, + selectionToolbarState.range + ) + if (!serialized) return + + createHighlight.mutate({ + entryIdentifier, + highlightedText: serialized.highlightedText, + note, + textOffset: serialized.textOffset, + textLength: serialized.textLength, + textPrefix: serialized.textPrefix, + textSuffix: serialized.textSuffix, + color: "yellow", + }) + + window.getSelection()?.removeAllRanges() + setSelectionToolbarState(null) + } + + function handleUpdateHighlightNote(note: string | null) { + if (!highlightPopoverState) return + updateHighlightNote.mutate({ + highlightIdentifier: highlightPopoverState.highlightIdentifier, + note, + entryIdentifier, + }) + setHighlightPopoverState(null) + } + + function handleDeleteHighlight() { + if (!highlightPopoverState) return + const container = proseContainerReference.current + if (container) { + removeHighlightFromDom( + container, + highlightPopoverState.highlightIdentifier + ) + } + deleteHighlight.mutate({ + highlightIdentifier: highlightPopoverState.highlightIdentifier, + entryIdentifier, + }) + setHighlightPopoverState(null) + } + + if (isLoading || !entryDetail) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + loading ... + </div> + ) + } + + const readingTimeMinutes = estimateReadingTimeMinutes(contentHtml) + const isRead = currentEntry?.isRead ?? false + const isSaved = currentEntry?.isSaved ?? false + + return ( + <div data-detail-panel className="flex h-full flex-col"> + <div className="flex items-center gap-2 overflow-x-auto border-b border-border px-4 py-2"> + <button + type="button" + onClick={() => + toggleReadState.mutate({ + entryIdentifier, + isRead: !isRead, + }) + } + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {isRead ? "mark unread" : "mark read"} + </button> + <button + type="button" + onClick={() => + toggleSavedState.mutate({ + entryIdentifier, + isSaved: !isSaved, + }) + } + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {isSaved ? "unsave" : "save"} + </button> + {entryDetail.url && ( + <a + href={entryDetail.url} + target="_blank" + rel="noopener noreferrer" + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + open original + </a> + )} + {shareData?.isShared ? ( + <button + type="button" + onClick={() => unshareMutation.mutate(shareData.shareToken!)} + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + unshare + </button> + ) : ( + <button + type="button" + onClick={() => { + const note = window.prompt("add a note (optional):") + shareMutation.mutate(note || null) + }} + className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + share + </button> + )} + <div className="flex-1" /> + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="hidden px-2 py-1 text-text-dim transition-colors hover:text-text-secondary md:block" + > + close + </button> + </div> + <article className="flex-1 overflow-auto px-6 py-4"> + <h2 className="mb-1 text-base text-text-primary"> + {entryDetail.title} + </h2> + <div className="mb-4 text-text-dim"> + {entryDetail.feeds?.title && ( + <span>{entryDetail.feeds.title}</span> + )} + {entryDetail.author && ( + <span> · {entryDetail.author}</span> + )} + <span> · {readingTimeMinutes} min read</span> + </div> + {entryDetail.enclosure_url && ( + <div className="mb-4 border border-border p-3"> + <audio + controls + preload="none" + src={entryDetail.enclosure_url} + className="w-full" + /> + </div> + )} + {unpositionedHighlights.length > 0 && ( + <div className="mb-4 border border-border px-3 py-2"> + <p className="mb-2 text-text-dim"> + {unpositionedHighlights.length} highlight + {unpositionedHighlights.length !== 1 && "s"} could not be positioned + (the article content may have changed) + </p> + {unpositionedHighlights.map((highlight) => ( + <div + key={highlight.identifier} + className="mb-1 border-l-2 border-text-dim pl-2 text-text-secondary last:mb-0" + > + <span className="bg-background-tertiary text-text-primary"> + {highlight.highlightedText} + </span> + {highlight.note && ( + <span className="ml-2 text-text-dim"> + — {highlight.note} + </span> + )} + </div> + ))} + </div> + )} + <div className="relative"> + <div + ref={proseContainerReference} + className="prose-reader text-text-secondary" + /> + {selectionToolbarState && ( + <HighlightSelectionToolbar + selectionRect={selectionToolbarState.selectionRect} + containerRect={selectionToolbarState.containerRect} + onHighlight={handleCreateHighlight} + onDismiss={() => setSelectionToolbarState(null)} + /> + )} + {highlightPopoverState && ( + <HighlightPopover + highlightIdentifier={highlightPopoverState.highlightIdentifier} + note={highlightPopoverState.note} + anchorRect={highlightPopoverState.anchorRect} + containerRect={highlightPopoverState.containerRect} + onUpdateNote={handleUpdateHighlightNote} + onDelete={handleDeleteHighlight} + onDismiss={() => setHighlightPopoverState(null)} + /> + )} + </div> + </article> + </div> + ) +} diff --git a/apps/web/app/reader/_components/entry-list-item.tsx b/apps/web/app/reader/_components/entry-list-item.tsx new file mode 100644 index 0000000..375b0f5 --- /dev/null +++ b/apps/web/app/reader/_components/entry-list-item.tsx @@ -0,0 +1,125 @@ +"use client" + +import { formatDistanceToNow } from "date-fns" +import { classNames } from "@/lib/utilities" +import type { TimelineEntry } from "@/lib/types/timeline" +import type { VirtualItem } from "@tanstack/react-virtual" + +interface EntryListItemProperties { + entry: TimelineEntry + isSelected: boolean + isFocused: boolean + viewMode: "compact" | "comfortable" | "expanded" + onSelect: () => void + measureReference: (element: HTMLElement | null) => void + virtualItem: VirtualItem +} + +function stripHtmlTags(html: string): string { + return html.replace(/<[^>]*>/g, "").replace(/&\w+;/g, " ").trim() +} + +export function EntryListItem({ + entry, + isSelected, + isFocused, + viewMode, + onSelect, + measureReference, + virtualItem, +}: EntryListItemProperties) { + const relativeTimestamp = entry.publishedAt + ? formatDistanceToNow(new Date(entry.publishedAt), { addSuffix: true }) + : "" + + const displayTitle = entry.customTitle ?? entry.feedTitle + + return ( + <div + ref={measureReference} + data-index={virtualItem.index} + onClick={onSelect} + className={classNames( + "absolute left-0 top-0 w-full cursor-pointer border-b border-border px-4 transition-colors", + isSelected + ? "bg-background-tertiary" + : isFocused + ? "bg-background-secondary" + : "hover:bg-background-secondary", + isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "", + entry.isRead ? "opacity-60" : "" + )} + style={{ transform: `translateY(${virtualItem.start}px)` }} + > + {viewMode === "compact" && ( + <div className="flex items-center gap-2 py-2.5"> + <span className="shrink-0 text-text-dim">{displayTitle}</span> + {entry.enclosureUrl && ( + <span className="shrink-0 text-text-dim" title="podcast episode">♫</span> + )} + <span className="min-w-0 flex-1 truncate text-text-primary"> + {entry.entryTitle} + </span> + <span className="shrink-0 text-text-dim">{relativeTimestamp}</span> + </div> + )} + + {viewMode === "comfortable" && ( + <div className="py-2.5"> + <div className="truncate text-text-primary">{entry.entryTitle}</div> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + <span>{displayTitle}</span> + {entry.enclosureUrl && ( + <span title="podcast episode">♫</span> + )} + {entry.author && ( + <> + <span>·</span> + <span>{entry.author}</span> + </> + )} + <span>·</span> + <span>{relativeTimestamp}</span> + </div> + </div> + )} + + {viewMode === "expanded" && ( + <div className="flex gap-3 py-3"> + <div className="min-w-0 flex-1"> + <div className="truncate text-text-primary"> + {entry.entryTitle} + </div> + {entry.summary && ( + <p className="mt-1 line-clamp-2 text-text-secondary"> + {stripHtmlTags(entry.summary)} + </p> + )} + <div className="mt-1 flex items-center gap-2 text-text-dim"> + <span>{displayTitle}</span> + {entry.enclosureUrl && ( + <span title="podcast episode">♫</span> + )} + {entry.author && ( + <> + <span>·</span> + <span>{entry.author}</span> + </> + )} + <span>·</span> + <span>{relativeTimestamp}</span> + </div> + </div> + {entry.imageUrl && ( + <img + src={entry.imageUrl} + alt="" + className="hidden h-16 w-16 shrink-0 object-cover sm:block" + loading="lazy" + /> + )} + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/_components/entry-list.tsx b/apps/web/app/reader/_components/entry-list.tsx new file mode 100644 index 0000000..6d4bcf3 --- /dev/null +++ b/apps/web/app/reader/_components/entry-list.tsx @@ -0,0 +1,217 @@ +"use client" + +import { useRef, useEffect } from "react" +import { useVirtualizer } from "@tanstack/react-virtual" +import { useTimeline } from "@/lib/queries/use-timeline" +import { useSavedEntries } from "@/lib/queries/use-saved-entries" +import { useCustomFeedTimeline } from "@/lib/queries/use-custom-feed-timeline" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { EntryListItem } from "./entry-list-item" + +interface EntryListProperties { + feedFilter: "all" | "saved" + folderIdentifier?: string | null + feedIdentifier?: string | null + customFeedIdentifier?: string | null +} + +function useEntryData( + feedFilter: "all" | "saved", + folderIdentifier?: string | null, + feedIdentifier?: string | null, + customFeedIdentifier?: string | null +) { + const timelineQuery = useTimeline( + feedFilter === "all" && !customFeedIdentifier ? folderIdentifier : undefined, + feedFilter === "all" && !customFeedIdentifier ? feedIdentifier : undefined, + false + ) + const savedQuery = useSavedEntries() + const customFeedQuery = useCustomFeedTimeline( + feedFilter === "all" ? (customFeedIdentifier ?? null) : null + ) + + if (feedFilter === "saved") { + return savedQuery + } + + if (customFeedIdentifier) { + return customFeedQuery + } + + return timelineQuery +} + +export function EntryList({ + feedFilter, + folderIdentifier, + feedIdentifier, + customFeedIdentifier, +}: EntryListProperties) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useEntryData(feedFilter, folderIdentifier, feedIdentifier, customFeedIdentifier) + + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setFocusedEntryIdentifier = useUserInterfaceStore( + (state) => state.setFocusedEntryIdentifier + ) + + const setNavigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.setNavigableEntryIdentifiers + ) + + const allEntries = data?.pages.flatMap((page) => page) ?? [] + const scrollContainerReference = useRef<HTMLDivElement>(null) + + const firstEntryIdentifier = allEntries[0]?.entryIdentifier + const lastEntryIdentifier = allEntries[allEntries.length - 1]?.entryIdentifier + + useEffect(() => { + setNavigableEntryIdentifiers( + allEntries.map((entry) => entry.entryIdentifier) + ) + }, [firstEntryIdentifier, lastEntryIdentifier, allEntries.length, setNavigableEntryIdentifiers]) + + function getEstimatedItemSize() { + switch (entryListViewMode) { + case "compact": + return 40 + case "comfortable": + return 60 + case "expanded": + return 108 + } + } + + const virtualizer = useVirtualizer({ + count: hasNextPage ? allEntries.length + 1 : allEntries.length, + getScrollElement: () => scrollContainerReference.current, + estimateSize: getEstimatedItemSize, + overscan: 10, + }) + + const virtualItems = virtualizer.getVirtualItems() + + useEffect(() => { + const lastItem = virtualItems[virtualItems.length - 1] + + if (!lastItem) return + + if ( + lastItem.index >= allEntries.length - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage() + } + }, [ + virtualItems, + allEntries.length, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + ]) + + const allEntriesReference = useRef(allEntries) + allEntriesReference.current = allEntries + + useEffect(() => { + if (!focusedEntryIdentifier) return + + const focusedIndex = allEntriesReference.current.findIndex( + (entry) => entry.entryIdentifier === focusedEntryIdentifier + ) + + if (focusedIndex !== -1) { + virtualizer.scrollToIndex(focusedIndex, { align: "auto" }) + } + }, [focusedEntryIdentifier]) + + if (isLoading) { + return ( + <div className="space-y-2 p-4"> + {Array.from({ length: 8 }).map((_, skeletonIndex) => ( + <div + key={skeletonIndex} + className="h-10 animate-[skeleton-shimmer_1.5s_ease-in-out_infinite] bg-background-tertiary" + /> + ))} + </div> + ) + } + + if (allEntries.length === 0) { + return ( + <div className="flex h-full items-center justify-center"> + <p className="text-text-tertiary"> + {feedFilter === "saved" + ? "no saved entries yet" + : "no entries yet \u2014 add a feed to get started"} + </p> + </div> + ) + } + + return ( + <div ref={scrollContainerReference} className="h-full overflow-auto"> + <div + style={{ + height: `${virtualizer.getTotalSize()}px`, + width: "100%", + position: "relative", + }} + > + {virtualItems.map((virtualItem) => { + const entry = allEntries[virtualItem.index] + + if (!entry) { + return ( + <div + key="loader" + data-index={virtualItem.index} + ref={virtualizer.measureElement} + className="absolute left-0 top-0 w-full" + style={{ + transform: `translateY(${virtualItem.start}px)`, + }} + > + <p className="p-4 text-center text-text-dim">loading ...</p> + </div> + ) + } + + return ( + <EntryListItem + key={entry.entryIdentifier} + entry={entry} + isSelected={ + entry.entryIdentifier === selectedEntryIdentifier + } + isFocused={ + entry.entryIdentifier === focusedEntryIdentifier + } + viewMode={entryListViewMode} + onSelect={() => { + setFocusedEntryIdentifier(entry.entryIdentifier) + setSelectedEntryIdentifier(entry.entryIdentifier) + }} + measureReference={virtualizer.measureElement} + virtualItem={virtualItem} + /> + ) + })} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/error-boundary.tsx b/apps/web/app/reader/_components/error-boundary.tsx new file mode 100644 index 0000000..6696e66 --- /dev/null +++ b/apps/web/app/reader/_components/error-boundary.tsx @@ -0,0 +1,55 @@ +"use client" + +import { Component, type ReactNode } from "react" + +interface ErrorBoundaryProperties { + fallback?: ReactNode + children: ReactNode +} + +interface ErrorBoundaryState { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component< + ErrorBoundaryProperties, + ErrorBoundaryState +> { + constructor(properties: ErrorBoundaryProperties) { + super(properties) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( + <div className="flex h-full items-center justify-center p-4"> + <div className="max-w-sm text-center"> + <p className="mb-2 text-text-primary">something went wrong</p> + <p className="mb-4 text-text-dim"> + {this.state.error?.message ?? "an unexpected error occurred"} + </p> + <button + type="button" + onClick={() => this.setState({ hasError: false, error: null })} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + try again + </button> + </div> + </div> + ) + } + + return this.props.children + } +} diff --git a/apps/web/app/reader/_components/highlight-popover.tsx b/apps/web/app/reader/_components/highlight-popover.tsx new file mode 100644 index 0000000..301c174 --- /dev/null +++ b/apps/web/app/reader/_components/highlight-popover.tsx @@ -0,0 +1,96 @@ +"use client" + +import { useState } from "react" + +interface HighlightPopoverProperties { + highlightIdentifier: string + note: string | null + anchorRect: DOMRect + containerRect: DOMRect + onUpdateNote: (note: string | null) => void + onDelete: () => void + onDismiss: () => void +} + +export function HighlightPopover({ + note, + anchorRect, + onUpdateNote, + onDelete, + onDismiss, +}: HighlightPopoverProperties) { + const [isEditingNote, setIsEditingNote] = useState(false) + const [editedNoteText, setEditedNoteText] = useState(note ?? "") + + const popoverLeft = anchorRect.left + anchorRect.width / 2 + const popoverTop = anchorRect.bottom + 4 + + function handleSaveNote() { + onUpdateNote(editedNoteText.trim() || null) + setIsEditingNote(false) + } + + return ( + <div + className="fixed z-[100] -translate-x-1/2" + style={{ left: popoverLeft, top: popoverTop }} + > + <div className="min-w-48 border border-border bg-background-secondary p-2"> + {isEditingNote ? ( + <div className="space-y-1"> + <input + type="text" + value={editedNoteText} + onChange={(event) => setEditedNoteText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveNote() + if (event.key === "Escape") onDismiss() + }} + placeholder="add a note..." + className="w-full border border-border bg-background-primary px-2 py-1 text-xs text-text-primary outline-none" + autoFocus + /> + <div className="flex gap-1"> + <button + type="button" + onClick={handleSaveNote} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + save + </button> + <button + type="button" + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-xs text-text-dim transition-colors hover:text-text-secondary" + > + cancel + </button> + </div> + </div> + ) : ( + <div className="space-y-1"> + {note && ( + <p className="text-xs text-text-secondary">{note}</p> + )} + <div className="flex gap-1"> + <button + type="button" + onClick={() => setIsEditingNote(true)} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {note ? "edit note" : "add note"} + </button> + <button + type="button" + onClick={onDelete} + className="px-2 py-1 text-xs text-status-error transition-colors hover:bg-background-tertiary" + > + remove + </button> + </div> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/highlight-selection-toolbar.tsx b/apps/web/app/reader/_components/highlight-selection-toolbar.tsx new file mode 100644 index 0000000..42522bf --- /dev/null +++ b/apps/web/app/reader/_components/highlight-selection-toolbar.tsx @@ -0,0 +1,80 @@ +"use client" + +import { useState } from "react" + +interface HighlightSelectionToolbarProperties { + selectionRect: DOMRect + containerRect: DOMRect + onHighlight: (note: string | null) => void + onDismiss: () => void +} + +export function HighlightSelectionToolbar({ + selectionRect, + onHighlight, + onDismiss, +}: HighlightSelectionToolbarProperties) { + const [showNoteInput, setShowNoteInput] = useState(false) + const [noteText, setNoteText] = useState("") + + const toolbarLeft = selectionRect.left + selectionRect.width / 2 + const toolbarTop = selectionRect.top - 8 + + function handleHighlightClick() { + if (showNoteInput) { + onHighlight(noteText.trim() || null) + } else { + onHighlight(null) + } + } + + return ( + <div + className="fixed z-[100] -translate-x-1/2 -translate-y-full" + style={{ left: toolbarLeft, top: toolbarTop }} + > + <div className="border border-border bg-background-secondary p-1"> + {showNoteInput ? ( + <div className="flex items-center gap-1"> + <input + type="text" + value={noteText} + onChange={(event) => setNoteText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleHighlightClick() + if (event.key === "Escape") onDismiss() + }} + placeholder="add a note..." + className="border border-border bg-background-primary px-2 py-1 text-xs text-text-primary outline-none" + autoFocus + /> + <button + type="button" + onClick={handleHighlightClick} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + save + </button> + </div> + ) : ( + <div className="flex items-center gap-1"> + <button + type="button" + onClick={handleHighlightClick} + className="px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + highlight + </button> + <button + type="button" + onClick={() => setShowNoteInput(true)} + className="px-2 py-1 text-xs text-text-dim transition-colors hover:bg-background-tertiary hover:text-text-secondary" + > + + note + </button> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/mfa-challenge.tsx b/apps/web/app/reader/_components/mfa-challenge.tsx new file mode 100644 index 0000000..347d8b4 --- /dev/null +++ b/apps/web/app/reader/_components/mfa-challenge.tsx @@ -0,0 +1,108 @@ +"use client" + +import { useState, useEffect } from "react" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export function MfaChallenge({ onVerified }: { onVerified: () => void }) { + const [verificationCode, setVerificationCode] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isVerifying, setIsVerifying] = useState(false) + const [factorIdentifier, setFactorIdentifier] = useState<string | null>(null) + const supabaseClient = createSupabaseBrowserClient() + + useEffect(() => { + async function loadFactor() { + const { data } = await supabaseClient.auth.mfa.listFactors() + + if (data?.totp && data.totp.length > 0) { + const verifiedFactor = data.totp.find( + (factor) => factor.status === "verified" + ) + + if (verifiedFactor) { + setFactorIdentifier(verifiedFactor.id) + } + } + } + + loadFactor() + }, []) + + async function handleVerify(event?: React.FormEvent) { + event?.preventDefault() + + if (!factorIdentifier || verificationCode.length !== 6) return + + setIsVerifying(true) + setErrorMessage(null) + + const { data: challengeData, error: challengeError } = + await supabaseClient.auth.mfa.challenge({ + factorId: factorIdentifier, + }) + + if (challengeError) { + setIsVerifying(false) + setErrorMessage("failed to create challenge — please try again") + return + } + + const { error: verifyError } = await supabaseClient.auth.mfa.verify({ + factorId: factorIdentifier, + challengeId: challengeData.id, + code: verificationCode, + }) + + setIsVerifying(false) + + if (verifyError) { + setErrorMessage("invalid code — please try again") + setVerificationCode("") + return + } + + onVerified() + } + + return ( + <div className="flex h-screen items-center justify-center bg-background-primary"> + <div className="w-full max-w-sm space-y-6 px-4"> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">two-factor authentication</h1> + <p className="text-text-secondary"> + enter the 6-digit code from your authenticator app + </p> + </div> + + <form onSubmit={handleVerify} className="space-y-4"> + <input + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={6} + value={verificationCode} + onChange={(event) => { + const filtered = event.target.value.replace(/\D/g, "") + setVerificationCode(filtered) + }} + placeholder="000000" + className="w-full border border-border bg-background-secondary px-3 py-3 text-center font-mono text-2xl tracking-[0.5em] text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + autoFocus + /> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isVerifying || verificationCode.length !== 6 || !factorIdentifier} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isVerifying ? "verifying ..." : "verify"} + </button> + </form> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/notification-panel.tsx b/apps/web/app/reader/_components/notification-panel.tsx new file mode 100644 index 0000000..216741f --- /dev/null +++ b/apps/web/app/reader/_components/notification-panel.tsx @@ -0,0 +1,129 @@ +"use client" + +import { useEffect, useRef } from "react" +import { formatDistanceToNow } from "date-fns" +import { toast } from "sonner" +import { + useNotificationStore, + type StoredNotification, +} from "@/lib/stores/notification-store" + +export function NotificationPanel({ onClose }: { onClose: () => void }) { + const panelReference = useRef<HTMLDivElement>(null) + const notifications = useNotificationStore((state) => state.notifications) + const dismissNotification = useNotificationStore( + (state) => state.dismissNotification + ) + const clearAllNotifications = useNotificationStore( + (state) => state.clearAllNotifications + ) + const markAllAsViewed = useNotificationStore( + (state) => state.markAllAsViewed + ) + + useEffect(() => { + markAllAsViewed() + }, [markAllAsViewed]) + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + panelReference.current && + !panelReference.current.contains(event.target as Node) + ) { + onClose() + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [onClose]) + + function handleNotificationClick(notification: StoredNotification) { + if (notification.actionUrl) { + navigator.clipboard.writeText(notification.actionUrl) + toast("link copied to clipboard") + } + } + + return ( + <div + ref={panelReference} + className="fixed bottom-16 left-2 z-50 w-80 max-w-[calc(100vw-1rem)] border border-border bg-background-secondary shadow-lg md:absolute md:bottom-full md:left-0 md:mb-1" + > + <div className="flex items-center justify-between border-b border-border px-3 py-2"> + <span className="text-text-primary">notifications</span> + {notifications.length > 0 && ( + <button + type="button" + onClick={() => { + clearAllNotifications() + onClose() + }} + className="text-text-dim transition-colors hover:text-text-secondary" + > + clear all + </button> + )} + </div> + <div className="max-h-64 overflow-auto"> + {notifications.length === 0 ? ( + <p className="px-3 py-4 text-center text-text-dim"> + no notifications + </p> + ) : ( + notifications.map((notification: StoredNotification) => ( + <div + key={notification.identifier} + className={`flex items-start gap-2 border-b border-border px-3 py-2 last:border-b-0 ${ + notification.actionUrl + ? "cursor-pointer transition-colors hover:bg-background-tertiary" + : "" + }`} + onClick={ + notification.actionUrl + ? () => handleNotificationClick(notification) + : undefined + } + > + <div className="min-w-0 flex-1"> + <p className="text-text-secondary">{notification.message}</p> + {notification.actionUrl && ( + <p className="mt-0.5 text-text-dim"> + tap to copy link + </p> + )} + <p className="mt-0.5 text-text-dim"> + {formatDistanceToNow(new Date(notification.timestamp), { + addSuffix: true, + })} + </p> + </div> + <button + type="button" + onClick={(event) => { + event.stopPropagation() + dismissNotification(notification.identifier) + }} + className="shrink-0 px-1 text-text-dim transition-colors hover:text-text-secondary" + > + × + </button> + </div> + )) + )} + </div> + </div> + ) +} + +export function useUnviewedNotificationCount(): number { + const notifications = useNotificationStore((state) => state.notifications) + const lastViewedAt = useNotificationStore((state) => state.lastViewedAt) + + if (!lastViewedAt) return notifications.length + + return notifications.filter( + (notification) => notification.timestamp > lastViewedAt + ).length +} diff --git a/apps/web/app/reader/_components/reader-layout-shell.tsx b/apps/web/app/reader/_components/reader-layout-shell.tsx new file mode 100644 index 0000000..7e0e80b --- /dev/null +++ b/apps/web/app/reader/_components/reader-layout-shell.tsx @@ -0,0 +1,204 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { classNames } from "@/lib/utilities" +import { ErrorBoundary } from "./error-boundary" +import { SidebarContent } from "./sidebar-content" +import { CommandPalette } from "./command-palette" +import { AddFeedDialog } from "./add-feed-dialog" +import { SearchOverlay } from "./search-overlay" +import { MfaChallenge } from "./mfa-challenge" +import { useKeyboardNavigation } from "@/lib/hooks/use-keyboard-navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +const DENSITY_FONT_SIZE_MAP: Record<string, string> = { + compact: "0.875rem", + default: "1rem", + spacious: "1.125rem", +} + +export function ReaderLayoutShell({ + sidebarFooter, + children, +}: { + sidebarFooter: React.ReactNode + children: React.ReactNode +}) { + const [requiresMfaVerification, setRequiresMfaVerification] = useState(false) + const [isMfaCheckComplete, setIsMfaCheckComplete] = useState(false) + + const isSidebarCollapsed = useUserInterfaceStore( + (state) => state.isSidebarCollapsed + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setSidebarCollapsed = useUserInterfaceStore( + (state) => state.setSidebarCollapsed + ) + const displayDensity = useUserInterfaceStore( + (state) => state.displayDensity + ) + const isSearchOpen = useUserInterfaceStore((state) => state.isSearchOpen) + const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const setFocusedPanel = useUserInterfaceStore((state) => state.setFocusedPanel) + const focusFollowsInteraction = useUserInterfaceStore( + (state) => state.focusFollowsInteraction + ) + + useKeyboardNavigation() + + useEffect(() => { + async function checkAssuranceLevel() { + const supabaseClient = createSupabaseBrowserClient() + const { data } = await supabaseClient.auth.mfa.getAuthenticatorAssuranceLevel() + + if ( + data && + data.currentLevel === "aal1" && + data.nextLevel === "aal2" + ) { + setRequiresMfaVerification(true) + } + + setIsMfaCheckComplete(true) + } + + checkAssuranceLevel() + }, []) + + useEffect(() => { + if (window.innerWidth < 768) { + setSidebarCollapsed(true) + } + }, [setSidebarCollapsed]) + + useEffect(() => { + document.body.style.setProperty( + "--base-font-size", + DENSITY_FONT_SIZE_MAP[displayDensity] ?? "0.8125rem" + ) + }, [displayDensity]) + + useEffect(() => { + if (!focusFollowsInteraction) return + + function handlePointerDown(event: PointerEvent) { + const target = event.target as HTMLElement + const zone = target.closest("[data-panel-zone]") + if (!zone) return + const panelZone = zone.getAttribute("data-panel-zone") + if ( + panelZone === "sidebar" || + panelZone === "entryList" || + panelZone === "detailPanel" + ) { + useUserInterfaceStore.getState().setFocusedPanel(panelZone) + } + } + + function handleScroll(event: Event) { + const target = event.target as HTMLElement + if (!target || !target.closest) return + const zone = target.closest("[data-panel-zone]") + if (!zone) return + const panelZone = zone.getAttribute("data-panel-zone") + if ( + panelZone === "sidebar" || + panelZone === "entryList" || + panelZone === "detailPanel" + ) { + const currentPanel = useUserInterfaceStore.getState().focusedPanel + if (currentPanel !== panelZone) { + useUserInterfaceStore.getState().setFocusedPanel(panelZone) + } + } + } + + document.addEventListener("pointerdown", handlePointerDown) + document.addEventListener("scroll", handleScroll, true) + return () => { + document.removeEventListener("pointerdown", handlePointerDown) + document.removeEventListener("scroll", handleScroll, true) + } + }, [focusFollowsInteraction]) + + if (!isMfaCheckComplete) { + return ( + <div className="flex h-screen items-center justify-center bg-background-primary"> + <span className="text-text-dim">loading ...</span> + </div> + ) + } + + if (requiresMfaVerification) { + return <MfaChallenge onVerified={() => setRequiresMfaVerification(false)} /> + } + + return ( + <div className="flex h-screen"> + <div + className={classNames( + "fixed inset-0 z-30 bg-black/50 transition-opacity md:hidden", + !isSidebarCollapsed + ? "pointer-events-auto opacity-100" + : "pointer-events-none opacity-0" + )} + onClick={toggleSidebar} + /> + + <aside + data-panel-zone="sidebar" + className={classNames( + "fixed z-40 flex h-full shrink-0 flex-col border-r border-border bg-background-secondary transition-transform duration-200 md:relative md:z-10 md:transition-[width]", + "w-64", + isSidebarCollapsed + ? "-translate-x-full md:w-0 md:translate-x-0 md:overflow-hidden" + : "translate-x-0", + focusedPanel === "sidebar" && !isSidebarCollapsed + ? "border-r-text-dim" + : "" + )} + > + <div className="flex items-center justify-between p-4"> + <h2 className="text-text-primary">asa.news</h2> + <button + type="button" + onClick={toggleSidebar} + className="px-1 py-0.5 text-lg leading-none text-text-dim transition-colors hover:text-text-secondary" + > + × + </button> + </div> + <ErrorBoundary> + <Suspense> + <SidebarContent /> + </Suspense> + </ErrorBoundary> + {sidebarFooter} + </aside> + + <main className="flex-1 overflow-hidden"> + <div className="flex h-full flex-col"> + {isSidebarCollapsed && ( + <div className="flex items-center border-b border-border px-2 py-1"> + <button + type="button" + onClick={toggleSidebar} + className="px-2 py-1 text-lg leading-none text-text-secondary transition-colors hover:text-text-primary" + > + ☰ + </button> + </div> + )} + <div className="flex-1 overflow-hidden">{children}</div> + </div> + </main> + <CommandPalette /> + <AddFeedDialog /> + {isSearchOpen && ( + <SearchOverlay onClose={() => setSearchOpen(false)} /> + )} + </div> + ) +} 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> + ) +} diff --git a/apps/web/app/reader/_components/search-overlay.tsx b/apps/web/app/reader/_components/search-overlay.tsx new file mode 100644 index 0000000..5cfdb57 --- /dev/null +++ b/apps/web/app/reader/_components/search-overlay.tsx @@ -0,0 +1,180 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useEntrySearch } from "@/lib/queries/use-entry-search" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +function getMatchSnippet(text: string, query: string): string | null { + const stripped = text.replace(/<[^>]*>/g, "") + const lowerStripped = stripped.toLowerCase() + const matchIndex = lowerStripped.indexOf(query) + if (matchIndex === -1) return null + const start = Math.max(0, matchIndex - 40) + const end = Math.min(stripped.length, matchIndex + query.length + 80) + const prefix = start > 0 ? "\u2026" : "" + const suffix = end < stripped.length ? "\u2026" : "" + return prefix + stripped.slice(start, end) + suffix +} + +function highlightText(text: string, query: string): React.ReactNode { + if (!query) return text + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const parts = text.split(new RegExp(`(${escapedQuery})`, "gi")) + return parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + <mark key={index} className="bg-[rgba(234,179,8,0.18)] text-text-primary"> + {part} + </mark> + ) : ( + part + ) + ) +} + +interface SearchOverlayProperties { + onClose: () => void +} + +export function SearchOverlay({ onClose }: SearchOverlayProperties) { + const [searchQuery, setSearchQuery] = useState("") + const [selectedResultIndex, setSelectedResultIndex] = useState(-1) + const inputReference = useRef<HTMLInputElement>(null) + const resultListReference = useRef<HTMLDivElement>(null) + const { data: results, isLoading } = useEntrySearch(searchQuery) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + + useEffect(() => { + inputReference.current?.focus() + }, []) + + useEffect(() => { + setSelectedResultIndex(-1) + }, [searchQuery]) + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + onClose() + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + function handleSelectEntry(entryIdentifier: string) { + setSelectedEntryIdentifier(entryIdentifier) + onClose() + } + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "Backspace" && searchQuery === "") { + onClose() + return + } + + if (!results || results.length === 0) return + + if (event.key === "ArrowDown") { + event.preventDefault() + setSelectedResultIndex((previous) => { + const nextIndex = previous < results.length - 1 ? previous + 1 : 0 + scrollResultIntoView(nextIndex) + return nextIndex + }) + } else if (event.key === "ArrowUp") { + event.preventDefault() + setSelectedResultIndex((previous) => { + const nextIndex = previous > 0 ? previous - 1 : results.length - 1 + scrollResultIntoView(nextIndex) + return nextIndex + }) + } else if (event.key === "Enter" && selectedResultIndex >= 0) { + event.preventDefault() + handleSelectEntry(results[selectedResultIndex].entryIdentifier) + } + } + + function scrollResultIntoView(index: number) { + const container = resultListReference.current + if (!container) return + const items = container.querySelectorAll("[data-result-item]") + items[index]?.scrollIntoView({ block: "nearest" }) + } + + function handleBackdropClick(event: React.MouseEvent) { + if (event.target === event.currentTarget) { + onClose() + } + } + + return ( + <div + className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh]" + onClick={handleBackdropClick} + > + <div className="w-full max-w-lg border border-border bg-background-primary shadow-lg"> + <div className="border-b border-border px-4 py-3"> + <input + ref={inputReference} + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + onKeyDown={handleInputKeyDown} + placeholder="search entries..." + className="w-full bg-transparent text-text-primary outline-none placeholder:text-text-dim" + /> + </div> + <div ref={resultListReference} className="max-h-80 overflow-auto"> + {isLoading && searchQuery.trim().length >= 2 && ( + <p className="px-4 py-3 text-text-dim">searching...</p> + )} + {!isLoading && + searchQuery.trim().length >= 2 && + results?.length === 0 && ( + <p className="px-4 py-3 text-text-dim">no results</p> + )} + {results?.map((entry, index) => { + const query = searchQuery.trim().toLowerCase() + const titleMatches = (entry.entryTitle ?? "").toLowerCase().includes(query) + const summarySnippet = !titleMatches && entry.summary + ? getMatchSnippet(entry.summary, query) + : null + + return ( + <button + key={entry.entryIdentifier} + type="button" + data-result-item + onClick={() => handleSelectEntry(entry.entryIdentifier)} + className={`block w-full px-4 py-2 text-left transition-colors hover:bg-background-tertiary ${ + index === selectedResultIndex + ? "bg-background-tertiary" + : "" + }`} + > + <p className="truncate text-text-primary"> + {titleMatches + ? highlightText(entry.entryTitle ?? "", query) + : entry.entryTitle} + </p> + <p className="truncate text-[0.6875rem] text-text-dim"> + {entry.customTitle ?? entry.feedTitle} + {entry.author && ` \u00b7 ${entry.author}`} + </p> + {summarySnippet && ( + <p className="mt-0.5 line-clamp-2 text-[0.6875rem] text-text-secondary"> + {highlightText(summarySnippet, query)} + </p> + )} + </button> + ) + })} + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/sidebar-content.tsx b/apps/web/app/reader/_components/sidebar-content.tsx new file mode 100644 index 0000000..ee5c873 --- /dev/null +++ b/apps/web/app/reader/_components/sidebar-content.tsx @@ -0,0 +1,356 @@ +"use client" + +import Link from "next/link" +import { usePathname, useSearchParams } from "next/navigation" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUnreadCounts } from "@/lib/queries/use-unread-counts" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { classNames } from "@/lib/utilities" + +const NAVIGATION_LINK_CLASS = + "block px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + +const ACTIVE_LINK_CLASS = "bg-background-tertiary text-text-primary" + +function getFaviconUrl(feedUrl: string): string | null { + try { + const hostname = new URL(feedUrl).hostname + return `https://www.google.com/s2/favicons?domain=${hostname}&sz=16` + } catch { + return null + } +} + +function FeedFavicon({ feedUrl }: { feedUrl: string }) { + const faviconUrl = getFaviconUrl(feedUrl) + if (!faviconUrl) return null + + return ( + <img + src={faviconUrl} + alt="" + width={16} + height={16} + className="shrink-0" + loading="lazy" + /> + ) +} + +function displayNameForSubscription(subscription: { + customTitle: string | null + feedTitle: string + feedUrl: string +}): string { + if (subscription.customTitle) return subscription.customTitle + if (subscription.feedTitle) return subscription.feedTitle + + try { + return new URL(subscription.feedUrl).hostname + } catch { + return subscription.feedUrl || "untitled feed" + } +} + +function UnreadBadge({ count }: { count: number }) { + if (count === 0) return null + + return ( + <span className="ml-auto shrink-0 text-[0.625rem] tabular-nums text-text-dim"> + {count > 999 ? "999+" : count} + </span> + ) +} + +function sidebarFocusClass( + focusedPanel: string, + focusedSidebarIndex: number, + navIndex: number +): string { + return focusedPanel === "sidebar" && focusedSidebarIndex === navIndex + ? "bg-background-tertiary text-text-primary" + : "" +} + +export function SidebarContent() { + const pathname = usePathname() + const searchParameters = useSearchParams() + const { data } = useSubscriptions() + const { data: unreadCounts } = useUnreadCounts() + const { data: customFeedsData } = useCustomFeeds() + const setAddFeedDialogOpen = useUserInterfaceStore( + (state) => state.setAddFeedDialogOpen + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const showFeedFavicons = useUserInterfaceStore( + (state) => state.showFeedFavicons + ) + const expandedFolderIdentifiers = useUserInterfaceStore( + (state) => state.expandedFolderIdentifiers + ) + const toggleFolderExpansion = useUserInterfaceStore( + (state) => state.toggleFolderExpansion + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const focusedSidebarIndex = useUserInterfaceStore( + (state) => state.focusedSidebarIndex + ) + + function closeSidebarOnMobile() { + if (typeof window !== "undefined" && window.innerWidth < 768) { + toggleSidebar() + } + } + + const folders = data?.folders ?? [] + const subscriptions = data?.subscriptions ?? [] + const ungroupedSubscriptions = subscriptions.filter( + (subscription) => !subscription.folderIdentifier + ) + + const totalUnreadCount = Object.values(unreadCounts ?? {}).reduce( + (sum, count) => sum + count, + 0 + ) + + function getFolderUnreadCount(folderIdentifier: string): number { + return subscriptions + .filter( + (subscription) => + subscription.folderIdentifier === folderIdentifier + ) + .reduce( + (sum, subscription) => + sum + (unreadCounts?.[subscription.feedIdentifier] ?? 0), + 0 + ) + } + + const activeFeedIdentifier = searchParameters.get("feed") + const activeFolderIdentifier = searchParameters.get("folder") + const activeCustomFeedIdentifier = searchParameters.get("custom_feed") + + let navIndex = 0 + + return ( + <nav className="flex-1 space-y-1 overflow-auto px-2"> + <Link + href="/reader" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center", + pathname === "/reader" && + !activeFeedIdentifier && + !activeFolderIdentifier && + !activeCustomFeedIdentifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + <span>all entries</span> + <UnreadBadge count={totalUnreadCount} /> + </Link> + <Link + href="/reader/saved" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/saved" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + saved + </Link> + <Link + href="/reader/highlights" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/highlights" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + highlights + </Link> + <Link + href="/reader/shares" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/shares" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + shares + </Link> + + {customFeedsData && customFeedsData.length > 0 && ( + <div className="mt-3 space-y-0.5"> + {customFeedsData.map((customFeed) => ( + <Link + key={customFeed.identifier} + href={`/reader?custom_feed=${customFeed.identifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "truncate pl-4 text-[0.85em]", + activeCustomFeedIdentifier === customFeed.identifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {customFeed.name} + </Link> + ))} + </div> + )} + + {ungroupedSubscriptions.length > 0 && ( + <div className="mt-3 space-y-0.5"> + {ungroupedSubscriptions.map((subscription) => ( + <Link + key={subscription.subscriptionIdentifier} + href={`/reader?feed=${subscription.feedIdentifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center truncate pl-4 text-[0.85em]", + activeFeedIdentifier === subscription.feedIdentifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {showFeedFavicons && ( + <FeedFavicon feedUrl={subscription.feedUrl} /> + )} + <span className={classNames("truncate", showFeedFavicons && "ml-2")}> + {displayNameForSubscription(subscription)} + </span> + {subscription.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> + )} + {subscription.consecutiveFailures > 0 && ( + <span className="ml-1 shrink-0 text-status-warning" title={subscription.lastFetchError ?? "feed error"}> + [!] + </span> + )} + <UnreadBadge + count={unreadCounts?.[subscription.feedIdentifier] ?? 0} + /> + </Link> + ))} + </div> + )} + + {folders.map((folder) => { + const isExpanded = expandedFolderIdentifiers.includes( + folder.folderIdentifier + ) + const folderSubscriptions = subscriptions.filter( + (subscription) => + subscription.folderIdentifier === folder.folderIdentifier + ) + const folderUnreadCount = getFolderUnreadCount( + folder.folderIdentifier + ) + + const folderNavIndex = navIndex++ + + return ( + <div key={folder.folderIdentifier} className="mt-2"> + <div + data-sidebar-nav-item + className={classNames( + "flex w-full items-center gap-1 px-2 py-1", + sidebarFocusClass(focusedPanel, focusedSidebarIndex, folderNavIndex) + )} + > + <button + type="button" + onClick={() => + toggleFolderExpansion(folder.folderIdentifier) + } + className="shrink-0 px-0.5 text-text-secondary transition-colors hover:text-text-primary" + > + {isExpanded ? "\u25BE" : "\u25B8"} + </button> + <Link + href={`/reader?folder=${folder.folderIdentifier}`} + onClick={closeSidebarOnMobile} + className={classNames( + "flex-1 truncate text-text-secondary transition-colors hover:text-text-primary", + activeFolderIdentifier === folder.folderIdentifier && + "text-text-primary" + )} + > + {folder.name} + </Link> + <UnreadBadge count={folderUnreadCount} /> + </div> + {isExpanded && ( + <div className="space-y-0.5"> + {folderSubscriptions.map((subscription) => ( + <Link + key={subscription.subscriptionIdentifier} + href={`/reader?feed=${subscription.feedIdentifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center truncate pl-6 text-[0.85em]", + activeFeedIdentifier === + subscription.feedIdentifier && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {showFeedFavicons && ( + <FeedFavicon feedUrl={subscription.feedUrl} /> + )} + <span className={classNames("truncate", showFeedFavicons && "ml-2")}> + {displayNameForSubscription(subscription)} + </span> + {subscription.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> + )} + {subscription.consecutiveFailures > 0 && ( + <span className="ml-1 shrink-0 text-status-warning" title={subscription.lastFetchError ?? "feed error"}> + [!] + </span> + )} + <UnreadBadge + count={ + unreadCounts?.[subscription.feedIdentifier] ?? 0 + } + /> + </Link> + ))} + </div> + )} + </div> + ) + })} + + <div className="mt-3"> + <button + type="button" + data-sidebar-nav-item + onClick={() => setAddFeedDialogOpen(true)} + className={classNames( + "w-full px-2 py-1 text-left text-text-dim transition-colors hover:bg-background-tertiary hover:text-text-secondary", + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + + add feed + </button> + </div> + </nav> + ) +} diff --git a/apps/web/app/reader/_components/sidebar-footer.tsx b/apps/web/app/reader/_components/sidebar-footer.tsx new file mode 100644 index 0000000..8c520c3 --- /dev/null +++ b/apps/web/app/reader/_components/sidebar-footer.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { signOut } from "../actions" +import { + NotificationPanel, + useUnviewedNotificationCount, +} from "./notification-panel" + +export function SidebarFooter() { + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setActiveSettingsTab = useUserInterfaceStore( + (state) => state.setActiveSettingsTab + ) + const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false) + const unviewedNotificationCount = useUnviewedNotificationCount() + const { data: userProfile } = useUserProfile() + + const displayName = userProfile?.displayName ?? "account" + + function closeSidebarOnMobile() { + if (typeof window !== "undefined" && window.innerWidth < 768) { + toggleSidebar() + } + } + + return ( + <div className="border-t border-border p-2"> + <Link + href="/reader/settings" + onClick={() => { + setActiveSettingsTab("account") + closeSidebarOnMobile() + }} + className="block truncate px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + {displayName} + </Link> + <Link + href="/reader/settings" + onClick={closeSidebarOnMobile} + className="block px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + settings + </Link> + <div className="relative"> + <button + type="button" + onClick={() => setIsNotificationPanelOpen(!isNotificationPanelOpen)} + className="w-full px-2 py-1 text-left text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + notifications + {unviewedNotificationCount > 0 && ( + <span className="ml-1 inline-flex h-4 min-w-4 items-center justify-center bg-accent-primary px-1 text-[0.6875rem] text-background-primary"> + {unviewedNotificationCount} + </span> + )} + </button> + {isNotificationPanelOpen && ( + <NotificationPanel + onClose={() => setIsNotificationPanelOpen(false)} + /> + )} + </div> + <form action={signOut}> + <button + type="submit" + onClick={closeSidebarOnMobile} + className="w-full px-2 py-1 text-left text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + sign out + </button> + </form> + </div> + ) +} diff --git a/apps/web/app/reader/actions.ts b/apps/web/app/reader/actions.ts new file mode 100644 index 0000000..efcc1ec --- /dev/null +++ b/apps/web/app/reader/actions.ts @@ -0,0 +1,10 @@ +"use server" + +import { redirect } from "next/navigation" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +export async function signOut() { + const supabaseClient = await createSupabaseServerClient() + await supabaseClient.auth.signOut() + redirect("/sign-in") +} diff --git a/apps/web/app/reader/highlights/_components/highlights-content.tsx b/apps/web/app/reader/highlights/_components/highlights-content.tsx new file mode 100644 index 0000000..4034210 --- /dev/null +++ b/apps/web/app/reader/highlights/_components/highlights-content.tsx @@ -0,0 +1,452 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { formatDistanceToNow } from "date-fns" +import { Group, Panel, Separator } from "react-resizable-panels" +import { useAllHighlights } from "@/lib/queries/use-all-highlights" +import { + useDeleteHighlight, + useUpdateHighlightNote, +} from "@/lib/queries/use-highlight-mutations" +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 type { HighlightWithEntryContext } from "@/lib/types/highlight" + +function groupHighlightsByEntry( + highlights: HighlightWithEntryContext[] +): Map<string, HighlightWithEntryContext[]> { + const grouped = new Map<string, HighlightWithEntryContext[]>() + + for (const highlight of highlights) { + const existing = grouped.get(highlight.entryIdentifier) + if (existing) { + existing.push(highlight) + } else { + grouped.set(highlight.entryIdentifier, [highlight]) + } + } + + return grouped +} + +function HighlightItem({ + highlight, + entryIdentifier, +}: { + highlight: HighlightWithEntryContext + entryIdentifier: string +}) { + const [showRemoveConfirm, setShowRemoveConfirm] = useState(false) + const [isEditingNote, setIsEditingNote] = useState(false) + const [editedNote, setEditedNote] = useState(highlight.note ?? "") + const deleteHighlight = useDeleteHighlight() + const updateNote = useUpdateHighlightNote() + + function handleSaveNote() { + const trimmedNote = editedNote.trim() + updateNote.mutate({ + highlightIdentifier: highlight.identifier, + note: trimmedNote || null, + entryIdentifier, + }) + setIsEditingNote(false) + } + + return ( + <div className="border-l-2 border-text-dim pl-3"> + <p className="text-text-secondary"> + {highlight.highlightedText} + </p> + {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 + onClick={handleSaveNote} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : highlight.note ? ( + <p className="mt-1 text-text-dim"> + {highlight.note} + </p> + ) : null} + <div className="mt-1 flex items-center gap-2 text-text-dim"> + <span> + {formatDistanceToNow( + new Date(highlight.createdAt), + { addSuffix: true } + )} + </span> + <button + type="button" + onClick={() => { + setEditedNote(highlight.note ?? "") + setIsEditingNote(true) + }} + className="text-text-secondary transition-colors hover:text-text-primary" + > + {highlight.note ? "edit note" : "add note"} + </button> + {showRemoveConfirm ? ( + <div className="flex items-center gap-1"> + <span>remove?</span> + <button + type="button" + onClick={() => { + deleteHighlight.mutate({ + highlightIdentifier: highlight.identifier, + entryIdentifier, + }) + setShowRemoveConfirm(false) + }} + className="text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + type="button" + onClick={() => setShowRemoveConfirm(false)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + type="button" + onClick={() => setShowRemoveConfirm(true)} + className="text-text-secondary transition-colors hover:text-status-error" + > + remove + </button> + )} + </div> + </div> + ) +} + +function HighlightsList({ + groupedByEntry, + entryIdentifiers, + selectedEntryIdentifier, + focusedEntryIdentifier, + viewMode, + onSelect, + lastElementReference, + hasNextPage, + isFetchingNextPage, +}: { + groupedByEntry: Map<string, HighlightWithEntryContext[]> + entryIdentifiers: string[] + selectedEntryIdentifier: string | null + focusedEntryIdentifier: string | null + viewMode: "compact" | "comfortable" | "expanded" + onSelect: (entryIdentifier: string) => void + lastElementReference: (node: HTMLDivElement | null) => (() => void) | undefined + hasNextPage: boolean + isFetchingNextPage: boolean +}) { + const listReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (!focusedEntryIdentifier) return + const container = listReference.current + if (!container) return + const items = container.querySelectorAll("[data-highlight-group-item]") + const focusedIndex = entryIdentifiers.indexOf(focusedEntryIdentifier) + items[focusedIndex]?.scrollIntoView({ block: "nearest" }) + }, [focusedEntryIdentifier, entryIdentifiers]) + + if (groupedByEntry.size === 0) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + <div className="text-center"> + <p>no highlights yet</p> + <p className="mt-1 text-xs"> + select text in an entry and click “highlight” to get started + </p> + </div> + </div> + ) + } + + return ( + <div ref={listReference} className="h-full overflow-auto"> + {entryIdentifiers.map((entryIdentifier) => { + const highlights = groupedByEntry.get(entryIdentifier)! + const firstHighlight = highlights[0]! + const isSelected = entryIdentifier === selectedEntryIdentifier + const isFocused = entryIdentifier === focusedEntryIdentifier + + 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 + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={rowClassName} + > + <div className="flex items-center gap-2 py-2.5"> + <span className="min-w-0 flex-1 truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + <span className="shrink-0 text-text-dim"> + {highlights.length} highlight{highlights.length !== 1 && "s"} + </span> + {firstHighlight.feedTitle && ( + <span className="shrink-0 text-text-dim"> + {firstHighlight.feedTitle} + </span> + )} + </div> + </div> + ) + } + + if (viewMode === "comfortable") { + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={rowClassName} + > + <div className="py-2.5"> + <span className="block truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + {firstHighlight.feedTitle && ( + <span>{firstHighlight.feedTitle}</span> + )} + <span> + {highlights.length} highlight{highlights.length !== 1 && "s"} + </span> + <span>·</span> + <span className="truncate"> + {firstHighlight.highlightedText} + </span> + </div> + </div> + </div> + ) + } + + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={classNames(rowClassName, "py-3")} + > + <div className="mb-2 flex items-center gap-2"> + <span className="truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + {firstHighlight.feedTitle && ( + <span className="shrink-0 text-text-dim"> + {firstHighlight.feedTitle} + </span> + )} + </div> + <div className="space-y-2"> + {highlights.map((highlight) => ( + <HighlightItem + key={highlight.identifier} + highlight={highlight} + entryIdentifier={entryIdentifier} + /> + ))} + </div> + </div> + ) + })} + {hasNextPage && ( + <div ref={lastElementReference} className="py-4 text-center"> + {isFetchingNextPage ? ( + <span className="text-text-dim">loading more ...</span> + ) : ( + <span className="text-text-dim"> </span> + )} + </div> + )} + </div> + ) +} + +export function HighlightsContent() { + const { + data, + isLoading, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useAllHighlights() + 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 lastElementReference = useCallback( + (node: HTMLDivElement | null) => { + if (!node || !hasNextPage || isFetchingNextPage) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + fetchNextPage() + } + }, + { threshold: 0.1 } + ) + + observer.observe(node) + return () => observer.disconnect() + }, + [hasNextPage, isFetchingNextPage, fetchNextPage] + ) + + const allHighlights = data?.pages.flat() ?? [] + const groupedByEntry = groupHighlightsByEntry(allHighlights) + const entryIdentifiers = Array.from(groupedByEntry.keys()) + + useEffect(() => { + setSelectedEntryIdentifier(null) + setNavigableEntryIdentifiers([]) + }, []) + + useEffect(() => { + setNavigableEntryIdentifiers(entryIdentifiers) + }, [entryIdentifiers.length, setNavigableEntryIdentifiers]) + + if (isLoading) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + loading ... + </div> + ) + } + + 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">highlights</h1> + </div> + <span className="text-text-dim">{allHighlights.length} highlight{allHighlights.length !== 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"> + <HighlightsList + groupedByEntry={groupedByEntry} + entryIdentifiers={entryIdentifiers} + selectedEntryIdentifier={null} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + lastElementReference={lastElementReference} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + /> + </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" + )}> + <HighlightsList + groupedByEntry={groupedByEntry} + entryIdentifiers={entryIdentifiers} + selectedEntryIdentifier={selectedEntryIdentifier} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + lastElementReference={lastElementReference} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + /> + </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> + ) +} diff --git a/apps/web/app/reader/highlights/page.tsx b/apps/web/app/reader/highlights/page.tsx new file mode 100644 index 0000000..c73c032 --- /dev/null +++ b/apps/web/app/reader/highlights/page.tsx @@ -0,0 +1,16 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { redirect } from "next/navigation" +import { HighlightsContent } from "./_components/highlights-content" + +export default async function HighlightsPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + redirect("/") + } + + return <HighlightsContent /> +} diff --git a/apps/web/app/reader/layout.tsx b/apps/web/app/reader/layout.tsx new file mode 100644 index 0000000..8efedbe --- /dev/null +++ b/apps/web/app/reader/layout.tsx @@ -0,0 +1,14 @@ +import { ReaderLayoutShell } from "./_components/reader-layout-shell" +import { SidebarFooter } from "./_components/sidebar-footer" + +export default function ReaderLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <ReaderLayoutShell sidebarFooter={<SidebarFooter />}> + {children} + </ReaderLayoutShell> + ) +} diff --git a/apps/web/app/reader/page.tsx b/apps/web/app/reader/page.tsx new file mode 100644 index 0000000..4773fd8 --- /dev/null +++ b/apps/web/app/reader/page.tsx @@ -0,0 +1,25 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { ReaderShell } from "./_components/reader-shell" + +export default async function ReaderPage({ + searchParams, +}: { + searchParams: Promise<{ folder?: string; feed?: string; custom_feed?: string }> +}) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + const resolvedSearchParams = await searchParams + + return ( + <ReaderShell + userEmailAddress={user?.email ?? null} + feedFilter="all" + folderIdentifier={resolvedSearchParams.folder} + feedIdentifier={resolvedSearchParams.feed} + customFeedIdentifier={resolvedSearchParams.custom_feed} + /> + ) +} diff --git a/apps/web/app/reader/saved/page.tsx b/apps/web/app/reader/saved/page.tsx new file mode 100644 index 0000000..0ad5ba3 --- /dev/null +++ b/apps/web/app/reader/saved/page.tsx @@ -0,0 +1,16 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { ReaderShell } from "../_components/reader-shell" + +export default async function SavedPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + return ( + <ReaderShell + userEmailAddress={user?.email ?? null} + feedFilter="saved" + /> + ) +} diff --git a/apps/web/app/reader/settings/_components/account-settings.tsx b/apps/web/app/reader/settings/_components/account-settings.tsx new file mode 100644 index 0000000..b9ed8c3 --- /dev/null +++ b/apps/web/app/reader/settings/_components/account-settings.tsx @@ -0,0 +1,368 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { TIER_LIMITS } from "@asa-news/shared" +import { notify } from "@/lib/notify" + +export function AccountSettings() { + const { data: userProfile, isLoading } = useUserProfile() + const [isEditingName, setIsEditingName] = useState(false) + const [editedName, setEditedName] = useState("") + const [isRequestingData, setIsRequestingData] = useState(false) + const [newEmailAddress, setNewEmailAddress] = useState("") + const [emailPassword, setEmailPassword] = useState("") + const [currentPassword, setCurrentPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmNewPassword, setConfirmNewPassword] = useState("") + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + const router = useRouter() + + const updateDisplayName = useMutation({ + mutationFn: async (displayName: string | null) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_profiles") + .update({ display_name: displayName }) + .eq("id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("display name updated") + }, + onError: (error: Error) => { + notify("failed to update display name: " + error.message) + }, + }) + + const updateEmailAddress = useMutation({ + mutationFn: async ({ + emailAddress, + password, + }: { + emailAddress: string + password: string + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user?.email) throw new Error("Not authenticated") + + const { error: signInError } = await supabaseClient.auth.signInWithPassword({ + email: user.email, + password, + }) + + if (signInError) throw new Error("incorrect password") + + const { error } = await supabaseClient.auth.updateUser({ + email: emailAddress, + }) + + if (error) throw error + }, + onSuccess: () => { + setNewEmailAddress("") + setEmailPassword("") + notify("confirmation email sent to your new address") + }, + onError: (error: Error) => { + notify("failed to update email: " + error.message) + }, + }) + + const updatePassword = useMutation({ + mutationFn: async ({ + currentPassword: current, + newPassword: updated, + }: { + currentPassword: string + newPassword: string + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user?.email) throw new Error("Not authenticated") + + const { error: signInError } = await supabaseClient.auth.signInWithPassword({ + email: user.email, + password: current, + }) + + if (signInError) throw new Error("current password is incorrect") + + const { error } = await supabaseClient.auth.updateUser({ + password: updated, + }) + + if (error) throw error + }, + onSuccess: async () => { + setCurrentPassword("") + setNewPassword("") + setConfirmNewPassword("") + notify("password updated — signing out all sessions") + await supabaseClient.auth.signOut({ scope: "global" }) + router.push("/sign-in") + }, + onError: (error: Error) => { + notify("failed to update password: " + error.message) + }, + }) + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading account ...</p> + } + + if (!userProfile) { + return <p className="px-4 py-6 text-text-dim">failed to load account</p> + } + + const tier = userProfile.tier + const tierLimits = TIER_LIMITS[tier] + + async function handleRequestData() { + setIsRequestingData(true) + try { + const response = await fetch("/api/account/data") + if (!response.ok) throw new Error("Export failed") + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json` + anchor.click() + URL.revokeObjectURL(url) + notify("data exported") + } catch { + notify("failed to export data") + } finally { + setIsRequestingData(false) + } + } + + function handleSaveName() { + const trimmedName = editedName.trim() + updateDisplayName.mutate(trimmedName || null) + setIsEditingName(false) + } + + function handleUpdateEmail(event: React.FormEvent) { + event.preventDefault() + const trimmedEmail = newEmailAddress.trim() + if (!trimmedEmail || !emailPassword) return + updateEmailAddress.mutate({ emailAddress: trimmedEmail, password: emailPassword }) + } + + function handleUpdatePassword(event: React.FormEvent) { + event.preventDefault() + if (!currentPassword) { + notify("current password is required") + return + } + if (!newPassword || newPassword !== confirmNewPassword) { + notify("passwords do not match") + return + } + if (newPassword.length < 8) { + notify("password must be at least 8 characters") + return + } + updatePassword.mutate({ currentPassword, newPassword }) + } + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">display name</h3> + {isEditingName ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + placeholder="display name" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveName() + if (event.key === "Escape") setIsEditingName(false) + }} + autoFocus + /> + <button + onClick={handleSaveName} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingName(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="text-text-secondary"> + {userProfile.displayName ?? "not set"} + </span> + <button + onClick={() => { + setEditedName(userProfile.displayName ?? "") + setIsEditingName(true) + }} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + edit + </button> + </div> + )} + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">email address</h3> + <p className="mb-2 text-text-dim"> + {userProfile.email ?? "no email on file"} + </p> + <form onSubmit={handleUpdateEmail} className="space-y-2"> + <input + type="email" + value={newEmailAddress} + onChange={(event) => setNewEmailAddress(event.target.value)} + placeholder="new email address" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={emailPassword} + onChange={(event) => setEmailPassword(event.target.value)} + placeholder="current password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={updateEmailAddress.isPending || !newEmailAddress.trim() || !emailPassword} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + update email + </button> + </form> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">change password</h3> + <form onSubmit={handleUpdatePassword} className="space-y-2"> + <input + type="password" + value={currentPassword} + onChange={(event) => setCurrentPassword(event.target.value)} + placeholder="current password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={newPassword} + onChange={(event) => setNewPassword(event.target.value)} + placeholder="new password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={confirmNewPassword} + onChange={(event) => setConfirmNewPassword(event.target.value)} + placeholder="confirm new password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={updatePassword.isPending || !currentPassword || !newPassword || newPassword !== confirmNewPassword} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + change password + </button> + </form> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">usage</h3> + <div className="space-y-1"> + <UsageRow + label="feeds" + current={userProfile.feedCount} + maximum={tierLimits.maximumFeeds} + /> + <UsageRow + label="folders" + current={userProfile.folderCount} + maximum={tierLimits.maximumFolders} + /> + <UsageRow + label="muted keywords" + current={userProfile.mutedKeywordCount} + maximum={tierLimits.maximumMutedKeywords} + /> + <UsageRow + label="custom feeds" + current={userProfile.customFeedCount} + maximum={tierLimits.maximumCustomFeeds} + /> + </div> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">your data</h3> + <p className="mb-3 text-text-dim"> + download all your data (profile, subscriptions, folders, highlights, saved entries) + </p> + <button + onClick={handleRequestData} + disabled={isRequestingData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isRequestingData ? "exporting ..." : "request all data"} + </button> + </div> + </div> + ) +} + +function UsageRow({ + label, + current, + maximum, +}: { + label: string + current: number + maximum: number +}) { + const isNearLimit = current >= maximum * 0.8 + const isAtLimit = current >= maximum + + return ( + <div className="flex items-center justify-between py-1"> + <span className="text-text-secondary">{label}</span> + <span + className={ + isAtLimit + ? "text-status-error" + : isNearLimit + ? "text-text-primary" + : "text-text-dim" + } + > + {current} / {maximum} + </span> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/api-settings.tsx b/apps/web/app/reader/settings/_components/api-settings.tsx new file mode 100644 index 0000000..cb69958 --- /dev/null +++ b/apps/web/app/reader/settings/_components/api-settings.tsx @@ -0,0 +1,529 @@ +"use client" + +import { useState, useEffect } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { notify } from "@/lib/notify" + +interface ApiKey { + keyIdentifier: string + keyPrefix: string + label: string | null + createdAt: string + lastUsedAt: string | null +} + +interface WebhookConfiguration { + webhookUrl: string | null + webhookSecret: string | null + webhookEnabled: boolean + consecutiveFailures: number +} + +function useApiKeys() { + return useQuery({ + queryKey: ["apiKeys"], + queryFn: async () => { + const response = await fetch("/api/v1/keys") + if (!response.ok) throw new Error("Failed to load API keys") + const data = await response.json() + return data.keys as ApiKey[] + }, + }) +} + +function useCreateApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (label: string | null) => { + const response = await fetch("/api/v1/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create API key") + } + return response.json() as Promise<{ + fullKey: string + keyPrefix: string + keyIdentifier: string + }> + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useRevokeApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (keyIdentifier: string) => { + const response = await fetch(`/api/v1/keys/${keyIdentifier}`, { + method: "DELETE", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to revoke API key") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useWebhookConfig() { + return useQuery({ + queryKey: ["webhookConfig"], + queryFn: async () => { + const response = await fetch("/api/webhook-config") + if (!response.ok) throw new Error("Failed to load webhook config") + return response.json() as Promise<WebhookConfiguration> + }, + }) +} + +function useUpdateWebhookConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ( + updates: Partial<{ + webhookUrl: string + webhookSecret: string + webhookEnabled: boolean + }> + ) => { + const response = await fetch("/api/webhook-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to update webhook config") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["webhookConfig"] }) + }, + }) +} + +function useTestWebhook() { + return useMutation({ + mutationFn: async () => { + const response = await fetch("/api/webhook-config/test", { + method: "POST", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to send test webhook") + } + return response.json() as Promise<{ + delivered: boolean + statusCode?: number + error?: string + }> + }, + }) +} + +function ApiKeysSection() { + const { data: apiKeys, isLoading } = useApiKeys() + const createApiKey = useCreateApiKey() + const revokeApiKey = useRevokeApiKey() + const [newKeyLabel, setNewKeyLabel] = useState("") + const [revealedKey, setRevealedKey] = useState<string | null>(null) + const [confirmRevokeIdentifier, setConfirmRevokeIdentifier] = useState< + string | null + >(null) + + function handleCreateKey() { + createApiKey.mutate(newKeyLabel.trim() || null, { + onSuccess: (data) => { + setRevealedKey(data.fullKey) + setNewKeyLabel("") + notify("API key created") + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleCopyKey() { + if (revealedKey) { + navigator.clipboard.writeText(revealedKey) + notify("API key copied to clipboard") + } + } + + function handleRevokeKey(keyIdentifier: string) { + revokeApiKey.mutate(keyIdentifier, { + onSuccess: () => { + notify("API key revoked") + setConfirmRevokeIdentifier(null) + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">API keys</h3> + <p className="mb-3 text-text-dim"> + use API keys to authenticate requests to the REST API + </p> + + {revealedKey && ( + <div className="mb-4 border border-status-warning p-3"> + <p className="mb-2 text-text-secondary"> + copy this key now — it will not be shown again + </p> + <div className="flex items-center gap-2"> + <code className="min-w-0 flex-1 overflow-x-auto bg-background-tertiary px-2 py-1 text-text-primary"> + {revealedKey} + </code> + <button + onClick={handleCopyKey} + className="shrink-0 border border-border px-3 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + copy + </button> + </div> + <button + onClick={() => setRevealedKey(null)} + className="mt-2 text-text-dim transition-colors hover:text-text-secondary" + > + dismiss + </button> + </div> + )} + + {isLoading ? ( + <p className="text-text-dim">loading keys ...</p> + ) : ( + <> + {apiKeys && apiKeys.length > 0 && ( + <div className="mb-4 border border-border"> + {apiKeys.map((apiKey) => ( + <div + key={apiKey.keyIdentifier} + className="flex items-center justify-between border-b border-border px-3 py-2 last:border-b-0" + > + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <code className="text-text-primary"> + {apiKey.keyPrefix}... + </code> + {apiKey.label && ( + <span className="text-text-dim">{apiKey.label}</span> + )} + </div> + <div className="text-text-dim"> + created{" "} + {new Date(apiKey.createdAt).toLocaleDateString()} + {apiKey.lastUsedAt && + ` · last used ${new Date(apiKey.lastUsedAt).toLocaleDateString()}`} + </div> + </div> + {confirmRevokeIdentifier === apiKey.keyIdentifier ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">revoke?</span> + <button + onClick={() => + handleRevokeKey(apiKey.keyIdentifier) + } + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setConfirmRevokeIdentifier(null)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => + setConfirmRevokeIdentifier(apiKey.keyIdentifier) + } + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + revoke + </button> + )} + </div> + ))} + </div> + )} + + {(!apiKeys || apiKeys.length < 5) && ( + <div className="flex items-center gap-2"> + <input + type="text" + value={newKeyLabel} + onChange={(event) => setNewKeyLabel(event.target.value)} + placeholder="key label (optional)" + 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" + onKeyDown={(event) => { + if (event.key === "Enter") handleCreateKey() + }} + /> + <button + onClick={handleCreateKey} + disabled={createApiKey.isPending} + className="shrink-0 border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {createApiKey.isPending ? "creating ..." : "create key"} + </button> + </div> + )} + </> + )} + </div> + ) +} + +function WebhookSection() { + const { data: webhookConfig, isLoading } = useWebhookConfig() + const updateWebhookConfig = useUpdateWebhookConfig() + const testWebhook = useTestWebhook() + const [webhookUrl, setWebhookUrl] = useState("") + const [webhookSecret, setWebhookSecret] = useState("") + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + + useEffect(() => { + if (webhookConfig) { + setWebhookUrl(webhookConfig.webhookUrl ?? "") + setWebhookSecret(webhookConfig.webhookSecret ?? "") + } + }, [webhookConfig]) + + function handleSaveWebhookConfig() { + updateWebhookConfig.mutate( + { + webhookUrl: webhookUrl.trim(), + webhookSecret: webhookSecret.trim(), + }, + { + onSuccess: () => { + notify("webhook configuration saved") + setHasUnsavedChanges(false) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleToggleEnabled() { + if (!webhookConfig) return + + updateWebhookConfig.mutate( + { webhookEnabled: !webhookConfig.webhookEnabled }, + { + onSuccess: () => { + notify( + webhookConfig.webhookEnabled + ? "webhooks disabled" + : "webhooks enabled" + ) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleTestWebhook() { + testWebhook.mutate(undefined, { + onSuccess: (data) => { + if (data.delivered) { + notify(`test webhook delivered (status ${data.statusCode})`) + } else { + notify(`test webhook failed: ${data.error}`) + } + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleGenerateSecret() { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + const generatedSecret = Array.from(array) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + setWebhookSecret(generatedSecret) + setHasUnsavedChanges(true) + } + + if (isLoading) { + return <p className="text-text-dim">loading webhook configuration ...</p> + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">webhooks</h3> + <p className="mb-3 text-text-dim"> + receive HTTP POST notifications when new entries arrive in your + subscribed feeds + </p> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary">webhook URL</label> + <input + type="url" + value={webhookUrl} + onChange={(event) => { + setWebhookUrl(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="https://example.com/webhook" + className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary"> + signing secret + </label> + <div className="flex items-center gap-2"> + <input + type="text" + value={webhookSecret} + onChange={(event) => { + setWebhookSecret(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="optional HMAC-SHA256 signing secret" + 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" + /> + <button + onClick={handleGenerateSecret} + className="shrink-0 border border-border px-3 py-1.5 text-text-secondary transition-colors hover:text-text-primary" + > + generate + </button> + </div> + </div> + + <div className="mb-4 flex items-center gap-4"> + <button + onClick={handleSaveWebhookConfig} + disabled={!hasUnsavedChanges || updateWebhookConfig.isPending} + className="border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {updateWebhookConfig.isPending ? "saving ..." : "save"} + </button> + <button + onClick={handleToggleEnabled} + disabled={updateWebhookConfig.isPending} + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {webhookConfig?.webhookEnabled ? "disable" : "enable"} + </button> + <button + onClick={handleTestWebhook} + disabled={ + testWebhook.isPending || + !webhookConfig?.webhookEnabled || + !webhookConfig?.webhookUrl + } + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {testWebhook.isPending ? "sending ..." : "send test"} + </button> + </div> + + {webhookConfig && ( + <div className="flex items-center gap-3 text-text-dim"> + <span> + status:{" "} + <span + className={ + webhookConfig.webhookEnabled + ? "text-text-primary" + : "text-text-dim" + } + > + {webhookConfig.webhookEnabled ? "enabled" : "disabled"} + </span> + </span> + {webhookConfig.consecutiveFailures > 0 && ( + <span className="text-status-warning"> + {webhookConfig.consecutiveFailures} consecutive failure + {webhookConfig.consecutiveFailures !== 1 && "s"} + </span> + )} + </div> + )} + </div> + ) +} + +export function ApiSettings() { + const { data: userProfile, isLoading } = useUserProfile() + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading API settings ...</p> + } + + if (!userProfile) { + return ( + <p className="px-4 py-6 text-text-dim">failed to load API settings</p> + ) + } + + if (userProfile.tier !== "developer") { + return ( + <div className="px-4 py-3"> + <h3 className="mb-2 text-text-primary">developer API</h3> + <p className="mb-3 text-text-dim"> + the developer plan includes a read-only REST API and webhook push + notifications. upgrade to developer to access these features. + </p> + </div> + ) + } + + return ( + <div className="px-4 py-3"> + <ApiKeysSection /> + <WebhookSection /> + + <div> + <h3 className="mb-2 text-text-primary">API documentation</h3> + <p className="mb-3 text-text-dim"> + authenticate requests with an API key in the Authorization header: + </p> + <code className="block bg-background-tertiary px-3 py-2 text-text-secondary"> + Authorization: Bearer asn_your_key_here + </code> + <div className="mt-3 space-y-1 text-text-dim"> + <p>GET /api/v1/profile — your account info and limits</p> + <p>GET /api/v1/feeds — your subscribed feeds</p> + <p>GET /api/v1/folders — your folders</p> + <p> + GET /api/v1/entries — entries with ?cursor, ?limit, ?feedIdentifier, + ?readStatus, ?savedStatus filters + </p> + <p>GET /api/v1/entries/:id — single entry with full content</p> + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/appearance-settings.tsx b/apps/web/app/reader/settings/_components/appearance-settings.tsx new file mode 100644 index 0000000..9c0e214 --- /dev/null +++ b/apps/web/app/reader/settings/_components/appearance-settings.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useTheme } from "next-themes" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +export function AppearanceSettings() { + const { theme, setTheme } = useTheme() + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const displayDensity = useUserInterfaceStore( + (state) => state.displayDensity + ) + const setDisplayDensity = useUserInterfaceStore( + (state) => state.setDisplayDensity + ) + const showFeedFavicons = useUserInterfaceStore( + (state) => state.showFeedFavicons + ) + const setShowFeedFavicons = useUserInterfaceStore( + (state) => state.setShowFeedFavicons + ) + const focusFollowsInteraction = useUserInterfaceStore( + (state) => state.focusFollowsInteraction + ) + const setFocusFollowsInteraction = useUserInterfaceStore( + (state) => state.setFocusFollowsInteraction + ) + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">theme</h3> + <p className="mb-3 text-text-dim"> + controls the colour scheme of the application + </p> + <select + value={theme ?? "system"} + onChange={(event) => setTheme(event.target.value)} + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="system">system</option> + <option value="light">light</option> + <option value="dark">dark</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">display density</h3> + <p className="mb-3 text-text-dim"> + controls the overall text size and spacing + </p> + <select + value={displayDensity} + onChange={(event) => + setDisplayDensity( + event.target.value as "compact" | "default" | "spacious" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="compact">compact</option> + <option value="default">default</option> + <option value="spacious">spacious</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">entry list view</h3> + <p className="mb-3 text-text-dim"> + controls how entries are displayed in the list + </p> + <select + value={entryListViewMode} + onChange={(event) => + setEntryListViewMode( + event.target.value as "compact" | "comfortable" | "expanded" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="compact">compact</option> + <option value="comfortable">comfortable</option> + <option value="expanded">expanded</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">feed favicons</h3> + <p className="mb-3 text-text-dim"> + show website icons next to feed names in the sidebar + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={showFeedFavicons} + onChange={(event) => setShowFeedFavicons(event.target.checked)} + className="accent-text-primary" + /> + <span>show favicons</span> + </label> + </div> + <div> + <h3 className="mb-2 text-text-primary">focus follows interaction</h3> + <p className="mb-3 text-text-dim"> + automatically move keyboard panel focus to the last pane you + interacted with (clicked or scrolled) + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={focusFollowsInteraction} + onChange={(event) => + setFocusFollowsInteraction(event.target.checked) + } + className="accent-text-primary" + /> + <span>enable focus follows interaction</span> + </label> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/billing-settings.tsx b/apps/web/app/reader/settings/_components/billing-settings.tsx new file mode 100644 index 0000000..e49720a --- /dev/null +++ b/apps/web/app/reader/settings/_components/billing-settings.tsx @@ -0,0 +1,301 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useSearchParams } from "next/navigation" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { TIER_LIMITS } from "@asa-news/shared" +import { classNames } from "@/lib/utilities" +import { notify } from "@/lib/notify" + +function useCreateCheckoutSession() { + return useMutation({ + mutationFn: async ({ + billingInterval, + targetTier, + }: { + billingInterval: "monthly" | "yearly" + targetTier: "pro" | "developer" + }) => { + const response = await fetch("/api/billing/create-checkout-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ billingInterval, targetTier }), + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create checkout session") + } + + const data = await response.json() + return data as { url?: string; upgraded?: boolean } + }, + }) +} + +function useCreatePortalSession() { + return useMutation({ + mutationFn: async () => { + const response = await fetch("/api/billing/create-portal-session", { + method: "POST", + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create portal session") + } + + const data = await response.json() + return data as { url: string } + }, + }) +} + +const PRO_FEATURES = [ + `${TIER_LIMITS.pro.maximumFeeds} feeds`, + `${Number.isFinite(TIER_LIMITS.pro.historyRetentionDays) ? TIER_LIMITS.pro.historyRetentionDays.toLocaleString() + " days" : "unlimited"} history retention`, + `${TIER_LIMITS.pro.refreshIntervalSeconds / 60}-minute refresh interval`, + "authenticated feeds", + "OPML export", + "manual feed refresh", +] + +const DEVELOPER_FEATURES = [ + `${TIER_LIMITS.developer.maximumFeeds} feeds`, + "everything in pro", + "read-only REST API", + "webhook push notifications", +] + +function UpgradeCard({ + targetTier, + features, + monthlyPrice, + yearlyPrice, +}: { + targetTier: "pro" | "developer" + features: string[] + monthlyPrice: string + yearlyPrice: string +}) { + const [billingInterval, setBillingInterval] = useState< + "monthly" | "yearly" + >("yearly") + const queryClient = useQueryClient() + const createCheckoutSession = useCreateCheckoutSession() + + function handleUpgrade() { + createCheckoutSession.mutate( + { billingInterval, targetTier }, + { + onSuccess: (data) => { + if (data.upgraded) { + queryClient.invalidateQueries({ + queryKey: queryKeys.userProfile.all, + }) + notify(`upgraded to ${targetTier}!`) + } else if (data.url) { + window.location.href = data.url + } + }, + onError: (error: Error) => { + notify("failed to start checkout: " + error.message) + }, + } + ) + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">upgrade to {targetTier}</h3> + <ul className="mb-4 space-y-1"> + {features.map((feature) => ( + <li key={feature} className="text-text-secondary"> + + {feature} + </li> + ))} + </ul> + <div className="mb-4 flex items-center gap-2"> + <button + type="button" + onClick={() => setBillingInterval("monthly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "monthly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + {monthlyPrice} / month + </button> + <button + type="button" + onClick={() => setBillingInterval("yearly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "yearly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + {yearlyPrice} / year + </button> + </div> + <button + onClick={handleUpgrade} + disabled={createCheckoutSession.isPending} + className="border border-text-primary px-4 py-2 text-text-primary transition-colors hover:bg-text-primary hover:text-background-primary disabled:opacity-50" + > + {createCheckoutSession.isPending + ? "redirecting ..." + : `upgrade to ${targetTier}`} + </button> + </div> + ) +} + +export function BillingSettings() { + const { data: userProfile, isLoading } = useUserProfile() + const queryClient = useQueryClient() + const searchParameters = useSearchParams() + const hasShownSuccessToast = useRef(false) + + const createPortalSession = useCreatePortalSession() + + useEffect(() => { + if ( + searchParameters.get("billing") === "success" && + !hasShownSuccessToast.current + ) { + hasShownSuccessToast.current = true + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("subscription activated!") + const url = new URL(window.location.href) + url.searchParams.delete("billing") + window.history.replaceState({}, "", url.pathname) + } + }, [searchParameters, queryClient]) + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading billing ...</p> + } + + if (!userProfile) { + return <p className="px-4 py-6 text-text-dim">failed to load billing</p> + } + + function handleManageSubscription() { + createPortalSession.mutate(undefined, { + onSuccess: (data) => { + window.location.href = data.url + }, + onError: (error: Error) => { + notify("failed to open billing portal: " + error.message) + }, + }) + } + + const isPaidTier = + userProfile.tier === "pro" || userProfile.tier === "developer" + const isCancelling = + userProfile.stripeSubscriptionStatus === "active" && + isPaidTier && + userProfile.stripeCurrentPeriodEnd !== null + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">current plan</h3> + <span className="border border-border px-2 py-1 text-text-secondary"> + {userProfile.tier} + </span> + {userProfile.stripeSubscriptionStatus && ( + <span className="ml-2 text-text-dim"> + ({userProfile.stripeSubscriptionStatus}) + </span> + )} + </div> + + {userProfile.tier === "free" && ( + <> + <UpgradeCard + targetTier="pro" + features={PRO_FEATURES} + monthlyPrice="$3" + yearlyPrice="$30" + /> + <UpgradeCard + targetTier="developer" + features={DEVELOPER_FEATURES} + monthlyPrice="$6" + yearlyPrice="$60" + /> + </> + )} + + {userProfile.tier === "pro" && ( + <> + <UpgradeCard + targetTier="developer" + features={DEVELOPER_FEATURES} + monthlyPrice="$6" + yearlyPrice="$60" + /> + <div className="mb-6"> + {isCancelling && userProfile.stripeCurrentPeriodEnd && ( + <p className="mb-3 text-text-secondary"> + your pro plan is active until{" "} + {new Date( + userProfile.stripeCurrentPeriodEnd + ).toLocaleDateString()} + </p> + )} + {userProfile.stripeSubscriptionStatus === "past_due" && ( + <p className="mb-3 text-status-error"> + payment failed — please update your payment method + </p> + )} + <button + onClick={handleManageSubscription} + disabled={createPortalSession.isPending} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50" + > + {createPortalSession.isPending + ? "redirecting ..." + : "manage subscription"} + </button> + </div> + </> + )} + + {userProfile.tier === "developer" && ( + <div className="mb-6"> + {isCancelling && userProfile.stripeCurrentPeriodEnd && ( + <p className="mb-3 text-text-secondary"> + your developer plan is active until{" "} + {new Date( + userProfile.stripeCurrentPeriodEnd + ).toLocaleDateString()} + </p> + )} + {userProfile.stripeSubscriptionStatus === "past_due" && ( + <p className="mb-3 text-status-error"> + payment failed — please update your payment method + </p> + )} + <button + onClick={handleManageSubscription} + disabled={createPortalSession.isPending} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50" + > + {createPortalSession.isPending + ? "redirecting ..." + : "manage subscription"} + </button> + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx new file mode 100644 index 0000000..b7b588b --- /dev/null +++ b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx @@ -0,0 +1,283 @@ +"use client" + +import { useState } from "react" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" +import { + useCreateCustomFeed, + useUpdateCustomFeed, + useDeleteCustomFeed, +} from "@/lib/queries/use-custom-feed-mutations" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function CustomFeedsSettings() { + const { data: customFeeds, isLoading } = useCustomFeeds() + const { data: subscriptionsData } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const createCustomFeed = useCreateCustomFeed() + + const [newName, setNewName] = useState("") + const [newKeywords, setNewKeywords] = useState("") + const [newMatchMode, setNewMatchMode] = useState<"and" | "or">("or") + const [newSourceFolderId, setNewSourceFolderId] = useState<string>("") + + const folders = subscriptionsData?.folders ?? [] + const tier = userProfile?.tier ?? "free" + const maximumCustomFeeds = TIER_LIMITS[tier].maximumCustomFeeds + + function handleCreate(event: React.FormEvent) { + event.preventDefault() + const trimmedName = newName.trim() + const trimmedKeywords = newKeywords.trim() + + if (!trimmedName || !trimmedKeywords) return + + createCustomFeed.mutate({ + name: trimmedName, + query: trimmedKeywords, + matchMode: newMatchMode, + sourceFolderIdentifier: newSourceFolderId || null, + }) + + setNewName("") + setNewKeywords("") + setNewMatchMode("or") + setNewSourceFolderId("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading custom feeds ...</p> + } + + const feedsList = customFeeds ?? [] + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-2 text-text-dim"> + {feedsList.length} / {maximumCustomFeeds} custom feeds used + </p> + <form onSubmit={handleCreate} className="space-y-2"> + <input + type="text" + value={newName} + onChange={(event) => setNewName(event.target.value)} + placeholder="feed name" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="text" + value={newKeywords} + onChange={(event) => setNewKeywords(event.target.value)} + placeholder="keywords (space-separated)" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <div className="flex gap-2"> + <select + value={newMatchMode} + onChange={(event) => + setNewMatchMode(event.target.value as "and" | "or") + } + className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none" + > + <option value="or">match any keyword</option> + <option value="and">match all keywords</option> + </select> + <select + value={newSourceFolderId} + onChange={(event) => setNewSourceFolderId(event.target.value)} + className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none" + > + <option value="">all feeds</option> + {folders.map((folder) => ( + <option key={folder.folderIdentifier} value={folder.folderIdentifier}> + {folder.name} + </option> + ))} + </select> + <button + type="submit" + disabled={ + createCustomFeed.isPending || + !newName.trim() || + !newKeywords.trim() + } + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + create + </button> + </div> + </form> + </div> + {feedsList.length === 0 ? ( + <p className="px-4 py-6 text-text-dim">no custom feeds yet</p> + ) : ( + feedsList.map((customFeed) => ( + <CustomFeedRow + key={customFeed.identifier} + customFeed={customFeed} + folders={folders} + /> + )) + )} + </div> + ) +} + +function CustomFeedRow({ + customFeed, + folders, +}: { + customFeed: { + identifier: string + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + } + folders: { folderIdentifier: string; name: string }[] +}) { + const updateCustomFeed = useUpdateCustomFeed() + const deleteCustomFeed = useDeleteCustomFeed() + const [isEditing, setIsEditing] = useState(false) + const [editedName, setEditedName] = useState(customFeed.name) + const [editedKeywords, setEditedKeywords] = useState(customFeed.query) + const [editedMatchMode, setEditedMatchMode] = useState(customFeed.matchMode) + const [editedSourceFolderId, setEditedSourceFolderId] = useState( + customFeed.sourceFolderIdentifier ?? "" + ) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + function handleSave() { + const trimmedName = editedName.trim() + const trimmedKeywords = editedKeywords.trim() + + if (!trimmedName || !trimmedKeywords) return + + updateCustomFeed.mutate({ + customFeedIdentifier: customFeed.identifier, + name: trimmedName, + query: trimmedKeywords, + matchMode: editedMatchMode, + sourceFolderIdentifier: editedSourceFolderId || null, + }) + setIsEditing(false) + } + + const sourceFolderName = customFeed.sourceFolderIdentifier + ? folders.find( + (folder) => + folder.folderIdentifier === customFeed.sourceFolderIdentifier + )?.name ?? "unknown folder" + : "all feeds" + + return ( + <div className="border-b border-border px-4 py-3 last:border-b-0"> + {isEditing ? ( + <div className="space-y-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + autoFocus + /> + <input + type="text" + value={editedKeywords} + onChange={(event) => setEditedKeywords(event.target.value)} + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + /> + <div className="flex gap-2"> + <select + value={editedMatchMode} + onChange={(event) => + setEditedMatchMode(event.target.value as "and" | "or") + } + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="or">match any</option> + <option value="and">match all</option> + </select> + <select + value={editedSourceFolderId} + onChange={(event) => setEditedSourceFolderId(event.target.value)} + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="">all feeds</option> + {folders.map((folder) => ( + <option + key={folder.folderIdentifier} + value={folder.folderIdentifier} + > + {folder.name} + </option> + ))} + </select> + </div> + <div className="flex gap-2"> + <button + onClick={handleSave} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditing(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + ) : ( + <div> + <div className="flex items-center justify-between"> + <span className="text-text-primary">{customFeed.name}</span> + <div className="flex items-center gap-2"> + <button + onClick={() => setIsEditing(true)} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + edit + </button> + {showDeleteConfirm ? ( + <div className="flex items-center gap-1"> + <button + onClick={() => { + deleteCustomFeed.mutate({ + customFeedIdentifier: customFeed.identifier, + }) + setShowDeleteConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowDeleteConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + delete + </button> + )} + </div> + </div> + <p className="text-text-dim"> + keywords: {customFeed.query} ({customFeed.matchMode === "and" ? "all" : "any"}) + </p> + <p className="text-text-dim">source: {sourceFolderName}</p> + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/danger-zone-settings.tsx b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx new file mode 100644 index 0000000..76c48d4 --- /dev/null +++ b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx @@ -0,0 +1,156 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation } from "@tanstack/react-query" +import { useUnsubscribeAll } from "@/lib/queries/use-subscription-mutations" +import { useDeleteAllFolders } from "@/lib/queries/use-folder-mutations" +import { notify } from "@/lib/notify" + +export function DangerZoneSettings() { + const router = useRouter() + const unsubscribeAll = useUnsubscribeAll() + const deleteAllFolders = useDeleteAllFolders() + const [showDeleteSubsConfirm, setShowDeleteSubsConfirm] = useState(false) + const [showDeleteFoldersConfirm, setShowDeleteFoldersConfirm] = useState(false) + const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false) + const [deleteConfirmText, setDeleteConfirmText] = useState("") + + const deleteAccount = useMutation({ + mutationFn: async () => { + const response = await fetch("/api/account", { method: "DELETE" }) + if (!response.ok) throw new Error("Failed to delete account") + }, + onSuccess: () => { + router.push("/sign-in") + }, + onError: (error: Error) => { + notify("failed to delete account: " + error.message) + }, + }) + + return ( + <div className="px-4 py-3"> + <p className="mb-6 text-text-dim"> + these actions are irreversible. proceed with caution. + </p> + + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">remove all subscriptions</h3> + <p className="mb-2 text-text-dim"> + unsubscribe from every feed. entries will remain but no new ones will be fetched. + </p> + {showDeleteSubsConfirm ? ( + <div className="flex items-center gap-2"> + <span className="text-status-error">are you sure?</span> + <button + onClick={() => { + unsubscribeAll.mutate() + setShowDeleteSubsConfirm(false) + }} + disabled={unsubscribeAll.isPending} + className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + yes, remove all + </button> + <button + onClick={() => setShowDeleteSubsConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteSubsConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + remove all subscriptions + </button> + )} + </div> + + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">delete all folders</h3> + <p className="mb-2 text-text-dim"> + remove all folders. feeds will be ungrouped but not unsubscribed. + </p> + {showDeleteFoldersConfirm ? ( + <div className="flex items-center gap-2"> + <span className="text-status-error">are you sure?</span> + <button + onClick={() => { + deleteAllFolders.mutate() + setShowDeleteFoldersConfirm(false) + }} + disabled={deleteAllFolders.isPending} + className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + yes, delete all + </button> + <button + onClick={() => setShowDeleteFoldersConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteFoldersConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + delete all folders + </button> + )} + </div> + + <div> + <h3 className="mb-2 text-text-primary">delete account</h3> + <p className="mb-2 text-text-dim"> + permanently delete your account and all associated data. this cannot be undone. + </p> + {showDeleteAccountConfirm ? ( + <div> + <p className="mb-2 text-status-error"> + type DELETE to confirm account deletion. + </p> + <div className="flex items-center gap-2"> + <input + type="text" + value={deleteConfirmText} + onChange={(event) => setDeleteConfirmText(event.target.value)} + placeholder="type DELETE" + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-status-error" + autoFocus + /> + <button + onClick={() => deleteAccount.mutate()} + disabled={deleteConfirmText !== "DELETE" || deleteAccount.isPending} + className="border border-status-error px-4 py-2 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + {deleteAccount.isPending ? "deleting ..." : "confirm delete"} + </button> + <button + onClick={() => { + setShowDeleteAccountConfirm(false) + setDeleteConfirmText("") + }} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + ) : ( + <button + onClick={() => setShowDeleteAccountConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + delete account and all data + </button> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/folders-settings.tsx b/apps/web/app/reader/settings/_components/folders-settings.tsx new file mode 100644 index 0000000..8a0012e --- /dev/null +++ b/apps/web/app/reader/settings/_components/folders-settings.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { + useCreateFolder, + useRenameFolder, + useDeleteFolder, +} from "@/lib/queries/use-folder-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function FoldersSettings() { + const [newFolderName, setNewFolderName] = useState("") + const [searchQuery, setSearchQuery] = useState("") + const { data: subscriptionsData, isLoading } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const createFolder = useCreateFolder() + const renameFolder = useRenameFolder() + const deleteFolder = useDeleteFolder() + + const folders = subscriptionsData?.folders ?? [] + const subscriptions = subscriptionsData?.subscriptions ?? [] + const tier = userProfile?.tier ?? "free" + const tierLimits = TIER_LIMITS[tier] + + function feedCountForFolder(folderIdentifier: string): number { + return subscriptions.filter( + (subscription) => subscription.folderIdentifier === folderIdentifier + ).length + } + + function handleCreateFolder(event: React.FormEvent) { + event.preventDefault() + const trimmedName = newFolderName.trim() + + if (!trimmedName) return + + createFolder.mutate({ name: trimmedName }) + setNewFolderName("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading folders ...</p> + } + + const normalizedQuery = searchQuery.toLowerCase().trim() + const filteredFolders = normalizedQuery + ? folders.filter((folder) => folder.name.toLowerCase().includes(normalizedQuery)) + : folders + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-2 text-text-dim"> + {folders.length} / {tierLimits.maximumFolders} folders used + </p> + <form onSubmit={handleCreateFolder} className="mb-2 flex gap-2"> + <input + type="text" + value={newFolderName} + onChange={(event) => setNewFolderName(event.target.value)} + placeholder="new folder name" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={createFolder.isPending || !newFolderName.trim()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + create + </button> + </form> + {folders.length > 5 && ( + <input + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="search folders..." + className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + )} + </div> + {filteredFolders.length === 0 ? ( + <p className="px-4 py-6 text-text-dim"> + {folders.length === 0 ? "no folders yet" : "no folders match your search"} + </p> + ) : ( + <div> + {filteredFolders.map((folder) => ( + <FolderRow + key={folder.folderIdentifier} + folderIdentifier={folder.folderIdentifier} + name={folder.name} + feedCount={feedCountForFolder(folder.folderIdentifier)} + onRename={(name) => + renameFolder.mutate({ + folderIdentifier: folder.folderIdentifier, + name, + }) + } + onDelete={() => + deleteFolder.mutate({ + folderIdentifier: folder.folderIdentifier, + }) + } + /> + ))} + </div> + )} + </div> + ) +} + +function FolderRow({ + folderIdentifier, + name, + feedCount, + onRename, + onDelete, +}: { + folderIdentifier: string + name: string + feedCount: number + onRename: (name: string) => void + onDelete: () => void +}) { + const [isEditing, setIsEditing] = useState(false) + const [editedName, setEditedName] = useState(name) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + function handleSave() { + const trimmedName = editedName.trim() + + if (trimmedName && trimmedName !== name) { + onRename(trimmedName) + } + + setIsEditing(false) + } + + return ( + <div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0"> + <div className="min-w-0 flex-1"> + {isEditing ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + 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") handleSave() + if (event.key === "Escape") setIsEditing(false) + }} + autoFocus + /> + <button + onClick={handleSave} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => { + setEditedName(name) + setIsEditing(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="text-text-primary">{name}</span> + <span className="text-text-dim"> + ({feedCount} feed{feedCount !== 1 && "s"}) + </span> + <button + onClick={() => setIsEditing(true)} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + rename + </button> + </div> + )} + </div> + {showDeleteConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim"> + {feedCount > 0 ? "has feeds, delete?" : "delete?"} + </span> + <button + onClick={() => { + onDelete() + setShowDeleteConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowDeleteConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + delete + </button> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/import-export-settings.tsx b/apps/web/app/reader/settings/_components/import-export-settings.tsx new file mode 100644 index 0000000..efb3f09 --- /dev/null +++ b/apps/web/app/reader/settings/_components/import-export-settings.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState, useRef } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useSubscribeToFeed } from "@/lib/queries/use-subscribe-to-feed" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { downloadOpml, parseOpml } from "@/lib/opml" +import type { ParsedOpmlGroup } from "@/lib/opml" +import { notify } from "@/lib/notify" + +export function ImportExportSettings() { + const { data: subscriptionsData } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const subscribeToFeed = useSubscribeToFeed() + const [parsedGroups, setParsedGroups] = useState<ParsedOpmlGroup[] | null>( + null + ) + const [isImporting, setIsImporting] = useState(false) + const [isExportingData, setIsExportingData] = useState(false) + const fileInputReference = useRef<HTMLInputElement>(null) + + const tier = userProfile?.tier ?? "free" + + function handleExport() { + if (!subscriptionsData) return + + downloadOpml(subscriptionsData.subscriptions, subscriptionsData.folders) + notify("subscriptions exported") + } + + function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + + if (!file) return + + const reader = new FileReader() + reader.onload = (loadEvent) => { + const xmlString = loadEvent.target?.result as string + + try { + const groups = parseOpml(xmlString) + setParsedGroups(groups) + } catch { + notify("failed to parse OPML file") + } + } + reader.readAsText(file) + + if (fileInputReference.current) { + fileInputReference.current.value = "" + } + } + + async function handleImport() { + if (!parsedGroups) return + + setIsImporting(true) + let importedCount = 0 + let failedCount = 0 + + for (const group of parsedGroups) { + for (const feed of group.feeds) { + try { + await new Promise<void>((resolve, reject) => { + subscribeToFeed.mutate( + { + feedUrl: feed.url, + customTitle: feed.title || null, + }, + { + onSuccess: () => { + importedCount++ + resolve() + }, + onError: (error) => { + failedCount++ + resolve() + }, + } + ) + }) + } catch { + failedCount++ + } + } + } + + setIsImporting(false) + setParsedGroups(null) + + if (failedCount > 0) { + notify(`imported ${importedCount} feeds, ${failedCount} failed`) + } else { + notify(`imported ${importedCount} feeds`) + } + } + + async function handleDataExport() { + setIsExportingData(true) + try { + const response = await fetch("/api/export") + if (!response.ok) throw new Error("Export failed") + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `asa-news-export-${new Date().toISOString().slice(0, 10)}.json` + anchor.click() + URL.revokeObjectURL(url) + notify("data exported") + } catch { + notify("failed to export data") + } finally { + setIsExportingData(false) + } + } + + const totalFeedsInImport = + parsedGroups?.reduce((sum, group) => sum + group.feeds.length, 0) ?? 0 + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">export OPML</h3> + <p className="mb-3 text-text-dim"> + download your subscriptions as an OPML file + </p> + <button + onClick={handleExport} + disabled={!subscriptionsData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + export OPML + </button> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">export data</h3> + <p className="mb-3 text-text-dim"> + {tier === "pro" || tier === "developer" + ? "download all your data as JSON (subscriptions, folders, saved entries)" + : "download your saved entries as JSON (upgrade to pro for full export)"} + </p> + <button + onClick={handleDataExport} + disabled={isExportingData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isExportingData ? "exporting..." : "export data"} + </button> + </div> + <div> + <h3 className="mb-2 text-text-primary">import</h3> + <p className="mb-3 text-text-dim"> + import subscriptions from an OPML file + </p> + {parsedGroups === null ? ( + <div> + <input + ref={fileInputReference} + type="file" + accept=".opml,.xml" + onChange={handleFileSelect} + className="hidden" + /> + <button + onClick={() => fileInputReference.current?.click()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border" + > + select OPML file + </button> + </div> + ) : ( + <div> + <p className="mb-3 text-text-secondary"> + found {totalFeedsInImport} feed + {totalFeedsInImport !== 1 && "s"} to import: + </p> + <div className="mb-3 max-h-60 overflow-y-auto border border-border"> + {parsedGroups.map((group, groupIndex) => ( + <div key={groupIndex}> + {group.folderName && ( + <div className="bg-background-tertiary px-3 py-1 text-text-secondary"> + {group.folderName} + </div> + )} + {group.feeds.map((feed, feedIndex) => ( + <div + key={feedIndex} + className="border-b border-border px-3 py-2 last:border-b-0" + > + <p className="truncate text-text-primary">{feed.title}</p> + <p className="truncate text-text-dim">{feed.url}</p> + </div> + ))} + </div> + ))} + </div> + <div className="flex gap-2"> + <button + onClick={() => setParsedGroups(null)} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + cancel + </button> + <button + onClick={handleImport} + disabled={isImporting} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isImporting + ? "importing..." + : `import ${totalFeedsInImport} feeds`} + </button> + </div> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx new file mode 100644 index 0000000..bef4786 --- /dev/null +++ b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx @@ -0,0 +1,89 @@ +"use client" + +import { useState } from "react" +import { useMutedKeywords } from "@/lib/queries/use-muted-keywords" +import { + useAddMutedKeyword, + useDeleteMutedKeyword, +} from "@/lib/queries/use-muted-keyword-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function MutedKeywordsSettings() { + const [newKeyword, setNewKeyword] = useState("") + const { data: keywords, isLoading } = useMutedKeywords() + const { data: userProfile } = useUserProfile() + const addKeyword = useAddMutedKeyword() + const deleteKeyword = useDeleteMutedKeyword() + + const tier = userProfile?.tier ?? "free" + const tierLimits = TIER_LIMITS[tier] + + function handleAddKeyword(event: React.FormEvent) { + event.preventDefault() + const trimmedKeyword = newKeyword.trim() + + if (!trimmedKeyword) return + + addKeyword.mutate({ keyword: trimmedKeyword }) + setNewKeyword("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading muted keywords...</p> + } + + const keywordList = keywords ?? [] + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-1 text-text-dim"> + {keywordList.length} / {tierLimits.maximumMutedKeywords} keywords used + </p> + <p className="mb-2 text-text-dim"> + entries containing muted keywords are hidden from your timeline + </p> + <form onSubmit={handleAddKeyword} className="flex gap-2"> + <input + type="text" + value={newKeyword} + onChange={(event) => setNewKeyword(event.target.value)} + placeholder="keyword to mute" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={addKeyword.isPending || !newKeyword.trim()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + mute + </button> + </form> + </div> + {keywordList.length === 0 ? ( + <p className="px-4 py-6 text-text-dim">no muted keywords</p> + ) : ( + keywordList.map((keyword) => ( + <div + key={keyword.identifier} + className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0" + > + <span className="text-text-primary">{keyword.keyword}</span> + <button + onClick={() => + deleteKeyword.mutate({ + keywordIdentifier: keyword.identifier, + }) + } + disabled={deleteKeyword.isPending} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error disabled:opacity-50" + > + unmute + </button> + </div> + )) + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/security-settings.tsx b/apps/web/app/reader/settings/_components/security-settings.tsx new file mode 100644 index 0000000..4a00241 --- /dev/null +++ b/apps/web/app/reader/settings/_components/security-settings.tsx @@ -0,0 +1,280 @@ +"use client" + +import { useState, useEffect } from "react" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { notify } from "@/lib/notify" +import type { Factor } from "@supabase/supabase-js" + +type EnrollmentState = + | { step: "idle" } + | { step: "enrolling"; factorIdentifier: string; qrCodeSvg: string; otpauthUri: string } + | { step: "verifying"; factorIdentifier: string; challengeIdentifier: string } + +export function SecuritySettings() { + const [enrolledFactors, setEnrolledFactors] = useState<Factor[]>([]) + const [isLoading, setIsLoading] = useState(true) + const [enrollmentState, setEnrollmentState] = useState<EnrollmentState>({ step: "idle" }) + const [factorName, setFactorName] = useState("") + const [verificationCode, setVerificationCode] = useState("") + const [isProcessing, setIsProcessing] = useState(false) + const [unenrollConfirmIdentifier, setUnenrollConfirmIdentifier] = useState<string | null>(null) + const supabaseClient = createSupabaseBrowserClient() + + async function loadFactors() { + const { data, error } = await supabaseClient.auth.mfa.listFactors() + + if (error) { + notify("failed to load MFA factors") + setIsLoading(false) + return + } + + setEnrolledFactors( + data.totp.filter((factor) => factor.status === "verified") + ) + setIsLoading(false) + } + + useEffect(() => { + loadFactors() + }, []) + + async function handleBeginEnrollment() { + setIsProcessing(true) + + const enrollOptions: { factorType: "totp"; friendlyName?: string } = { + factorType: "totp", + } + if (factorName.trim()) { + enrollOptions.friendlyName = factorName.trim() + } + + const { data, error } = await supabaseClient.auth.mfa.enroll(enrollOptions) + + setIsProcessing(false) + + if (error) { + notify("failed to start MFA enrolment: " + error.message) + return + } + + setEnrollmentState({ + step: "enrolling", + factorIdentifier: data.id, + qrCodeSvg: data.totp.qr_code, + otpauthUri: data.totp.uri, + }) + setVerificationCode("") + } + + async function handleVerifyEnrollment() { + if (enrollmentState.step !== "enrolling") return + if (verificationCode.length !== 6) return + + setIsProcessing(true) + + const { data: challengeData, error: challengeError } = + await supabaseClient.auth.mfa.challenge({ + factorId: enrollmentState.factorIdentifier, + }) + + if (challengeError) { + setIsProcessing(false) + notify("failed to create MFA challenge: " + challengeError.message) + return + } + + const { error: verifyError } = await supabaseClient.auth.mfa.verify({ + factorId: enrollmentState.factorIdentifier, + challengeId: challengeData.id, + code: verificationCode, + }) + + setIsProcessing(false) + + if (verifyError) { + notify("invalid code — please try again") + setVerificationCode("") + return + } + + notify("two-factor authentication enabled") + setEnrollmentState({ step: "idle" }) + setVerificationCode("") + setFactorName("") + await supabaseClient.auth.refreshSession() + await loadFactors() + } + + async function handleCancelEnrollment() { + if (enrollmentState.step === "enrolling") { + await supabaseClient.auth.mfa.unenroll({ + factorId: enrollmentState.factorIdentifier, + }) + } + + setEnrollmentState({ step: "idle" }) + setVerificationCode("") + setFactorName("") + } + + async function handleUnenrollFactor(factorIdentifier: string) { + setIsProcessing(true) + + const { error } = await supabaseClient.auth.mfa.unenroll({ + factorId: factorIdentifier, + }) + + setIsProcessing(false) + + if (error) { + notify("failed to remove factor: " + error.message) + return + } + + notify("two-factor authentication removed") + setUnenrollConfirmIdentifier(null) + await supabaseClient.auth.refreshSession() + await loadFactors() + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading security settings ...</p> + } + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">two-factor authentication</h3> + <p className="mb-4 text-text-dim"> + add an extra layer of security to your account with a time-based one-time password (TOTP) authenticator app + </p> + + {enrollmentState.step === "idle" && enrolledFactors.length === 0 && ( + <div className="flex items-center gap-2"> + <input + type="text" + value={factorName} + onChange={(event) => setFactorName(event.target.value)} + placeholder="authenticator name (optional)" + className="w-64 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + onClick={handleBeginEnrollment} + disabled={isProcessing} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isProcessing ? "setting up ..." : "set up"} + </button> + </div> + )} + + {enrollmentState.step === "enrolling" && ( + <div className="space-y-4"> + <p className="text-text-secondary"> + scan this QR code with your authenticator app, then enter the 6-digit code below + </p> + <div className="inline-block bg-white p-4"> + <img + src={enrollmentState.qrCodeSvg} + alt="TOTP QR code" + className="h-48 w-48" + /> + </div> + <details className="text-text-dim"> + <summary className="cursor-pointer transition-colors hover:text-text-secondary"> + can't scan? copy manual entry key + </summary> + <code className="mt-2 block break-all bg-background-secondary p-2 text-text-secondary"> + {enrollmentState.otpauthUri} + </code> + </details> + <div className="flex items-center gap-2"> + <input + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={6} + value={verificationCode} + onChange={(event) => { + const filtered = event.target.value.replace(/\D/g, "") + setVerificationCode(filtered) + }} + placeholder="000000" + className="w-32 border border-border bg-background-primary px-3 py-2 text-center font-mono text-lg tracking-widest text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + autoFocus + onKeyDown={(event) => { + if (event.key === "Enter") handleVerifyEnrollment() + if (event.key === "Escape") handleCancelEnrollment() + }} + /> + <button + onClick={handleVerifyEnrollment} + disabled={isProcessing || verificationCode.length !== 6} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isProcessing ? "verifying ..." : "verify"} + </button> + <button + onClick={handleCancelEnrollment} + className="px-4 py-2 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + )} + + {enrolledFactors.length > 0 && enrollmentState.step === "idle" && ( + <div className="space-y-3"> + {enrolledFactors.map((factor) => ( + <div + key={factor.id} + className="flex items-center justify-between border border-border px-4 py-3" + > + <div> + <span className="text-text-primary"> + {factor.friendly_name || "TOTP authenticator"} + </span> + <span className="ml-2 text-text-dim"> + added{" "} + {new Date(factor.created_at).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + </span> + </div> + {unenrollConfirmIdentifier === factor.id ? ( + <div className="flex items-center gap-2"> + <span className="text-text-dim">remove?</span> + <button + onClick={() => handleUnenrollFactor(factor.id)} + disabled={isProcessing} + className="text-status-error transition-colors hover:text-text-primary disabled:opacity-50" + > + yes + </button> + <button + onClick={() => setUnenrollConfirmIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setUnenrollConfirmIdentifier(factor.id)} + className="text-text-secondary transition-colors hover:text-status-error" + > + remove + </button> + )} + </div> + ))} + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/settings-shell.tsx b/apps/web/app/reader/settings/_components/settings-shell.tsx new file mode 100644 index 0000000..ae432f3 --- /dev/null +++ b/apps/web/app/reader/settings/_components/settings-shell.tsx @@ -0,0 +1,86 @@ +"use client" + +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { SubscriptionsSettings } from "./subscriptions-settings" +import { FoldersSettings } from "./folders-settings" +import { MutedKeywordsSettings } from "./muted-keywords-settings" +import { CustomFeedsSettings } from "./custom-feeds-settings" +import { ImportExportSettings } from "./import-export-settings" +import { AppearanceSettings } from "./appearance-settings" +import { AccountSettings } from "./account-settings" +import { SecuritySettings } from "./security-settings" +import { BillingSettings } from "./billing-settings" +import { ApiSettings } from "./api-settings" +import { DangerZoneSettings } from "./danger-zone-settings" + +const TABS = [ + { key: "subscriptions", label: "subscriptions" }, + { key: "folders", label: "folders" }, + { key: "muted-keywords", label: "muted keywords" }, + { key: "custom-feeds", label: "custom feeds" }, + { key: "import-export", label: "import / export" }, + { key: "appearance", label: "appearance" }, + { key: "account", label: "account" }, + { key: "security", label: "security" }, + { key: "billing", label: "billing" }, + { key: "api", label: "API" }, + { key: "danger", label: "danger zone" }, +] as const + +export function SettingsShell() { + const activeTab = useUserInterfaceStore((state) => state.activeSettingsTab) + const setActiveTab = useUserInterfaceStore( + (state) => state.setActiveSettingsTab + ) + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center border-b border-border px-4 py-3"> + <h1 className="text-text-primary">settings</h1> + </header> + <nav className="border-b border-border"> + <select + value={activeTab} + onChange={(event) => setActiveTab(event.target.value as typeof activeTab)} + className="w-full border-none bg-background-primary px-4 py-2 text-text-primary outline-none md:hidden" + > + {TABS.map((tab) => ( + <option key={tab.key} value={tab.key}> + {tab.label} + </option> + ))} + </select> + <div className="hidden md:flex"> + {TABS.map((tab) => ( + <button + key={tab.key} + onClick={() => setActiveTab(tab.key)} + className={`shrink-0 px-4 py-2 transition-colors ${ + activeTab === tab.key + ? "border-b-2 border-text-primary text-text-primary" + : "text-text-dim hover:text-text-secondary" + }`} + > + {tab.label} + </button> + ))} + </div> + </nav> + <div className="flex-1 overflow-y-auto"> + <div className="max-w-3xl"> + {activeTab === "subscriptions" && <SubscriptionsSettings />} + {activeTab === "folders" && <FoldersSettings />} + {activeTab === "muted-keywords" && <MutedKeywordsSettings />} + {activeTab === "custom-feeds" && <CustomFeedsSettings />} + {activeTab === "import-export" && <ImportExportSettings />} + {activeTab === "appearance" && <AppearanceSettings />} + {activeTab === "account" && <AccountSettings />} + {activeTab === "security" && <SecuritySettings />} + {activeTab === "billing" && <BillingSettings />} + {activeTab === "api" && <ApiSettings />} + {activeTab === "danger" && <DangerZoneSettings />} + </div> + </div> + </div> + ) +} 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> + ) +} diff --git a/apps/web/app/reader/settings/page.tsx b/apps/web/app/reader/settings/page.tsx new file mode 100644 index 0000000..3a49bd7 --- /dev/null +++ b/apps/web/app/reader/settings/page.tsx @@ -0,0 +1,16 @@ +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { redirect } from "next/navigation" +import { SettingsShell } from "./_components/settings-shell" + +export default async function SettingsPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + redirect("/sign-in") + } + + return <SettingsShell /> +} 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> + ) +} diff --git a/apps/web/app/reader/shares/page.tsx b/apps/web/app/reader/shares/page.tsx new file mode 100644 index 0000000..d912b9e --- /dev/null +++ b/apps/web/app/reader/shares/page.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { SharesContent } from "./_components/shares-content" + +export default async function SharesPage() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + redirect("/sign-in") + } + + return <SharesContent /> +} |