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.
This commit is contained in:
Richard R 2026-05-13 12:40:58 -06:00
parent 913c6d5d76
commit c8a35e505f
9 changed files with 349 additions and 351 deletions

View file

@ -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",

View file

@ -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

View file

@ -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 (
<RuntimeConfigProvider>
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<ThemeProvider>
<AuthLoader>
<>
{children}
{authEnabled && <PrivacyModal />}
</>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
</RuntimeConfigProvider>
);
}
return (
const content = isAuthPage ? (
<RuntimeConfigProvider>
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<ThemeProvider>
<AuthLoader>
<>
{children}
{authEnabled && <PrivacyModal />}
</>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
</RuntimeConfigProvider>
) : (
<RuntimeConfigProvider>
<AuthRateLimitProvider
authEnabled={authEnabled}
@ -82,4 +87,10 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
</AuthRateLimitProvider>
</RuntimeConfigProvider>
);
return (
<QueryClientProvider client={queryClient}>
{content}
</QueryClientProvider>
);
}

View file

@ -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<SettingsResponse> {
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<string, unknown>; reset?: string[] }): Promise<void> {
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<SettingsResponse | null>(null);
const queryClient = useQueryClient();
const { data, error } = useQuery({
queryKey: ADMIN_SETTINGS_QUERY_KEY,
queryFn: fetchAdminSettings,
});
const [draft, setDraft] = useState<Record<string, unknown>>({});
const [dirty, setDirty] = useState<Set<string>>(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<string, unknown>) => {
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<string, unknown> = {};
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<string, unknown> = {};
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<ProviderOption[]>(() => {
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 */}
<Card className="space-y-1.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@ -241,7 +235,6 @@ export function AdminFeaturesPanel() {
)}
</Card>
{/* Boolean TTS toggles */}
<ToggleRow
label="Restrict user API keys (recommended)"
description="When on, users cannot supply personal API keys/base URLs; TTS requests must use admin-configured shared providers."

View file

@ -3,32 +3,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons';
import {
providerSupportsCustomModel,
resolveProviderModels,
type TtsModelDefinition,
type TtsProviderId,
} from '@/lib/shared/tts-provider-catalog';
import {
defaultBaseUrlForProviderType,
defaultModelForProviderType,
resolveTtsProviderModelPolicy,
} from '@/lib/shared/tts-provider-policy';
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import {
Badge,
Card,
Field,
Section,
ToggleRow,
btnDanger,
btnOutline,
btnPrimary,
btnSecondary,
inputClass,
} from '@/components/admin/ui';
import { Badge, Card, Field, Section, ToggleRow, btnDanger, btnOutline, btnPrimary, btnSecondary, inputClass } from '@/components/admin/ui';
type ProviderType = TtsProviderId;
@ -65,6 +46,7 @@ interface FormState {
}
const providerDefaultModel = defaultModelForProviderType;
const ADMIN_PROVIDERS_QUERY_KEY = ['admin-providers'] as const;
function createEmptyForm(): FormState {
return {
@ -79,33 +61,90 @@ function createEmptyForm(): FormState {
};
}
async function fetchAdminProviders(): Promise<AdminProviderMasked[]> {
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<void> {
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<void> {
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<AdminProviderMasked[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null); // null = none, '__new' = create
const queryClient = useQueryClient();
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(() => 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 (
<Section
@ -492,7 +490,7 @@ export function AdminProvidersPanel() {
disabled={submitDisabled}
className={`${btnPrimary} px-4 py-1.5`}
>
{saving ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
{saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
</Button>
</div>
</Card>
@ -527,14 +525,14 @@ export function AdminProvidersPanel() {
<Button
onClick={() => startEdit(p)}
className={`${btnOutline} px-2.5 py-1 text-xs`}
disabled={!!editingId}
disabled={!!editingId || deleteMutation.isPending}
>
Edit
</Button>
<Button
onClick={() => remove(p.id)}
className={`${btnDanger} px-2.5 py-1 text-xs`}
disabled={!!editingId}
disabled={!!editingId || deleteMutation.isPending}
>
Delete
</Button>

View file

@ -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<RateLimitStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
// Track pending TTS operations to delay count updates
const pendingTTSRef = useRef<number>(0);
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(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<RateLimitStatus | null>(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<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prev) =>
prev ? { ...prev, remainingChars: 0, allowed: false } : null,
);
},
};
return (

View file

@ -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<BaseDocument & { type: 'pdf' }>;
addPDFDocument: (file: File) => Promise<string>;
removePDFDocument: (id: string) => Promise<void>;
isPDFLoading: boolean;
// EPUB Documents
epubDocs: Array<BaseDocument & { type: 'epub' }>;
addEPUBDocument: (file: File) => Promise<string>;
removeEPUBDocument: (id: string) => Promise<void>;
isEPUBLoading: boolean;
// HTML Documents
htmlDocs: Array<BaseDocument & { type: 'html' }>;
addHTMLDocument: (file: File) => Promise<string>;
removeHTMLDocument: (id: string) => Promise<void>;
isHTMLLoading: boolean;
refreshDocuments: () => Promise<void>;
}
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
const DOCUMENTS_QUERY_KEY = 'documents';
export function DocumentProvider({ children }: { children: ReactNode }) {
const [docs, setDocs] = useState<BaseDocument[] | null>(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<BaseDocument & { type: 'pdf' }>;
const epubDocs = (docs ?? []).filter((d) => d.type === 'epub') as Array<BaseDocument & { type: 'epub' }>;
const htmlDocs = (docs ?? []).filter((d) => d.type === 'html') as Array<BaseDocument & { type: 'html' }>;
const pdfDocs = docs.filter((d) => d.type === 'pdf') as Array<BaseDocument & { type: 'pdf' }>;
const epubDocs = docs.filter((d) => d.type === 'epub') as Array<BaseDocument & { type: 'epub' }>;
const htmlDocs = docs.filter((d) => d.type === 'html') as Array<BaseDocument & { type: 'html' }>;
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<Array<BaseDocument & { type: 'pdf' | 'epub' | 'html' }>>(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<Array<BaseDocument & { type: 'pdf' | 'epub' | 'html' }>>(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 (
<DocumentContext.Provider value={{
pdfDocs: docsByType.pdfDocs,

View file

@ -1373,6 +1373,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel,
voice,
effectiveNativeSpeed,
providerModelPolicy.supportsInstructions,
ttsInstructions,
ttsSegmentMaxBlockLength,
clearPendingEpubJump,
@ -1592,6 +1593,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
openApiKey,
openApiBaseUrl,
configProviderRef,
configProviderType,
providerModelPolicy.supportsInstructions,
isEPUB,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
@ -2622,9 +2625,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
voice,
effectiveNativeSpeed,
configProviderRef,
configProviderType,
ttsModel,
openApiKey,
openApiBaseUrl,
providerModelPolicy.supportsInstructions,
ttsInstructions,
smartSentenceSplitting,
onTTSStart,
@ -3077,7 +3082,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
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

View file

@ -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<SharedProviderEntry[]> | 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<SharedProviderEntry[]> {
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<SharedProviderEntry[]> {
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<void>;
} {
const [state, setState] = useState<State>(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 };
}