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 /apps/web/lib/hooks | |
| 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
Diffstat (limited to 'apps/web/lib/hooks')
| -rw-r--r-- | apps/web/lib/hooks/use-keyboard-navigation.ts | 173 |
1 files changed, 164 insertions, 9 deletions
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, ]) } |