summaryrefslogtreecommitdiff
path: root/apps/web/lib
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
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')
-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
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 {