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:
parent
913c6d5d76
commit
c8a35e505f
9 changed files with 349 additions and 351 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue