diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/lib | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
Diffstat (limited to 'apps/web/lib')
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, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +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)) +} |