summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 03:26:15 -0800
committerFuwn <[email protected]>2026-02-07 03:26:15 -0800
commitf2a5d1c04b9787bbd9f41af699345be6c0345ca8 (patch)
treeffbbacd807f0d3d30efb7110058bd70d6404681e
parentstyle: lowercase all user-facing strings and add custom eslint rule (diff)
downloadasa.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
-rw-r--r--apps/web/app/(auth)/layout.tsx19
-rw-r--r--apps/web/app/(marketing)/_components/feature-grid.tsx4
-rw-r--r--apps/web/app/(marketing)/_components/pricing-table.tsx2
-rw-r--r--apps/web/app/reader/_components/add-feed-dialog.tsx28
-rw-r--r--apps/web/app/reader/_components/command-palette.tsx13
-rw-r--r--apps/web/app/reader/_components/entry-detail-panel.tsx23
-rw-r--r--apps/web/app/reader/_components/entry-list-item.tsx24
-rw-r--r--apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx127
-rw-r--r--apps/web/app/reader/_components/reader-layout-shell.tsx145
-rw-r--r--apps/web/app/reader/_components/reader-shell.tsx36
-rw-r--r--apps/web/app/reader/_components/sidebar-content.tsx22
-rw-r--r--apps/web/app/reader/actions.ts2
-rw-r--r--apps/web/app/reader/settings/_components/account-settings.tsx271
-rw-r--r--apps/web/app/reader/settings/_components/appearance-settings.tsx89
-rw-r--r--apps/web/app/reader/settings/_components/custom-feeds-settings.tsx12
-rw-r--r--apps/web/app/reader/settings/_components/folders-settings.tsx80
-rw-r--r--apps/web/app/reader/settings/_components/muted-phrases-settings.tsx (renamed from apps/web/app/reader/settings/_components/muted-keywords-settings.tsx)54
-rw-r--r--apps/web/app/reader/settings/_components/security-settings.tsx280
-rw-r--r--apps/web/app/reader/settings/_components/settings-shell.tsx9
-rw-r--r--apps/web/eslint-rules/no-comments.mjs66
-rw-r--r--apps/web/eslint.config.mjs3
-rw-r--r--apps/web/lib/hooks/use-keyboard-navigation.ts173
-rw-r--r--apps/web/lib/queries/use-custom-feed-mutations.ts17
-rw-r--r--apps/web/lib/queries/use-custom-feeds.ts4
-rw-r--r--apps/web/lib/queries/use-folder-mutations.ts7
-rw-r--r--apps/web/lib/queries/use-muted-keyword-mutations.ts8
-rw-r--r--apps/web/lib/queries/use-subscriptions.ts4
-rw-r--r--apps/web/lib/stores/user-interface-store.ts55
-rw-r--r--apps/web/lib/types/custom-feed.ts1
-rw-r--r--apps/web/lib/types/subscription.ts1
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> &middot; {entryDetail.author}</span>
)}
- <span> &middot; {readingTimeMinutes} min read</span>
+ {entryDetail.published_at && (
+ <span>
+ {" "}&middot;{" "}
+ {timeDisplayFormat === "absolute"
+ ? format(new Date(entryDetail.published_at), "MMM d, h:mm a")
+ : formatDistanceToNow(new Date(entryDetail.published_at), {
+ addSuffix: true,
+ })}
+ </span>
+ )}
+ {showReadingTime && (
+ <span> &middot; {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>&middot;</span>
- <span>{relativeTimestamp}</span>
+ <span>{formattedTimestamp}</span>
</div>
</div>
)}
@@ -107,10 +117,10 @@ export function EntryListItem({
</>
)}
<span>&middot;</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"
+ )}
>
- &times;
- </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"
>
- &#9776;
+ &times;
</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"
+ >
+ &#9776;
+ </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"
+ >
+ &times;
+ </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"
+ >
+ &#9776;
+ </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&apos;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. &ldquo;2h ago&rdquo;) 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&apos;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 {