diff options
| author | Fuwn <[email protected]> | 2026-02-07 05:45:41 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 05:45:41 -0800 |
| commit | 6368c74432ced80e0ac6ad2c5fe9c2495d1bc6ae (patch) | |
| tree | c8b583a21bd489170b8f664e8c028fbbd9d95d49 | |
| parent | fix: resolve 6 pre-ship audit bugs (diff) | |
| download | asa.news-6368c74432ced80e0ac6ad2c5fe9c2495d1bc6ae.tar.xz asa.news-6368c74432ced80e0ac6ad2c5fe9c2495d1bc6ae.zip | |
feat: resolve 7 pre-ship QoL items
- Space/Shift+Space: page down/up in detail panel (80% scroll)
- Content font: sans-serif/serif/monospace selector in appearance
settings, applied to article content in detail panel
- Accessibility: entry-list-item uses button instead of div, folder
toggles have aria-expanded, shortcut keys have aria-labels
- Share notes: replaced window.prompt with proper modal dialog
matching existing UI patterns
- Worker .env.example: template with all 10 environment variables
- Worker poisoned messages: archive unprocessable queue messages
instead of leaving them stuck forever
- Worker pool Submit: check return value, reschedule dropped feeds
30s into the future, log warnings for rejected submissions
| -rw-r--r-- | apps/web/app/reader/_components/entry-detail-panel.tsx | 92 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/entry-list-item.tsx | 7 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx | 1 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/sidebar-content.tsx | 1 | ||||
| -rw-r--r-- | apps/web/app/reader/settings/_components/appearance-settings.tsx | 23 | ||||
| -rw-r--r-- | apps/web/lib/hooks/use-keyboard-navigation.ts | 14 | ||||
| -rw-r--r-- | apps/web/lib/stores/user-interface-store.ts | 8 | ||||
| -rw-r--r-- | services/worker/.env.example | 29 | ||||
| -rw-r--r-- | services/worker/internal/scheduler/scheduler.go | 78 |
9 files changed, 242 insertions, 11 deletions
diff --git a/apps/web/app/reader/_components/entry-detail-panel.tsx b/apps/web/app/reader/_components/entry-detail-panel.tsx index 5825b1e..4d15e1f 100644 --- a/apps/web/app/reader/_components/entry-detail-panel.tsx +++ b/apps/web/app/reader/_components/entry-detail-panel.tsx @@ -68,6 +68,14 @@ export function EntryDetailPanel({ const showReadingTime = useUserInterfaceStore( (state) => state.showReadingTime ) + const contentFont = useUserInterfaceStore((state) => state.contentFont) + + const contentFontClass = + contentFont === "serif" + ? "font-serif" + : contentFont === "monospace" + ? "font-mono" + : "font-sans" const proseContainerReference = useRef<HTMLDivElement>(null) const [selectionToolbarState, setSelectionToolbarState] = useState<{ @@ -82,6 +90,9 @@ export function EntryDetailPanel({ containerRect: DOMRect } | null>(null) const [unpositionedHighlights, setUnpositionedHighlights] = useState<Highlight[]>([]) + const [isShareNoteDialogOpen, setIsShareNoteDialogOpen] = useState(false) + const [shareNoteText, setShareNoteText] = useState("") + const shareNoteTextareaReference = useRef<HTMLTextAreaElement>(null) const { data: timelineData } = useTimeline() const currentEntry = timelineData?.pages @@ -270,6 +281,32 @@ export function EntryDetailPanel({ return () => container.removeEventListener("click", handleMarkClick) }, [highlightsData]) + useEffect(() => { + if (isShareNoteDialogOpen) { + setTimeout(() => shareNoteTextareaReference.current?.focus(), 0) + } + }, [isShareNoteDialogOpen]) + + useEffect(() => { + if (!isShareNoteDialogOpen) return + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + setIsShareNoteDialogOpen(false) + } + } + + document.addEventListener("keydown", handleKeyDown, true) + return () => document.removeEventListener("keydown", handleKeyDown, true) + }, [isShareNoteDialogOpen]) + + function handleShareConfirm() { + shareMutation.mutate(shareNoteText.trim() || null) + setIsShareNoteDialogOpen(false) + } + function handleCreateHighlight(note: string | null) { const container = proseContainerReference.current if (!container || !selectionToolbarState) return @@ -382,8 +419,8 @@ export function EntryDetailPanel({ <button type="button" onClick={() => { - const note = window.prompt("add a note (optional):") - shareMutation.mutate(note || null) + setShareNoteText("") + setIsShareNoteDialogOpen(true) }} className="shrink-0 whitespace-nowrap border border-border px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" > @@ -461,7 +498,7 @@ export function EntryDetailPanel({ <div className="relative"> <div ref={proseContainerReference} - className="prose-reader text-text-secondary" + className={`prose-reader text-text-secondary ${contentFontClass}`} /> {selectionToolbarState && ( <HighlightSelectionToolbar @@ -484,6 +521,55 @@ export function EntryDetailPanel({ )} </div> </article> + {isShareNoteDialogOpen && ( + <div className="fixed inset-0 z-50 flex items-center justify-center"> + <div + className="fixed inset-0 bg-background-primary/80" + onClick={() => setIsShareNoteDialogOpen(false)} + /> + <div className="relative w-full max-w-md border border-border bg-background-secondary p-6"> + <h2 className="mb-4 text-text-primary">share entry</h2> + <form + onSubmit={(event) => { + event.preventDefault() + handleShareConfirm() + }} + className="space-y-4" + > + <div className="space-y-2"> + <label htmlFor="share-note-textarea" className="text-text-secondary"> + add a note (optional) + </label> + <textarea + ref={shareNoteTextareaReference} + id="share-note-textarea" + value={shareNoteText} + onChange={(event) => setShareNoteText(event.target.value)} + rows={4} + placeholder="Write a note to accompany this shared entry..." + className="w-full resize-y border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + <div className="flex gap-2"> + <button + type="button" + onClick={() => setIsShareNoteDialogOpen(false)} + className="flex-1 border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + cancel + </button> + <button + type="submit" + disabled={shareMutation.isPending} + className="flex-1 border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {shareMutation.isPending ? "sharing..." : "share"} + </button> + </div> + </form> + </div> + </div> + )} </div> ) } diff --git a/apps/web/app/reader/_components/entry-list-item.tsx b/apps/web/app/reader/_components/entry-list-item.tsx index d192081..1669658 100644 --- a/apps/web/app/reader/_components/entry-list-item.tsx +++ b/apps/web/app/reader/_components/entry-list-item.tsx @@ -45,12 +45,13 @@ export function EntryListItem({ const displayTitle = entry.customTitle ?? entry.feedTitle return ( - <div + <button + type="button" ref={measureReference} data-index={virtualItem.index} onClick={onSelect} className={classNames( - "absolute left-0 top-0 w-full cursor-pointer border-b border-border px-4 transition-colors", + "absolute left-0 top-0 w-full cursor-pointer border-b border-border bg-transparent px-4 text-left font-[inherit] text-[inherit] transition-colors", isSelected ? "bg-background-tertiary" : isFocused @@ -130,6 +131,6 @@ export function EntryListItem({ )} </div> )} - </div> + </button> ) } diff --git a/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx b/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx index 139249b..f6e95eb 100644 --- a/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx +++ b/apps/web/app/reader/_components/keyboard-shortcuts-dialog.tsx @@ -110,6 +110,7 @@ export function KeyboardShortcutsDialog() { {shortcut.keys.map((key) => ( <kbd key={key} + aria-label={`${key}: ${shortcut.description}`} className="border border-border bg-background-secondary px-1.5 py-0.5 font-mono text-[0.75rem] text-text-primary" > {key} diff --git a/apps/web/app/reader/_components/sidebar-content.tsx b/apps/web/app/reader/_components/sidebar-content.tsx index e13926d..17d07bb 100644 --- a/apps/web/app/reader/_components/sidebar-content.tsx +++ b/apps/web/app/reader/_components/sidebar-content.tsx @@ -279,6 +279,7 @@ export function SidebarContent() { > <button type="button" + aria-expanded={isExpanded} onClick={() => toggleFolderExpansion(folder.folderIdentifier) } diff --git a/apps/web/app/reader/settings/_components/appearance-settings.tsx b/apps/web/app/reader/settings/_components/appearance-settings.tsx index 6c04f00..508051f 100644 --- a/apps/web/app/reader/settings/_components/appearance-settings.tsx +++ b/apps/web/app/reader/settings/_components/appearance-settings.tsx @@ -31,6 +31,10 @@ export function AppearanceSettings() { ) const fontSize = useUserInterfaceStore((state) => state.fontSize) const setFontSize = useUserInterfaceStore((state) => state.setFontSize) + const contentFont = useUserInterfaceStore((state) => state.contentFont) + const setContentFont = useUserInterfaceStore( + (state) => state.setContentFont + ) const timeDisplayFormat = useUserInterfaceStore( (state) => state.timeDisplayFormat ) @@ -155,6 +159,25 @@ export function AppearanceSettings() { </select> </div> <div className="mb-6"> + <h3 className="mb-2 text-text-primary">content font</h3> + <p className="mb-3 text-text-dim"> + controls the typeface used for article content + </p> + <select + value={contentFont} + onChange={(event) => + setContentFont( + event.target.value as "sans-serif" | "serif" | "monospace" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="sans-serif">sans-serif</option> + <option value="serif">serif</option> + <option value="monospace">monospace</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. “2h ago”) or diff --git a/apps/web/lib/hooks/use-keyboard-navigation.ts b/apps/web/lib/hooks/use-keyboard-navigation.ts index 24a4761..8685396 100644 --- a/apps/web/lib/hooks/use-keyboard-navigation.ts +++ b/apps/web/lib/hooks/use-keyboard-navigation.ts @@ -368,6 +368,7 @@ export function useKeyboardNavigation() { function handleDetailPanelKeyDown(event: KeyboardEvent) { const SCROLL_AMOUNT = 100 + const PAGE_SCROLL_FRACTION = 0.8 const detailArticle = document.querySelector<HTMLElement>( "[data-detail-article]" ) @@ -385,6 +386,19 @@ export function useKeyboardNavigation() { if (detailArticle) detailArticle.scrollTop -= SCROLL_AMOUNT break } + case " ": { + event.preventDefault() + if (detailArticle) { + const pageScrollAmount = + detailArticle.clientHeight * PAGE_SCROLL_FRACTION + if (event.shiftKey) { + detailArticle.scrollTop -= pageScrollAmount + } else { + detailArticle.scrollTop += pageScrollAmount + } + } + break + } case "?": { event.preventDefault() toggleShortcutsDialog() diff --git a/apps/web/lib/stores/user-interface-store.ts b/apps/web/lib/stores/user-interface-store.ts index 01dceba..fdee347 100644 --- a/apps/web/lib/stores/user-interface-store.ts +++ b/apps/web/lib/stores/user-interface-store.ts @@ -7,6 +7,8 @@ type DisplayDensity = "compact" | "default" | "spacious" type FontSize = "small" | "default" | "large" +type ContentFont = "sans-serif" | "serif" | "monospace" + type TimeDisplayFormat = "relative" | "absolute" type FocusedPanel = "sidebar" | "entryList" | "detailPanel" @@ -38,6 +40,7 @@ interface UserInterfaceState { showFeedFavicons: boolean focusFollowsInteraction: boolean fontSize: FontSize + contentFont: ContentFont timeDisplayFormat: TimeDisplayFormat showEntryImages: boolean showReadingTime: boolean @@ -62,6 +65,7 @@ interface UserInterfaceState { setShowFeedFavicons: (show: boolean) => void setFocusFollowsInteraction: (enabled: boolean) => void setFontSize: (size: FontSize) => void + setContentFont: (font: ContentFont) => void setTimeDisplayFormat: (format: TimeDisplayFormat) => void setShowEntryImages: (show: boolean) => void setShowReadingTime: (show: boolean) => void @@ -90,6 +94,7 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( showFeedFavicons: true, focusFollowsInteraction: false, fontSize: "default", + contentFont: "sans-serif", timeDisplayFormat: "relative", showEntryImages: true, showReadingTime: true, @@ -134,6 +139,8 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( setFontSize: (size) => set({ fontSize: size }), + setContentFont: (font) => set({ contentFont: font }), + setTimeDisplayFormat: (format) => set({ timeDisplayFormat: format }), setShowEntryImages: (show) => set({ showEntryImages: show }), @@ -187,6 +194,7 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( focusFollowsInteraction: state.focusFollowsInteraction, expandedFolderIdentifiers: state.expandedFolderIdentifiers, fontSize: state.fontSize, + contentFont: state.contentFont, timeDisplayFormat: state.timeDisplayFormat, showEntryImages: state.showEntryImages, showReadingTime: state.showReadingTime, diff --git a/services/worker/.env.example b/services/worker/.env.example new file mode 100644 index 0000000..4a4a706 --- /dev/null +++ b/services/worker/.env.example @@ -0,0 +1,29 @@ +# Database connection string (required) +DATABASE_URL=postgresql://user:password@localhost:5432/asa_news + +# Number of concurrent workers processing feed updates +WORKER_CONCURRENCY=10 + +# Interval for polling the job queue (duration string, e.g., 30s, 5m) +POLL_INTERVAL=30s + +# Timeout for individual feed fetch operations +FETCH_TIMEOUT=30s + +# Interval for checking the task queue +QUEUE_POLL_INTERVAL=5s + +# Number of entries to process per batch +BATCH_SIZE=50 + +# Port for health check HTTP server +HEALTH_PORT=8080 + +# Encryption key for sensitive vault credentials (optional) +ENCRYPTION_KEY= + +# Log level: debug, info, warn, error +LOG_LEVEL=info + +# Enable JSON structured logging output +LOG_JSON=false diff --git a/services/worker/internal/scheduler/scheduler.go b/services/worker/internal/scheduler/scheduler.go index 646e263..19023e1 100644 --- a/services/worker/internal/scheduler/scheduler.go +++ b/services/worker/internal/scheduler/scheduler.go @@ -104,7 +104,7 @@ func (feedScheduler *Scheduler) executePollCycle(cycleContext context.Context) { for _, claimedFeed := range claimedFeeds { capturedFeed := claimedFeed - feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) { + submitted := feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) { ProcessRefreshRequest( workContext, capturedFeed, @@ -115,6 +115,16 @@ func (feedScheduler *Scheduler) executePollCycle(cycleContext context.Context) { feedScheduler.logger, ) }) + + if !submitted { + feedScheduler.logger.Warn( + "failed to submit feed refresh to worker pool, rescheduling", + "feed_identifier", capturedFeed.Identifier, + "feed_url", capturedFeed.URL, + ) + + feedScheduler.rescheduleDroppedFeed(cycleContext, capturedFeed) + } } } @@ -134,7 +144,13 @@ func (feedScheduler *Scheduler) executeQueueCycle(cycleContext context.Context) unmarshalError := json.Unmarshal(queueMessage.Message, &refreshRequest) if unmarshalError != nil { - feedScheduler.logger.Error("failed to unmarshal refresh request", "error", unmarshalError) + feedScheduler.logger.Error( + "failed to unmarshal refresh request, archiving poisoned message", + "message_identifier", queueMessage.MsgID, + "error", unmarshalError, + ) + + feedScheduler.archivePoisonedMessage(cycleContext, queueMessage.MsgID) return } @@ -143,17 +159,20 @@ func (feedScheduler *Scheduler) executeQueueCycle(cycleContext context.Context) if lookupError != nil { feedScheduler.logger.Error( - "failed to look up feed for queue request", + "failed to look up feed for queue request, archiving unresolvable message", + "message_identifier", queueMessage.MsgID, "feed_identifier", refreshRequest.FeedIdentifier, "error", lookupError, ) + feedScheduler.archivePoisonedMessage(cycleContext, queueMessage.MsgID) + return } capturedMessageIdentifier := queueMessage.MsgID - feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) { + submitted := feedScheduler.workerPool.Submit(cycleContext, func(workContext context.Context) { ProcessRefreshRequest( workContext, feed, @@ -169,11 +188,20 @@ func (feedScheduler *Scheduler) executeQueueCycle(cycleContext context.Context) if archiveError != nil { feedScheduler.logger.Error( "failed to archive queue message", - "message_id", capturedMessageIdentifier, + "message_identifier", capturedMessageIdentifier, "error", archiveError, ) } }) + + if !submitted { + feedScheduler.logger.Warn( + "failed to submit queue refresh to worker pool, message will retry after visibility timeout", + "message_identifier", capturedMessageIdentifier, + "feed_identifier", feed.Identifier, + "feed_url", feed.URL, + ) + } } func (feedScheduler *Scheduler) claimDueFeeds(claimContext context.Context) ([]model.Feed, error) { @@ -281,3 +309,43 @@ func (feedScheduler *Scheduler) lookupFeed(lookupContext context.Context, feedId return feed, nil } + +func (feedScheduler *Scheduler) archivePoisonedMessage(archiveContext context.Context, messageIdentifier int64) { + _, archiveError := pgmq.Archive(archiveContext, feedScheduler.databaseConnectionPool, "feed_refresh", messageIdentifier) + + if archiveError != nil { + feedScheduler.logger.Error( + "failed to archive poisoned queue message", + "message_identifier", messageIdentifier, + "error", archiveError, + ) + } +} + +const droppedFeedRescheduleDelay = 30 * time.Second + +func (feedScheduler *Scheduler) rescheduleDroppedFeed(rescheduleContext context.Context, droppedFeed model.Feed) { + rescheduleQuery := ` + UPDATE feeds + SET next_fetch_at = $1 + WHERE id = $2 + ` + + rescheduleTime := time.Now().UTC().Add(droppedFeedRescheduleDelay) + + _, executeError := feedScheduler.databaseConnectionPool.Exec( + rescheduleContext, + rescheduleQuery, + rescheduleTime, + droppedFeed.Identifier, + ) + + if executeError != nil { + feedScheduler.logger.Error( + "failed to reschedule dropped feed", + "feed_identifier", droppedFeed.Identifier, + "feed_url", droppedFeed.URL, + "error", executeError, + ) + } +} |