diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/lib/hooks | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
Diffstat (limited to 'apps/web/lib/hooks')
| -rw-r--r-- | apps/web/lib/hooks/use-is-mobile.ts | 26 | ||||
| -rw-r--r-- | apps/web/lib/hooks/use-keyboard-navigation.ts | 380 | ||||
| -rw-r--r-- | apps/web/lib/hooks/use-realtime-entries.ts | 74 |
3 files changed, 480 insertions, 0 deletions
diff --git a/apps/web/lib/hooks/use-is-mobile.ts b/apps/web/lib/hooks/use-is-mobile.ts new file mode 100644 index 0000000..a56e36c --- /dev/null +++ b/apps/web/lib/hooks/use-is-mobile.ts @@ -0,0 +1,26 @@ +"use client" + +import { useState, useEffect } from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(false) + + useEffect(() => { + const mediaQuery = window.matchMedia( + `(max-width: ${MOBILE_BREAKPOINT - 1}px)` + ) + + setIsMobile(mediaQuery.matches) + + function handleChange(event: MediaQueryListEvent) { + setIsMobile(event.matches) + } + + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, []) + + return isMobile +} diff --git a/apps/web/lib/hooks/use-keyboard-navigation.ts b/apps/web/lib/hooks/use-keyboard-navigation.ts new file mode 100644 index 0000000..c4b3f5f --- /dev/null +++ b/apps/web/lib/hooks/use-keyboard-navigation.ts @@ -0,0 +1,380 @@ +"use client" + +import { useEffect } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { + useToggleEntryReadState, + useToggleEntrySavedState, +} from "@/lib/queries/use-entry-state-mutations" +import { useMarkAllAsRead } from "@/lib/queries/use-mark-all-as-read" +import { queryKeys } from "@/lib/queries/query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" +import type { InfiniteData } from "@tanstack/react-query" + +function findEntryInCache( + queryClient: ReturnType<typeof useQueryClient>, + entryIdentifier: string +): TimelineEntry | undefined { + const allQueries = [ + ...queryClient.getQueriesData<InfiniteData<TimelineEntry[]>>({ + queryKey: queryKeys.timeline.all, + }), + ...queryClient.getQueriesData<InfiniteData<TimelineEntry[]>>({ + queryKey: ["custom-feed-timeline"], + }), + ...queryClient.getQueriesData<InfiniteData<TimelineEntry[]>>({ + queryKey: queryKeys.savedEntries.all, + }), + ] + + for (const [, data] of allQueries) { + if (!data) continue + for (const page of data.pages) { + const match = page.find( + (entry) => entry.entryIdentifier === entryIdentifier + ) + if (match) return match + } + } + + return undefined +} + +const PANEL_ORDER = ["sidebar", "entryList", "detailPanel"] as const + +export function useKeyboardNavigation() { + const queryClient = useQueryClient() + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setFocusedEntryIdentifier = useUserInterfaceStore( + (state) => state.setFocusedEntryIdentifier + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const setFocusedPanel = useUserInterfaceStore( + (state) => state.setFocusedPanel + ) + const focusedSidebarIndex = useUserInterfaceStore( + (state) => state.focusedSidebarIndex + ) + const setFocusedSidebarIndex = useUserInterfaceStore( + (state) => state.setFocusedSidebarIndex + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const setCommandPaletteOpen = useUserInterfaceStore( + (state) => state.setCommandPaletteOpen + ) + const isCommandPaletteOpen = useUserInterfaceStore( + (state) => state.isCommandPaletteOpen + ) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const isSearchOpen = useUserInterfaceStore((state) => state.isSearchOpen) + const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) + const setSidebarCollapsed = useUserInterfaceStore( + (state) => state.setSidebarCollapsed + ) + const isSidebarCollapsed = useUserInterfaceStore( + (state) => state.isSidebarCollapsed + ) + const navigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.navigableEntryIdentifiers + ) + const toggleReadState = useToggleEntryReadState() + const toggleSavedState = useToggleEntrySavedState() + const markAllAsRead = useMarkAllAsRead() + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + const target = event.target as HTMLElement + + if ( + event.key !== "Escape" && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return + } + + if ((isCommandPaletteOpen || isSearchOpen) && event.key !== "Escape") return + + if (event.ctrlKey) { + switch (event.key) { + case "h": { + event.preventDefault() + const currentPanelIndex = PANEL_ORDER.indexOf(focusedPanel) + if (currentPanelIndex > 0) { + const targetPanel = PANEL_ORDER[currentPanelIndex - 1] + setFocusedPanel(targetPanel) + if (targetPanel === "sidebar") { + setSidebarCollapsed(false) + } + } else { + setSidebarCollapsed(false) + setFocusedPanel("sidebar") + } + return + } + case "l": { + event.preventDefault() + const currentPanelIndex = PANEL_ORDER.indexOf(focusedPanel) + if (currentPanelIndex < PANEL_ORDER.length - 1) { + const targetPanel = PANEL_ORDER[currentPanelIndex + 1] + if (targetPanel === "detailPanel" && !selectedEntryIdentifier) { + return + } + setFocusedPanel(targetPanel) + } + return + } + } + + return + } + + if (focusedPanel === "sidebar") { + handleSidebarKeyDown(event) + return + } + + if (focusedPanel === "detailPanel") { + handleDetailPanelKeyDown(event) + return + } + + const activeIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + const currentIndex = activeIdentifier + ? navigableEntryIdentifiers.indexOf(activeIdentifier) + : -1 + + switch (event.key) { + case "j": + case "ArrowDown": { + event.preventDefault() + + if (navigableEntryIdentifiers.length === 0) break + + const nextIndex = + currentIndex === -1 + ? 0 + : Math.min(currentIndex + 1, navigableEntryIdentifiers.length - 1) + + setFocusedEntryIdentifier(navigableEntryIdentifiers[nextIndex]) + + break + } + case "k": + case "ArrowUp": { + event.preventDefault() + + if (navigableEntryIdentifiers.length === 0) break + + if (currentIndex === -1) { + setFocusedEntryIdentifier(navigableEntryIdentifiers[0]) + } else { + const previousIndex = Math.max(currentIndex - 1, 0) + setFocusedEntryIdentifier(navigableEntryIdentifiers[previousIndex]) + } + + break + } + case "Enter": { + if (focusedEntryIdentifier) { + event.preventDefault() + setSelectedEntryIdentifier(focusedEntryIdentifier) + } + + break + } + case "Escape": { + if (isCommandPaletteOpen) { + setCommandPaletteOpen(false) + } else if (isSearchOpen) { + setSearchOpen(false) + } else if (selectedEntryIdentifier) { + setSelectedEntryIdentifier(null) + } else if (focusedEntryIdentifier) { + setFocusedEntryIdentifier(null) + } + + break + } + case "r": { + const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + if (targetIdentifier) { + const entry = findEntryInCache(queryClient, targetIdentifier) + if (entry) { + toggleReadState.mutate({ + entryIdentifier: entry.entryIdentifier, + isRead: !entry.isRead, + }) + } + } + + break + } + case "s": { + const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + if (targetIdentifier) { + const entry = findEntryInCache(queryClient, targetIdentifier) + if (entry) { + toggleSavedState.mutate({ + entryIdentifier: entry.entryIdentifier, + isSaved: !entry.isSaved, + }) + } + } + + break + } + case "o": { + const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier + if (targetIdentifier) { + const entry = findEntryInCache(queryClient, targetIdentifier) + if (entry?.entryUrl) { + window.open(entry.entryUrl, "_blank", "noopener,noreferrer") + } + } + + break + } + case "A": { + if (event.shiftKey) { + event.preventDefault() + markAllAsRead.mutate({}) + } + + break + } + case "/": { + event.preventDefault() + setSearchOpen(true) + + break + } + case "b": { + toggleSidebar() + + break + } + case "1": { + setEntryListViewMode("compact") + + break + } + case "2": { + setEntryListViewMode("comfortable") + + break + } + case "3": { + setEntryListViewMode("expanded") + + break + } + } + } + + function handleDetailPanelKeyDown(event: KeyboardEvent) { + const SCROLL_AMOUNT = 100 + + switch (event.key) { + case "j": + case "ArrowDown": { + event.preventDefault() + const detailArticle = document.querySelector( + "[data-detail-panel] article" + ) + detailArticle?.scrollBy({ top: SCROLL_AMOUNT, behavior: "smooth" }) + break + } + case "k": + case "ArrowUp": { + event.preventDefault() + const detailArticle = document.querySelector( + "[data-detail-panel] article" + ) + detailArticle?.scrollBy({ top: -SCROLL_AMOUNT, behavior: "smooth" }) + break + } + case "Escape": { + setFocusedPanel("entryList") + break + } + } + } + + function handleSidebarKeyDown(event: KeyboardEvent) { + const sidebarLinks = document.querySelectorAll<HTMLElement>( + "[data-sidebar-nav-item]" + ) + const itemCount = sidebarLinks.length + + if (itemCount === 0) return + + switch (event.key) { + case "j": + case "ArrowDown": { + event.preventDefault() + const nextIndex = Math.min(focusedSidebarIndex + 1, itemCount - 1) + setFocusedSidebarIndex(nextIndex) + sidebarLinks[nextIndex]?.scrollIntoView({ block: "nearest" }) + break + } + case "k": + case "ArrowUp": { + event.preventDefault() + const previousIndex = Math.max(focusedSidebarIndex - 1, 0) + setFocusedSidebarIndex(previousIndex) + sidebarLinks[previousIndex]?.scrollIntoView({ block: "nearest" }) + break + } + case "Enter": { + event.preventDefault() + sidebarLinks[focusedSidebarIndex]?.click() + setFocusedPanel("entryList") + break + } + case "Escape": { + setFocusedPanel("entryList") + break + } + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [ + selectedEntryIdentifier, + focusedEntryIdentifier, + focusedPanel, + focusedSidebarIndex, + isCommandPaletteOpen, + isSearchOpen, + isSidebarCollapsed, + navigableEntryIdentifiers, + queryClient, + setSelectedEntryIdentifier, + setFocusedEntryIdentifier, + setFocusedPanel, + setFocusedSidebarIndex, + setCommandPaletteOpen, + setSidebarCollapsed, + toggleSidebar, + setEntryListViewMode, + setSearchOpen, + toggleReadState, + toggleSavedState, + markAllAsRead, + ]) +} diff --git a/apps/web/lib/hooks/use-realtime-entries.ts b/apps/web/lib/hooks/use-realtime-entries.ts new file mode 100644 index 0000000..0eaba77 --- /dev/null +++ b/apps/web/lib/hooks/use-realtime-entries.ts @@ -0,0 +1,74 @@ +"use client" + +import { useEffect, useRef } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "@/lib/queries/query-keys" +import { toast } from "sonner" +import { useNotificationStore } from "@/lib/stores/notification-store" + +const DEBOUNCE_MILLISECONDS = 3000 + +export function useRealtimeEntries() { + const queryClient = useQueryClient() + const supabaseClientReference = useRef(createSupabaseBrowserClient()) + const pendingCountReference = useRef(0) + const debounceTimerReference = useRef<ReturnType<typeof setTimeout> | null>(null) + + useEffect(() => { + function flushPendingNotifications() { + const count = pendingCountReference.current + if (count === 0) return + + pendingCountReference.current = 0 + debounceTimerReference.current = null + + const message = + count === 1 ? "1 new entry" : `${count} new entries` + + useNotificationStore.getState().addNotification(message) + toast(message, { + action: { + label: "refresh", + onClick: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.timeline.all, + }) + }, + }, + }) + } + + const channel = supabaseClientReference.current + .channel("entries-realtime") + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "entries", + }, + () => { + pendingCountReference.current++ + + if (debounceTimerReference.current) { + clearTimeout(debounceTimerReference.current) + } + + debounceTimerReference.current = setTimeout( + flushPendingNotifications, + DEBOUNCE_MILLISECONDS + ) + } + ) + .subscribe() + + return () => { + if (debounceTimerReference.current) { + clearTimeout(debounceTimerReference.current) + } + + supabaseClientReference.current.removeChannel(channel) + } + }, [queryClient]) +} |