summaryrefslogtreecommitdiff
path: root/apps/web/lib/queries
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/queries
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/queries')
-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
21 files changed, 1590 insertions, 0 deletions
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
+ },
+ })
+}