summaryrefslogtreecommitdiff
path: root/apps/web/lib
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
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')
-rw-r--r--apps/web/lib/api-auth.ts80
-rw-r--r--apps/web/lib/api-key.ts20
-rw-r--r--apps/web/lib/highlight-positioning.ts258
-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
-rw-r--r--apps/web/lib/notify.ts11
-rw-r--r--apps/web/lib/opml.ts161
-rw-r--r--apps/web/lib/queries/query-keys.ts43
-rw-r--r--apps/web/lib/queries/use-all-highlights.ts85
-rw-r--r--apps/web/lib/queries/use-custom-feed-mutations.ts122
-rw-r--r--apps/web/lib/queries/use-custom-feed-timeline.ts76
-rw-r--r--apps/web/lib/queries/use-custom-feeds.ts49
-rw-r--r--apps/web/lib/queries/use-entry-highlights.ts56
-rw-r--r--apps/web/lib/queries/use-entry-search.ts58
-rw-r--r--apps/web/lib/queries/use-entry-share.ts36
-rw-r--r--apps/web/lib/queries/use-entry-state-mutations.ts133
-rw-r--r--apps/web/lib/queries/use-folder-mutations.ts137
-rw-r--r--apps/web/lib/queries/use-highlight-mutations.ts132
-rw-r--r--apps/web/lib/queries/use-mark-all-as-read.ts48
-rw-r--r--apps/web/lib/queries/use-muted-keyword-mutations.ts68
-rw-r--r--apps/web/lib/queries/use-muted-keywords.ts30
-rw-r--r--apps/web/lib/queries/use-saved-entries.ts88
-rw-r--r--apps/web/lib/queries/use-subscribe-to-feed.ts37
-rw-r--r--apps/web/lib/queries/use-subscription-mutations.ts158
-rw-r--r--apps/web/lib/queries/use-subscriptions.ts78
-rw-r--r--apps/web/lib/queries/use-timeline.ts78
-rw-r--r--apps/web/lib/queries/use-unread-counts.ts32
-rw-r--r--apps/web/lib/queries/use-user-profile.ts46
-rw-r--r--apps/web/lib/query-client.ts12
-rw-r--r--apps/web/lib/rate-limit.ts24
-rw-r--r--apps/web/lib/sanitize.ts43
-rw-r--r--apps/web/lib/stores/notification-store.ts66
-rw-r--r--apps/web/lib/stores/user-interface-store.ts135
-rw-r--r--apps/web/lib/stripe.ts11
-rw-r--r--apps/web/lib/supabase/admin.ts8
-rw-r--r--apps/web/lib/supabase/client.ts8
-rw-r--r--apps/web/lib/supabase/middleware.ts39
-rw-r--r--apps/web/lib/supabase/server.ts27
-rw-r--r--apps/web/lib/types/custom-feed.ts8
-rw-r--r--apps/web/lib/types/highlight.ts17
-rw-r--r--apps/web/lib/types/subscription.ts19
-rw-r--r--apps/web/lib/types/timeline.ts16
-rw-r--r--apps/web/lib/types/user-profile.ts18
-rw-r--r--apps/web/lib/utilities.ts6
45 files changed, 3057 insertions, 0 deletions
diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts
new file mode 100644
index 0000000..309fbe9
--- /dev/null
+++ b/apps/web/lib/api-auth.ts
@@ -0,0 +1,80 @@
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { hashApiKey } from "@/lib/api-key"
+import { rateLimit } from "@/lib/rate-limit"
+
+interface AuthenticatedApiUser {
+ userIdentifier: string
+ tier: string
+}
+
+export async function authenticateApiRequest(
+ request: Request
+): Promise<
+ | { authenticated: true; user: AuthenticatedApiUser }
+ | { authenticated: false; status: number; error: string }
+> {
+ const authorizationHeader = request.headers.get("authorization")
+
+ if (!authorizationHeader?.startsWith("Bearer ")) {
+ return {
+ authenticated: false,
+ status: 401,
+ error: "Missing or invalid Authorization header",
+ }
+ }
+
+ const apiKey = authorizationHeader.slice(7)
+
+ if (!apiKey.startsWith("asn_")) {
+ return { authenticated: false, status: 401, error: "Invalid API key format" }
+ }
+
+ const keyHash = hashApiKey(apiKey)
+ const adminClient = createSupabaseAdminClient()
+
+ const { data: keyRow } = await adminClient
+ .from("api_keys")
+ .select("user_id")
+ .eq("key_hash", keyHash)
+ .is("revoked_at", null)
+ .single()
+
+ if (!keyRow) {
+ return { authenticated: false, status: 401, error: "Invalid or revoked API key" }
+ }
+
+ const { data: userProfile } = await adminClient
+ .from("user_profiles")
+ .select("tier")
+ .eq("id", keyRow.user_id)
+ .single()
+
+ if (!userProfile || userProfile.tier !== "developer") {
+ return {
+ authenticated: false,
+ status: 403,
+ error: "API access requires the developer plan",
+ }
+ }
+
+ const rateLimitResult = rateLimit(`api:${keyRow.user_id}`, 100, 60_000)
+
+ if (!rateLimitResult.success) {
+ return {
+ authenticated: false,
+ status: 429,
+ error: `Rate limit exceeded. ${rateLimitResult.remaining} requests remaining.`,
+ }
+ }
+
+ adminClient
+ .from("api_keys")
+ .update({ last_used_at: new Date().toISOString() })
+ .eq("key_hash", keyHash)
+ .then(() => {})
+
+ return {
+ authenticated: true,
+ user: { userIdentifier: keyRow.user_id, tier: userProfile.tier },
+ }
+}
diff --git a/apps/web/lib/api-key.ts b/apps/web/lib/api-key.ts
new file mode 100644
index 0000000..ce59f89
--- /dev/null
+++ b/apps/web/lib/api-key.ts
@@ -0,0 +1,20 @@
+import { randomBytes, createHash } from "crypto"
+
+const API_KEY_PREFIX = "asn_"
+
+export function generateApiKey(): {
+ fullKey: string
+ keyHash: string
+ keyPrefix: string
+} {
+ const randomPart = randomBytes(20).toString("hex")
+ const fullKey = `${API_KEY_PREFIX}${randomPart}`
+ const keyHash = hashApiKey(fullKey)
+ const keyPrefix = fullKey.slice(0, 8)
+
+ return { fullKey, keyHash, keyPrefix }
+}
+
+export function hashApiKey(key: string): string {
+ return createHash("sha256").update(key).digest("hex")
+}
diff --git a/apps/web/lib/highlight-positioning.ts b/apps/web/lib/highlight-positioning.ts
new file mode 100644
index 0000000..4c4c068
--- /dev/null
+++ b/apps/web/lib/highlight-positioning.ts
@@ -0,0 +1,258 @@
+interface SerializedHighlightRange {
+ highlightedText: string
+ textOffset: number
+ textLength: number
+ textPrefix: string
+ textSuffix: string
+}
+
+function collectTextContent(containerElement: HTMLElement): string {
+ const treeWalker = document.createTreeWalker(
+ containerElement,
+ NodeFilter.SHOW_TEXT
+ )
+ let fullText = ""
+ while (treeWalker.nextNode()) {
+ fullText += treeWalker.currentNode.textContent ?? ""
+ }
+ return fullText
+}
+
+function computeAbsoluteTextOffset(
+ containerElement: HTMLElement,
+ targetNode: Node,
+ targetOffset: number
+): number {
+ const treeWalker = document.createTreeWalker(
+ containerElement,
+ NodeFilter.SHOW_TEXT
+ )
+ let absoluteOffset = 0
+ while (treeWalker.nextNode()) {
+ if (treeWalker.currentNode === targetNode) {
+ return absoluteOffset + targetOffset
+ }
+ absoluteOffset += (treeWalker.currentNode.textContent ?? "").length
+ }
+ return absoluteOffset + targetOffset
+}
+
+function findTextNodeAtOffset(
+ containerElement: HTMLElement,
+ targetOffset: number
+): { node: Text; offset: number } | null {
+ const treeWalker = document.createTreeWalker(
+ containerElement,
+ NodeFilter.SHOW_TEXT
+ )
+ let currentOffset = 0
+ while (treeWalker.nextNode()) {
+ const textNode = treeWalker.currentNode as Text
+ const nodeLength = (textNode.textContent ?? "").length
+ if (currentOffset + nodeLength >= targetOffset) {
+ return { node: textNode, offset: targetOffset - currentOffset }
+ }
+ currentOffset += nodeLength
+ }
+ return null
+}
+
+export function serializeSelectionRange(
+ containerElement: HTMLElement,
+ selectionRange: Range
+): SerializedHighlightRange | null {
+ const selectedText = selectionRange.toString()
+ if (!selectedText.trim()) return null
+
+ if (!containerElement.contains(selectionRange.startContainer)) return null
+
+ const fullText = collectTextContent(containerElement)
+ const textOffset = computeAbsoluteTextOffset(
+ containerElement,
+ selectionRange.startContainer,
+ selectionRange.startOffset
+ )
+ const textLength = selectedText.length
+
+ const prefixStart = Math.max(0, textOffset - 50)
+ const textPrefix = fullText.slice(prefixStart, textOffset)
+ const textSuffix = fullText.slice(
+ textOffset + textLength,
+ textOffset + textLength + 50
+ )
+
+ return {
+ highlightedText: selectedText,
+ textOffset,
+ textLength,
+ textPrefix,
+ textSuffix,
+ }
+}
+
+export function deserializeHighlightRange(
+ containerElement: HTMLElement,
+ highlight: SerializedHighlightRange
+): Range | null {
+ const fullText = collectTextContent(containerElement)
+
+ let matchOffset = -1
+
+ const candidateText = fullText.slice(
+ highlight.textOffset,
+ highlight.textOffset + highlight.textLength
+ )
+ if (candidateText === highlight.highlightedText) {
+ matchOffset = highlight.textOffset
+ }
+
+ if (matchOffset === -1) {
+ const searchStart = Math.max(0, highlight.textOffset - 100)
+ const searchEnd = Math.min(
+ fullText.length,
+ highlight.textOffset + highlight.textLength + 100
+ )
+ const searchWindow = fullText.slice(searchStart, searchEnd)
+ const foundIndex = searchWindow.indexOf(highlight.highlightedText)
+ if (foundIndex !== -1) {
+ matchOffset = searchStart + foundIndex
+ }
+ }
+
+ if (matchOffset === -1) {
+ const globalIndex = fullText.indexOf(highlight.highlightedText)
+ if (globalIndex !== -1) {
+ matchOffset = globalIndex
+ }
+ }
+
+ if (matchOffset === -1) return null
+
+ const startPosition = findTextNodeAtOffset(containerElement, matchOffset)
+ const endPosition = findTextNodeAtOffset(
+ containerElement,
+ matchOffset + highlight.textLength
+ )
+
+ if (!startPosition || !endPosition) return null
+
+ const highlightRange = document.createRange()
+ highlightRange.setStart(startPosition.node, startPosition.offset)
+ highlightRange.setEnd(endPosition.node, endPosition.offset)
+
+ return highlightRange
+}
+
+interface TextNodeSegment {
+ node: Text
+ startOffset: number
+ endOffset: number
+}
+
+function collectTextNodesInRange(range: Range): TextNodeSegment[] {
+ const segments: TextNodeSegment[] = []
+
+ if (
+ range.startContainer === range.endContainer &&
+ range.startContainer.nodeType === Node.TEXT_NODE
+ ) {
+ segments.push({
+ node: range.startContainer as Text,
+ startOffset: range.startOffset,
+ endOffset: range.endOffset,
+ })
+ return segments
+ }
+
+ const ancestor = range.commonAncestorContainer
+ const walkRoot =
+ ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode! : ancestor
+ const treeWalker = document.createTreeWalker(
+ walkRoot,
+ NodeFilter.SHOW_TEXT
+ )
+
+ let foundStart = false
+
+ while (treeWalker.nextNode()) {
+ const textNode = treeWalker.currentNode as Text
+
+ if (textNode === range.startContainer) {
+ foundStart = true
+ segments.push({
+ node: textNode,
+ startOffset: range.startOffset,
+ endOffset: (textNode.textContent ?? "").length,
+ })
+ } else if (textNode === range.endContainer) {
+ segments.push({
+ node: textNode,
+ startOffset: 0,
+ endOffset: range.endOffset,
+ })
+ break
+ } else if (foundStart) {
+ segments.push({
+ node: textNode,
+ startOffset: 0,
+ endOffset: (textNode.textContent ?? "").length,
+ })
+ }
+ }
+
+ return segments
+}
+
+export function applyHighlightToRange(
+ highlightRange: Range,
+ highlightIdentifier: string,
+ color: string,
+ hasNote: boolean
+): void {
+ const segments = collectTextNodesInRange(highlightRange)
+
+ if (segments.length === 0) return
+
+ for (const segment of segments) {
+ let targetNode = segment.node
+
+ if (segment.endOffset < (targetNode.textContent ?? "").length) {
+ targetNode.splitText(segment.endOffset)
+ }
+
+ if (segment.startOffset > 0) {
+ targetNode = targetNode.splitText(segment.startOffset)
+ }
+
+ const markElement = document.createElement("mark")
+ markElement.setAttribute("data-highlight-identifier", highlightIdentifier)
+ markElement.setAttribute("data-highlight-color", color)
+ if (hasNote) {
+ markElement.setAttribute("data-has-note", "true")
+ }
+
+ targetNode.parentNode!.insertBefore(markElement, targetNode)
+ markElement.appendChild(targetNode)
+ }
+}
+
+export function removeHighlightFromDom(
+ containerElement: HTMLElement,
+ highlightIdentifier: string
+): void {
+ const markElements = containerElement.querySelectorAll(
+ `mark[data-highlight-identifier="${highlightIdentifier}"]`
+ )
+
+ for (const markElement of markElements) {
+ const parentNode = markElement.parentNode
+ if (!parentNode) continue
+
+ while (markElement.firstChild) {
+ parentNode.insertBefore(markElement.firstChild, markElement)
+ }
+
+ parentNode.removeChild(markElement)
+ parentNode.normalize()
+ }
+}
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])
+}
diff --git a/apps/web/lib/notify.ts b/apps/web/lib/notify.ts
new file mode 100644
index 0000000..911364f
--- /dev/null
+++ b/apps/web/lib/notify.ts
@@ -0,0 +1,11 @@
+import { toast } from "sonner"
+import { useNotificationStore } from "./stores/notification-store"
+
+export function notify(
+ message: string,
+ type: "info" | "success" | "error" = "info",
+ actionUrl?: string
+) {
+ toast(message)
+ useNotificationStore.getState().addNotification(message, type, actionUrl)
+}
diff --git a/apps/web/lib/opml.ts b/apps/web/lib/opml.ts
new file mode 100644
index 0000000..bd0c3a7
--- /dev/null
+++ b/apps/web/lib/opml.ts
@@ -0,0 +1,161 @@
+import type { Folder, Subscription } from "@/lib/types/subscription"
+
+function escapeXml(text: string): string {
+ return text
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&apos;")
+}
+
+export function generateOpml(
+ subscriptions: Subscription[],
+ folders: Folder[]
+): string {
+ const lines: string[] = [
+ '<?xml version="1.0" encoding="UTF-8"?>',
+ '<opml version="2.0">',
+ " <head>",
+ " <title>asa.news subscriptions</title>",
+ ` <dateCreated>${new Date().toUTCString()}</dateCreated>`,
+ " </head>",
+ " <body>",
+ ]
+
+ const folderMap = new Map<string, Folder>()
+
+ for (const folder of folders) {
+ folderMap.set(folder.folderIdentifier, folder)
+ }
+
+ const subscriptionsByFolder = new Map<string | null, Subscription[]>()
+
+ for (const subscription of subscriptions) {
+ const key = subscription.folderIdentifier
+ const existing = subscriptionsByFolder.get(key) ?? []
+ existing.push(subscription)
+ subscriptionsByFolder.set(key, existing)
+ }
+
+ const ungrouped = subscriptionsByFolder.get(null) ?? []
+
+ for (const subscription of ungrouped) {
+ const title = escapeXml(subscription.customTitle ?? subscription.feedTitle)
+ const xmlUrl = escapeXml(subscription.feedUrl)
+ lines.push(
+ ` <outline type="rss" text="${title}" title="${title}" xmlUrl="${xmlUrl}" />`
+ )
+ }
+
+ for (const folder of folders) {
+ const folderSubscriptions =
+ subscriptionsByFolder.get(folder.folderIdentifier) ?? []
+ const folderName = escapeXml(folder.name)
+ lines.push(` <outline text="${folderName}" title="${folderName}">`)
+
+ for (const subscription of folderSubscriptions) {
+ const title = escapeXml(subscription.customTitle ?? subscription.feedTitle)
+ const xmlUrl = escapeXml(subscription.feedUrl)
+ lines.push(
+ ` <outline type="rss" text="${title}" title="${title}" xmlUrl="${xmlUrl}" />`
+ )
+ }
+
+ lines.push(" </outline>")
+ }
+
+ lines.push(" </body>")
+ lines.push("</opml>")
+
+ return lines.join("\n")
+}
+
+export interface ParsedOpmlFeed {
+ url: string
+ title: string
+}
+
+export interface ParsedOpmlGroup {
+ folderName: string | null
+ feeds: ParsedOpmlFeed[]
+}
+
+export function parseOpml(xmlString: string): ParsedOpmlGroup[] {
+ const parser = new DOMParser()
+ const document = parser.parseFromString(xmlString, "application/xml")
+ const parseError = document.querySelector("parsererror")
+
+ if (parseError) {
+ throw new Error("Invalid OPML file")
+ }
+
+ const body = document.querySelector("body")
+
+ if (!body) {
+ throw new Error("Invalid OPML: no body element")
+ }
+
+ const groups: ParsedOpmlGroup[] = []
+ const ungroupedFeeds: ParsedOpmlFeed[] = []
+ const topLevelOutlines = body.querySelectorAll(":scope > outline")
+
+ for (const outline of topLevelOutlines) {
+ const xmlUrl = outline.getAttribute("xmlUrl")
+
+ if (xmlUrl) {
+ ungroupedFeeds.push({
+ url: xmlUrl,
+ title:
+ outline.getAttribute("title") ??
+ outline.getAttribute("text") ??
+ xmlUrl,
+ })
+ } else {
+ const folderName =
+ outline.getAttribute("title") ?? outline.getAttribute("text")
+ const feeds: ParsedOpmlFeed[] = []
+ const childOutlines = outline.querySelectorAll(":scope > outline")
+
+ for (const child of childOutlines) {
+ const childXmlUrl = child.getAttribute("xmlUrl")
+
+ if (childXmlUrl) {
+ feeds.push({
+ url: childXmlUrl,
+ title:
+ child.getAttribute("title") ??
+ child.getAttribute("text") ??
+ childXmlUrl,
+ })
+ }
+ }
+
+ if (feeds.length > 0) {
+ groups.push({ folderName: folderName, feeds })
+ }
+ }
+ }
+
+ if (ungroupedFeeds.length > 0) {
+ groups.unshift({ folderName: null, feeds: ungroupedFeeds })
+ }
+
+ return groups
+}
+
+export function downloadOpml(
+ subscriptions: Subscription[],
+ folders: Folder[]
+): void {
+ const opmlContent = generateOpml(subscriptions, folders)
+ const blob = new Blob([opmlContent], { type: "application/xml" })
+ const url = URL.createObjectURL(blob)
+ const anchor = window.document.createElement("a")
+ anchor.href = url
+ anchor.download = "asa-news-subscriptions.opml"
+ window.document.body.appendChild(anchor)
+ anchor.click()
+ window.document.body.removeChild(anchor)
+ URL.revokeObjectURL(url)
+}
diff --git a/apps/web/lib/queries/query-keys.ts b/apps/web/lib/queries/query-keys.ts
new file mode 100644
index 0000000..69e3407
--- /dev/null
+++ b/apps/web/lib/queries/query-keys.ts
@@ -0,0 +1,43 @@
+export const queryKeys = {
+ timeline: {
+ all: ["timeline"] as const,
+ list: (folderIdentifier?: string | null, feedIdentifier?: string | null, unreadOnly?: boolean) =>
+ ["timeline", { folderIdentifier, feedIdentifier, unreadOnly }] as const,
+ },
+ savedEntries: {
+ all: ["saved-entries"] as const,
+ },
+ subscriptions: {
+ all: ["subscriptions"] as const,
+ },
+ entryDetail: {
+ single: (entryIdentifier: string) =>
+ ["entry-detail", entryIdentifier] as const,
+ },
+ userProfile: {
+ all: ["user-profile"] as const,
+ },
+ mutedKeywords: {
+ all: ["muted-keywords"] as const,
+ },
+ unreadCounts: {
+ all: ["unread-counts"] as const,
+ },
+ entrySearch: {
+ query: (searchQuery: string) => ["entry-search", searchQuery] as const,
+ },
+ entryShare: {
+ single: (entryIdentifier: string) =>
+ ["entry-share", entryIdentifier] as const,
+ },
+ highlights: {
+ forEntry: (entryIdentifier: string) =>
+ ["highlights", entryIdentifier] as const,
+ all: ["highlights"] as const,
+ },
+ customFeeds: {
+ all: ["custom-feeds"] as const,
+ timeline: (customFeedIdentifier: string) =>
+ ["custom-feed-timeline", customFeedIdentifier] as const,
+ },
+}
diff --git a/apps/web/lib/queries/use-all-highlights.ts b/apps/web/lib/queries/use-all-highlights.ts
new file mode 100644
index 0000000..39988de
--- /dev/null
+++ b/apps/web/lib/queries/use-all-highlights.ts
@@ -0,0 +1,85 @@
+"use client"
+
+import { useInfiniteQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { HighlightWithEntryContext } from "@/lib/types/highlight"
+
+const HIGHLIGHTS_PAGE_SIZE = 50
+
+interface HighlightWithContextRow {
+ id: string
+ entry_id: string
+ highlighted_text: string
+ note: string | null
+ text_offset: number
+ text_length: number
+ text_prefix: string
+ text_suffix: string
+ color: string
+ created_at: string
+ entries: {
+ id: string
+ title: string | null
+ feeds: {
+ title: string | null
+ }
+ }
+}
+
+function mapRowToHighlightWithContext(
+ row: HighlightWithContextRow
+): HighlightWithEntryContext {
+ return {
+ identifier: row.id,
+ entryIdentifier: row.entry_id,
+ highlightedText: row.highlighted_text,
+ note: row.note,
+ textOffset: row.text_offset,
+ textLength: row.text_length,
+ textPrefix: row.text_prefix,
+ textSuffix: row.text_suffix,
+ color: row.color,
+ createdAt: row.created_at,
+ entryTitle: row.entries?.title ?? null,
+ feedTitle: row.entries?.feeds?.title ?? null,
+ }
+}
+
+export function useAllHighlights() {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useInfiniteQuery({
+ queryKey: queryKeys.highlights.all,
+ queryFn: async ({
+ pageParam,
+ }: {
+ pageParam: string | undefined
+ }) => {
+ let query = supabaseClient
+ .from("user_highlights")
+ .select(
+ "id, entry_id, highlighted_text, note, text_offset, text_length, text_prefix, text_suffix, color, created_at, entries!inner(id, title, feeds!inner(title))"
+ )
+ .order("created_at", { ascending: false })
+ .limit(HIGHLIGHTS_PAGE_SIZE)
+
+ if (pageParam) {
+ query = query.lt("created_at", pageParam)
+ }
+
+ const { data, error } = await query
+
+ if (error) throw error
+
+ return (
+ (data as unknown as HighlightWithContextRow[]) ?? []
+ ).map(mapRowToHighlightWithContext)
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage: HighlightWithEntryContext[]) => {
+ if (lastPage.length < HIGHLIGHTS_PAGE_SIZE) return undefined
+ return lastPage[lastPage.length - 1].createdAt
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-custom-feed-mutations.ts b/apps/web/lib/queries/use-custom-feed-mutations.ts
new file mode 100644
index 0000000..f0751db
--- /dev/null
+++ b/apps/web/lib/queries/use-custom-feed-mutations.ts
@@ -0,0 +1,122 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import { notify } from "@/lib/notify"
+
+export function useCreateCustomFeed() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ name,
+ query,
+ matchMode,
+ sourceFolderIdentifier,
+ }: {
+ name: string
+ query: string
+ matchMode: "and" | "or"
+ sourceFolderIdentifier: string | null
+ }) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { error } = await supabaseClient.from("custom_feeds").insert({
+ user_id: user.id,
+ name,
+ query,
+ match_mode: matchMode,
+ source_folder_id: sourceFolderIdentifier,
+ position: 0,
+ })
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("custom feed created")
+ },
+ onError: (error: Error) => {
+ notify(
+ error.message.includes("limit")
+ ? "custom feed limit reached for your plan"
+ : "failed to create custom feed: " + error.message
+ )
+ },
+ })
+}
+
+export function useUpdateCustomFeed() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ customFeedIdentifier,
+ name,
+ query,
+ matchMode,
+ sourceFolderIdentifier,
+ }: {
+ customFeedIdentifier: string
+ name: string
+ query: string
+ matchMode: "and" | "or"
+ sourceFolderIdentifier: string | null
+ }) => {
+ const { error } = await supabaseClient
+ .from("custom_feeds")
+ .update({
+ name,
+ query,
+ match_mode: matchMode,
+ source_folder_id: sourceFolderIdentifier,
+ })
+ .eq("id", customFeedIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all })
+ notify("custom feed updated")
+ },
+ onError: (error: Error) => {
+ notify("failed to update custom feed: " + error.message)
+ },
+ })
+}
+
+export function useDeleteCustomFeed() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ customFeedIdentifier,
+ }: {
+ customFeedIdentifier: string
+ }) => {
+ const { error } = await supabaseClient
+ .from("custom_feeds")
+ .delete()
+ .eq("id", customFeedIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("custom feed deleted")
+ },
+ onError: (error: Error) => {
+ notify("failed to delete custom feed: " + error.message)
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-custom-feed-timeline.ts b/apps/web/lib/queries/use-custom-feed-timeline.ts
new file mode 100644
index 0000000..4224123
--- /dev/null
+++ b/apps/web/lib/queries/use-custom-feed-timeline.ts
@@ -0,0 +1,76 @@
+"use client"
+
+import { useInfiniteQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { TimelineEntry } from "@/lib/types/timeline"
+
+const TIMELINE_PAGE_SIZE = 50
+
+interface TimelineRow {
+ entry_id: string
+ feed_id: string
+ feed_title: string
+ custom_title: string | null
+ entry_title: string
+ entry_url: string
+ author: string | null
+ summary: string | null
+ image_url: string | null
+ published_at: string
+ is_read: boolean
+ is_saved: boolean
+ enclosure_url: string | null
+ enclosure_type: string | null
+}
+
+function mapRowToTimelineEntry(row: TimelineRow): TimelineEntry {
+ return {
+ entryIdentifier: row.entry_id,
+ feedIdentifier: row.feed_id,
+ feedTitle: row.feed_title,
+ customTitle: row.custom_title,
+ entryTitle: row.entry_title,
+ entryUrl: row.entry_url,
+ author: row.author,
+ summary: row.summary,
+ imageUrl: row.image_url,
+ publishedAt: row.published_at,
+ isRead: row.is_read,
+ isSaved: row.is_saved,
+ enclosureUrl: row.enclosure_url,
+ enclosureType: row.enclosure_type,
+ }
+}
+
+export function useCustomFeedTimeline(customFeedIdentifier: string | null) {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useInfiniteQuery({
+ queryKey: queryKeys.customFeeds.timeline(customFeedIdentifier ?? ""),
+ queryFn: async ({
+ pageParam,
+ }: {
+ pageParam: string | undefined
+ }) => {
+ const { data, error } = await supabaseClient.rpc(
+ "get_custom_feed_timeline",
+ {
+ p_custom_feed_id: customFeedIdentifier!,
+ p_result_limit: TIMELINE_PAGE_SIZE,
+ p_pagination_cursor: pageParam ?? undefined,
+ }
+ )
+
+ if (error) throw error
+
+ return ((data as TimelineRow[]) ?? []).map(mapRowToTimelineEntry)
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage: TimelineEntry[]) => {
+ if (lastPage.length < TIMELINE_PAGE_SIZE) return undefined
+ return lastPage[lastPage.length - 1].publishedAt
+ },
+ enabled: !!customFeedIdentifier,
+ })
+}
diff --git a/apps/web/lib/queries/use-custom-feeds.ts b/apps/web/lib/queries/use-custom-feeds.ts
new file mode 100644
index 0000000..5c11721
--- /dev/null
+++ b/apps/web/lib/queries/use-custom-feeds.ts
@@ -0,0 +1,49 @@
+"use client"
+
+import { useQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { CustomFeed } from "@/lib/types/custom-feed"
+
+interface CustomFeedRow {
+ id: string
+ name: string
+ query: string
+ match_mode: string
+ source_folder_id: string | null
+ position: number
+}
+
+export function useCustomFeeds() {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useQuery({
+ queryKey: queryKeys.customFeeds.all,
+ queryFn: async () => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { data, error } = await supabaseClient
+ .from("custom_feeds")
+ .select("id, name, query, match_mode, source_folder_id, position")
+ .eq("user_id", user.id)
+ .order("position")
+
+ if (error) throw error
+
+ return ((data as CustomFeedRow[]) ?? []).map(
+ (row): CustomFeed => ({
+ identifier: row.id,
+ name: row.name,
+ query: row.query,
+ matchMode: row.match_mode as "and" | "or",
+ sourceFolderIdentifier: row.source_folder_id,
+ position: row.position,
+ })
+ )
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-entry-highlights.ts b/apps/web/lib/queries/use-entry-highlights.ts
new file mode 100644
index 0000000..3fdada5
--- /dev/null
+++ b/apps/web/lib/queries/use-entry-highlights.ts
@@ -0,0 +1,56 @@
+"use client"
+
+import { useQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { Highlight } from "@/lib/types/highlight"
+
+interface HighlightRow {
+ id: string
+ entry_id: string
+ highlighted_text: string
+ note: string | null
+ text_offset: number
+ text_length: number
+ text_prefix: string
+ text_suffix: string
+ color: string
+ created_at: string
+}
+
+function mapRowToHighlight(row: HighlightRow): Highlight {
+ return {
+ identifier: row.id,
+ entryIdentifier: row.entry_id,
+ highlightedText: row.highlighted_text,
+ note: row.note,
+ textOffset: row.text_offset,
+ textLength: row.text_length,
+ textPrefix: row.text_prefix,
+ textSuffix: row.text_suffix,
+ color: row.color,
+ createdAt: row.created_at,
+ }
+}
+
+export function useEntryHighlights(entryIdentifier: string | null) {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useQuery({
+ queryKey: queryKeys.highlights.forEntry(entryIdentifier ?? ""),
+ enabled: !!entryIdentifier,
+ queryFn: async () => {
+ const { data, error } = await supabaseClient
+ .from("user_highlights")
+ .select(
+ "id, entry_id, highlighted_text, note, text_offset, text_length, text_prefix, text_suffix, color, created_at"
+ )
+ .eq("entry_id", entryIdentifier!)
+ .order("text_offset", { ascending: true })
+
+ if (error) throw error
+
+ return ((data as unknown as HighlightRow[]) ?? []).map(mapRowToHighlight)
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-entry-search.ts b/apps/web/lib/queries/use-entry-search.ts
new file mode 100644
index 0000000..9e05ac8
--- /dev/null
+++ b/apps/web/lib/queries/use-entry-search.ts
@@ -0,0 +1,58 @@
+"use client"
+
+import { useQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { TimelineEntry } from "@/lib/types/timeline"
+
+interface SearchResultRow {
+ entry_id: string
+ feed_id: string
+ feed_title: string
+ custom_title: string | null
+ entry_title: string
+ entry_url: string
+ author: string | null
+ summary: string | null
+ image_url: string | null
+ published_at: string
+ is_read: boolean
+ is_saved: boolean
+}
+
+export function useEntrySearch(searchQuery: string) {
+ const supabaseClient = createSupabaseBrowserClient()
+ const trimmedQuery = searchQuery.trim()
+
+ return useQuery({
+ queryKey: queryKeys.entrySearch.query(trimmedQuery),
+ queryFn: async () => {
+ const { data, error } = await supabaseClient.rpc("search_entries", {
+ p_query: trimmedQuery,
+ p_result_limit: 30,
+ })
+
+ if (error) throw error
+
+ return ((data as SearchResultRow[]) ?? []).map(
+ (row): TimelineEntry => ({
+ entryIdentifier: row.entry_id,
+ feedIdentifier: row.feed_id,
+ feedTitle: row.feed_title,
+ customTitle: row.custom_title,
+ entryTitle: row.entry_title,
+ entryUrl: row.entry_url,
+ author: row.author,
+ summary: row.summary,
+ imageUrl: row.image_url,
+ publishedAt: row.published_at,
+ isRead: row.is_read,
+ isSaved: row.is_saved,
+ enclosureUrl: null,
+ enclosureType: null,
+ })
+ )
+ },
+ enabled: trimmedQuery.length >= 2,
+ })
+}
diff --git a/apps/web/lib/queries/use-entry-share.ts b/apps/web/lib/queries/use-entry-share.ts
new file mode 100644
index 0000000..bba7aa3
--- /dev/null
+++ b/apps/web/lib/queries/use-entry-share.ts
@@ -0,0 +1,36 @@
+"use client"
+
+import { useQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+
+interface EntryShareResult {
+ shareToken: string | null
+ isShared: boolean
+}
+
+export function useEntryShare(entryIdentifier: string | null): {
+ data: EntryShareResult | undefined
+ isLoading: boolean
+} {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useQuery({
+ queryKey: queryKeys.entryShare.single(entryIdentifier ?? ""),
+ enabled: !!entryIdentifier,
+ queryFn: async (): Promise<EntryShareResult> => {
+ const { data, error } = await supabaseClient
+ .from("shared_entries")
+ .select("share_token")
+ .eq("entry_id", entryIdentifier!)
+ .maybeSingle()
+
+ if (error) throw error
+
+ return {
+ shareToken: data?.share_token ?? null,
+ isShared: !!data,
+ }
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-entry-state-mutations.ts b/apps/web/lib/queries/use-entry-state-mutations.ts
new file mode 100644
index 0000000..5f79fc0
--- /dev/null
+++ b/apps/web/lib/queries/use-entry-state-mutations.ts
@@ -0,0 +1,133 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { TimelineEntry } from "@/lib/types/timeline"
+import type { InfiniteData } from "@tanstack/react-query"
+
+export function useToggleEntryReadState() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ entryIdentifier,
+ isRead,
+ }: {
+ entryIdentifier: string
+ isRead: boolean
+ }) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { error } = await supabaseClient
+ .from("user_entry_states")
+ .upsert(
+ {
+ user_id: user.id,
+ entry_id: entryIdentifier,
+ read: isRead,
+ read_at: isRead ? new Date().toISOString() : null,
+ },
+ { onConflict: "user_id,entry_id" }
+ )
+
+ if (error) throw error
+ },
+ onMutate: async ({ entryIdentifier, isRead }) => {
+ await queryClient.cancelQueries({ queryKey: queryKeys.timeline.all })
+
+ const previousTimeline = queryClient.getQueriesData<
+ InfiniteData<TimelineEntry[]>
+ >({ queryKey: queryKeys.timeline.all })
+
+ queryClient.setQueriesData<InfiniteData<TimelineEntry[]>>(
+ { queryKey: queryKeys.timeline.all },
+ (existingData) => {
+ if (!existingData) return existingData
+
+ return {
+ ...existingData,
+ pages: existingData.pages.map((page) =>
+ page.map((entry) =>
+ entry.entryIdentifier === entryIdentifier
+ ? { ...entry, isRead }
+ : entry
+ )
+ ),
+ }
+ }
+ )
+
+ return { previousTimeline }
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all })
+ },
+ })
+}
+
+export function useToggleEntrySavedState() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ entryIdentifier,
+ isSaved,
+ }: {
+ entryIdentifier: string
+ isSaved: boolean
+ }) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { error } = await supabaseClient
+ .from("user_entry_states")
+ .upsert(
+ {
+ user_id: user.id,
+ entry_id: entryIdentifier,
+ saved: isSaved,
+ saved_at: isSaved ? new Date().toISOString() : null,
+ },
+ { onConflict: "user_id,entry_id" }
+ )
+
+ if (error) throw error
+ },
+ onMutate: async ({ entryIdentifier, isSaved }) => {
+ await queryClient.cancelQueries({ queryKey: queryKeys.timeline.all })
+
+ queryClient.setQueriesData<InfiniteData<TimelineEntry[]>>(
+ { queryKey: queryKeys.timeline.all },
+ (existingData) => {
+ if (!existingData) return existingData
+
+ return {
+ ...existingData,
+ pages: existingData.pages.map((page) =>
+ page.map((entry) =>
+ entry.entryIdentifier === entryIdentifier
+ ? { ...entry, isSaved }
+ : entry
+ )
+ ),
+ }
+ }
+ )
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all })
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-folder-mutations.ts b/apps/web/lib/queries/use-folder-mutations.ts
new file mode 100644
index 0000000..8595a60
--- /dev/null
+++ b/apps/web/lib/queries/use-folder-mutations.ts
@@ -0,0 +1,137 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import { notify } from "@/lib/notify"
+
+export function useCreateFolder() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({ name }: { name: string }) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { error } = await supabaseClient.from("folders").insert({
+ user_id: user.id,
+ name,
+ position: 0,
+ })
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("folder created")
+ },
+ onError: (error: Error) => {
+ notify(error.message.includes("limit")
+ ? "folder limit reached for your plan"
+ : "failed to create folder: " + error.message)
+ },
+ })
+}
+
+export function useDeleteAllFolders() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async () => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ await supabaseClient
+ .from("subscriptions")
+ .update({ folder_id: null })
+ .eq("user_id", user.id)
+ .not("folder_id", "is", null)
+
+ const { error } = await supabaseClient
+ .from("folders")
+ .delete()
+ .eq("user_id", user.id)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("all folders deleted")
+ },
+ onError: (error: Error) => {
+ notify("failed to delete all folders: " + error.message)
+ },
+ })
+}
+
+export function useRenameFolder() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ folderIdentifier,
+ name,
+ }: {
+ folderIdentifier: string
+ name: string
+ }) => {
+ const { error } = await supabaseClient
+ .from("folders")
+ .update({ name })
+ .eq("id", folderIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all })
+ notify("folder renamed")
+ },
+ onError: (error: Error) => {
+ notify("failed to rename folder: " + error.message)
+ },
+ })
+}
+
+export function useDeleteFolder() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ folderIdentifier,
+ }: {
+ folderIdentifier: string
+ }) => {
+ await supabaseClient
+ .from("subscriptions")
+ .update({ folder_id: null })
+ .eq("folder_id", folderIdentifier)
+
+ const { error } = await supabaseClient
+ .from("folders")
+ .delete()
+ .eq("id", folderIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("folder deleted")
+ },
+ onError: (error: Error) => {
+ notify("failed to delete folder: " + error.message)
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-highlight-mutations.ts b/apps/web/lib/queries/use-highlight-mutations.ts
new file mode 100644
index 0000000..0e228c8
--- /dev/null
+++ b/apps/web/lib/queries/use-highlight-mutations.ts
@@ -0,0 +1,132 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import { notify } from "@/lib/notify"
+
+interface CreateHighlightParameters {
+ entryIdentifier: string
+ highlightedText: string
+ textOffset: number
+ textLength: number
+ textPrefix: string
+ textSuffix: string
+ note?: string | null
+ color?: string
+}
+
+export function useCreateHighlight() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (parameters: CreateHighlightParameters) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { data, error } = await supabaseClient
+ .from("user_highlights")
+ .insert({
+ user_id: user.id,
+ entry_id: parameters.entryIdentifier,
+ highlighted_text: parameters.highlightedText,
+ text_offset: parameters.textOffset,
+ text_length: parameters.textLength,
+ text_prefix: parameters.textPrefix,
+ text_suffix: parameters.textSuffix,
+ note: parameters.note ?? null,
+ color: parameters.color ?? "yellow",
+ })
+ .select("id")
+ .single()
+
+ if (error) throw error
+
+ return data.id as string
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier),
+ })
+ queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("text highlighted")
+ },
+ onError: (error: Error) => {
+ notify(
+ error.message.includes("limit")
+ ? "highlight limit reached for your plan"
+ : "failed to create highlight"
+ )
+ },
+ })
+}
+
+export function useUpdateHighlightNote() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ highlightIdentifier,
+ note,
+ }: {
+ highlightIdentifier: string
+ note: string | null
+ entryIdentifier: string
+ }) => {
+ const { error } = await supabaseClient
+ .from("user_highlights")
+ .update({ note })
+ .eq("id", highlightIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier),
+ })
+ queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all })
+ notify("note updated")
+ },
+ onError: () => {
+ notify("failed to update note")
+ },
+ })
+}
+
+export function useDeleteHighlight() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ highlightIdentifier,
+ }: {
+ highlightIdentifier: string
+ entryIdentifier: string
+ }) => {
+ const { error } = await supabaseClient
+ .from("user_highlights")
+ .delete()
+ .eq("id", highlightIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier),
+ })
+ queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("highlight removed")
+ },
+ onError: () => {
+ notify("failed to remove highlight")
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-mark-all-as-read.ts b/apps/web/lib/queries/use-mark-all-as-read.ts
new file mode 100644
index 0000000..fdda661
--- /dev/null
+++ b/apps/web/lib/queries/use-mark-all-as-read.ts
@@ -0,0 +1,48 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import { notify } from "@/lib/notify"
+
+export function useMarkAllAsRead() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ feedIdentifier,
+ folderIdentifier,
+ readState = true,
+ }: {
+ feedIdentifier?: string | null
+ folderIdentifier?: string | null
+ readState?: boolean
+ } = {}) => {
+ const { data, error } = await supabaseClient.rpc("mark_all_as_read", {
+ p_feed_id: feedIdentifier ?? null,
+ p_folder_id: folderIdentifier ?? null,
+ p_read_state: readState,
+ })
+
+ if (error) throw error
+
+ return data as number
+ },
+ onSuccess: (affectedCount, variables) => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.unreadCounts.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all })
+
+ const action = variables?.readState === false ? "unread" : "read"
+
+ if (affectedCount > 0) {
+ notify(`marked ${affectedCount} entries as ${action}`)
+ }
+ },
+ onError: (_, variables) => {
+ const action = variables?.readState === false ? "unread" : "read"
+ notify(`failed to mark entries as ${action}`)
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-muted-keyword-mutations.ts b/apps/web/lib/queries/use-muted-keyword-mutations.ts
new file mode 100644
index 0000000..67bcf33
--- /dev/null
+++ b/apps/web/lib/queries/use-muted-keyword-mutations.ts
@@ -0,0 +1,68 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import { notify } from "@/lib/notify"
+
+export function useAddMutedKeyword() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({ keyword }: { keyword: string }) => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { error } = await supabaseClient.from("muted_keywords").insert({
+ user_id: user.id,
+ keyword: keyword.toLowerCase().trim(),
+ })
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("keyword muted")
+ },
+ onError: (error: Error) => {
+ notify(error.message.includes("limit")
+ ? "muted keyword limit reached for your plan"
+ : "failed to mute keyword: " + error.message)
+ },
+ })
+}
+
+export function useDeleteMutedKeyword() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ keywordIdentifier,
+ }: {
+ keywordIdentifier: string
+ }) => {
+ const { error } = await supabaseClient
+ .from("muted_keywords")
+ .delete()
+ .eq("id", keywordIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("keyword unmuted")
+ },
+ onError: (error: Error) => {
+ notify("failed to unmute keyword: " + error.message)
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-muted-keywords.ts b/apps/web/lib/queries/use-muted-keywords.ts
new file mode 100644
index 0000000..ce1b53e
--- /dev/null
+++ b/apps/web/lib/queries/use-muted-keywords.ts
@@ -0,0 +1,30 @@
+"use client"
+
+import { useQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { MutedKeyword } from "@/lib/types/user-profile"
+
+export function useMutedKeywords() {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useQuery({
+ queryKey: queryKeys.mutedKeywords.all,
+ queryFn: async () => {
+ const { data, error } = await supabaseClient
+ .from("muted_keywords")
+ .select("id, keyword, created_at")
+ .order("created_at", { ascending: false })
+
+ if (error) throw error
+
+ const keywords: MutedKeyword[] = (data ?? []).map((row) => ({
+ identifier: row.id,
+ keyword: row.keyword,
+ createdAt: row.created_at,
+ }))
+
+ return keywords
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-saved-entries.ts b/apps/web/lib/queries/use-saved-entries.ts
new file mode 100644
index 0000000..bdfcec9
--- /dev/null
+++ b/apps/web/lib/queries/use-saved-entries.ts
@@ -0,0 +1,88 @@
+"use client"
+
+import { useInfiniteQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { TimelineEntry } from "@/lib/types/timeline"
+
+const SAVED_PAGE_SIZE = 50
+
+interface SavedEntryRow {
+ entry_id: string
+ read: boolean
+ saved: boolean
+ saved_at: string
+ entries: {
+ id: string
+ feed_id: string
+ title: string | null
+ url: string | null
+ author: string | null
+ summary: string | null
+ image_url: string | null
+ published_at: string | null
+ enclosure_url: string | null
+ enclosure_type: string | null
+ feeds: {
+ title: string | null
+ }
+ }
+}
+
+function mapSavedRowToTimelineEntry(row: SavedEntryRow): TimelineEntry {
+ return {
+ entryIdentifier: row.entries.id,
+ feedIdentifier: row.entries.feed_id,
+ feedTitle: row.entries.feeds?.title ?? "",
+ customTitle: null,
+ entryTitle: row.entries.title ?? "",
+ entryUrl: row.entries.url ?? "",
+ author: row.entries.author,
+ summary: row.entries.summary,
+ imageUrl: row.entries.image_url,
+ publishedAt: row.entries.published_at ?? "",
+ isRead: row.read,
+ isSaved: row.saved,
+ enclosureUrl: row.entries.enclosure_url,
+ enclosureType: row.entries.enclosure_type,
+ }
+}
+
+export function useSavedEntries() {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useInfiniteQuery({
+ queryKey: queryKeys.savedEntries.all,
+ queryFn: async ({
+ pageParam,
+ }: {
+ pageParam: string | undefined
+ }) => {
+ let query = supabaseClient
+ .from("user_entry_states")
+ .select(
+ "entry_id, read, saved, saved_at, entries!inner(id, feed_id, title, url, author, summary, image_url, published_at, enclosure_url, enclosure_type, feeds!inner(title))"
+ )
+ .eq("saved", true)
+ .order("saved_at", { ascending: false })
+ .limit(SAVED_PAGE_SIZE)
+
+ if (pageParam) {
+ query = query.lt("saved_at", pageParam)
+ }
+
+ const { data, error } = await query
+
+ if (error) throw error
+
+ return ((data as unknown as SavedEntryRow[]) ?? []).map(
+ mapSavedRowToTimelineEntry
+ )
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage: TimelineEntry[]) => {
+ if (lastPage.length < SAVED_PAGE_SIZE) return undefined
+ return lastPage[lastPage.length - 1].publishedAt
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-subscribe-to-feed.ts b/apps/web/lib/queries/use-subscribe-to-feed.ts
new file mode 100644
index 0000000..5e585a9
--- /dev/null
+++ b/apps/web/lib/queries/use-subscribe-to-feed.ts
@@ -0,0 +1,37 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import { notify } from "@/lib/notify"
+
+export function useSubscribeToFeed() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (parameters: {
+ feedUrl: string
+ folderIdentifier?: string | null
+ customTitle?: string | null
+ }) => {
+ const { data, error } = await supabaseClient.rpc("subscribe_to_feed", {
+ feed_url: parameters.feedUrl,
+ target_folder_id: parameters.folderIdentifier ?? undefined,
+ feed_custom_title: parameters.customTitle ?? undefined,
+ })
+
+ if (error) throw error
+
+ return data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all })
+ notify("feed added successfully")
+ },
+ onError: (error: Error) => {
+ notify("failed to add feed: " + error.message)
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-subscription-mutations.ts b/apps/web/lib/queries/use-subscription-mutations.ts
new file mode 100644
index 0000000..3b4b3ba
--- /dev/null
+++ b/apps/web/lib/queries/use-subscription-mutations.ts
@@ -0,0 +1,158 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import { notify } from "@/lib/notify"
+
+export function useUpdateSubscriptionTitle() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ subscriptionIdentifier,
+ customTitle,
+ }: {
+ subscriptionIdentifier: string
+ customTitle: string | null
+ }) => {
+ const { error } = await supabaseClient
+ .from("subscriptions")
+ .update({ custom_title: customTitle })
+ .eq("id", subscriptionIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all })
+ notify("title updated")
+ },
+ onError: (error: Error) => {
+ notify("failed to update title: " + error.message)
+ },
+ })
+}
+
+export function useMoveSubscriptionToFolder() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ subscriptionIdentifier,
+ folderIdentifier,
+ }: {
+ subscriptionIdentifier: string
+ folderIdentifier: string | null
+ feedTitle?: string
+ sourceFolderName?: string
+ folderName?: string
+ }) => {
+ const { error } = await supabaseClient
+ .from("subscriptions")
+ .update({ folder_id: folderIdentifier })
+ .eq("id", subscriptionIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all })
+ const source = variables.sourceFolderName ?? "no folder"
+ const destination = variables.folderName ?? "no folder"
+ const feedLabel = variables.feedTitle ?? "feed"
+ notify(`moved "${feedLabel}" from ${source} to ${destination}`)
+ },
+ onError: (error: Error) => {
+ notify("failed to move feed: " + error.message)
+ },
+ })
+}
+
+export function useUnsubscribe() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ subscriptionIdentifier,
+ }: {
+ subscriptionIdentifier: string
+ }) => {
+ const { error } = await supabaseClient
+ .from("subscriptions")
+ .delete()
+ .eq("id", subscriptionIdentifier)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("unsubscribed")
+ },
+ onError: (error: Error) => {
+ notify("failed to unsubscribe: " + error.message)
+ },
+ })
+}
+
+export function useUnsubscribeAll() {
+ const supabaseClient = createSupabaseBrowserClient()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async () => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { error } = await supabaseClient
+ .from("subscriptions")
+ .delete()
+ .eq("user_id", user.id)
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.unreadCounts.all })
+ queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all })
+ notify("all feeds removed")
+ },
+ onError: (error: Error) => {
+ notify("failed to remove all feeds: " + error.message)
+ },
+ })
+}
+
+export function useRequestFeedRefresh() {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useMutation({
+ mutationFn: async ({
+ subscriptionIdentifier,
+ }: {
+ subscriptionIdentifier: string
+ }) => {
+ const { error } = await supabaseClient.rpc("request_feed_refresh", {
+ target_subscription_id: subscriptionIdentifier,
+ })
+
+ if (error) throw error
+ },
+ onSuccess: () => {
+ notify("refresh requested")
+ },
+ onError: (error: Error) => {
+ notify(error.message.includes("Pro")
+ ? "manual refresh requires a pro subscription"
+ : "failed to request refresh: " + error.message)
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-subscriptions.ts b/apps/web/lib/queries/use-subscriptions.ts
new file mode 100644
index 0000000..ebf099d
--- /dev/null
+++ b/apps/web/lib/queries/use-subscriptions.ts
@@ -0,0 +1,78 @@
+"use client"
+
+import { useQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { Folder, Subscription } from "@/lib/types/subscription"
+
+interface SubscriptionRow {
+ id: string
+ feed_id: string
+ folder_id: string | null
+ custom_title: string | null
+ position: number
+ feeds: {
+ title: string | null
+ url: string
+ consecutive_failures: number
+ last_fetch_error: string | null
+ last_fetched_at: string | null
+ fetch_interval_seconds: number
+ feed_type: string | null
+ }
+}
+
+interface FolderRow {
+ id: string
+ name: string
+ position: number
+}
+
+export function useSubscriptions() {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useQuery({
+ queryKey: queryKeys.subscriptions.all,
+ queryFn: async () => {
+ const [subscriptionsResult, foldersResult] = await Promise.all([
+ supabaseClient
+ .from("subscriptions")
+ .select("id, feed_id, folder_id, custom_title, position, feeds(title, url, consecutive_failures, last_fetch_error, last_fetched_at, fetch_interval_seconds, feed_type)")
+ .order("position", { ascending: true }),
+ supabaseClient
+ .from("folders")
+ .select("id, name, position")
+ .order("position", { ascending: true }),
+ ])
+
+ if (subscriptionsResult.error) throw subscriptionsResult.error
+ if (foldersResult.error) throw foldersResult.error
+
+ const subscriptions: Subscription[] = (
+ (subscriptionsResult.data as unknown as SubscriptionRow[]) ?? []
+ ).map((row) => ({
+ subscriptionIdentifier: row.id,
+ feedIdentifier: row.feed_id,
+ folderIdentifier: row.folder_id,
+ customTitle: row.custom_title,
+ feedTitle: row.feeds?.title ?? "",
+ feedUrl: row.feeds?.url ?? "",
+ consecutiveFailures: row.feeds?.consecutive_failures ?? 0,
+ lastFetchError: row.feeds?.last_fetch_error ?? null,
+ lastFetchedAt: row.feeds?.last_fetched_at ?? null,
+ fetchIntervalSeconds: row.feeds?.fetch_interval_seconds ?? 3600,
+ feedType: row.feeds?.feed_type ?? null,
+ }))
+
+ const folders: Folder[] = (
+ (foldersResult.data as unknown as FolderRow[]) ?? []
+ ).map((row) => ({
+ folderIdentifier: row.id,
+ name: row.name,
+ position: row.position,
+ }))
+
+ return { subscriptions, folders }
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-timeline.ts b/apps/web/lib/queries/use-timeline.ts
new file mode 100644
index 0000000..5a38aba
--- /dev/null
+++ b/apps/web/lib/queries/use-timeline.ts
@@ -0,0 +1,78 @@
+"use client"
+
+import { useInfiniteQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { TimelineEntry } from "@/lib/types/timeline"
+
+const TIMELINE_PAGE_SIZE = 50
+
+interface TimelineRow {
+ entry_id: string
+ feed_id: string
+ feed_title: string
+ custom_title: string | null
+ entry_title: string
+ entry_url: string
+ author: string | null
+ summary: string | null
+ image_url: string | null
+ published_at: string
+ is_read: boolean
+ is_saved: boolean
+ enclosure_url: string | null
+ enclosure_type: string | null
+}
+
+function mapRowToTimelineEntry(row: TimelineRow): TimelineEntry {
+ return {
+ entryIdentifier: row.entry_id,
+ feedIdentifier: row.feed_id,
+ feedTitle: row.feed_title,
+ customTitle: row.custom_title,
+ entryTitle: row.entry_title,
+ entryUrl: row.entry_url,
+ author: row.author,
+ summary: row.summary,
+ imageUrl: row.image_url,
+ publishedAt: row.published_at,
+ isRead: row.is_read,
+ isSaved: row.is_saved,
+ enclosureUrl: row.enclosure_url,
+ enclosureType: row.enclosure_type,
+ }
+}
+
+export function useTimeline(
+ folderIdentifier?: string | null,
+ feedIdentifier?: string | null,
+ unreadOnly?: boolean
+) {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useInfiniteQuery({
+ queryKey: queryKeys.timeline.list(folderIdentifier, feedIdentifier, unreadOnly),
+ queryFn: async ({
+ pageParam,
+ }: {
+ pageParam: string | undefined
+ }) => {
+ const { data, error } = await supabaseClient.rpc("get_timeline", {
+ target_folder_id: folderIdentifier ?? undefined,
+ target_feed_id: feedIdentifier ?? undefined,
+ result_limit: TIMELINE_PAGE_SIZE,
+ pagination_cursor: pageParam ?? undefined,
+ unread_only: unreadOnly ?? false,
+ })
+
+ if (error) throw error
+
+ return ((data as TimelineRow[]) ?? []).map(mapRowToTimelineEntry)
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage: TimelineEntry[]) => {
+ if (lastPage.length < TIMELINE_PAGE_SIZE) return undefined
+ return lastPage[lastPage.length - 1].publishedAt
+ },
+ })
+}
diff --git a/apps/web/lib/queries/use-unread-counts.ts b/apps/web/lib/queries/use-unread-counts.ts
new file mode 100644
index 0000000..75deccb
--- /dev/null
+++ b/apps/web/lib/queries/use-unread-counts.ts
@@ -0,0 +1,32 @@
+"use client"
+
+import { useQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+
+interface UnreadCountRow {
+ feed_id: string
+ unread_count: number
+}
+
+export function useUnreadCounts() {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useQuery({
+ queryKey: queryKeys.unreadCounts.all,
+ queryFn: async () => {
+ const { data, error } = await supabaseClient.rpc("get_unread_counts")
+
+ if (error) throw error
+
+ const countsByFeedIdentifier: Record<string, number> = {}
+
+ for (const row of (data as UnreadCountRow[]) ?? []) {
+ countsByFeedIdentifier[row.feed_id] = row.unread_count
+ }
+
+ return countsByFeedIdentifier
+ },
+ refetchInterval: 60_000,
+ })
+}
diff --git a/apps/web/lib/queries/use-user-profile.ts b/apps/web/lib/queries/use-user-profile.ts
new file mode 100644
index 0000000..760f970
--- /dev/null
+++ b/apps/web/lib/queries/use-user-profile.ts
@@ -0,0 +1,46 @@
+"use client"
+
+import { useQuery } from "@tanstack/react-query"
+import { createSupabaseBrowserClient } from "@/lib/supabase/client"
+import { queryKeys } from "./query-keys"
+import type { UserProfile } from "@/lib/types/user-profile"
+
+export function useUserProfile() {
+ const supabaseClient = createSupabaseBrowserClient()
+
+ return useQuery({
+ queryKey: queryKeys.userProfile.all,
+ queryFn: async () => {
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ if (!user) throw new Error("Not authenticated")
+
+ const { data, error } = await supabaseClient
+ .from("user_profiles")
+ .select(
+ "id, display_name, tier, feed_count, folder_count, muted_keyword_count, custom_feed_count, stripe_subscription_status, stripe_current_period_end"
+ )
+ .eq("id", user.id)
+ .single()
+
+ if (error) throw error
+
+ const profile: UserProfile = {
+ identifier: data.id,
+ email: user.email ?? null,
+ displayName: data.display_name,
+ tier: data.tier,
+ feedCount: data.feed_count,
+ folderCount: data.folder_count,
+ mutedKeywordCount: data.muted_keyword_count,
+ customFeedCount: data.custom_feed_count,
+ stripeSubscriptionStatus: data.stripe_subscription_status,
+ stripeCurrentPeriodEnd: data.stripe_current_period_end,
+ }
+
+ return profile
+ },
+ })
+}
diff --git a/apps/web/lib/query-client.ts b/apps/web/lib/query-client.ts
new file mode 100644
index 0000000..82be2df
--- /dev/null
+++ b/apps/web/lib/query-client.ts
@@ -0,0 +1,12 @@
+import { QueryClient } from "@tanstack/react-query"
+
+export function createQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 60_000,
+ refetchOnWindowFocus: false,
+ },
+ },
+ })
+}
diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts
new file mode 100644
index 0000000..4016781
--- /dev/null
+++ b/apps/web/lib/rate-limit.ts
@@ -0,0 +1,24 @@
+const requestTimestamps = new Map<string, number[]>()
+
+export function rateLimit(
+ identifier: string,
+ limit: number,
+ windowMilliseconds: number
+): { success: boolean; remaining: number } {
+ const now = Date.now()
+ const timestamps = requestTimestamps.get(identifier) ?? []
+ const windowStart = now - windowMilliseconds
+ const recentTimestamps = timestamps.filter(
+ (timestamp) => timestamp > windowStart
+ )
+
+ if (recentTimestamps.length >= limit) {
+ requestTimestamps.set(identifier, recentTimestamps)
+ return { success: false, remaining: 0 }
+ }
+
+ recentTimestamps.push(now)
+ requestTimestamps.set(identifier, recentTimestamps)
+
+ return { success: true, remaining: limit - recentTimestamps.length }
+}
diff --git a/apps/web/lib/sanitize.ts b/apps/web/lib/sanitize.ts
new file mode 100644
index 0000000..b63cee1
--- /dev/null
+++ b/apps/web/lib/sanitize.ts
@@ -0,0 +1,43 @@
+import sanitizeHtml from "sanitize-html"
+
+const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
+ allowedTags: [
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "p",
+ "a",
+ "ul",
+ "ol",
+ "li",
+ "blockquote",
+ "pre",
+ "code",
+ "em",
+ "strong",
+ "del",
+ "br",
+ "hr",
+ "img",
+ "figure",
+ "figcaption",
+ "table",
+ "thead",
+ "tbody",
+ "tr",
+ "th",
+ "td",
+ ],
+ allowedAttributes: {
+ a: ["href", "title", "rel"],
+ img: ["src", "alt", "title", "width", "height"],
+ },
+ allowedSchemes: ["http", "https"],
+}
+
+export function sanitizeEntryContent(htmlContent: string): string {
+ return sanitizeHtml(htmlContent, SANITIZE_OPTIONS)
+}
diff --git a/apps/web/lib/stores/notification-store.ts b/apps/web/lib/stores/notification-store.ts
new file mode 100644
index 0000000..d7eee57
--- /dev/null
+++ b/apps/web/lib/stores/notification-store.ts
@@ -0,0 +1,66 @@
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+
+const MAXIMUM_NOTIFICATIONS = 50
+
+export interface StoredNotification {
+ identifier: string
+ message: string
+ timestamp: string
+ type: "info" | "success" | "error"
+ actionUrl?: string
+}
+
+interface NotificationState {
+ notifications: StoredNotification[]
+ lastViewedAt: string | null
+
+ addNotification: (
+ message: string,
+ type?: "info" | "success" | "error",
+ actionUrl?: string
+ ) => void
+ dismissNotification: (identifier: string) => void
+ clearAllNotifications: () => void
+ markAllAsViewed: () => void
+}
+
+export const useNotificationStore = create<NotificationState>()(
+ persist(
+ (set) => ({
+ notifications: [],
+ lastViewedAt: null,
+
+ addNotification: (message, type = "info", actionUrl) =>
+ set((state) => {
+ const newNotification: StoredNotification = {
+ identifier: crypto.randomUUID(),
+ message,
+ timestamp: new Date().toISOString(),
+ type,
+ ...(actionUrl ? { actionUrl } : {}),
+ }
+ const updated = [newNotification, ...state.notifications].slice(
+ 0,
+ MAXIMUM_NOTIFICATIONS
+ )
+ return { notifications: updated }
+ }),
+
+ dismissNotification: (identifier) =>
+ set((state) => ({
+ notifications: state.notifications.filter(
+ (notification) => notification.identifier !== identifier
+ ),
+ })),
+
+ clearAllNotifications: () => set({ notifications: [] }),
+
+ markAllAsViewed: () =>
+ set({ lastViewedAt: new Date().toISOString() }),
+ }),
+ {
+ name: "asa-news-notifications",
+ }
+ )
+)
diff --git a/apps/web/lib/stores/user-interface-store.ts b/apps/web/lib/stores/user-interface-store.ts
new file mode 100644
index 0000000..468542d
--- /dev/null
+++ b/apps/web/lib/stores/user-interface-store.ts
@@ -0,0 +1,135 @@
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+
+type EntryListViewMode = "compact" | "comfortable" | "expanded"
+
+type DisplayDensity = "compact" | "default" | "spacious"
+
+type FocusedPanel = "sidebar" | "entryList" | "detailPanel"
+
+type SettingsTab =
+ | "subscriptions"
+ | "folders"
+ | "muted-keywords"
+ | "custom-feeds"
+ | "import-export"
+ | "appearance"
+ | "account"
+ | "security"
+ | "billing"
+ | "api"
+ | "danger"
+
+interface UserInterfaceState {
+ isSidebarCollapsed: boolean
+ isCommandPaletteOpen: boolean
+ isAddFeedDialogOpen: boolean
+ isSearchOpen: boolean
+ selectedEntryIdentifier: string | null
+ focusedEntryIdentifier: string | null
+ focusedPanel: FocusedPanel
+ focusedSidebarIndex: number
+ entryListViewMode: EntryListViewMode
+ displayDensity: DisplayDensity
+ activeSettingsTab: SettingsTab
+ showFeedFavicons: boolean
+ focusFollowsInteraction: boolean
+ expandedFolderIdentifiers: string[]
+ navigableEntryIdentifiers: string[]
+
+ toggleSidebar: () => void
+ setSidebarCollapsed: (isCollapsed: boolean) => void
+ setCommandPaletteOpen: (isOpen: boolean) => void
+ setAddFeedDialogOpen: (isOpen: boolean) => void
+ setSearchOpen: (isOpen: boolean) => void
+ setSelectedEntryIdentifier: (identifier: string | null) => void
+ setFocusedEntryIdentifier: (identifier: string | null) => void
+ setFocusedPanel: (panel: FocusedPanel) => void
+ setFocusedSidebarIndex: (index: number) => void
+ setEntryListViewMode: (mode: EntryListViewMode) => void
+ setDisplayDensity: (density: DisplayDensity) => void
+ setActiveSettingsTab: (tab: SettingsTab) => void
+ setShowFeedFavicons: (show: boolean) => void
+ setFocusFollowsInteraction: (enabled: boolean) => void
+ toggleFolderExpansion: (folderIdentifier: string) => void
+ setNavigableEntryIdentifiers: (identifiers: string[]) => void
+}
+
+export const useUserInterfaceStore = create<UserInterfaceState>()(
+ persist(
+ (set) => ({
+ isSidebarCollapsed: false,
+ isCommandPaletteOpen: false,
+ isAddFeedDialogOpen: false,
+ isSearchOpen: false,
+ selectedEntryIdentifier: null,
+ focusedEntryIdentifier: null,
+ focusedPanel: "entryList",
+ focusedSidebarIndex: 0,
+ entryListViewMode: "comfortable",
+ displayDensity: "default",
+ activeSettingsTab: "subscriptions",
+ showFeedFavicons: true,
+ focusFollowsInteraction: false,
+ expandedFolderIdentifiers: [],
+ navigableEntryIdentifiers: [],
+
+ toggleSidebar: () =>
+ set((state) => ({ isSidebarCollapsed: !state.isSidebarCollapsed })),
+
+ setSidebarCollapsed: (isCollapsed) =>
+ set({ isSidebarCollapsed: isCollapsed }),
+
+ setCommandPaletteOpen: (isOpen) => set({ isCommandPaletteOpen: isOpen }),
+
+ setAddFeedDialogOpen: (isOpen) => set({ isAddFeedDialogOpen: isOpen }),
+
+ setSearchOpen: (isOpen) => set({ isSearchOpen: isOpen }),
+
+ setSelectedEntryIdentifier: (identifier) =>
+ set({ selectedEntryIdentifier: identifier }),
+
+ setFocusedEntryIdentifier: (identifier) =>
+ set({ focusedEntryIdentifier: identifier }),
+
+ setFocusedPanel: (panel) => set({ focusedPanel: panel }),
+
+ setFocusedSidebarIndex: (index) => set({ focusedSidebarIndex: index }),
+
+ setEntryListViewMode: (mode) => set({ entryListViewMode: mode }),
+
+ setDisplayDensity: (density) => set({ displayDensity: density }),
+
+ setActiveSettingsTab: (tab) => set({ activeSettingsTab: tab }),
+
+ setShowFeedFavicons: (show) => set({ showFeedFavicons: show }),
+
+ setFocusFollowsInteraction: (enabled) =>
+ set({ focusFollowsInteraction: enabled }),
+
+ toggleFolderExpansion: (folderIdentifier) =>
+ set((state) => {
+ const current = state.expandedFolderIdentifiers
+ const isExpanded = current.includes(folderIdentifier)
+ return {
+ expandedFolderIdentifiers: isExpanded
+ ? current.filter((id) => id !== folderIdentifier)
+ : [...current, folderIdentifier],
+ }
+ }),
+
+ setNavigableEntryIdentifiers: (identifiers) =>
+ set({ navigableEntryIdentifiers: identifiers }),
+ }),
+ {
+ name: "asa-news-ui-preferences",
+ partialize: (state) => ({
+ entryListViewMode: state.entryListViewMode,
+ displayDensity: state.displayDensity,
+ showFeedFavicons: state.showFeedFavicons,
+ focusFollowsInteraction: state.focusFollowsInteraction,
+ expandedFolderIdentifiers: state.expandedFolderIdentifiers,
+ }),
+ }
+ )
+)
diff --git a/apps/web/lib/stripe.ts b/apps/web/lib/stripe.ts
new file mode 100644
index 0000000..1955c02
--- /dev/null
+++ b/apps/web/lib/stripe.ts
@@ -0,0 +1,11 @@
+import Stripe from "stripe"
+
+let stripeInstance: Stripe | null = null
+
+export function getStripe(): Stripe {
+ if (!stripeInstance) {
+ stripeInstance = new Stripe(process.env.STRIPE_SECRET_KEY!)
+ }
+
+ return stripeInstance
+}
diff --git a/apps/web/lib/supabase/admin.ts b/apps/web/lib/supabase/admin.ts
new file mode 100644
index 0000000..5f5684d
--- /dev/null
+++ b/apps/web/lib/supabase/admin.ts
@@ -0,0 +1,8 @@
+import { createClient } from "@supabase/supabase-js"
+
+export function createSupabaseAdminClient() {
+ return createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SERVICE_ROLE_KEY!
+ )
+}
diff --git a/apps/web/lib/supabase/client.ts b/apps/web/lib/supabase/client.ts
new file mode 100644
index 0000000..c6747fb
--- /dev/null
+++ b/apps/web/lib/supabase/client.ts
@@ -0,0 +1,8 @@
+import { createBrowserClient } from "@supabase/ssr"
+
+export function createSupabaseBrowserClient() {
+ return createBrowserClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
+ )
+}
diff --git a/apps/web/lib/supabase/middleware.ts b/apps/web/lib/supabase/middleware.ts
new file mode 100644
index 0000000..038e7c0
--- /dev/null
+++ b/apps/web/lib/supabase/middleware.ts
@@ -0,0 +1,39 @@
+import { createServerClient } from "@supabase/ssr"
+import { NextResponse, type NextRequest } from "next/server"
+
+export async function updateSupabaseSession(request: NextRequest) {
+ let supabaseResponse = NextResponse.next({
+ request,
+ })
+
+ const supabaseClient = createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ {
+ cookies: {
+ getAll() {
+ return request.cookies.getAll()
+ },
+ setAll(cookiesToSet) {
+ cookiesToSet.forEach(({ name, value }) =>
+ request.cookies.set(name, value)
+ )
+
+ supabaseResponse = NextResponse.next({
+ request,
+ })
+
+ cookiesToSet.forEach(({ name, value, options }) =>
+ supabaseResponse.cookies.set(name, value, options)
+ )
+ },
+ },
+ }
+ )
+
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser()
+
+ return { user, supabaseResponse }
+}
diff --git a/apps/web/lib/supabase/server.ts b/apps/web/lib/supabase/server.ts
new file mode 100644
index 0000000..f781393
--- /dev/null
+++ b/apps/web/lib/supabase/server.ts
@@ -0,0 +1,27 @@
+import { createServerClient } from "@supabase/ssr"
+import { cookies } from "next/headers"
+
+export async function createSupabaseServerClient() {
+ const cookieStore = await cookies()
+
+ return createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ {
+ cookies: {
+ getAll() {
+ return cookieStore.getAll()
+ },
+ setAll(cookiesToSet) {
+ try {
+ cookiesToSet.forEach(({ name, value, options }) =>
+ cookieStore.set(name, value, options)
+ )
+ } catch {
+ // no-op
+ }
+ },
+ },
+ }
+ )
+}
diff --git a/apps/web/lib/types/custom-feed.ts b/apps/web/lib/types/custom-feed.ts
new file mode 100644
index 0000000..d729a12
--- /dev/null
+++ b/apps/web/lib/types/custom-feed.ts
@@ -0,0 +1,8 @@
+export interface CustomFeed {
+ identifier: string
+ name: string
+ query: string
+ matchMode: "and" | "or"
+ sourceFolderIdentifier: string | null
+ position: number
+}
diff --git a/apps/web/lib/types/highlight.ts b/apps/web/lib/types/highlight.ts
new file mode 100644
index 0000000..60ec53c
--- /dev/null
+++ b/apps/web/lib/types/highlight.ts
@@ -0,0 +1,17 @@
+export interface Highlight {
+ identifier: string
+ entryIdentifier: string
+ highlightedText: string
+ note: string | null
+ textOffset: number
+ textLength: number
+ textPrefix: string
+ textSuffix: string
+ color: string
+ createdAt: string
+}
+
+export interface HighlightWithEntryContext extends Highlight {
+ entryTitle: string | null
+ feedTitle: string | null
+}
diff --git a/apps/web/lib/types/subscription.ts b/apps/web/lib/types/subscription.ts
new file mode 100644
index 0000000..36d16d4
--- /dev/null
+++ b/apps/web/lib/types/subscription.ts
@@ -0,0 +1,19 @@
+export interface Folder {
+ folderIdentifier: string
+ name: string
+ position: number
+}
+
+export interface Subscription {
+ subscriptionIdentifier: string
+ feedIdentifier: string
+ folderIdentifier: string | null
+ customTitle: string | null
+ feedTitle: string
+ feedUrl: string
+ consecutiveFailures: number
+ lastFetchError: string | null
+ lastFetchedAt: string | null
+ fetchIntervalSeconds: number
+ feedType: string | null
+}
diff --git a/apps/web/lib/types/timeline.ts b/apps/web/lib/types/timeline.ts
new file mode 100644
index 0000000..888e428
--- /dev/null
+++ b/apps/web/lib/types/timeline.ts
@@ -0,0 +1,16 @@
+export interface TimelineEntry {
+ entryIdentifier: string
+ feedIdentifier: string
+ feedTitle: string
+ customTitle: string | null
+ entryTitle: string
+ entryUrl: string
+ author: string | null
+ summary: string | null
+ imageUrl: string | null
+ publishedAt: string
+ isRead: boolean
+ isSaved: boolean
+ enclosureUrl: string | null
+ enclosureType: string | null
+}
diff --git a/apps/web/lib/types/user-profile.ts b/apps/web/lib/types/user-profile.ts
new file mode 100644
index 0000000..68eeb75
--- /dev/null
+++ b/apps/web/lib/types/user-profile.ts
@@ -0,0 +1,18 @@
+export interface UserProfile {
+ identifier: string
+ email: string | null
+ displayName: string | null
+ tier: "free" | "pro" | "developer"
+ feedCount: number
+ folderCount: number
+ mutedKeywordCount: number
+ customFeedCount: number
+ stripeSubscriptionStatus: string | null
+ stripeCurrentPeriodEnd: string | null
+}
+
+export interface MutedKeyword {
+ identifier: string
+ keyword: string
+ createdAt: string
+}
diff --git a/apps/web/lib/utilities.ts b/apps/web/lib/utilities.ts
new file mode 100644
index 0000000..c4b84f2
--- /dev/null
+++ b/apps/web/lib/utilities.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function classNames(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}