summaryrefslogtreecommitdiff
path: root/apps/web/lib/hooks
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 /apps/web/lib/hooks
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
Diffstat (limited to 'apps/web/lib/hooks')
-rw-r--r--apps/web/lib/hooks/use-keyboard-navigation.ts173
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,
])
}