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",
|
"@aws-sdk/s3-request-presigner": "^3.987.0",
|
||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@napi-rs/canvas": "^0.1.91",
|
"@napi-rs/canvas": "^0.1.91",
|
||||||
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ importers:
|
||||||
'@napi-rs/canvas':
|
'@napi-rs/canvas':
|
||||||
specifier: ^0.1.91
|
specifier: ^0.1.91
|
||||||
version: 0.1.91
|
version: 0.1.91
|
||||||
|
'@tanstack/react-query':
|
||||||
|
specifier: ^5.100.10
|
||||||
|
version: 5.100.10(react@19.2.4)
|
||||||
'@types/archiver':
|
'@types/archiver':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0
|
version: 7.0.0
|
||||||
|
|
@ -1382,6 +1385,14 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
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':
|
'@tanstack/react-virtual@3.13.18':
|
||||||
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -5486,6 +5497,13 @@ snapshots:
|
||||||
postcss-selector-parser: 6.0.10
|
postcss-selector-parser: 6.0.10
|
||||||
tailwindcss: 3.4.19
|
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)':
|
'@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.13.18
|
'@tanstack/virtual-core': 3.13.18
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode, useState } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { DocumentProvider } from '@/contexts/DocumentContext';
|
import { DocumentProvider } from '@/contexts/DocumentContext';
|
||||||
import { PDFProvider } from '@/contexts/PDFContext';
|
import { PDFProvider } from '@/contexts/PDFContext';
|
||||||
|
|
@ -26,31 +27,35 @@ interface ProvidersProps {
|
||||||
|
|
||||||
export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }: ProvidersProps) {
|
export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }: ProvidersProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [queryClient] = useState(() => new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
const isAuthPage = pathname?.startsWith('/signin') || pathname?.startsWith('/signup');
|
const isAuthPage = pathname?.startsWith('/signin') || pathname?.startsWith('/signup');
|
||||||
|
|
||||||
if (isAuthPage) {
|
const content = isAuthPage ? (
|
||||||
return (
|
<RuntimeConfigProvider>
|
||||||
<RuntimeConfigProvider>
|
<AuthRateLimitProvider
|
||||||
<AuthRateLimitProvider
|
authEnabled={authEnabled}
|
||||||
authEnabled={authEnabled}
|
authBaseUrl={authBaseUrl}
|
||||||
authBaseUrl={authBaseUrl}
|
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
githubAuthEnabled={githubAuthEnabled}
|
>
|
||||||
>
|
<ThemeProvider>
|
||||||
<ThemeProvider>
|
<AuthLoader>
|
||||||
<AuthLoader>
|
<>
|
||||||
<>
|
{children}
|
||||||
{children}
|
{authEnabled && <PrivacyModal />}
|
||||||
{authEnabled && <PrivacyModal />}
|
</>
|
||||||
</>
|
</AuthLoader>
|
||||||
</AuthLoader>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
</AuthRateLimitProvider>
|
||||||
</AuthRateLimitProvider>
|
</RuntimeConfigProvider>
|
||||||
</RuntimeConfigProvider>
|
) : (
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RuntimeConfigProvider>
|
<RuntimeConfigProvider>
|
||||||
<AuthRateLimitProvider
|
<AuthRateLimitProvider
|
||||||
authEnabled={authEnabled}
|
authEnabled={authEnabled}
|
||||||
|
|
@ -82,4 +87,10 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
|
||||||
</AuthRateLimitProvider>
|
</AuthRateLimitProvider>
|
||||||
</RuntimeConfigProvider>
|
</RuntimeConfigProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{content}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { Button, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
|
||||||
Button,
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
Listbox,
|
|
||||||
ListboxButton,
|
|
||||||
ListboxOption,
|
|
||||||
ListboxOptions,
|
|
||||||
Transition,
|
|
||||||
} from '@headlessui/react';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
||||||
import {
|
import { Badge, Card, Section, ToggleRow, btnPrimary, btnSecondary } from '@/components/admin/ui';
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
Section,
|
|
||||||
ToggleRow,
|
|
||||||
btnPrimary,
|
|
||||||
btnSecondary,
|
|
||||||
} from '@/components/admin/ui';
|
|
||||||
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
||||||
|
|
||||||
|
|
@ -33,33 +20,76 @@ interface ProviderOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
providerType: TtsProviderId;
|
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() {
|
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 [draft, setDraft] = useState<Record<string, unknown>>({});
|
||||||
const [dirty, setDirty] = useState<Set<string>>(new Set());
|
const [dirty, setDirty] = useState<Set<string>>(new Set());
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const { providers: sharedProviders } = useSharedProviders();
|
const { providers: sharedProviders } = useSharedProviders();
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
useEffect(() => {
|
||||||
try {
|
if (!data) return;
|
||||||
const res = await fetch('/api/admin/settings');
|
setDraft({ ...data.values });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
setDirty(new Set());
|
||||||
const next = (await res.json()) as SettingsResponse;
|
}, [data]);
|
||||||
setData(next);
|
|
||||||
setDraft({ ...next.values });
|
|
||||||
setDirty(new Set());
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[AdminFeaturesPanel] load failed:', error);
|
|
||||||
toast.error('Failed to load site settings');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
if (!error) return;
|
||||||
}, [refresh]);
|
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) => {
|
const updateDraft = (key: string, value: unknown) => {
|
||||||
setDraft((d) => ({ ...d, [key]: value }));
|
setDraft((d) => ({ ...d, [key]: value }));
|
||||||
|
|
@ -70,45 +100,16 @@ export function AdminFeaturesPanel() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetField = async (key: string) => {
|
const resetField = (key: string) => {
|
||||||
setSaving(true);
|
if (saving) return;
|
||||||
try {
|
resetMutation.mutate(key);
|
||||||
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 saveAll = async () => {
|
const saveAll = () => {
|
||||||
if (saving || dirty.size === 0) return;
|
if (saving || dirty.size === 0) return;
|
||||||
setSaving(true);
|
const updates: Record<string, unknown> = {};
|
||||||
try {
|
for (const key of dirty) updates[key] = draft[key];
|
||||||
const updates: Record<string, unknown> = {};
|
saveMutation.mutate(updates);
|
||||||
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 discardAll = () => {
|
const discardAll = () => {
|
||||||
|
|
@ -117,14 +118,11 @@ export function AdminFeaturesPanel() {
|
||||||
setDirty(new Set());
|
setDirty(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Provider option resolution ---
|
|
||||||
|
|
||||||
const providerOptions = useMemo<ProviderOption[]>(() => {
|
const providerOptions = useMemo<ProviderOption[]>(() => {
|
||||||
return sharedProviders.map((entry) => ({
|
return sharedProviders.map((entry) => ({
|
||||||
id: entry.slug,
|
id: entry.slug,
|
||||||
name: `${entry.displayName} (shared)`,
|
name: `${entry.displayName} (shared)`,
|
||||||
providerType: entry.providerType,
|
providerType: entry.providerType,
|
||||||
shared: true,
|
|
||||||
}));
|
}));
|
||||||
}, [sharedProviders]);
|
}, [sharedProviders]);
|
||||||
|
|
||||||
|
|
@ -141,7 +139,6 @@ export function AdminFeaturesPanel() {
|
||||||
id: currentSharedEntry.slug,
|
id: currentSharedEntry.slug,
|
||||||
name: `${currentSharedEntry.displayName} (shared)`,
|
name: `${currentSharedEntry.displayName} (shared)`,
|
||||||
providerType: currentSharedEntry.providerType,
|
providerType: currentSharedEntry.providerType,
|
||||||
shared: true,
|
|
||||||
} as ProviderOption
|
} as ProviderOption
|
||||||
: fallbackShared;
|
: fallbackShared;
|
||||||
const selectedProviderOption = effectiveSelectedProvider;
|
const selectedProviderOption = effectiveSelectedProvider;
|
||||||
|
|
@ -150,8 +147,6 @@ export function AdminFeaturesPanel() {
|
||||||
updateDraft('defaultTtsProvider', opt.id);
|
updateDraft('defaultTtsProvider', opt.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Renderers ---
|
|
||||||
|
|
||||||
const renderSource = (key: string) => {
|
const renderSource = (key: string) => {
|
||||||
const source = data?.sources?.[key] ?? 'default';
|
const source = data?.sources?.[key] ?? 'default';
|
||||||
const isDirty = dirty.has(key);
|
const isDirty = dirty.has(key);
|
||||||
|
|
@ -180,7 +175,6 @@ export function AdminFeaturesPanel() {
|
||||||
title="TTS defaults"
|
title="TTS defaults"
|
||||||
subtitle="What new users start with."
|
subtitle="What new users start with."
|
||||||
>
|
>
|
||||||
{/* Provider picker */}
|
|
||||||
<Card className="space-y-1.5">
|
<Card className="space-y-1.5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -241,7 +235,6 @@ export function AdminFeaturesPanel() {
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Boolean TTS toggles */}
|
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Restrict user API keys (recommended)"
|
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."
|
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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
|
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons';
|
||||||
import {
|
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
providerSupportsCustomModel,
|
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
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 { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||||
import {
|
import { Badge, Card, Field, Section, ToggleRow, btnDanger, btnOutline, btnPrimary, btnSecondary, inputClass } from '@/components/admin/ui';
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
Field,
|
|
||||||
Section,
|
|
||||||
ToggleRow,
|
|
||||||
btnDanger,
|
|
||||||
btnOutline,
|
|
||||||
btnPrimary,
|
|
||||||
btnSecondary,
|
|
||||||
inputClass,
|
|
||||||
} from '@/components/admin/ui';
|
|
||||||
|
|
||||||
type ProviderType = TtsProviderId;
|
type ProviderType = TtsProviderId;
|
||||||
|
|
||||||
|
|
@ -65,6 +46,7 @@ interface FormState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerDefaultModel = defaultModelForProviderType;
|
const providerDefaultModel = defaultModelForProviderType;
|
||||||
|
const ADMIN_PROVIDERS_QUERY_KEY = ['admin-providers'] as const;
|
||||||
|
|
||||||
function createEmptyForm(): FormState {
|
function createEmptyForm(): FormState {
|
||||||
return {
|
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() {
|
export function AdminProvidersPanel() {
|
||||||
const runtimeConfig = useRuntimeConfig();
|
const runtimeConfig = useRuntimeConfig();
|
||||||
const [providers, setProviders] = useState<AdminProviderMasked[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null); // null = none, '__new' = create
|
|
||||||
const [form, setForm] = useState<FormState>(() => createEmptyForm());
|
const [form, setForm] = useState<FormState>(() => createEmptyForm());
|
||||||
const [customModelInput, setCustomModelInput] = useState('');
|
const [customModelInput, setCustomModelInput] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const { data: providers = [], isPending: isLoading, error } = useQuery({
|
||||||
setIsLoading(true);
|
queryKey: ADMIN_PROVIDERS_QUERY_KEY,
|
||||||
try {
|
queryFn: fetchAdminProviders,
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
if (!error) return;
|
||||||
}, [refresh]);
|
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 = () => {
|
const startCreate = () => {
|
||||||
setForm(createEmptyForm());
|
setForm(createEmptyForm());
|
||||||
|
|
@ -128,62 +167,21 @@ export function AdminProvidersPanel() {
|
||||||
setEditingId(provider.id);
|
setEditingId(provider.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
function cancelEdit() {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setForm(createEmptyForm());
|
setForm(createEmptyForm());
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (saveMutation.isPending) return;
|
||||||
|
saveMutation.mutate({ editingId, form });
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const remove = (id: string) => {
|
||||||
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) => {
|
|
||||||
if (!confirm('Delete this shared provider? Users selecting it will lose access until they switch.')) return;
|
if (!confirm('Delete this shared provider? Users selecting it will lose access until they switch.')) return;
|
||||||
try {
|
if (deleteMutation.isPending) return;
|
||||||
const res = await fetch(`/api/admin/providers/${id}`, { method: 'DELETE' });
|
deleteMutation.mutate(id);
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEditingExisting = editingId !== null && editingId !== '__new';
|
const isEditingExisting = editingId !== null && editingId !== '__new';
|
||||||
|
|
@ -191,7 +189,7 @@ export function AdminProvidersPanel() {
|
||||||
? providers.find((p) => p.id === editingId)
|
? providers.find((p) => p.id === editingId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const submitDisabled =
|
const submitDisabled =
|
||||||
saving ||
|
saveMutation.isPending ||
|
||||||
!form.slug.trim() ||
|
!form.slug.trim() ||
|
||||||
!form.displayName.trim();
|
!form.displayName.trim();
|
||||||
|
|
||||||
|
|
@ -213,11 +211,11 @@ export function AdminProvidersPanel() {
|
||||||
? 'custom'
|
? 'custom'
|
||||||
: modelDefinitions[0]?.id ?? '';
|
: modelDefinitions[0]?.id ?? '';
|
||||||
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
|
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
|
||||||
const modelSupportsInstructions = (model: string) => resolveTtsProviderModelPolicy({
|
const modelSupportsInstructions = useCallback((model: string) => resolveTtsProviderModelPolicy({
|
||||||
providerRef: form.slug,
|
providerRef: form.slug,
|
||||||
providerType: form.providerType,
|
providerType: form.providerType,
|
||||||
model,
|
model,
|
||||||
}).supportsInstructions;
|
}).supportsInstructions, [form.slug, form.providerType]);
|
||||||
const baseUrlPlaceholder = form.providerType === 'custom-openai'
|
const baseUrlPlaceholder = form.providerType === 'custom-openai'
|
||||||
? 'https://your-tts-host/v1'
|
? 'https://your-tts-host/v1'
|
||||||
: defaultBaseUrlForProviderType(form.providerType);
|
: defaultBaseUrlForProviderType(form.providerType);
|
||||||
|
|
@ -241,7 +239,7 @@ export function AdminProvidersPanel() {
|
||||||
if (modelSupportsInstructions(form.defaultModel)) return;
|
if (modelSupportsInstructions(form.defaultModel)) return;
|
||||||
if (!form.defaultInstructions) return;
|
if (!form.defaultInstructions) return;
|
||||||
setForm((prev) => ({ ...prev, defaultInstructions: '' }));
|
setForm((prev) => ({ ...prev, defaultInstructions: '' }));
|
||||||
}, [form.defaultModel, form.defaultInstructions, form.providerType, form.slug]);
|
}, [form.defaultModel, form.defaultInstructions, form.providerType, form.slug, modelSupportsInstructions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
|
|
@ -492,7 +490,7 @@ export function AdminProvidersPanel() {
|
||||||
disabled={submitDisabled}
|
disabled={submitDisabled}
|
||||||
className={`${btnPrimary} px-4 py-1.5`}
|
className={`${btnPrimary} px-4 py-1.5`}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
|
{saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -527,14 +525,14 @@ export function AdminProvidersPanel() {
|
||||||
<Button
|
<Button
|
||||||
onClick={() => startEdit(p)}
|
onClick={() => startEdit(p)}
|
||||||
className={`${btnOutline} px-2.5 py-1 text-xs`}
|
className={`${btnOutline} px-2.5 py-1 text-xs`}
|
||||||
disabled={!!editingId}
|
disabled={!!editingId || deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => remove(p.id)}
|
onClick={() => remove(p.id)}
|
||||||
className={`${btnDanger} px-2.5 py-1 text-xs`}
|
className={`${btnDanger} px-2.5 py-1 text-xs`}
|
||||||
disabled={!!editingId}
|
disabled={!!editingId || deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'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';
|
import { coerceTimestampMs, nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
||||||
export interface RateLimitStatus {
|
export interface RateLimitStatus {
|
||||||
|
|
@ -43,7 +44,6 @@ export function useAuthRateLimit(): AuthRateLimitContextType {
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export specific hooks for backward compatibility or convenience if needed
|
|
||||||
export function useAuthConfig() {
|
export function useAuthConfig() {
|
||||||
const { authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit();
|
const { authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit();
|
||||||
return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled };
|
return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled };
|
||||||
|
|
@ -94,11 +94,9 @@ function parseRateLimitStatus(raw: unknown): RateLimitStatus | null {
|
||||||
export function formatCharCount(count: number): string {
|
export function formatCharCount(count: number): string {
|
||||||
if (count >= 1_000_000) {
|
if (count >= 1_000_000) {
|
||||||
const m = 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`;
|
return `${parseFloat(m.toFixed(1))}M`;
|
||||||
} else if (count >= 1_000) {
|
} else if (count >= 1_000) {
|
||||||
const k = Math.round(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';
|
if (k >= 1_000) return '1M';
|
||||||
return `${k}K`;
|
return `${k}K`;
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +111,8 @@ interface AuthRateLimitProviderProps {
|
||||||
githubAuthEnabled: boolean;
|
githubAuthEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RATE_LIMIT_QUERY_KEY = ['rate-limit-status'] as const;
|
||||||
|
|
||||||
export function AuthRateLimitProvider({
|
export function AuthRateLimitProvider({
|
||||||
children,
|
children,
|
||||||
authEnabled,
|
authEnabled,
|
||||||
|
|
@ -120,64 +120,63 @@ export function AuthRateLimitProvider({
|
||||||
allowAnonymousAuthSessions,
|
allowAnonymousAuthSessions,
|
||||||
githubAuthEnabled,
|
githubAuthEnabled,
|
||||||
}: AuthRateLimitProviderProps) {
|
}: AuthRateLimitProviderProps) {
|
||||||
const [status, setStatus] = useState<RateLimitStatus | null>(null);
|
const queryClient = useQueryClient();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Track pending TTS operations to delay count updates
|
|
||||||
const pendingTTSRef = useRef<number>(0);
|
const pendingTTSRef = useRef<number>(0);
|
||||||
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
const {
|
||||||
// Skip if auth is not enabled
|
data: queryStatus,
|
||||||
if (!authEnabled) {
|
error: queryError,
|
||||||
setStatus({
|
isPending,
|
||||||
allowed: true,
|
isFetching,
|
||||||
currentCount: 0,
|
refetch,
|
||||||
// Avoid Infinity to prevent JSON/serialization edge cases elsewhere.
|
} = useQuery({
|
||||||
limit: Number.MAX_SAFE_INTEGER,
|
queryKey: RATE_LIMIT_QUERY_KEY,
|
||||||
remainingChars: Number.MAX_SAFE_INTEGER,
|
queryFn: async () => {
|
||||||
resetTimeMs: nextUtcMidnightTimestampMs(),
|
|
||||||
userType: 'unauthenticated',
|
|
||||||
authEnabled: false
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await fetch('/api/rate-limit/status');
|
const response = await fetch('/api/rate-limit/status');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch rate limit status: ${response.status}`);
|
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();
|
const status = authEnabled
|
||||||
setStatus(parseRateLimitStatus(data));
|
? (queryStatus ?? null)
|
||||||
} catch (err) {
|
: {
|
||||||
console.error('Error fetching rate limit status:', err);
|
allowed: true,
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
currentCount: 0,
|
||||||
} finally {
|
// Avoid Infinity to prevent JSON/serialization edge cases elsewhere.
|
||||||
setLoading(false);
|
limit: Number.MAX_SAFE_INTEGER,
|
||||||
}
|
remainingChars: Number.MAX_SAFE_INTEGER,
|
||||||
}, [authEnabled]);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchStatus();
|
if (!queryError) return;
|
||||||
}, [fetchStatus]);
|
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) : '';
|
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;
|
const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false;
|
||||||
|
|
||||||
// Increment count locally (for immediate UI feedback)
|
|
||||||
const incrementCount = useCallback((charCount: number) => {
|
const incrementCount = useCallback((charCount: number) => {
|
||||||
setStatus(prevStatus => {
|
if (!authEnabled) return;
|
||||||
|
queryClient.setQueryData<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prevStatus) => {
|
||||||
if (!prevStatus) return prevStatus;
|
if (!prevStatus) return prevStatus;
|
||||||
|
|
||||||
const newCurrentCount = prevStatus.currentCount + charCount;
|
const newCurrentCount = prevStatus.currentCount + charCount;
|
||||||
|
|
@ -190,39 +189,33 @@ export function AuthRateLimitProvider({
|
||||||
allowed: newRemainingChars > 0
|
allowed: newRemainingChars > 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, [authEnabled, queryClient]);
|
||||||
|
|
||||||
// Called when a TTS request starts
|
|
||||||
const onTTSStart = useCallback(() => {
|
const onTTSStart = useCallback(() => {
|
||||||
pendingTTSRef.current += 1;
|
pendingTTSRef.current += 1;
|
||||||
|
|
||||||
// Clear any existing timeout
|
|
||||||
if (updateTimeoutRef.current) {
|
if (updateTimeoutRef.current) {
|
||||||
clearTimeout(updateTimeoutRef.current);
|
clearTimeout(updateTimeoutRef.current);
|
||||||
updateTimeoutRef.current = null;
|
updateTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Called when a TTS request completes (success or error)
|
|
||||||
const onTTSComplete = useCallback(() => {
|
const onTTSComplete = useCallback(() => {
|
||||||
pendingTTSRef.current = Math.max(0, pendingTTSRef.current - 1);
|
pendingTTSRef.current = Math.max(0, pendingTTSRef.current - 1);
|
||||||
|
|
||||||
// Clear any existing timeout
|
|
||||||
if (updateTimeoutRef.current) {
|
if (updateTimeoutRef.current) {
|
||||||
clearTimeout(updateTimeoutRef.current);
|
clearTimeout(updateTimeoutRef.current);
|
||||||
updateTimeoutRef.current = null;
|
updateTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no more pending requests, schedule an update
|
|
||||||
if (pendingTTSRef.current === 0) {
|
if (pendingTTSRef.current === 0) {
|
||||||
updateTimeoutRef.current = setTimeout(() => {
|
updateTimeoutRef.current = setTimeout(() => {
|
||||||
fetchStatus();
|
void refresh();
|
||||||
updateTimeoutRef.current = null;
|
updateTimeoutRef.current = null;
|
||||||
}, 1000); // Wait 1 second after completion to refresh
|
}, 1000);
|
||||||
}
|
}
|
||||||
}, [fetchStatus]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (updateTimeoutRef.current) {
|
if (updateTimeoutRef.current) {
|
||||||
|
|
@ -239,13 +232,18 @@ export function AuthRateLimitProvider({
|
||||||
status,
|
status,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refresh: fetchStatus,
|
refresh,
|
||||||
isAtLimit,
|
isAtLimit,
|
||||||
timeUntilReset,
|
timeUntilReset,
|
||||||
incrementCount,
|
incrementCount,
|
||||||
onTTSStart,
|
onTTSStart,
|
||||||
onTTSComplete,
|
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 (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,65 @@
|
||||||
'use client';
|
'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 type { BaseDocument } from '@/types/documents';
|
||||||
import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client/api/documents';
|
import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client/api/documents';
|
||||||
import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/client/cache/documents';
|
import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/client/cache/documents';
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
|
|
||||||
interface DocumentContextType {
|
interface DocumentContextType {
|
||||||
// PDF Documents
|
|
||||||
pdfDocs: Array<BaseDocument & { type: 'pdf' }>;
|
pdfDocs: Array<BaseDocument & { type: 'pdf' }>;
|
||||||
addPDFDocument: (file: File) => Promise<string>;
|
addPDFDocument: (file: File) => Promise<string>;
|
||||||
removePDFDocument: (id: string) => Promise<void>;
|
removePDFDocument: (id: string) => Promise<void>;
|
||||||
isPDFLoading: boolean;
|
isPDFLoading: boolean;
|
||||||
|
|
||||||
// EPUB Documents
|
|
||||||
epubDocs: Array<BaseDocument & { type: 'epub' }>;
|
epubDocs: Array<BaseDocument & { type: 'epub' }>;
|
||||||
addEPUBDocument: (file: File) => Promise<string>;
|
addEPUBDocument: (file: File) => Promise<string>;
|
||||||
removeEPUBDocument: (id: string) => Promise<void>;
|
removeEPUBDocument: (id: string) => Promise<void>;
|
||||||
isEPUBLoading: boolean;
|
isEPUBLoading: boolean;
|
||||||
|
|
||||||
// HTML Documents
|
|
||||||
htmlDocs: Array<BaseDocument & { type: 'html' }>;
|
htmlDocs: Array<BaseDocument & { type: 'html' }>;
|
||||||
addHTMLDocument: (file: File) => Promise<string>;
|
addHTMLDocument: (file: File) => Promise<string>;
|
||||||
removeHTMLDocument: (id: string) => Promise<void>;
|
removeHTMLDocument: (id: string) => Promise<void>;
|
||||||
isHTMLLoading: boolean;
|
isHTMLLoading: boolean;
|
||||||
|
|
||||||
refreshDocuments: () => Promise<void>;
|
refreshDocuments: () => Promise<void>;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
|
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
|
||||||
|
const DOCUMENTS_QUERY_KEY = 'documents';
|
||||||
|
|
||||||
export function DocumentProvider({ children }: { children: ReactNode }) {
|
export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
const [docs, setDocs] = useState<BaseDocument[] | null>(null);
|
const queryClient = useQueryClient();
|
||||||
const isLoading = docs === null;
|
|
||||||
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
||||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
||||||
|
const documentsQueryKey = useMemo(() => [DOCUMENTS_QUERY_KEY, sessionKey] as const, [sessionKey]);
|
||||||
|
|
||||||
const refreshDocuments = useCallback(async () => {
|
const loadDocuments = useCallback(async () => {
|
||||||
const serverDocs = await listDocuments();
|
try {
|
||||||
// Keep only viewer-supported types
|
const serverDocs = await listDocuments();
|
||||||
setDocs(serverDocs.filter((d) => d.type === 'pdf' || d.type === 'epub' || d.type === 'html'));
|
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;
|
if (isSessionPending) return;
|
||||||
refreshDocuments().catch((err) => {
|
await queryClient.invalidateQueries({ queryKey: documentsQueryKey });
|
||||||
console.error('Failed to load documents from server:', err);
|
await refetch();
|
||||||
setDocs([]);
|
}, [isSessionPending, queryClient, documentsQueryKey, refetch]);
|
||||||
});
|
|
||||||
}, [refreshDocuments, sessionKey, isSessionPending]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
|
|
@ -66,9 +75,9 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
}, [refreshDocuments]);
|
}, [refreshDocuments]);
|
||||||
|
|
||||||
const docsByType = useMemo(() => {
|
const docsByType = useMemo(() => {
|
||||||
const pdfDocs = (docs ?? []).filter((d) => d.type === 'pdf') as Array<BaseDocument & { type: 'pdf' }>;
|
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 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 htmlDocs = docs.filter((d) => d.type === 'html') as Array<BaseDocument & { type: 'html' }>;
|
||||||
return { pdfDocs, epubDocs, htmlDocs };
|
return { pdfDocs, epubDocs, htmlDocs };
|
||||||
}, [docs]);
|
}, [docs]);
|
||||||
|
|
||||||
|
|
@ -93,14 +102,15 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
const [stored] = await uploadDocuments([file]);
|
const [stored] = await uploadDocuments([file]);
|
||||||
if (!stored) throw new Error('Upload succeeded but returned no document');
|
if (!stored) throw new Error('Upload succeeded but returned no document');
|
||||||
await cacheUploaded(stored, file);
|
await cacheUploaded(stored, file);
|
||||||
setDocs((prev) => {
|
const isSupported = stored.type === 'pdf' || stored.type === 'epub' || stored.type === 'html';
|
||||||
const current = prev ?? [];
|
if (!isSupported) return stored.id;
|
||||||
// Replace if same id exists (e.g. re-upload)
|
const supportedStored = stored as BaseDocument & { type: 'pdf' | 'epub' | 'html' };
|
||||||
const next = current.filter((d) => d.id !== stored.id);
|
queryClient.setQueryData<Array<BaseDocument & { type: 'pdf' | 'epub' | 'html' }>>(documentsQueryKey, (prev = []) => {
|
||||||
return [stored, ...next];
|
const next = prev.filter((d) => d.id !== supportedStored.id);
|
||||||
|
return [supportedStored, ...next];
|
||||||
});
|
});
|
||||||
return stored.id;
|
return stored.id;
|
||||||
}, [cacheUploaded]);
|
}, [cacheUploaded, queryClient, documentsQueryKey]);
|
||||||
|
|
||||||
const addPDFDocument = useCallback(async (file: File) => addDocument(file), [addDocument]);
|
const addPDFDocument = useCallback(async (file: File) => addDocument(file), [addDocument]);
|
||||||
const addEPUBDocument = 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) => {
|
const removeById = useCallback(async (id: string) => {
|
||||||
await deleteDocuments({ ids: [id] });
|
await deleteDocuments({ ids: [id] });
|
||||||
await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(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 removePDFDocument = useCallback(async (id: string) => removeById(id), [removeById]);
|
||||||
const removeEPUBDocument = 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]);
|
const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]);
|
||||||
|
|
||||||
// Removed unused clear functions
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentContext.Provider value={{
|
<DocumentContext.Provider value={{
|
||||||
pdfDocs: docsByType.pdfDocs,
|
pdfDocs: docsByType.pdfDocs,
|
||||||
|
|
|
||||||
|
|
@ -1373,6 +1373,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
|
providerModelPolicy.supportsInstructions,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
clearPendingEpubJump,
|
clearPendingEpubJump,
|
||||||
|
|
@ -1592,6 +1593,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
openApiKey,
|
openApiKey,
|
||||||
openApiBaseUrl,
|
openApiBaseUrl,
|
||||||
configProviderRef,
|
configProviderRef,
|
||||||
|
configProviderType,
|
||||||
|
providerModelPolicy.supportsInstructions,
|
||||||
isEPUB,
|
isEPUB,
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
|
|
@ -2622,9 +2625,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
configProviderRef,
|
configProviderRef,
|
||||||
|
configProviderType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
openApiKey,
|
openApiKey,
|
||||||
openApiBaseUrl,
|
openApiBaseUrl,
|
||||||
|
providerModelPolicy.supportsInstructions,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
onTTSStart,
|
onTTSStart,
|
||||||
|
|
@ -3077,7 +3082,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
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
|
* Renders the TTS context provider with its children
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'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';
|
import type { TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
|
||||||
export interface SharedProviderEntry {
|
export interface SharedProviderEntry {
|
||||||
|
|
@ -11,45 +12,17 @@ export interface SharedProviderEntry {
|
||||||
defaultInstructions: string | null;
|
defaultInstructions: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
export const SHARED_PROVIDERS_QUERY_KEY = ['tts-shared-providers'] as const;
|
||||||
data: SharedProviderEntry[];
|
|
||||||
loaded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY: State = { data: [], loaded: false };
|
async function fetchSharedProviders(): Promise<SharedProviderEntry[]> {
|
||||||
|
try {
|
||||||
/**
|
const res = await fetch('/api/tts/shared-providers', { credentials: 'same-origin' });
|
||||||
* Fetches the list of admin-configured shared TTS providers visible to the
|
if (!res.ok) return [];
|
||||||
* current user. Cached in module-scope state to avoid refetching on every
|
const data = (await res.json()) as { providers?: SharedProviderEntry[] };
|
||||||
* mount. The list rarely changes during a session; admin edits land on the
|
return data.providers ?? [];
|
||||||
* next page load via `__OPENREADER_RUNTIME_CONFIG__`-style behavior.
|
} catch {
|
||||||
*/
|
return [];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSharedProviders(): {
|
export function useSharedProviders(): {
|
||||||
|
|
@ -57,26 +30,17 @@ export function useSharedProviders(): {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const [state, setState] = useState<State>(cached);
|
const queryClient = useQueryClient();
|
||||||
|
const { data = [], isPending, refetch } = useQuery({
|
||||||
useEffect(() => {
|
queryKey: SHARED_PROVIDERS_QUERY_KEY,
|
||||||
listeners.add(setState);
|
queryFn: fetchSharedProviders,
|
||||||
if (!cached.loaded) {
|
staleTime: 5 * 60 * 1000,
|
||||||
void fetchOnce();
|
});
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
listeners.delete(setState);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setCached({ ...cached, loaded: false });
|
await queryClient.invalidateQueries({ queryKey: SHARED_PROVIDERS_QUERY_KEY });
|
||||||
await fetchOnce();
|
await refetch();
|
||||||
}, []);
|
}, [queryClient, refetch]);
|
||||||
|
|
||||||
return { providers: state.data, isLoading: !state.loaded, refresh };
|
return { providers: data, isLoading: isPending, refresh };
|
||||||
}
|
|
||||||
|
|
||||||
export function getCachedSharedProviders(): SharedProviderEntry[] {
|
|
||||||
return cached.data;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue