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 | |
| 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')
| -rw-r--r-- | apps/web/lib/hooks/use-keyboard-navigation.ts | 173 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-custom-feed-mutations.ts | 17 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-custom-feeds.ts | 4 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-folder-mutations.ts | 7 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-muted-keyword-mutations.ts | 8 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-subscriptions.ts | 4 | ||||
| -rw-r--r-- | apps/web/lib/stores/user-interface-store.ts | 55 | ||||
| -rw-r--r-- | apps/web/lib/types/custom-feed.ts | 1 | ||||
| -rw-r--r-- | apps/web/lib/types/subscription.ts | 1 |
9 files changed, 245 insertions, 25 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, ]) } 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 { |