diff options
| author | Fuwn <[email protected]> | 2026-02-07 03:26:15 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 03:26:15 -0800 |
| commit | f2a5d1c04b9787bbd9f41af699345be6c0345ca8 (patch) | |
| tree | ffbbacd807f0d3d30efb7110058bd70d6404681e | |
| parent | style: lowercase all user-facing strings and add custom eslint rule (diff) | |
| download | asa.news-f2a5d1c04b9787bbd9f41af699345be6c0345ca8.tar.xz asa.news-f2a5d1c04b9787bbd9f41af699345be6c0345ca8.zip | |
feat: pre-ship polish — UI improvements, keyboard shortcuts, appearance settings
- Rename "muted keywords" to "muted phrases" throughout settings UI
- Add header with navigation to auth pages (sign-in, sign-up, etc.)
- Merge security tab (TOTP setup) into account settings tab
- Fix TOTP name input truncation on Safari (w-64 → flex-1 min-w-0)
- Add appearance settings: font size, time display format, entry images toggle, reading time toggle
- Add keyboard shortcuts dialog (? key) with all keybindings documented
- Add extended vim shortcuts: gg, G, n/N (next/prev unread), Ctrl+h/l (panel focus)
- Add command palette shortcut (⌘K) to shortcuts dialog
- Add icon URL fields for folders and custom feeds (DB + queries + settings UI)
- Add data-has-unreads attribute for sidebar keyboard navigation
- Fix SSR prerendering crash from Zustand persist and react-resizable-panels localStorage access
- Add detail panel layout persistence via useDefaultLayout
- Update marketing copy to advertise vim-like keyboard navigation
30 files changed, 1127 insertions, 452 deletions
diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx index 6707b36..433f464 100644 --- a/apps/web/app/(auth)/layout.tsx +++ b/apps/web/app/(auth)/layout.tsx @@ -1,11 +1,26 @@ +import Link from "next/link" + export default function AuthLayout({ children, }: { children: React.ReactNode }) { return ( - <div className="flex min-h-screen items-center justify-center px-4"> - <div className="w-full max-w-sm space-y-6">{children}</div> + <div className="flex min-h-screen flex-col"> + <header className="flex items-center justify-between border-b border-border px-6 py-3"> + <Link href="/" className="text-text-primary"> + asa.news + </Link> + <Link + href="/" + className="text-text-secondary transition-colors hover:text-text-primary" + > + home + </Link> + </header> + <div className="flex flex-1 items-center justify-center px-4"> + <div className="w-full max-w-sm space-y-6">{children}</div> + </div> </div> ) } diff --git a/apps/web/app/(marketing)/_components/feature-grid.tsx b/apps/web/app/(marketing)/_components/feature-grid.tsx index 607e82f..4a35ca1 100644 --- a/apps/web/app/(marketing)/_components/feature-grid.tsx +++ b/apps/web/app/(marketing)/_components/feature-grid.tsx @@ -1,8 +1,8 @@ const FEATURES = [ { - title: "keyboard shortcuts", + title: "vim-like keyboard shortcuts", description: - "full keyboard shortcut support for power users. works just as well with a mouse or trackpad.", + "vim-like keyboard navigation for power users. also works with a mouse or trackpad.", }, { title: "podcast support", diff --git a/apps/web/app/(marketing)/_components/pricing-table.tsx b/apps/web/app/(marketing)/_components/pricing-table.tsx index c06b4f9..15c2bc6 100644 --- a/apps/web/app/(marketing)/_components/pricing-table.tsx +++ b/apps/web/app/(marketing)/_components/pricing-table.tsx @@ -36,7 +36,7 @@ const COMPARISON_ROWS = [ developer: formatLimit(TIER_LIMITS.developer.historyRetentionDays), }, { - label: "muted keywords", + label: "muted phrases", free: formatLimit(TIER_LIMITS.free.maximumMutedKeywords), pro: formatLimit(TIER_LIMITS.pro.maximumMutedKeywords), developer: formatLimit(TIER_LIMITS.developer.maximumMutedKeywords), diff --git a/apps/web/app/reader/_components/add-feed-dialog.tsx b/apps/web/app/reader/_components/add-feed-dialog.tsx index 4eb119c..ff3e916 100644 --- a/apps/web/app/reader/_components/add-feed-dialog.tsx +++ b/apps/web/app/reader/_components/add-feed-dialog.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useState, useEffect, useRef } 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" @@ -40,6 +40,30 @@ export function AddFeedDialog() { ) } + const dialogReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (!isOpen) return + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + handleClose() + } + } + + document.addEventListener("keydown", handleKeyDown, true) + return () => document.removeEventListener("keydown", handleKeyDown, true) + }, [isOpen]) + + useEffect(() => { + if (isOpen) { + const urlInput = dialogReference.current?.querySelector<HTMLInputElement>("#feed-url") + urlInput?.focus() + } + }, [isOpen]) + if (!isOpen) return null return ( @@ -48,7 +72,7 @@ export function AddFeedDialog() { 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"> + <div ref={dialogReference} 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"> diff --git a/apps/web/app/reader/_components/command-palette.tsx b/apps/web/app/reader/_components/command-palette.tsx index f3ff992..551537a 100644 --- a/apps/web/app/reader/_components/command-palette.tsx +++ b/apps/web/app/reader/_components/command-palette.tsx @@ -192,6 +192,19 @@ export function CommandPalette() { > expanded view </Command.Item> + <Command.Item + onSelect={() => + actionAndClose(() => { + localStorage.removeItem( + "react-resizable-panels:asa-detail-layout" + ) + window.location.reload() + }) + } + className="cursor-pointer px-2 py-1 text-text-secondary aria-selected:bg-background-tertiary aria-selected:text-text-primary" + > + reset panel sizes + </Command.Item> </Command.Group> </Command.List> </Command> diff --git a/apps/web/app/reader/_components/entry-detail-panel.tsx b/apps/web/app/reader/_components/entry-detail-panel.tsx index b823fe7..5825b1e 100644 --- a/apps/web/app/reader/_components/entry-detail-panel.tsx +++ b/apps/web/app/reader/_components/entry-detail-panel.tsx @@ -26,6 +26,7 @@ import { } from "@/lib/highlight-positioning" import { HighlightSelectionToolbar } from "./highlight-selection-toolbar" import { HighlightPopover } from "./highlight-popover" +import { formatDistanceToNow, format } from "date-fns" import { notify } from "@/lib/notify" import type { Highlight } from "@/lib/types/highlight" @@ -61,6 +62,12 @@ export function EntryDetailPanel({ const setSelectedEntryIdentifier = useUserInterfaceStore( (state) => state.setSelectedEntryIdentifier ) + const timeDisplayFormat = useUserInterfaceStore( + (state) => state.timeDisplayFormat + ) + const showReadingTime = useUserInterfaceStore( + (state) => state.showReadingTime + ) const proseContainerReference = useRef<HTMLDivElement>(null) const [selectionToolbarState, setSelectionToolbarState] = useState<{ @@ -392,7 +399,7 @@ export function EntryDetailPanel({ close </button> </div> - <article className="flex-1 overflow-auto px-6 py-4"> + <article data-detail-article className="min-h-0 flex-1 overflow-y-scroll px-6 py-4"> <h2 className="mb-1 text-base text-text-primary"> {entryDetail.title} </h2> @@ -403,7 +410,19 @@ export function EntryDetailPanel({ {entryDetail.author && ( <span> · {entryDetail.author}</span> )} - <span> · {readingTimeMinutes} min read</span> + {entryDetail.published_at && ( + <span> + {" "}·{" "} + {timeDisplayFormat === "absolute" + ? format(new Date(entryDetail.published_at), "MMM d, h:mm a") + : formatDistanceToNow(new Date(entryDetail.published_at), { + addSuffix: true, + })} + </span> + )} + {showReadingTime && ( + <span> · {readingTimeMinutes} min read</span> + )} </div> {entryDetail.enclosure_url && ( <div className="mb-4 border border-border p-3"> diff --git a/apps/web/app/reader/_components/entry-list-item.tsx b/apps/web/app/reader/_components/entry-list-item.tsx index 375b0f5..d192081 100644 --- a/apps/web/app/reader/_components/entry-list-item.tsx +++ b/apps/web/app/reader/_components/entry-list-item.tsx @@ -1,7 +1,8 @@ "use client" -import { formatDistanceToNow } from "date-fns" +import { formatDistanceToNow, format } from "date-fns" import { classNames } from "@/lib/utilities" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import type { TimelineEntry } from "@/lib/types/timeline" import type { VirtualItem } from "@tanstack/react-virtual" @@ -28,8 +29,17 @@ export function EntryListItem({ measureReference, virtualItem, }: EntryListItemProperties) { - const relativeTimestamp = entry.publishedAt - ? formatDistanceToNow(new Date(entry.publishedAt), { addSuffix: true }) + const timeDisplayFormat = useUserInterfaceStore( + (state) => state.timeDisplayFormat + ) + const showEntryImages = useUserInterfaceStore( + (state) => state.showEntryImages + ) + + const formattedTimestamp = entry.publishedAt + ? timeDisplayFormat === "absolute" + ? format(new Date(entry.publishedAt), "MMM d, h:mm a") + : formatDistanceToNow(new Date(entry.publishedAt), { addSuffix: true }) : "" const displayTitle = entry.customTitle ?? entry.feedTitle @@ -60,7 +70,7 @@ export function EntryListItem({ <span className="min-w-0 flex-1 truncate text-text-primary"> {entry.entryTitle} </span> - <span className="shrink-0 text-text-dim">{relativeTimestamp}</span> + <span className="shrink-0 text-text-dim">{formattedTimestamp}</span> </div> )} @@ -79,7 +89,7 @@ export function EntryListItem({ </> )} <span>·</span> - <span>{relativeTimestamp}</span> + <span>{formattedTimestamp}</span> </div> </div> )} @@ -107,10 +117,10 @@ export function EntryListItem({ </> )} <span>·</span> - <span>{relativeTimestamp}</span> + <span>{formattedTimestamp}</span> </div> </div> - {entry.imageUrl && ( + {showEntryImages && entry.imageUrl && ( <img src={entry.imageUrl} alt="" diff --git a/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx b/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx new file mode 100644 index 0000000..139249b --- /dev/null +++ b/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx @@ -0,0 +1,127 @@ +"use client" + +import { useEffect, useRef } from "react" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +const SHORTCUT_SECTIONS = [ + { + title: "navigation", + shortcuts: [ + { keys: ["j", "k"], description: "move down / up in list" }, + { keys: ["Enter"], description: "open selected entry" }, + { keys: ["Ctrl+h", "Ctrl+l"], description: "move focus left / right between panels" }, + { keys: ["g g"], description: "jump to first entry" }, + { keys: ["G"], description: "jump to last entry" }, + { keys: ["n"], description: "next unread entry" }, + { keys: ["N"], description: "previous unread entry" }, + ], + }, + { + title: "actions", + shortcuts: [ + { keys: ["r"], description: "toggle read" }, + { keys: ["s"], description: "toggle save" }, + { keys: ["o"], description: "open original link" }, + { keys: ["Shift+A"], description: "mark all as read" }, + ], + }, + { + title: "views", + shortcuts: [ + { keys: ["1", "2", "3"], description: "compact / comfortable / expanded" }, + { keys: ["b"], description: "toggle sidebar" }, + { keys: ["/"], description: "open search" }, + ], + }, + { + title: "article", + shortcuts: [ + { keys: ["j", "k"], description: "scroll down / up (when detail focused)" }, + ], + }, + { + title: "general", + shortcuts: [ + { keys: ["⌘K"], description: "command palette" }, + { keys: ["?"], description: "show this dialog" }, + { keys: ["Escape"], description: "close dialog / deselect" }, + ], + }, +] + +export function KeyboardShortcutsDialog() { + const isOpen = useUserInterfaceStore((state) => state.isShortcutsDialogOpen) + const setOpen = useUserInterfaceStore( + (state) => state.setShortcutsDialogOpen + ) + const dialogReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (!isOpen) return + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + setOpen(false) + } + } + + document.addEventListener("keydown", handleKeyDown, true) + return () => document.removeEventListener("keydown", handleKeyDown, true) + }, [isOpen, setOpen]) + + if (!isOpen) return null + + return ( + <div + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" + onClick={(event) => { + if (event.target === event.currentTarget) setOpen(false) + }} + > + <div + ref={dialogReference} + className="max-h-[80vh] w-full max-w-lg overflow-y-auto border border-border bg-background-primary p-6" + > + <div className="mb-4 flex items-center justify-between"> + <h2 className="text-text-primary">shortcuts</h2> + <button + type="button" + onClick={() => setOpen(false)} + className="text-text-dim transition-colors hover:text-text-secondary" + > + close + </button> + </div> + {SHORTCUT_SECTIONS.map((section) => ( + <div key={section.title} className="mb-4 last:mb-0"> + <h3 className="mb-2 text-text-dim">{section.title}</h3> + <div className="space-y-1"> + {section.shortcuts.map((shortcut) => ( + <div + key={shortcut.description} + className="flex items-center justify-between py-0.5" + > + <span className="text-text-secondary"> + {shortcut.description} + </span> + <div className="flex gap-1"> + {shortcut.keys.map((key) => ( + <kbd + key={key} + className="border border-border bg-background-secondary px-1.5 py-0.5 font-mono text-[0.75rem] text-text-primary" + > + {key} + </kbd> + ))} + </div> + </div> + ))} + </div> + </div> + ))} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/_components/reader-layout-shell.tsx b/apps/web/app/reader/_components/reader-layout-shell.tsx index 7e0e80b..ab2d195 100644 --- a/apps/web/app/reader/_components/reader-layout-shell.tsx +++ b/apps/web/app/reader/_components/reader-layout-shell.tsx @@ -3,11 +3,13 @@ import { Suspense, useEffect, useState } from "react" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import { classNames } from "@/lib/utilities" +import { useIsMobile } from "@/lib/hooks/use-is-mobile" 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 { KeyboardShortcutsDialog } from "./keyboard-shortcuts-dialog" import { MfaChallenge } from "./mfa-challenge" import { useKeyboardNavigation } from "@/lib/hooks/use-keyboard-navigation" import { createSupabaseBrowserClient } from "@/lib/supabase/client" @@ -41,10 +43,10 @@ export function ReaderLayoutShell({ 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 ) + const isMobile = useIsMobile() useKeyboardNavigation() @@ -137,68 +139,111 @@ export function ReaderLayoutShell({ 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" + {isMobile ? ( + <> + <div + className={classNames( + "fixed inset-0 z-30 bg-black/50 transition-opacity", + !isSidebarCollapsed + ? "pointer-events-auto opacity-100" + : "pointer-events-none opacity-0" + )} onClick={toggleSidebar} - className="px-1 py-0.5 text-lg leading-none text-text-dim transition-colors hover:text-text-secondary" + /> + <aside + data-panel-zone="sidebar" + className={classNames( + "fixed z-40 flex h-full w-64 shrink-0 flex-col border-r border-border bg-background-secondary transition-transform duration-200", + isSidebarCollapsed ? "-translate-x-full" : "translate-x-0" + )} > - × - </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"> + <div className="flex items-center justify-between p-4"> + <h2 className="text-text-primary">asa.news</h2> <button type="button" onClick={toggleSidebar} - className="px-2 py-1 text-lg leading-none text-text-secondary transition-colors hover:text-text-primary" + className="px-1 py-0.5 text-lg leading-none text-text-dim transition-colors hover:text-text-secondary" > - ☰ + × </button> </div> - )} - <div className="flex-1 overflow-hidden">{children}</div> - </div> - </main> + <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> + </> + ) : ( + <> + <aside + data-panel-zone="sidebar" + className={classNames( + "flex h-full w-64 shrink-0 flex-col border-r border-border bg-background-secondary transition-all duration-200", + isSidebarCollapsed ? "w-0 overflow-hidden border-r-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)} /> )} + <KeyboardShortcutsDialog /> </div> ) } diff --git a/apps/web/app/reader/_components/reader-shell.tsx b/apps/web/app/reader/_components/reader-shell.tsx index fe7e4c2..58ede9c 100644 --- a/apps/web/app/reader/_components/reader-shell.tsx +++ b/apps/web/app/reader/_components/reader-shell.tsx @@ -1,6 +1,6 @@ "use client" -import { Group, Panel, Separator } from "react-resizable-panels" +import { Group, Panel, Separator, useDefaultLayout } 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" @@ -47,6 +47,15 @@ export function ReaderShell({ const { data: customFeedsData } = useCustomFeeds() const isMobile = useIsMobile() const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const fontSize = useUserInterfaceStore((state) => state.fontSize) + const toggleShortcutsDialog = useUserInterfaceStore( + (state) => state.toggleShortcutsDialog + ) + + const detailLayout = useDefaultLayout({ + id: "asa-detail-layout", + storage: typeof window !== "undefined" ? localStorage : { getItem: () => null, setItem: () => {} }, + }) useRealtimeEntries() @@ -92,7 +101,10 @@ export function ReaderShell({ const allAreRead = totalUnreadCount === 0 return ( - <div className="flex h-full flex-col"> + <div className={classNames( + "flex h-full flex-col", + fontSize === "small" ? "text-sm" : fontSize === "large" ? "text-lg" : "text-base" + )}> <header className="flex items-center justify-between border-b border-border px-4 py-3"> {isMobile && selectedEntryIdentifier ? ( <button @@ -140,6 +152,13 @@ export function ReaderShell({ <option value="comfortable">comfortable</option> <option value="expanded">expanded</option> </select> + <button + type="button" + onClick={() => toggleShortcutsDialog()} + className="hidden text-text-dim transition-colors hover:text-text-secondary sm:block" + > + shortcuts + </button> </> )} </div> @@ -167,8 +186,13 @@ export function ReaderShell({ </div> ) ) : ( - <Group orientation="horizontal" className="flex-1"> - <Panel defaultSize={selectedEntryIdentifier ? 40 : 100} minSize={25}> + <Group + orientation="horizontal" + className="min-h-0 flex-1" + defaultLayout={detailLayout.defaultLayout} + onLayoutChanged={detailLayout.onLayoutChanged} + > + <Panel id="entry-list" 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" @@ -186,9 +210,9 @@ export function ReaderShell({ {selectedEntryIdentifier && ( <> <Separator className="w-px bg-border transition-colors hover:bg-text-dim" /> - <Panel defaultSize={60} minSize={30}> + <Panel id="detail-panel" defaultSize={60} minSize={30}> <div data-panel-zone="detailPanel" className={classNames( - "h-full", + "h-full overflow-hidden", focusedPanel === "detailPanel" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" )}> <ErrorBoundary> diff --git a/apps/web/app/reader/_components/sidebar-content.tsx b/apps/web/app/reader/_components/sidebar-content.tsx index ee5c873..be59390 100644 --- a/apps/web/app/reader/_components/sidebar-content.tsx +++ b/apps/web/app/reader/_components/sidebar-content.tsx @@ -96,7 +96,6 @@ export function SidebarContent() { const focusedSidebarIndex = useUserInterfaceStore( (state) => state.focusedSidebarIndex ) - function closeSidebarOnMobile() { if (typeof window !== "undefined" && window.innerWidth < 768) { toggleSidebar() @@ -138,6 +137,7 @@ export function SidebarContent() { <Link href="/reader" data-sidebar-nav-item + {...(totalUnreadCount > 0 ? { "data-has-unreads": "" } : {})} onClick={closeSidebarOnMobile} className={classNames( NAVIGATION_LINK_CLASS, @@ -189,7 +189,6 @@ export function SidebarContent() { > shares </Link> - {customFeedsData && customFeedsData.length > 0 && ( <div className="mt-3 space-y-0.5"> {customFeedsData.map((customFeed) => ( @@ -200,13 +199,16 @@ export function SidebarContent() { onClick={closeSidebarOnMobile} className={classNames( NAVIGATION_LINK_CLASS, - "truncate pl-4 text-[0.85em]", + "flex items-center gap-2 truncate pl-4 text-[0.85em]", activeCustomFeedIdentifier === customFeed.identifier && ACTIVE_LINK_CLASS, sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) )} > - {customFeed.name} + {customFeed.iconUrl && ( + <img src={customFeed.iconUrl} alt="" width={16} height={16} className="shrink-0" loading="lazy" /> + )} + <span className="truncate">{customFeed.name}</span> </Link> ))} </div> @@ -219,6 +221,7 @@ export function SidebarContent() { key={subscription.subscriptionIdentifier} href={`/reader?feed=${subscription.feedIdentifier}`} data-sidebar-nav-item + {...((unreadCounts?.[subscription.feedIdentifier] ?? 0) > 0 ? { "data-has-unreads": "" } : {})} onClick={closeSidebarOnMobile} className={classNames( NAVIGATION_LINK_CLASS, @@ -268,6 +271,7 @@ export function SidebarContent() { <div key={folder.folderIdentifier} className="mt-2"> <div data-sidebar-nav-item + {...(folderUnreadCount > 0 ? { "data-has-unreads": "" } : {})} className={classNames( "flex w-full items-center gap-1 px-2 py-1", sidebarFocusClass(focusedPanel, focusedSidebarIndex, folderNavIndex) @@ -286,12 +290,15 @@ export function SidebarContent() { href={`/reader?folder=${folder.folderIdentifier}`} onClick={closeSidebarOnMobile} className={classNames( - "flex-1 truncate text-text-secondary transition-colors hover:text-text-primary", + "flex flex-1 items-center gap-2 truncate text-text-secondary transition-colors hover:text-text-primary", activeFolderIdentifier === folder.folderIdentifier && "text-text-primary" )} > - {folder.name} + {folder.iconUrl && ( + <img src={folder.iconUrl} alt="" width={16} height={16} className="shrink-0" loading="lazy" /> + )} + <span className="truncate">{folder.name}</span> </Link> <UnreadBadge count={folderUnreadCount} /> </div> @@ -302,6 +309,7 @@ export function SidebarContent() { key={subscription.subscriptionIdentifier} href={`/reader?feed=${subscription.feedIdentifier}`} data-sidebar-nav-item + {...((unreadCounts?.[subscription.feedIdentifier] ?? 0) > 0 ? { "data-has-unreads": "" } : {})} onClick={closeSidebarOnMobile} className={classNames( NAVIGATION_LINK_CLASS, @@ -338,7 +346,7 @@ export function SidebarContent() { ) })} - <div className="mt-3"> + <div className="mt-3 space-y-0.5"> <button type="button" data-sidebar-nav-item diff --git a/apps/web/app/reader/actions.ts b/apps/web/app/reader/actions.ts index efcc1ec..2d7e520 100644 --- a/apps/web/app/reader/actions.ts +++ b/apps/web/app/reader/actions.ts @@ -6,5 +6,5 @@ import { createSupabaseServerClient } from "@/lib/supabase/server" export async function signOut() { const supabaseClient = await createSupabaseServerClient() await supabaseClient.auth.signOut() - redirect("/sign-in") + redirect("/") } diff --git a/apps/web/app/reader/settings/_components/account-settings.tsx b/apps/web/app/reader/settings/_components/account-settings.tsx index ccb09dd..953f4a6 100644 --- a/apps/web/app/reader/settings/_components/account-settings.tsx +++ b/apps/web/app/reader/settings/_components/account-settings.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import { useRouter } from "next/navigation" import { useMutation, useQueryClient } from "@tanstack/react-query" import { createSupabaseBrowserClient } from "@/lib/supabase/client" @@ -8,6 +8,11 @@ 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" +import type { Factor } from "@supabase/supabase-js" + +type EnrollmentState = + | { step: "idle" } + | { step: "enrolling"; factorIdentifier: string; qrCodeSvg: string; otpauthUri: string } export function AccountSettings() { const { data: userProfile, isLoading } = useUserProfile() @@ -19,6 +24,13 @@ export function AccountSettings() { const [currentPassword, setCurrentPassword] = useState("") const [newPassword, setNewPassword] = useState("") const [confirmNewPassword, setConfirmNewPassword] = useState("") + const [enrolledFactors, setEnrolledFactors] = useState<Factor[]>([]) + const [isTotpLoading, setIsTotpLoading] = useState(true) + const [enrollmentState, setEnrollmentState] = useState<EnrollmentState>({ step: "idle" }) + const [factorName, setFactorName] = useState("") + const [verificationCode, setVerificationCode] = useState("") + const [isTotpProcessing, setIsTotpProcessing] = useState(false) + const [unenrollConfirmIdentifier, setUnenrollConfirmIdentifier] = useState<string | null>(null) const supabaseClient = createSupabaseBrowserClient() const queryClient = useQueryClient() const router = useRouter() @@ -124,6 +136,124 @@ export function AccountSettings() { }, }) + async function loadFactors() { + const { data, error } = await supabaseClient.auth.mfa.listFactors() + + if (error) { + notify("failed to load mfa factors") + setIsTotpLoading(false) + return + } + + setEnrolledFactors( + data.totp.filter((factor) => factor.status === "verified") + ) + setIsTotpLoading(false) + } + + useEffect(() => { + loadFactors() + }, []) + + async function handleBeginEnrollment() { + setIsTotpProcessing(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) + + setIsTotpProcessing(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 + + setIsTotpProcessing(true) + + const { data: challengeData, error: challengeError } = + await supabaseClient.auth.mfa.challenge({ + factorId: enrollmentState.factorIdentifier, + }) + + if (challengeError) { + setIsTotpProcessing(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, + }) + + setIsTotpProcessing(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) { + setIsTotpProcessing(true) + + const { error } = await supabaseClient.auth.mfa.unenroll({ + factorId: factorIdentifier, + }) + + setIsTotpProcessing(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 account ...</p> } @@ -296,6 +426,143 @@ export function AccountSettings() { </form> </div> <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> + + {isTotpLoading ? ( + <p className="text-text-dim">loading ...</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="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 + onClick={handleBeginEnrollment} + disabled={isTotpProcessing} + className="shrink-0 border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isTotpProcessing ? "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={isTotpProcessing || 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" + > + {isTotpProcessing ? "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={isTotpProcessing} + 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 className="mb-6"> <h3 className="mb-2 text-text-primary">usage</h3> <div className="space-y-1"> <UsageRow @@ -309,7 +576,7 @@ export function AccountSettings() { maximum={tierLimits.maximumFolders} /> <UsageRow - label="muted keywords" + label="muted phrases" current={userProfile.mutedKeywordCount} maximum={tierLimits.maximumMutedKeywords} /> diff --git a/apps/web/app/reader/settings/_components/appearance-settings.tsx b/apps/web/app/reader/settings/_components/appearance-settings.tsx index 9c0e214..6c04f00 100644 --- a/apps/web/app/reader/settings/_components/appearance-settings.tsx +++ b/apps/web/app/reader/settings/_components/appearance-settings.tsx @@ -29,7 +29,26 @@ export function AppearanceSettings() { const setFocusFollowsInteraction = useUserInterfaceStore( (state) => state.setFocusFollowsInteraction ) - + const fontSize = useUserInterfaceStore((state) => state.fontSize) + const setFontSize = useUserInterfaceStore((state) => state.setFontSize) + const timeDisplayFormat = useUserInterfaceStore( + (state) => state.timeDisplayFormat + ) + const setTimeDisplayFormat = useUserInterfaceStore( + (state) => state.setTimeDisplayFormat + ) + const showEntryImages = useUserInterfaceStore( + (state) => state.showEntryImages + ) + const setShowEntryImages = useUserInterfaceStore( + (state) => state.setShowEntryImages + ) + const showReadingTime = useUserInterfaceStore( + (state) => state.showReadingTime + ) + const setShowReadingTime = useUserInterfaceStore( + (state) => state.setShowReadingTime + ) return ( <div className="px-4 py-3"> <div className="mb-6"> @@ -100,7 +119,7 @@ export function AppearanceSettings() { <span>show favicons</span> </label> </div> - <div> + <div className="mb-6"> <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 @@ -118,6 +137,72 @@ export function AppearanceSettings() { <span>enable focus follows interaction</span> </label> </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">font size</h3> + <p className="mb-3 text-text-dim"> + controls the base text size in the reader + </p> + <select + value={fontSize} + onChange={(event) => + setFontSize(event.target.value as "small" | "default" | "large") + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="small">small</option> + <option value="default">default</option> + <option value="large">large</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">time display</h3> + <p className="mb-3 text-text-dim"> + choose between relative timestamps (e.g. “2h ago”) or + absolute dates + </p> + <select + value={timeDisplayFormat} + onChange={(event) => + setTimeDisplayFormat( + event.target.value as "relative" | "absolute" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="relative">relative</option> + <option value="absolute">absolute</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">entry images</h3> + <p className="mb-3 text-text-dim"> + show thumbnail images next to entries in the expanded view + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={showEntryImages} + onChange={(event) => setShowEntryImages(event.target.checked)} + className="accent-text-primary" + /> + <span>show entry images</span> + </label> + </div> + <div> + <h3 className="mb-2 text-text-primary">reading time</h3> + <p className="mb-3 text-text-dim"> + display estimated reading time for articles + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={showReadingTime} + onChange={(event) => setShowReadingTime(event.target.checked)} + className="accent-text-primary" + /> + <span>show reading time</span> + </label> + </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 index b7b588b..2db2c7c 100644 --- a/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx +++ b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx @@ -135,6 +135,7 @@ function CustomFeedRow({ query: string matchMode: "and" | "or" sourceFolderIdentifier: string | null + iconUrl: string | null } folders: { folderIdentifier: string; name: string }[] }) { @@ -147,6 +148,9 @@ function CustomFeedRow({ const [editedSourceFolderId, setEditedSourceFolderId] = useState( customFeed.sourceFolderIdentifier ?? "" ) + const [editedIconUrl, setEditedIconUrl] = useState( + customFeed.iconUrl ?? "" + ) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) function handleSave() { @@ -161,6 +165,7 @@ function CustomFeedRow({ query: trimmedKeywords, matchMode: editedMatchMode, sourceFolderIdentifier: editedSourceFolderId || null, + iconUrl: editedIconUrl.trim() || null, }) setIsEditing(false) } @@ -216,6 +221,13 @@ function CustomFeedRow({ ))} </select> </div> + <input + type="text" + value={editedIconUrl} + onChange={(event) => setEditedIconUrl(event.target.value)} + placeholder="icon url (optional)" + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> <div className="flex gap-2"> <button onClick={handleSave} diff --git a/apps/web/app/reader/settings/_components/folders-settings.tsx b/apps/web/app/reader/settings/_components/folders-settings.tsx index 8a0012e..2c3d5f2 100644 --- a/apps/web/app/reader/settings/_components/folders-settings.tsx +++ b/apps/web/app/reader/settings/_components/folders-settings.tsx @@ -92,11 +92,13 @@ export function FoldersSettings() { key={folder.folderIdentifier} folderIdentifier={folder.folderIdentifier} name={folder.name} + iconUrl={folder.iconUrl} feedCount={feedCountForFolder(folder.folderIdentifier)} - onRename={(name) => + onSave={(name, iconUrl) => renameFolder.mutate({ folderIdentifier: folder.folderIdentifier, name, + iconUrl, }) } onDelete={() => @@ -115,25 +117,28 @@ export function FoldersSettings() { function FolderRow({ folderIdentifier, name, + iconUrl, feedCount, - onRename, + onSave, onDelete, }: { folderIdentifier: string name: string + iconUrl: string | null feedCount: number - onRename: (name: string) => void + onSave: (name: string, iconUrl: string | null) => void onDelete: () => void }) { const [isEditing, setIsEditing] = useState(false) const [editedName, setEditedName] = useState(name) + const [editedIconUrl, setEditedIconUrl] = useState(iconUrl ?? "") const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) function handleSave() { const trimmedName = editedName.trim() - if (trimmedName && trimmedName !== name) { - onRename(trimmedName) + if (trimmedName) { + onSave(trimmedName, editedIconUrl.trim() || null) } setIsEditing(false) @@ -143,36 +148,51 @@ function FolderRow({ <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"> + <div className="space-y-2"> + <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 + /> + </div> <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 + value={editedIconUrl} + onChange={(event) => setEditedIconUrl(event.target.value)} + placeholder="icon url (optional)" + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" /> - <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 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={() => { + setEditedName(name) + setEditedIconUrl(iconUrl ?? "") + setIsEditing(false) + }} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> </div> ) : ( <div className="flex items-center gap-2"> + {iconUrl && ( + <img src={iconUrl} alt="" width={16} height={16} className="shrink-0" /> + )} <span className="text-text-primary">{name}</span> <span className="text-text-dim"> ({feedCount} feed{feedCount !== 1 && "s"}) @@ -181,7 +201,7 @@ function FolderRow({ onClick={() => setIsEditing(true)} className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" > - rename + edit </button> </div> )} diff --git a/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx b/apps/web/app/reader/settings/_components/muted-phrases-settings.tsx index bef4786..fc151a7 100644 --- a/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx +++ b/apps/web/app/reader/settings/_components/muted-phrases-settings.tsx @@ -9,74 +9,74 @@ import { 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() +export function MutedPhrasesSettings() { + const [newPhrase, setNewPhrase] = useState("") + const { data: phrases, isLoading } = useMutedKeywords() const { data: userProfile } = useUserProfile() - const addKeyword = useAddMutedKeyword() - const deleteKeyword = useDeleteMutedKeyword() + const addPhrase = useAddMutedKeyword() + const deletePhrase = useDeleteMutedKeyword() const tier = userProfile?.tier ?? "free" const tierLimits = TIER_LIMITS[tier] - function handleAddKeyword(event: React.FormEvent) { + function handleAddPhrase(event: React.FormEvent) { event.preventDefault() - const trimmedKeyword = newKeyword.trim() + const trimmedPhrase = newPhrase.trim() - if (!trimmedKeyword) return + if (!trimmedPhrase) return - addKeyword.mutate({ keyword: trimmedKeyword }) - setNewKeyword("") + addPhrase.mutate({ keyword: trimmedPhrase }) + setNewPhrase("") } if (isLoading) { - return <p className="px-4 py-6 text-text-dim">loading muted keywords...</p> + return <p className="px-4 py-6 text-text-dim">loading muted phrases ...</p> } - const keywordList = keywords ?? [] + const phraseList = phrases ?? [] 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 + {phraseList.length} / {tierLimits.maximumMutedKeywords} phrases used </p> <p className="mb-2 text-text-dim"> - entries containing muted keywords are hidden from your timeline + entries containing muted phrases are hidden from your timeline </p> - <form onSubmit={handleAddKeyword} className="flex gap-2"> + <form onSubmit={handleAddPhrase} className="flex gap-2"> <input type="text" - value={newKeyword} - onChange={(event) => setNewKeyword(event.target.value)} - placeholder="keyword to mute" + value={newPhrase} + onChange={(event) => setNewPhrase(event.target.value)} + placeholder="phrase 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()} + disabled={addPhrase.isPending || !newPhrase.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> + {phraseList.length === 0 ? ( + <p className="px-4 py-6 text-text-dim">no muted phrases</p> ) : ( - keywordList.map((keyword) => ( + phraseList.map((phrase) => ( <div - key={keyword.identifier} + key={phrase.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> + <span className="text-text-primary">{phrase.keyword}</span> <button onClick={() => - deleteKeyword.mutate({ - keywordIdentifier: keyword.identifier, + deletePhrase.mutate({ + keywordIdentifier: phrase.identifier, }) } - disabled={deleteKeyword.isPending} + disabled={deletePhrase.isPending} className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error disabled:opacity-50" > unmute diff --git a/apps/web/app/reader/settings/_components/security-settings.tsx b/apps/web/app/reader/settings/_components/security-settings.tsx deleted file mode 100644 index 32c84c4..0000000 --- a/apps/web/app/reader/settings/_components/security-settings.tsx +++ /dev/null @@ -1,280 +0,0 @@ -"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 index 3c25281..4153fc4 100644 --- a/apps/web/app/reader/settings/_components/settings-shell.tsx +++ b/apps/web/app/reader/settings/_components/settings-shell.tsx @@ -3,12 +3,11 @@ 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 { MutedPhrasesSettings } from "./muted-phrases-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" @@ -16,12 +15,11 @@ import { DangerZoneSettings } from "./danger-zone-settings" const TABS = [ { key: "subscriptions", label: "subscriptions" }, { key: "folders", label: "folders" }, - { key: "muted-keywords", label: "muted keywords" }, + { key: "muted-phrases", label: "muted phrases" }, { 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" }, @@ -70,12 +68,11 @@ export function SettingsShell() { <div className="max-w-3xl"> {activeTab === "subscriptions" && <SubscriptionsSettings />} {activeTab === "folders" && <FoldersSettings />} - {activeTab === "muted-keywords" && <MutedKeywordsSettings />} + {activeTab === "muted-phrases" && <MutedPhrasesSettings />} {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 />} diff --git a/apps/web/eslint-rules/no-comments.mjs b/apps/web/eslint-rules/no-comments.mjs new file mode 100644 index 0000000..7efafae --- /dev/null +++ b/apps/web/eslint-rules/no-comments.mjs @@ -0,0 +1,66 @@ +const DIRECTIVE_PATTERNS = [ + /^\s*eslint-disable/, + /^\s*eslint-enable/, + /^\s*eslint-disable-next-line/, + /^\s*eslint-disable-line/, + /^\s*@ts-ignore/, + /^\s*@ts-expect-error/, + /^\s*@ts-nocheck/, + /^\s*@ts-check/, + /^\s*@type\s/, + /^\s*@param\s/, + /^\s*@returns?\s/, + /^\s*@typedef\s/, + /^\s*prettier-ignore/, + /^\s*webpackChunkName/, +] + +function isDirectiveComment(value) { + return DIRECTIVE_PATTERNS.some((pattern) => pattern.test(value)) +} + +const rule = { + meta: { + type: "suggestion", + docs: { + description: "disallow comments in favour of self-documenting code", + }, + messages: { + noComment: + "avoid comments — code should be self-documenting. refactor to make the intent clear from the code itself.", + }, + schema: [], + }, + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode() + + return { + Program() { + for (const comment of sourceCode.getAllComments()) { + const value = comment.value.trim() + + if (!value) continue + + if (isDirectiveComment(value)) continue + + context.report({ + loc: comment.loc, + messageId: "noComment", + }) + } + }, + } + }, +} + +const plugin = { + meta: { + name: "asa-no-comments", + version: "1.0.0", + }, + rules: { + "no-comments": rule, + }, +} + +export default plugin diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 0ca21d3..7bf5713 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -2,6 +2,7 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; import asaLowercase from "./eslint-rules/lowercase-strings.mjs"; +import asaNoComments from "./eslint-rules/no-comments.mjs"; const eslintConfig = defineConfig([ ...nextVitals, @@ -15,9 +16,11 @@ const eslintConfig = defineConfig([ { plugins: { "asa-lowercase": asaLowercase, + "asa-no-comments": asaNoComments, }, rules: { "asa-lowercase/lowercase-strings": "warn", + "asa-no-comments/no-comments": "warn", }, }, ]); diff --git a/apps/web/lib/hooks/use-keyboard-navigation.ts b/apps/web/lib/hooks/use-keyboard-navigation.ts index c4b3f5f..24a4761 100644 --- a/apps/web/lib/hooks/use-keyboard-navigation.ts +++ b/apps/web/lib/hooks/use-keyboard-navigation.ts @@ -1,6 +1,6 @@ "use client" -import { useEffect } from "react" +import { useEffect, useRef } from "react" import { useQueryClient } from "@tanstack/react-query" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import { @@ -88,9 +88,19 @@ export function useKeyboardNavigation() { const navigableEntryIdentifiers = useUserInterfaceStore( (state) => state.navigableEntryIdentifiers ) + const isAddFeedDialogOpen = useUserInterfaceStore( + (state) => state.isAddFeedDialogOpen + ) + const isShortcutsDialogOpen = useUserInterfaceStore( + (state) => state.isShortcutsDialogOpen + ) + const toggleShortcutsDialog = useUserInterfaceStore( + (state) => state.toggleShortcutsDialog + ) const toggleReadState = useToggleEntryReadState() const toggleSavedState = useToggleEntrySavedState() const markAllAsRead = useMarkAllAsRead() + const pendingGKeyTimestamp = useRef<number>(0) useEffect(() => { function handleKeyDown(event: KeyboardEvent) { @@ -105,6 +115,8 @@ export function useKeyboardNavigation() { return } + if (isShortcutsDialogOpen || isAddFeedDialogOpen) return + if ((isCommandPaletteOpen || isSearchOpen) && event.key !== "Escape") return if (event.ctrlKey) { @@ -281,29 +293,101 @@ export function useKeyboardNavigation() { break } + case "?": { + event.preventDefault() + toggleShortcutsDialog() + + break + } + case "g": { + const now = Date.now() + if (now - pendingGKeyTimestamp.current < 500) { + pendingGKeyTimestamp.current = 0 + if (navigableEntryIdentifiers.length > 0) { + setFocusedEntryIdentifier(navigableEntryIdentifiers[0]) + } + } else { + pendingGKeyTimestamp.current = now + } + + break + } + case "G": { + if (event.shiftKey && navigableEntryIdentifiers.length > 0) { + event.preventDefault() + setFocusedEntryIdentifier( + navigableEntryIdentifiers[navigableEntryIdentifiers.length - 1] + ) + } + + break + } + case "n": { + if (navigableEntryIdentifiers.length === 0) break + + const startIndex = currentIndex === -1 ? 0 : currentIndex + 1 + for ( + let i = startIndex; + i < navigableEntryIdentifiers.length; + i++ + ) { + const entry = findEntryInCache( + queryClient, + navigableEntryIdentifiers[i] + ) + if (entry && !entry.isRead) { + setFocusedEntryIdentifier(navigableEntryIdentifiers[i]) + break + } + } + + break + } + case "N": { + if (event.shiftKey && navigableEntryIdentifiers.length > 0) { + const startIndex = + currentIndex === -1 + ? navigableEntryIdentifiers.length - 1 + : currentIndex - 1 + for (let i = startIndex; i >= 0; i--) { + const entry = findEntryInCache( + queryClient, + navigableEntryIdentifiers[i] + ) + if (entry && !entry.isRead) { + setFocusedEntryIdentifier(navigableEntryIdentifiers[i]) + break + } + } + } + + break + } } } function handleDetailPanelKeyDown(event: KeyboardEvent) { const SCROLL_AMOUNT = 100 + const detailArticle = document.querySelector<HTMLElement>( + "[data-detail-article]" + ) switch (event.key) { case "j": case "ArrowDown": { event.preventDefault() - const detailArticle = document.querySelector( - "[data-detail-panel] article" - ) - detailArticle?.scrollBy({ top: SCROLL_AMOUNT, behavior: "smooth" }) + if (detailArticle) detailArticle.scrollTop += SCROLL_AMOUNT break } case "k": case "ArrowUp": { event.preventDefault() - const detailArticle = document.querySelector( - "[data-detail-panel] article" - ) - detailArticle?.scrollBy({ top: -SCROLL_AMOUNT, behavior: "smooth" }) + if (detailArticle) detailArticle.scrollTop -= SCROLL_AMOUNT + break + } + case "?": { + event.preventDefault() + toggleShortcutsDialog() break } case "Escape": { @@ -338,6 +422,74 @@ export function useKeyboardNavigation() { sidebarLinks[previousIndex]?.scrollIntoView({ block: "nearest" }) break } + case "g": { + const now = Date.now() + if (now - pendingGKeyTimestamp.current < 500) { + pendingGKeyTimestamp.current = 0 + setFocusedSidebarIndex(0) + sidebarLinks[0]?.scrollIntoView({ block: "nearest" }) + } else { + pendingGKeyTimestamp.current = now + } + break + } + case "G": { + if (event.shiftKey) { + event.preventDefault() + const lastIndex = itemCount - 1 + setFocusedSidebarIndex(lastIndex) + sidebarLinks[lastIndex]?.scrollIntoView({ block: "nearest" }) + } + break + } + case "n": { + const unreadItems = document.querySelectorAll<HTMLElement>( + "[data-sidebar-nav-item][data-has-unreads]" + ) + if (unreadItems.length === 0) break + const allItems = document.querySelectorAll<HTMLElement>( + "[data-sidebar-nav-item]" + ) + const allIndexes = Array.from(allItems) + const unreadIndexes = Array.from(unreadItems).map((element) => + allIndexes.indexOf(element) + ) + const nextUnread = unreadIndexes.find( + (index) => index > focusedSidebarIndex + ) + if (nextUnread !== undefined) { + setFocusedSidebarIndex(nextUnread) + allItems[nextUnread]?.scrollIntoView({ block: "nearest" }) + } + break + } + case "N": { + if (!event.shiftKey) break + const unreadItems = document.querySelectorAll<HTMLElement>( + "[data-sidebar-nav-item][data-has-unreads]" + ) + if (unreadItems.length === 0) break + const allItems = document.querySelectorAll<HTMLElement>( + "[data-sidebar-nav-item]" + ) + const allIndexes = Array.from(allItems) + const unreadIndexes = Array.from(unreadItems).map((element) => + allIndexes.indexOf(element) + ) + const previousUnread = unreadIndexes + .filter((index) => index < focusedSidebarIndex) + .pop() + if (previousUnread !== undefined) { + setFocusedSidebarIndex(previousUnread) + allItems[previousUnread]?.scrollIntoView({ block: "nearest" }) + } + break + } + case "?": { + event.preventDefault() + toggleShortcutsDialog() + break + } case "Enter": { event.preventDefault() sidebarLinks[focusedSidebarIndex]?.click() @@ -359,8 +511,10 @@ export function useKeyboardNavigation() { focusedEntryIdentifier, focusedPanel, focusedSidebarIndex, + isAddFeedDialogOpen, isCommandPaletteOpen, isSearchOpen, + isShortcutsDialogOpen, isSidebarCollapsed, navigableEntryIdentifiers, queryClient, @@ -376,5 +530,6 @@ export function useKeyboardNavigation() { toggleReadState, toggleSavedState, markAllAsRead, + toggleShortcutsDialog, ]) } diff --git a/apps/web/lib/queries/use-custom-feed-mutations.ts b/apps/web/lib/queries/use-custom-feed-mutations.ts index ad6b328..0afa19c 100644 --- a/apps/web/lib/queries/use-custom-feed-mutations.ts +++ b/apps/web/lib/queries/use-custom-feed-mutations.ts @@ -64,21 +64,26 @@ export function useUpdateCustomFeed() { query, matchMode, sourceFolderIdentifier, + iconUrl, }: { customFeedIdentifier: string name: string query: string matchMode: "and" | "or" sourceFolderIdentifier: string | null + iconUrl?: string | null }) => { + const updatePayload: Record<string, unknown> = { + name, + query, + match_mode: matchMode, + source_folder_id: sourceFolderIdentifier, + } + if (iconUrl !== undefined) updatePayload.icon_url = iconUrl + const { error } = await supabaseClient .from("custom_feeds") - .update({ - name, - query, - match_mode: matchMode, - source_folder_id: sourceFolderIdentifier, - }) + .update(updatePayload) .eq("id", customFeedIdentifier) if (error) throw error diff --git a/apps/web/lib/queries/use-custom-feeds.ts b/apps/web/lib/queries/use-custom-feeds.ts index a93e431..f2918b5 100644 --- a/apps/web/lib/queries/use-custom-feeds.ts +++ b/apps/web/lib/queries/use-custom-feeds.ts @@ -12,6 +12,7 @@ interface CustomFeedRow { match_mode: string source_folder_id: string | null position: number + icon_url: string | null } export function useCustomFeeds() { @@ -28,7 +29,7 @@ export function useCustomFeeds() { const { data, error } = await supabaseClient .from("custom_feeds") - .select("id, name, query, match_mode, source_folder_id, position") + .select("id, name, query, match_mode, source_folder_id, position, icon_url") .eq("user_id", user.id) .order("position") @@ -42,6 +43,7 @@ export function useCustomFeeds() { matchMode: row.match_mode as "and" | "or", sourceFolderIdentifier: row.source_folder_id, position: row.position, + iconUrl: row.icon_url, }) ) }, diff --git a/apps/web/lib/queries/use-folder-mutations.ts b/apps/web/lib/queries/use-folder-mutations.ts index 642bd96..4bc1247 100644 --- a/apps/web/lib/queries/use-folder-mutations.ts +++ b/apps/web/lib/queries/use-folder-mutations.ts @@ -82,13 +82,18 @@ export function useRenameFolder() { mutationFn: async ({ folderIdentifier, name, + iconUrl, }: { folderIdentifier: string name: string + iconUrl?: string | null }) => { + const updatePayload: Record<string, unknown> = { name } + if (iconUrl !== undefined) updatePayload.icon_url = iconUrl + const { error } = await supabaseClient .from("folders") - .update({ name }) + .update(updatePayload) .eq("id", folderIdentifier) if (error) throw error diff --git a/apps/web/lib/queries/use-muted-keyword-mutations.ts b/apps/web/lib/queries/use-muted-keyword-mutations.ts index de4e03f..0b92dbd 100644 --- a/apps/web/lib/queries/use-muted-keyword-mutations.ts +++ b/apps/web/lib/queries/use-muted-keyword-mutations.ts @@ -28,12 +28,12 @@ export function useAddMutedKeyword() { queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all }) queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) - notify("keyword muted") + notify("phrase muted") }, onError: (error: Error) => { notify(error.message.includes("limit") - ? "muted keyword limit reached for your plan" - : "failed to mute keyword: " + error.message) + ? "muted phrase limit reached for your plan" + : "failed to mute phrase: " + error.message) }, }) } @@ -59,7 +59,7 @@ export function useDeleteMutedKeyword() { queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all }) queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) - notify("keyword unmuted") + notify("phrase unmuted") }, onError: (error: Error) => { notify("failed to unmute keyword: " + error.message) diff --git a/apps/web/lib/queries/use-subscriptions.ts b/apps/web/lib/queries/use-subscriptions.ts index ebf099d..e6b84ef 100644 --- a/apps/web/lib/queries/use-subscriptions.ts +++ b/apps/web/lib/queries/use-subscriptions.ts @@ -26,6 +26,7 @@ interface FolderRow { id: string name: string position: number + icon_url: string | null } export function useSubscriptions() { @@ -41,7 +42,7 @@ export function useSubscriptions() { .order("position", { ascending: true }), supabaseClient .from("folders") - .select("id, name, position") + .select("id, name, position, icon_url") .order("position", { ascending: true }), ]) @@ -70,6 +71,7 @@ export function useSubscriptions() { folderIdentifier: row.id, name: row.name, position: row.position, + iconUrl: row.icon_url, })) return { subscriptions, folders } diff --git a/apps/web/lib/stores/user-interface-store.ts b/apps/web/lib/stores/user-interface-store.ts index 468542d..5890167 100644 --- a/apps/web/lib/stores/user-interface-store.ts +++ b/apps/web/lib/stores/user-interface-store.ts @@ -1,21 +1,24 @@ import { create } from "zustand" -import { persist } from "zustand/middleware" +import { persist, createJSONStorage } from "zustand/middleware" type EntryListViewMode = "compact" | "comfortable" | "expanded" type DisplayDensity = "compact" | "default" | "spacious" +type FontSize = "small" | "default" | "large" + +type TimeDisplayFormat = "relative" | "absolute" + type FocusedPanel = "sidebar" | "entryList" | "detailPanel" type SettingsTab = | "subscriptions" | "folders" - | "muted-keywords" + | "muted-phrases" | "custom-feeds" | "import-export" | "appearance" | "account" - | "security" | "billing" | "api" | "danger" @@ -34,6 +37,11 @@ interface UserInterfaceState { activeSettingsTab: SettingsTab showFeedFavicons: boolean focusFollowsInteraction: boolean + fontSize: FontSize + timeDisplayFormat: TimeDisplayFormat + showEntryImages: boolean + showReadingTime: boolean + isShortcutsDialogOpen: boolean expandedFolderIdentifiers: string[] navigableEntryIdentifiers: string[] @@ -51,6 +59,12 @@ interface UserInterfaceState { setActiveSettingsTab: (tab: SettingsTab) => void setShowFeedFavicons: (show: boolean) => void setFocusFollowsInteraction: (enabled: boolean) => void + setFontSize: (size: FontSize) => void + setTimeDisplayFormat: (format: TimeDisplayFormat) => void + setShowEntryImages: (show: boolean) => void + setShowReadingTime: (show: boolean) => void + setShortcutsDialogOpen: (isOpen: boolean) => void + toggleShortcutsDialog: () => void toggleFolderExpansion: (folderIdentifier: string) => void setNavigableEntryIdentifiers: (identifiers: string[]) => void } @@ -71,6 +85,11 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( activeSettingsTab: "subscriptions", showFeedFavicons: true, focusFollowsInteraction: false, + fontSize: "default", + timeDisplayFormat: "relative", + showEntryImages: true, + showReadingTime: true, + isShortcutsDialogOpen: false, expandedFolderIdentifiers: [], navigableEntryIdentifiers: [], @@ -107,6 +126,22 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( setFocusFollowsInteraction: (enabled) => set({ focusFollowsInteraction: enabled }), + setFontSize: (size) => set({ fontSize: size }), + + setTimeDisplayFormat: (format) => set({ timeDisplayFormat: format }), + + setShowEntryImages: (show) => set({ showEntryImages: show }), + + setShowReadingTime: (show) => set({ showReadingTime: show }), + + setShortcutsDialogOpen: (isOpen) => + set({ isShortcutsDialogOpen: isOpen }), + + toggleShortcutsDialog: () => + set((state) => ({ + isShortcutsDialogOpen: !state.isShortcutsDialogOpen, + })), + toggleFolderExpansion: (folderIdentifier) => set((state) => { const current = state.expandedFolderIdentifiers @@ -123,12 +158,26 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( }), { name: "asa-news-ui-preferences", + storage: createJSONStorage(() => { + if (typeof window === "undefined") { + return { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + } + } + return localStorage + }), partialize: (state) => ({ entryListViewMode: state.entryListViewMode, displayDensity: state.displayDensity, showFeedFavicons: state.showFeedFavicons, focusFollowsInteraction: state.focusFollowsInteraction, expandedFolderIdentifiers: state.expandedFolderIdentifiers, + fontSize: state.fontSize, + timeDisplayFormat: state.timeDisplayFormat, + showEntryImages: state.showEntryImages, + showReadingTime: state.showReadingTime, }), } ) diff --git a/apps/web/lib/types/custom-feed.ts b/apps/web/lib/types/custom-feed.ts index d729a12..dd518c7 100644 --- a/apps/web/lib/types/custom-feed.ts +++ b/apps/web/lib/types/custom-feed.ts @@ -5,4 +5,5 @@ export interface CustomFeed { matchMode: "and" | "or" sourceFolderIdentifier: string | null position: number + iconUrl: string | null } diff --git a/apps/web/lib/types/subscription.ts b/apps/web/lib/types/subscription.ts index 36d16d4..f2ba995 100644 --- a/apps/web/lib/types/subscription.ts +++ b/apps/web/lib/types/subscription.ts @@ -2,6 +2,7 @@ export interface Folder { folderIdentifier: string name: string position: number + iconUrl: string | null } export interface Subscription { |