From c8a35e505f0e2dff69489454989e8fff720445e7 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 13 May 2026 12:40:58 -0600 Subject: [PATCH] feat(admin,providers,contexts): integrate react-query for admin panels and shared provider state Adopt @tanstack/react-query for data fetching and cache management in admin settings, provider management, shared provider hooks, and context providers. Replace legacy useState/useEffect data loading with react-query's useQuery and useMutation patterns. Refactor document, rate-limit, and shared provider contexts to use query keys and cache invalidation for consistent state across the app. Add QueryClientProvider to root providers. Update package.json to include react-query dependency. --- package.json | 1 + pnpm-lock.yaml | 18 ++ src/app/providers.tsx | 59 +++--- src/components/admin/AdminFeaturesPanel.tsx | 151 +++++++------- src/components/admin/AdminProvidersPanel.tsx | 198 +++++++++---------- src/contexts/AuthRateLimitContext.tsx | 114 ++++++----- src/contexts/DocumentContext.tsx | 72 ++++--- src/contexts/TTSContext.tsx | 7 +- src/hooks/useSharedProviders.ts | 80 +++----- 9 files changed, 349 insertions(+), 351 deletions(-) diff --git a/package.json b/package.json index 8f8ef8b..7f72bfb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@aws-sdk/s3-request-presigner": "^3.987.0", "@headlessui/react": "^2.2.9", "@napi-rs/canvas": "^0.1.91", + "@tanstack/react-query": "^5.100.10", "@types/archiver": "^7.0.0", "@vercel/analytics": "^1.6.1", "archiver": "^7.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5e9f11..146db0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@napi-rs/canvas': specifier: ^0.1.91 version: 0.1.91 + '@tanstack/react-query': + specifier: ^5.100.10 + version: 5.100.10(react@19.2.4) '@types/archiver': specifier: ^7.0.0 version: 7.0.0 @@ -1382,6 +1385,14 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/query-core@5.100.10': + resolution: {integrity: sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==} + + '@tanstack/react-query@5.100.10': + resolution: {integrity: sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-virtual@3.13.18': resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} peerDependencies: @@ -5486,6 +5497,13 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.19 + '@tanstack/query-core@5.100.10': {} + + '@tanstack/react-query@5.100.10(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.100.10 + react: 19.2.4 + '@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/virtual-core': 3.13.18 diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 5c887eb..361f3d3 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,7 +1,8 @@ 'use client'; -import { ReactNode } from 'react'; +import { ReactNode, useState } from 'react'; import { usePathname } from 'next/navigation'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { DocumentProvider } from '@/contexts/DocumentContext'; import { PDFProvider } from '@/contexts/PDFContext'; @@ -26,31 +27,35 @@ interface ProvidersProps { export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }: ProvidersProps) { const pathname = usePathname(); + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, + })); const isAuthPage = pathname?.startsWith('/signin') || pathname?.startsWith('/signup'); - if (isAuthPage) { - return ( - - - - - <> - {children} - {authEnabled && } - - - - - - ); - } - - return ( + const content = isAuthPage ? ( + + + + + <> + {children} + {authEnabled && } + + + + + + ) : ( ); + + return ( + + {content} + + ); } diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 624cb82..c35742b 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -1,24 +1,11 @@ 'use client'; -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; -import { - Button, - Listbox, - ListboxButton, - ListboxOption, - ListboxOptions, - Transition, -} from '@headlessui/react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Button, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; -import { - Badge, - Card, - Section, - ToggleRow, - btnPrimary, - btnSecondary, -} from '@/components/admin/ui'; +import { Badge, Card, Section, ToggleRow, btnPrimary, btnSecondary } from '@/components/admin/ui'; import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders'; @@ -33,33 +20,76 @@ interface ProviderOption { id: string; name: string; providerType: TtsProviderId; - shared: boolean; +} + +const ADMIN_SETTINGS_QUERY_KEY = ['admin-settings'] as const; + +async function fetchAdminSettings(): Promise { + const res = await fetch('/api/admin/settings'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return (await res.json()) as SettingsResponse; +} + +async function patchAdminSettings(payload: { updates?: Record; reset?: string[] }): Promise { + const res = await fetch('/api/admin/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`); } export function AdminFeaturesPanel() { - const [data, setData] = useState(null); + const queryClient = useQueryClient(); + const { data, error } = useQuery({ + queryKey: ADMIN_SETTINGS_QUERY_KEY, + queryFn: fetchAdminSettings, + }); const [draft, setDraft] = useState>({}); const [dirty, setDirty] = useState>(new Set()); - const [saving, setSaving] = useState(false); const { providers: sharedProviders } = useSharedProviders(); - const refresh = useCallback(async () => { - try { - const res = await fetch('/api/admin/settings'); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const next = (await res.json()) as SettingsResponse; - setData(next); - setDraft({ ...next.values }); - setDirty(new Set()); - } catch (error) { - console.error('[AdminFeaturesPanel] load failed:', error); - toast.error('Failed to load site settings'); - } - }, []); + useEffect(() => { + if (!data) return; + setDraft({ ...data.values }); + setDirty(new Set()); + }, [data]); useEffect(() => { - refresh(); - }, [refresh]); + if (!error) return; + console.error('[AdminFeaturesPanel] load failed:', error); + toast.error('Failed to load site settings'); + }, [error]); + + const resetMutation = useMutation({ + mutationFn: async (key: string) => { + await patchAdminSettings({ reset: [key] }); + }, + onSuccess: async () => { + toast.success('Reset to env default'); + await queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }); + }, + onError: (mutationError) => { + console.error(mutationError); + toast.error('Reset failed'); + }, + }); + + const saveMutation = useMutation({ + mutationFn: async (updates: Record) => { + await patchAdminSettings({ updates }); + }, + onSuccess: async () => { + toast.success('Settings saved'); + await queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }); + }, + onError: (mutationError) => { + console.error(mutationError); + toast.error('Save failed'); + }, + }); + + const saving = resetMutation.isPending || saveMutation.isPending; const updateDraft = (key: string, value: unknown) => { setDraft((d) => ({ ...d, [key]: value })); @@ -70,45 +100,16 @@ export function AdminFeaturesPanel() { }); }; - const resetField = async (key: string) => { - setSaving(true); - try { - const res = await fetch('/api/admin/settings', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reset: [key] }), - }); - if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`); - toast.success('Reset to env default'); - await refresh(); - } catch (error) { - console.error(error); - toast.error('Reset failed'); - } finally { - setSaving(false); - } + const resetField = (key: string) => { + if (saving) return; + resetMutation.mutate(key); }; - const saveAll = async () => { + const saveAll = () => { if (saving || dirty.size === 0) return; - setSaving(true); - try { - const updates: Record = {}; - for (const key of dirty) updates[key] = draft[key]; - const res = await fetch('/api/admin/settings', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ updates }), - }); - if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`); - toast.success('Settings saved'); - await refresh(); - } catch (error) { - console.error(error); - toast.error('Save failed'); - } finally { - setSaving(false); - } + const updates: Record = {}; + for (const key of dirty) updates[key] = draft[key]; + saveMutation.mutate(updates); }; const discardAll = () => { @@ -117,14 +118,11 @@ export function AdminFeaturesPanel() { setDirty(new Set()); }; - // --- Provider option resolution --- - const providerOptions = useMemo(() => { return sharedProviders.map((entry) => ({ id: entry.slug, name: `${entry.displayName} (shared)`, providerType: entry.providerType, - shared: true, })); }, [sharedProviders]); @@ -141,7 +139,6 @@ export function AdminFeaturesPanel() { id: currentSharedEntry.slug, name: `${currentSharedEntry.displayName} (shared)`, providerType: currentSharedEntry.providerType, - shared: true, } as ProviderOption : fallbackShared; const selectedProviderOption = effectiveSelectedProvider; @@ -150,8 +147,6 @@ export function AdminFeaturesPanel() { updateDraft('defaultTtsProvider', opt.id); }; - // --- Renderers --- - const renderSource = (key: string) => { const source = data?.sources?.[key] ?? 'default'; const isDirty = dirty.has(key); @@ -180,7 +175,6 @@ export function AdminFeaturesPanel() { title="TTS defaults" subtitle="What new users start with." > - {/* Provider picker */}
@@ -241,7 +235,6 @@ export function AdminFeaturesPanel() { )} - {/* Boolean TTS toggles */} { + const res = await fetch('/api/admin/providers'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as { providers: AdminProviderMasked[] }; + return data.providers; +} + +async function upsertAdminProvider(input: { + editingId: string | null; + form: FormState; +}): Promise { + const isNew = input.editingId === '__new'; + const body = { + slug: input.form.slug.trim(), + displayName: input.form.displayName.trim(), + providerType: input.form.providerType, + baseUrl: input.form.baseUrl.trim() || null, + ...(input.form.apiKey.length > 0 ? { apiKey: input.form.apiKey } : {}), + defaultModel: input.form.defaultModel.trim() || null, + defaultInstructions: input.form.defaultInstructions.trim() || null, + enabled: input.form.enabled, + }; + const url = isNew ? '/api/admin/providers' : `/api/admin/providers/${input.editingId}`; + const res = await fetch(url, { + method: isNew ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error || `HTTP ${res.status}`); + } +} + +async function deleteAdminProvider(id: string): Promise { + const res = await fetch(`/api/admin/providers/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error || `HTTP ${res.status}`); + } +} + export function AdminProvidersPanel() { const runtimeConfig = useRuntimeConfig(); - const [providers, setProviders] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [editingId, setEditingId] = useState(null); // null = none, '__new' = create + const queryClient = useQueryClient(); + const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(() => createEmptyForm()); const [customModelInput, setCustomModelInput] = useState(''); - const [saving, setSaving] = useState(false); - const refresh = useCallback(async () => { - setIsLoading(true); - try { - const res = await fetch('/api/admin/providers'); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = (await res.json()) as { providers: AdminProviderMasked[] }; - setProviders(data.providers); - } catch (error) { - console.error('[AdminProvidersPanel] load failed:', error); - toast.error('Failed to load admin providers'); - } finally { - setIsLoading(false); - } - }, []); + const { data: providers = [], isPending: isLoading, error } = useQuery({ + queryKey: ADMIN_PROVIDERS_QUERY_KEY, + queryFn: fetchAdminProviders, + }); useEffect(() => { - refresh(); - }, [refresh]); + if (!error) return; + console.error('[AdminProvidersPanel] load failed:', error); + toast.error('Failed to load admin providers'); + }, [error]); + + const saveMutation = useMutation({ + mutationFn: upsertAdminProvider, + onSuccess: async (_data, variables) => { + toast.success(variables.editingId === '__new' ? 'Provider created' : 'Provider updated'); + cancelEdit(); + await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }); + }, + onError: (mutationError) => { + console.error('[AdminProvidersPanel] save failed:', mutationError); + toast.error((mutationError as Error).message || 'Save failed'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: deleteAdminProvider, + onSuccess: async () => { + toast.success('Provider deleted'); + await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }); + }, + onError: (mutationError) => { + console.error('[AdminProvidersPanel] delete failed:', mutationError); + toast.error((mutationError as Error).message || 'Delete failed'); + }, + }); const startCreate = () => { setForm(createEmptyForm()); @@ -128,62 +167,21 @@ export function AdminProvidersPanel() { setEditingId(provider.id); }; - const cancelEdit = () => { + function cancelEdit() { setEditingId(null); setForm(createEmptyForm()); setCustomModelInput(''); + } + + const submit = () => { + if (saveMutation.isPending) return; + saveMutation.mutate({ editingId, form }); }; - const submit = async () => { - if (saving) return; - setSaving(true); - const isNew = editingId === '__new'; - try { - const body = { - slug: form.slug.trim(), - displayName: form.displayName.trim(), - providerType: form.providerType, - baseUrl: form.baseUrl.trim() || null, - ...(form.apiKey.length > 0 ? { apiKey: form.apiKey } : {}), - defaultModel: form.defaultModel.trim() || null, - defaultInstructions: form.defaultInstructions.trim() || null, - enabled: form.enabled, - }; - const url = isNew ? '/api/admin/providers' : `/api/admin/providers/${editingId}`; - const res = await fetch(url, { - method: isNew ? 'POST' : 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || `HTTP ${res.status}`); - } - toast.success(isNew ? 'Provider created' : 'Provider updated'); - cancelEdit(); - await refresh(); - } catch (error) { - console.error('[AdminProvidersPanel] save failed:', error); - toast.error((error as Error).message || 'Save failed'); - } finally { - setSaving(false); - } - }; - - const remove = async (id: string) => { + const remove = (id: string) => { if (!confirm('Delete this shared provider? Users selecting it will lose access until they switch.')) return; - try { - const res = await fetch(`/api/admin/providers/${id}`, { method: 'DELETE' }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || `HTTP ${res.status}`); - } - toast.success('Provider deleted'); - await refresh(); - } catch (error) { - console.error('[AdminProvidersPanel] delete failed:', error); - toast.error((error as Error).message || 'Delete failed'); - } + if (deleteMutation.isPending) return; + deleteMutation.mutate(id); }; const isEditingExisting = editingId !== null && editingId !== '__new'; @@ -191,7 +189,7 @@ export function AdminProvidersPanel() { ? providers.find((p) => p.id === editingId) : undefined; const submitDisabled = - saving || + saveMutation.isPending || !form.slug.trim() || !form.displayName.trim(); @@ -213,11 +211,11 @@ export function AdminProvidersPanel() { ? 'custom' : modelDefinitions[0]?.id ?? ''; const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId); - const modelSupportsInstructions = (model: string) => resolveTtsProviderModelPolicy({ + const modelSupportsInstructions = useCallback((model: string) => resolveTtsProviderModelPolicy({ providerRef: form.slug, providerType: form.providerType, model, - }).supportsInstructions; + }).supportsInstructions, [form.slug, form.providerType]); const baseUrlPlaceholder = form.providerType === 'custom-openai' ? 'https://your-tts-host/v1' : defaultBaseUrlForProviderType(form.providerType); @@ -241,7 +239,7 @@ export function AdminProvidersPanel() { if (modelSupportsInstructions(form.defaultModel)) return; if (!form.defaultInstructions) return; setForm((prev) => ({ ...prev, defaultInstructions: '' })); - }, [form.defaultModel, form.defaultInstructions, form.providerType, form.slug]); + }, [form.defaultModel, form.defaultInstructions, form.providerType, form.slug, modelSupportsInstructions]); return (
- {saving ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'} + {saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
@@ -527,14 +525,14 @@ export function AdminProvidersPanel() { diff --git a/src/contexts/AuthRateLimitContext.tsx b/src/contexts/AuthRateLimitContext.tsx index 75f8fa8..fb69fb2 100644 --- a/src/contexts/AuthRateLimitContext.tsx +++ b/src/contexts/AuthRateLimitContext.tsx @@ -1,6 +1,7 @@ 'use client'; -import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react'; +import React, { createContext, useContext, useEffect, useCallback, useRef, ReactNode } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { coerceTimestampMs, nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; export interface RateLimitStatus { @@ -43,7 +44,6 @@ export function useAuthRateLimit(): AuthRateLimitContextType { return context; } -// Re-export specific hooks for backward compatibility or convenience if needed export function useAuthConfig() { const { authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit(); return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }; @@ -94,11 +94,9 @@ function parseRateLimitStatus(raw: unknown): RateLimitStatus | null { export function formatCharCount(count: number): string { if (count >= 1_000_000) { const m = count / 1_000_000; - // Show up to 1 decimal place, stripping trailing zeros (1.0 -> 1) return `${parseFloat(m.toFixed(1))}M`; } else if (count >= 1_000) { const k = Math.round(count / 1_000); - // Handle edge case where rounding up reaches 1M (e.g., 999,999 -> 1000K -> 1M) if (k >= 1_000) return '1M'; return `${k}K`; } @@ -113,6 +111,8 @@ interface AuthRateLimitProviderProps { githubAuthEnabled: boolean; } +const RATE_LIMIT_QUERY_KEY = ['rate-limit-status'] as const; + export function AuthRateLimitProvider({ children, authEnabled, @@ -120,64 +120,63 @@ export function AuthRateLimitProvider({ allowAnonymousAuthSessions, githubAuthEnabled, }: AuthRateLimitProviderProps) { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); - // Track pending TTS operations to delay count updates const pendingTTSRef = useRef(0); const updateTimeoutRef = useRef(null); - const fetchStatus = useCallback(async () => { - // Skip if auth is not enabled - if (!authEnabled) { - setStatus({ - allowed: true, - currentCount: 0, - // Avoid Infinity to prevent JSON/serialization edge cases elsewhere. - limit: Number.MAX_SAFE_INTEGER, - remainingChars: Number.MAX_SAFE_INTEGER, - resetTimeMs: nextUtcMidnightTimestampMs(), - userType: 'unauthenticated', - authEnabled: false - }); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - + const { + data: queryStatus, + error: queryError, + isPending, + isFetching, + refetch, + } = useQuery({ + queryKey: RATE_LIMIT_QUERY_KEY, + queryFn: async () => { const response = await fetch('/api/rate-limit/status'); - if (!response.ok) { throw new Error(`Failed to fetch rate limit status: ${response.status}`); } + return parseRateLimitStatus(await response.json()); + }, + enabled: authEnabled, + retry: 0, + }); - const data = await response.json(); - setStatus(parseRateLimitStatus(data)); - } catch (err) { - console.error('Error fetching rate limit status:', err); - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }, [authEnabled]); + const status = authEnabled + ? (queryStatus ?? null) + : { + allowed: true, + currentCount: 0, + // Avoid Infinity to prevent JSON/serialization edge cases elsewhere. + limit: Number.MAX_SAFE_INTEGER, + remainingChars: Number.MAX_SAFE_INTEGER, + resetTimeMs: nextUtcMidnightTimestampMs(), + userType: 'unauthenticated' as const, + authEnabled: false, + }; + const loading = authEnabled ? (isPending || isFetching) : false; + const error = authEnabled + ? (queryError instanceof Error ? queryError.message : queryError ? 'Unknown error' : null) + : null; useEffect(() => { - fetchStatus(); - }, [fetchStatus]); + if (!queryError) return; + console.error('Error fetching rate limit status:', queryError); + }, [queryError]); + + const refresh = useCallback(async () => { + if (!authEnabled) return; + await refetch(); + }, [authEnabled, refetch]); - // Calculate time until reset const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : ''; - // Only treat the user as "at limit" when they are truly out of characters. - // The server allows the final request that may cross the limit, then blocks subsequent ones. const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false; - // Increment count locally (for immediate UI feedback) const incrementCount = useCallback((charCount: number) => { - setStatus(prevStatus => { + if (!authEnabled) return; + queryClient.setQueryData(RATE_LIMIT_QUERY_KEY, (prevStatus) => { if (!prevStatus) return prevStatus; const newCurrentCount = prevStatus.currentCount + charCount; @@ -190,39 +189,33 @@ export function AuthRateLimitProvider({ allowed: newRemainingChars > 0 }; }); - }, []); + }, [authEnabled, queryClient]); - // Called when a TTS request starts const onTTSStart = useCallback(() => { pendingTTSRef.current += 1; - // Clear any existing timeout if (updateTimeoutRef.current) { clearTimeout(updateTimeoutRef.current); updateTimeoutRef.current = null; } }, []); - // Called when a TTS request completes (success or error) const onTTSComplete = useCallback(() => { pendingTTSRef.current = Math.max(0, pendingTTSRef.current - 1); - // Clear any existing timeout if (updateTimeoutRef.current) { clearTimeout(updateTimeoutRef.current); updateTimeoutRef.current = null; } - // If no more pending requests, schedule an update if (pendingTTSRef.current === 0) { updateTimeoutRef.current = setTimeout(() => { - fetchStatus(); + void refresh(); updateTimeoutRef.current = null; - }, 1000); // Wait 1 second after completion to refresh + }, 1000); } - }, [fetchStatus]); + }, [refresh]); - // Cleanup timeout on unmount useEffect(() => { return () => { if (updateTimeoutRef.current) { @@ -239,13 +232,18 @@ export function AuthRateLimitProvider({ status, loading, error, - refresh: fetchStatus, + refresh, isAtLimit, timeUntilReset, incrementCount, onTTSStart, onTTSComplete, - triggerRateLimit: () => setStatus(prev => prev ? { ...prev, remainingChars: 0, allowed: false } : null) + triggerRateLimit: () => { + if (!authEnabled) return; + queryClient.setQueryData(RATE_LIMIT_QUERY_KEY, (prev) => + prev ? { ...prev, remainingChars: 0, allowed: false } : null, + ); + }, }; return ( diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index 8c1ae50..f45d8b1 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -1,56 +1,65 @@ 'use client'; -import { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { BaseDocument } from '@/types/documents'; import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client/api/documents'; import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/client/cache/documents'; import { useAuthSession } from '@/hooks/useAuthSession'; interface DocumentContextType { - // PDF Documents pdfDocs: Array; addPDFDocument: (file: File) => Promise; removePDFDocument: (id: string) => Promise; isPDFLoading: boolean; - // EPUB Documents epubDocs: Array; addEPUBDocument: (file: File) => Promise; removeEPUBDocument: (id: string) => Promise; isEPUBLoading: boolean; - // HTML Documents htmlDocs: Array; addHTMLDocument: (file: File) => Promise; removeHTMLDocument: (id: string) => Promise; isHTMLLoading: boolean; refreshDocuments: () => Promise; - - } const DocumentContext = createContext(undefined); +const DOCUMENTS_QUERY_KEY = 'documents'; export function DocumentProvider({ children }: { children: ReactNode }) { - const [docs, setDocs] = useState(null); - const isLoading = docs === null; + const queryClient = useQueryClient(); const { data: sessionData, isPending: isSessionPending } = useAuthSession(); const sessionKey = sessionData?.user?.id ?? 'no-session'; + const documentsQueryKey = useMemo(() => [DOCUMENTS_QUERY_KEY, sessionKey] as const, [sessionKey]); - const refreshDocuments = useCallback(async () => { - const serverDocs = await listDocuments(); - // Keep only viewer-supported types - setDocs(serverDocs.filter((d) => d.type === 'pdf' || d.type === 'epub' || d.type === 'html')); + const loadDocuments = useCallback(async () => { + try { + const serverDocs = await listDocuments(); + return serverDocs.filter((d): d is BaseDocument & { type: 'pdf' | 'epub' | 'html' } => + d.type === 'pdf' || d.type === 'epub' || d.type === 'html', + ); + } catch (err) { + console.error('Failed to load documents from server:', err); + return []; + } }, []); - useEffect(() => { + const { data: docs = [], isPending, refetch } = useQuery({ + queryKey: documentsQueryKey, + queryFn: loadDocuments, + enabled: !isSessionPending, + }); + + const isLoading = isSessionPending || (isPending && docs.length === 0); + + const refreshDocuments = useCallback(async () => { if (isSessionPending) return; - refreshDocuments().catch((err) => { - console.error('Failed to load documents from server:', err); - setDocs([]); - }); - }, [refreshDocuments, sessionKey, isSessionPending]); + await queryClient.invalidateQueries({ queryKey: documentsQueryKey }); + await refetch(); + }, [isSessionPending, queryClient, documentsQueryKey, refetch]); useEffect(() => { const handler = () => { @@ -66,9 +75,9 @@ export function DocumentProvider({ children }: { children: ReactNode }) { }, [refreshDocuments]); const docsByType = useMemo(() => { - const pdfDocs = (docs ?? []).filter((d) => d.type === 'pdf') as Array; - const epubDocs = (docs ?? []).filter((d) => d.type === 'epub') as Array; - const htmlDocs = (docs ?? []).filter((d) => d.type === 'html') as Array; + const pdfDocs = docs.filter((d) => d.type === 'pdf') as Array; + const epubDocs = docs.filter((d) => d.type === 'epub') as Array; + const htmlDocs = docs.filter((d) => d.type === 'html') as Array; return { pdfDocs, epubDocs, htmlDocs }; }, [docs]); @@ -93,14 +102,15 @@ export function DocumentProvider({ children }: { children: ReactNode }) { const [stored] = await uploadDocuments([file]); if (!stored) throw new Error('Upload succeeded but returned no document'); await cacheUploaded(stored, file); - setDocs((prev) => { - const current = prev ?? []; - // Replace if same id exists (e.g. re-upload) - const next = current.filter((d) => d.id !== stored.id); - return [stored, ...next]; + const isSupported = stored.type === 'pdf' || stored.type === 'epub' || stored.type === 'html'; + if (!isSupported) return stored.id; + const supportedStored = stored as BaseDocument & { type: 'pdf' | 'epub' | 'html' }; + queryClient.setQueryData>(documentsQueryKey, (prev = []) => { + const next = prev.filter((d) => d.id !== supportedStored.id); + return [supportedStored, ...next]; }); return stored.id; - }, [cacheUploaded]); + }, [cacheUploaded, queryClient, documentsQueryKey]); const addPDFDocument = useCallback(async (file: File) => addDocument(file), [addDocument]); const addEPUBDocument = useCallback(async (file: File) => addDocument(file), [addDocument]); @@ -109,15 +119,15 @@ export function DocumentProvider({ children }: { children: ReactNode }) { const removeById = useCallback(async (id: string) => { await deleteDocuments({ ids: [id] }); await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]); - setDocs((prev) => (prev ?? []).filter((d) => d.id !== id)); - }, []); + queryClient.setQueryData>(documentsQueryKey, (prev = []) => + prev.filter((d) => d.id !== id), + ); + }, [queryClient, documentsQueryKey]); const removePDFDocument = useCallback(async (id: string) => removeById(id), [removeById]); const removeEPUBDocument = useCallback(async (id: string) => removeById(id), [removeById]); const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]); - // Removed unused clear functions - return ( clearTimeout(timeoutId); } - }, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]); + }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]); /** * Renders the TTS context provider with its children diff --git a/src/hooks/useSharedProviders.ts b/src/hooks/useSharedProviders.ts index 03455aa..99d27ab 100644 --- a/src/hooks/useSharedProviders.ts +++ b/src/hooks/useSharedProviders.ts @@ -1,6 +1,7 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { TtsProviderId } from '@/lib/shared/tts-provider-catalog'; export interface SharedProviderEntry { @@ -11,45 +12,17 @@ export interface SharedProviderEntry { defaultInstructions: string | null; } -interface State { - data: SharedProviderEntry[]; - loaded: boolean; -} +export const SHARED_PROVIDERS_QUERY_KEY = ['tts-shared-providers'] as const; -const EMPTY: State = { data: [], loaded: false }; - -/** - * Fetches the list of admin-configured shared TTS providers visible to the - * current user. Cached in module-scope state to avoid refetching on every - * mount. The list rarely changes during a session; admin edits land on the - * next page load via `__OPENREADER_RUNTIME_CONFIG__`-style behavior. - */ -let cached: State = EMPTY; -let inflight: Promise | null = null; -const listeners = new Set<(s: State) => void>(); - -function setCached(next: State) { - cached = next; - for (const fn of listeners) fn(next); -} - -async function fetchOnce(): Promise { - if (inflight) return inflight; - inflight = (async () => { - try { - const res = await fetch('/api/tts/shared-providers', { credentials: 'same-origin' }); - if (!res.ok) return []; - const data = (await res.json()) as { providers?: SharedProviderEntry[] }; - return data.providers ?? []; - } catch { - return []; - } finally { - inflight = null; - } - })(); - const result = await inflight; - setCached({ data: result, loaded: true }); - return result; +async function fetchSharedProviders(): Promise { + try { + const res = await fetch('/api/tts/shared-providers', { credentials: 'same-origin' }); + if (!res.ok) return []; + const data = (await res.json()) as { providers?: SharedProviderEntry[] }; + return data.providers ?? []; + } catch { + return []; + } } export function useSharedProviders(): { @@ -57,26 +30,17 @@ export function useSharedProviders(): { isLoading: boolean; refresh: () => Promise; } { - const [state, setState] = useState(cached); - - useEffect(() => { - listeners.add(setState); - if (!cached.loaded) { - void fetchOnce(); - } - return () => { - listeners.delete(setState); - }; - }, []); + const queryClient = useQueryClient(); + const { data = [], isPending, refetch } = useQuery({ + queryKey: SHARED_PROVIDERS_QUERY_KEY, + queryFn: fetchSharedProviders, + staleTime: 5 * 60 * 1000, + }); const refresh = useCallback(async () => { - setCached({ ...cached, loaded: false }); - await fetchOnce(); - }, []); + await queryClient.invalidateQueries({ queryKey: SHARED_PROVIDERS_QUERY_KEY }); + await refetch(); + }, [queryClient, refetch]); - return { providers: state.data, isLoading: !state.loaded, refresh }; -} - -export function getCachedSharedProviders(): SharedProviderEntry[] { - return cached.data; + return { providers: data, isLoading: isPending, refresh }; }