summaryrefslogtreecommitdiff
path: root/apps/web/lib/hooks
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/lib/hooks
downloadasa.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.ts26
-rw-r--r--apps/web/lib/hooks/use-keyboard-navigation.ts380
-rw-r--r--apps/web/lib/hooks/use-realtime-entries.ts74
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])
+}