openreader/src/components/SettingsModal.tsx

1415 lines
63 KiB
TypeScript

'use client';
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { THEMES, getCustomThemeColors, type CustomThemeColors } from '@/contexts/ThemeContext';
import { ColorPicker } from '@/components/ColorPicker';
import { DocumentSelectionModal } from '@/components/documents/DocumentSelectionModal';
import { BaseDocument } from '@/types/documents';
import { getAuthClient } from '@/lib/client/auth-client';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useRouter } from 'next/navigation';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
import {
isBuiltInTtsProviderId,
type TtsProviderType,
} from '@/lib/shared/tts-provider-catalog';
import {
defaultBaseUrlForProviderType,
defaultModelForProviderType,
resolveProviderDefaults,
resolveEffectiveProviderType,
resolveTtsProviderModelPolicy,
} from '@/lib/shared/tts-provider-policy';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders';
import { flushUserPreferencesSync } from '@/lib/client/api/user-state';
import toast from 'react-hot-toast';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import {
SidebarNav,
SidebarNavItem,
SegmentedControl,
Button,
ChoiceTile,
IconButton,
Input,
Textarea,
ModalFrame,
ModalTitle,
Select,
} from '@/components/ui';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';
import {
findCurrentVersionIndex,
normalizeVersion,
type ChangelogManifestEntry,
type ChangelogReleaseBody,
} from '@/lib/shared/changelog';
import { useOnboardingFlow } from '@/contexts/OnboardingFlowContext';
// Hard-coded theme color palettes for the visual theme selector
type ThemeColorSet = { background: string; base: string; offbase: string; accent: string; secondaryAccent: string; foreground: string; muted: string };
const THEME_COLORS: Record<string, ThemeColorSet> = {
light: { background: '#ffffff', base: '#f7fafc', offbase: '#e2e8f0', accent: '#ef4444', secondaryAccent: '#ed6868', foreground: '#2d3748', muted: '#718096' },
dark: { background: '#111111', base: '#171717', offbase: '#343434', accent: '#f87171', secondaryAccent: '#eb6262', foreground: '#ededed', muted: '#a3a3a3' },
ocean: { background: '#020617', base: '#0f172a', offbase: '#1e293b', accent: '#38bdf8', secondaryAccent: '#22d3ee', foreground: '#e2e8f0', muted: '#94a3b8' },
forest: { background: '#0a0f0c', base: '#111a15', offbase: '#1a2820', accent: '#4ade80', secondaryAccent: '#22c55e', foreground: '#d4e8d0', muted: '#7c8f85' },
sunset: { background: '#1a0f0f', base: '#2c1810', offbase: '#3d1f14', accent: '#ff6b6b', secondaryAccent: '#f59e0b', foreground: '#ffe4d6', muted: '#bc8f8f' },
sea: { background: '#0c1922', base: '#102c3d', offbase: '#1a3c52', accent: '#06b6d4', secondaryAccent: '#0ea5e9', foreground: '#e0f2fe', muted: '#7ca7c4' },
mint: { background: '#0f1916', base: '#132d27', offbase: '#1c3d35', accent: '#2dd4bf', secondaryAccent: '#10b981', foreground: '#dcfce7', muted: '#75a99c' },
lavender: { background: '#faf8ff', base: '#f3effb', offbase: '#e4daf0', accent: '#7c3aed', secondaryAccent: '#a78bfa', foreground: '#3b2e5a', muted: '#8e7bab' },
rose: { background: '#fff8f8', base: '#fef1f1', offbase: '#f5dada', accent: '#e11d48', secondaryAccent: '#f472b6', foreground: '#4a2c2c', muted: '#b08a8a' },
sand: { background: '#fdfbf7', base: '#f7f2e8', offbase: '#e8dfc9', accent: '#b45309', secondaryAccent: '#d97706', foreground: '#44392a', muted: '#9a8b74' },
sky: { background: '#f6faff', base: '#edf4fc', offbase: '#d5e3f5', accent: '#2563eb', secondaryAccent: '#3b82f6', foreground: '#1e3a5f', muted: '#6b8db5' },
slate: { background: '#e8ecf0', base: '#dde2e8', offbase: '#c8ced6', accent: '#5b7a9d', secondaryAccent: '#7393b0', foreground: '#2c3440', muted: '#7a8694' },
};
const LIGHT_THEME_IDS = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']);
const allThemes = THEMES.filter(id => id !== 'custom').map(id => ({
id,
name: id.charAt(0).toUpperCase() + id.slice(1),
}));
const systemTheme = allThemes.find(t => t.id === 'system')!;
const lightThemes = allThemes.filter(t => LIGHT_THEME_IDS.has(t.id));
const darkThemes = allThemes.filter(t => t.id !== 'system' && !LIGHT_THEME_IDS.has(t.id));
const CUSTOM_COLOR_FIELDS: { key: keyof CustomThemeColors; label: string }[] = [
{ key: 'background', label: 'Background' },
{ key: 'base', label: 'Base' },
{ key: 'offbase', label: 'Off-base' },
{ key: 'accent', label: 'Accent' },
{ key: 'secondaryAccent', label: 'Accent 2' },
{ key: 'foreground', label: 'Foreground' },
{ key: 'muted', label: 'Muted' },
];
function ThemeSwatches({ colors }: { colors: ThemeColorSet }) {
return (
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
);
}
function ThemeChoice({
label,
colors,
selected,
onClick,
className,
}: {
label: string;
colors: ThemeColorSet;
selected: boolean;
onClick: () => void;
className?: string;
}) {
return (
<ChoiceTile
selected={selected}
onClick={onClick}
className={className}
style={{ backgroundColor: colors.base }}
>
{selected ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0" style={{ color: colors.accent }} />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{label}
</span>
<ThemeSwatches colors={colors} />
</ChoiceTile>
);
}
type SectionId = 'api' | 'theme' | 'docs' | 'account' | 'admin';
type SidebarSection = {
id: SectionId;
label: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
authOnly?: boolean;
adminOnly?: boolean;
};
const SIDEBAR_SECTIONS: SidebarSection[] = [
{ id: 'api', label: 'TTS Provider', icon: KeyIcon },
{ id: 'theme', label: 'Appearance', icon: PaletteIcon },
{ id: 'docs', label: 'Documents', icon: DocumentIcon },
{ id: 'account', label: 'Account', icon: UserIcon, authOnly: true },
{ id: 'admin', label: 'Admin', icon: SettingsIcon, authOnly: true, adminOnly: true },
];
type AdminSubTab = 'providers' | 'features' | 'tasks';
export function SettingsTrigger({
className = '',
triggerLabel,
variant = 'button',
onOpen,
}: {
className?: string;
triggerLabel?: string;
variant?: 'button' | 'sidebar';
onOpen: () => void;
}) {
if (variant === 'sidebar') {
return (
<SidebarNavItem
compact
onClick={onOpen}
className={className}
aria-label="Settings"
icon={<SettingsIcon className="w-3.5 h-3.5" />}
label={triggerLabel ?? 'Settings'}
/>
);
}
return (
<Button
variant="secondary"
size="sm"
onClick={onOpen}
className={className}
aria-label="Settings"
tabIndex={0}
>
<SettingsIcon className="w-4 h-4 transition-transform duration-base ease-standard hover:rotate-45" />
{triggerLabel && <span className="ml-2">{triggerLabel}</span>}
</Button>
);
}
export function SettingsModal({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const runtimeConfig = useRuntimeConfig();
const showAllProviderModels = runtimeConfig.showAllProviderModels;
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
const isOpen = open;
const setIsOpen = onOpenChange;
const [isChangelogOpen, setIsChangelogOpen] = useState(false);
const [activeSection, setActiveSection] = useState<SectionId>(enableTTSProvidersTab ? 'api' : 'theme');
const { theme, setTheme, applyCustomColors } = useTheme();
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
const [isCustomExpanded, setIsCustomExpanded] = useState(false);
const { apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
const { refreshDocuments } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
const [localProviderRef, setLocalProviderRef] = useState(providerRef);
const [localProviderType, setLocalProviderType] = useState<TtsProviderType>(providerType);
const [modelValue, setModelValue] = useState(ttsModel);
const [customModelInput, setCustomModelInput] = useState('');
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
const [isImportingLibrary, setIsImportingLibrary] = useState(false);
const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false);
const [selectionModalProps, setSelectionModalProps] = useState<{
title: string;
confirmLabel: string;
defaultSelected: boolean;
}>({
title: '',
confirmLabel: '',
defaultSelected: false
});
const [showProgress, setShowProgress] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const [abortController, setAbortController] = useState<AbortController | null>(null);
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { baseUrl: authBaseUrl } = useAuthConfig();
const { data: session } = useAuthSession();
const { changelogOpenSignal } = useOnboardingFlow();
const router = useRouter();
const isBusy = isImportingLibrary;
const {
documents: libraryDocuments,
isLoading: isLibraryDocumentsLoading,
errorMessage: libraryDocumentsErrorMessage,
prefetch: prefetchLibraryDocuments,
} = useLibraryDocumentsQuery(isSelectionModalOpen);
const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders();
const {
providers: ttsProviders,
models: ttsModels,
supportsCustomModel: supportsCustom,
selectedModelId,
canSubmit,
selectedSharedProvider,
selectedProviderRef,
selectedProviderType,
} = useMemo(() => resolveTtsSettingsViewModel({
providerRef: localProviderRef,
providerType: localProviderType,
apiKey: localApiKey,
modelValue,
customModelInput,
showAllProviderModels,
sharedProviders,
allowBuiltInProviders: !restrictUserApiKeys,
}), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllProviderModels, sharedProviders, restrictUserApiKeys]);
const isSharedSelected = Boolean(selectedSharedProvider);
const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0];
useEffect(() => {
if (changelogOpenSignal <= 0) return;
setIsOpen(true);
setIsChangelogOpen(true);
}, [changelogOpenSignal, setIsOpen]);
useEffect(() => {
// Only mirror persisted config into the local form while the modal is closed.
// While it's open the user may be mid-edit, and a background refresh (e.g. the
// focus refetch in ConfigContext, or async shared-provider loading) must not
// stomp their in-progress selection. On close, resetToCurrent re-syncs.
if (isOpen) return;
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef);
setLocalProviderType(providerType);
setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions);
}, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
useEffect(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
setCustomModelInput(modelValue);
} else {
setCustomModelInput('');
}
}, [modelValue, ttsModels]);
useEffect(() => {
if (selectedProviderOption) return;
if (ttsProviders.length === 0) return;
const fallback = ttsProviders[0];
setLocalProviderRef(fallback.id);
setLocalProviderType(fallback.providerType);
if (fallback.shared) {
const shared = sharedProviders.find((p) => p.slug === fallback.id);
if (shared?.defaultModel) {
setModelValue(shared.defaultModel);
}
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
setLocalApiKey('');
setLocalBaseUrl('');
setCustomModelInput('');
return;
}
if (isBuiltInTtsProviderId(fallback.providerType)) {
setModelValue(defaultModelForProviderType(fallback.providerType));
setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType));
setCustomModelInput('');
}
}, [selectedProviderOption, ttsProviders, sharedProviders]);
const handleRefresh = async () => {
try {
clearInMemoryDocumentPreviewCache();
await refreshDocuments();
} catch (error) {
console.error('Failed to refresh documents:', error);
}
};
const handleClearCache = async () => {
try {
await Promise.all([
clearDocumentCache(),
clearAllDocumentPreviewCaches(),
]);
} catch (error) {
console.error('Failed to clear cache:', error);
}
};
const handleImportLibrary = async () => {
// Start fetching as soon as the user opens the picker so cached data is
// often ready before the modal asks for it.
void prefetchLibraryDocuments();
setSelectionModalProps({
title: 'Import from Library',
confirmLabel: 'Import',
defaultSelected: false,
});
setIsSelectionModalOpen(true);
};
const handleModalConfirm = async (selectedFiles: BaseDocument[]) => {
const controller = new AbortController();
setAbortController(controller);
setIsSelectionModalOpen(false);
try {
setShowProgress(true);
setProgress(0);
setIsImportingLibrary(true);
for (let i = 0; i < selectedFiles.length; i++) {
if (controller.signal.aborted) break;
const doc = selectedFiles[i];
setStatusMessage(`Importing ${i + 1}/${selectedFiles.length}: ${doc.name}`);
setProgress((i / Math.max(1, selectedFiles.length)) * 90);
const contentResponse = await fetch(`/api/documents/library/content?id=${encodeURIComponent(doc.id)}`, {
signal: controller.signal,
});
if (!contentResponse.ok) {
console.warn(`Failed to download library document: ${doc.name}`);
continue;
}
const bytes = await contentResponse.arrayBuffer();
const file = new File([bytes], doc.name, {
type: mimeTypeForDoc(doc),
lastModified: doc.lastModified,
});
const uploaded = await uploadDocuments([file], { signal: controller.signal });
const stored = uploaded[0];
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch((err) => {
console.warn('Failed to cache imported document:', stored.id, err);
});
}
}
if (!controller.signal.aborted) {
setProgress(95);
await refreshDocuments();
setProgress(100);
setStatusMessage('Import complete');
}
} catch (error) {
if (controller.signal.aborted) {
console.log('library import cancelled');
setStatusMessage('Operation cancelled');
} else {
console.error('library import failed:', error);
setStatusMessage('Import failed. Please try again.');
}
} finally {
setIsImportingLibrary(false);
setShowProgress(false);
setProgress(0);
setStatusMessage('');
setAbortController(null);
}
};
const handleSignOut = async () => {
const client = getAuthClient(authBaseUrl);
await client.signOut();
router.push('/signin');
};
const handleDeleteAccount = async () => {
try {
const res = await fetch('/api/account/delete', { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete account');
const client = getAuthClient(authBaseUrl);
await client.signOut();
window.location.href = runtimeConfig.enableUserSignups ? '/signup' : '/signin';
} catch (error) {
console.error('Failed to delete account:', error);
}
setShowDeleteAccountConfirm(false);
};
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
if (type === 'apiKey') {
setLocalApiKey(value === '' ? '' : value);
} else if (type === 'baseUrl') {
setLocalBaseUrl(value === '' ? '' : value);
}
};
const resetToCurrent = useCallback(() => {
setIsOpen(false);
setIsChangelogOpen(false);
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef);
setLocalProviderType(providerType);
setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions);
if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') {
setCustomModelInput(ttsModel);
} else {
setCustomModelInput('');
}
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
const [systemIsDark, setSystemIsDark] = useState(
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
const getThemeColors = useCallback((id: string): ThemeColorSet => {
if (id === 'system') return THEME_COLORS[systemIsDark ? 'dark' : 'light'];
if (id === 'custom') {
return {
background: customColors.background,
base: customColors.base,
offbase: customColors.offbase,
accent: customColors.accent,
secondaryAccent: customColors.secondaryAccent,
foreground: customColors.foreground,
muted: customColors.muted,
};
}
return THEME_COLORS[id] || THEME_COLORS.light;
}, [systemIsDark, customColors]);
const isAdmin = Boolean(
(session?.user as unknown as { isAdmin?: boolean } | undefined)?.isAdmin,
);
const [adminSubTab, setAdminSubTab] = useState<AdminSubTab>('providers');
const visibleSections = useMemo(
() => SIDEBAR_SECTIONS.filter((section) => {
if (section.id === 'api' && !enableTTSProvidersTab) {
return false;
}
if (section.adminOnly && !isAdmin) return false;
return true;
}),
[isAdmin, enableTTSProvidersTab]
);
useEffect(() => {
if (visibleSections.some(section => section.id === activeSection)) {
return;
}
setActiveSection(visibleSections[0]?.id ?? 'theme');
}, [activeSection, visibleSections]);
const fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-soft';
const sectionShellClass = 'space-y-2 pb-3 border-b border-line-soft px-0.5';
const sectionHeadingClass = 'text-sm font-semibold text-foreground';
const effectiveProviderType = resolveEffectiveProviderType({
providerRef: selectedProviderRef,
providerType: localProviderType,
sharedProviders,
});
const providerModelPolicy = resolveTtsProviderModelPolicy({
providerRef: selectedProviderRef,
providerType: effectiveProviderType,
model: modelValue,
sharedProviders,
});
const shouldShowBaseUrl = !restrictUserApiKeys
&& !isSharedSelected
&& providerModelPolicy.isResolvedProviderType
&& providerModelPolicy.providerType !== 'replicate'
&& (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
const selectedModelVersion = selectedModel?.id?.includes(':')
? selectedModel.id.slice(selectedModel.id.indexOf(':'))
: '';
const displayVersion = normalizeVersion(runtimeConfig.appVersion || '');
return (
<>
<ModalFrame
open={isOpen}
onClose={resetToCurrent}
size="xl"
panelTestId="settings-modal"
className={isChangelogOpen ? 'z-[90]' : 'z-50'}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft">
<div className="flex items-baseline gap-4">
<ModalTitle>Settings</ModalTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setIsChangelogOpen(true)}
className="text-sm font-medium leading-6 text-soft hover:text-accent transition-colors"
>
{displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'}
</Button>
</div>
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
onClick={() => showPrivacyModal()}
className="text-sm font-medium text-soft hover:text-accent transition-colors"
>
Privacy
</Button>
</div>
</div>
{isChangelogOpen ? (
<SettingsChangelogPanel
appVersion={runtimeConfig.appVersion}
manifestUrl={runtimeConfig.changelogFeedUrl}
onClose={() => setIsChangelogOpen(false)}
/>
) : (
<>
{/* Mobile nav */}
<SidebarNav layout="grid" className="sm:hidden border-b border-line-soft bg-background p-2">
{visibleSections.map((section) => {
const Icon = section.icon;
return (
<SidebarNavItem
compact
key={section.id}
onClick={() => setActiveSection(section.id)}
active={activeSection === section.id}
icon={<Icon className="w-3.5 h-3.5" />}
label={section.label}
/>
);
})}
</SidebarNav>
<div className="flex flex-row h-[490px]">
{/* Desktop: vertical sidebar */}
<nav className="hidden sm:block w-44 shrink-0 border-r border-line-soft bg-background p-2">
<SidebarNav>
{visibleSections.map((section) => {
const Icon = section.icon;
const active = activeSection === section.id;
return (
<SidebarNavItem
key={section.id}
onClick={() => setActiveSection(section.id)}
active={active}
icon={<Icon className="w-4 h-4" />}
label={section.label}
className="whitespace-nowrap"
/>
);
})}
</SidebarNav>
</nav>
{/* Content */}
<div className={`flex-1 min-w-0 p-3 overflow-y-auto ${
activeSection === 'admin'
? 'bg-[radial-gradient(circle_at_top_right,color-mix(in_srgb,var(--accent),transparent_92%),transparent_35%)]'
: ''
}`}>
{/* API Section */}
{activeSection === 'api' && (
<div className="space-y-4">
<div className="space-y-1.5">
<label className={fieldLabelClass}>TTS Provider</label>
{sharedProvidersLoading ? (
<p className="text-xs text-soft">Loading providers</p>
) : ttsProviders.length === 0 ? (
<p className="text-xs text-accent">
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
</p>
) : (
<Select
value={selectedProviderOption!}
options={ttsProviders}
getOptionKey={(provider) => provider.id}
renderValue={(provider) => provider.name}
renderOption={(provider, { selected }) => (
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{provider.name}
</span>
)}
onChange={(provider) => {
const defaults = resolveProviderDefaults({
providerRef: provider.id,
providerType: provider.providerType,
sharedProviders,
});
setLocalProviderRef(provider.id);
setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel);
setLocalTTSInstructions(defaults.defaultInstructions);
if (provider.shared) {
// Shared admin provider — credentials live on the server.
setLocalApiKey('');
setLocalBaseUrl('');
} else if (isBuiltInTtsProviderId(provider.providerType)) {
setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType));
}
setCustomModelInput('');
}}
/>
)}
</div>
{restrictUserApiKeys && (
<p className="text-xs text-soft">
This instance restricts user API keys. TTS runs through admin-configured shared providers only.
</p>
)}
{shouldShowBaseUrl && (
<div className="space-y-1.5">
<label className={fieldLabelClass}>
API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<Input
type="text"
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
/>
</div>
)}
{shouldShowApiKey && (
<div className="space-y-1.5">
<label className={fieldLabelClass}>
API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<Input
type="password"
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder="Using environment variable"
/>
</div>
)}
{isSharedSelected && (
<p className="text-xs text-soft">
This is a shared provider configured by an admin. API key and base URL are managed server-side.
</p>
)}
<div className="space-y-1.5">
<label className={fieldLabelClass}>TTS Model</label>
{!showAllProviderModels && (
<p className="text-xs text-soft">
This instance restricts model selection to each provider&apos;s default model.
</p>
)}
<div className="flex flex-col gap-2">
<Select
value={selectedModel}
options={ttsModels}
getOptionKey={(model) => model.id}
renderValue={(model) => (
<span className="block">
<span className="block truncate">{model.name}</span>
{selectedModelVersion ? (
<span className="block truncate text-xs text-soft">
{selectedModelVersion}
</span>
) : null}
</span>
)}
renderOption={(model, { selected }) => (
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
<span className="block truncate">{model.name}</span>
{model.id.includes(':') ? (
<span className="block truncate text-xs text-soft">
{model.id.slice(model.id.indexOf(':'))}
</span>
) : null}
</span>
)}
onChange={(model) => {
if (model.id === 'custom') {
setModelValue(customModelInput);
} else {
setModelValue(model.id);
setCustomModelInput('');
}
}}
/>
{supportsCustom && selectedModelId === 'custom' && (
<Input
type="text"
value={customModelInput}
onChange={(e) => {
setCustomModelInput(e.target.value);
setModelValue(e.target.value);
}}
placeholder="Enter custom model name"
/>
)}
</div>
</div>
{providerModelPolicy.supportsInstructions && (
<div className="space-y-1.5">
<label className={fieldLabelClass}>TTS Instructions</label>
<Textarea
value={localTTSInstructions}
onChange={(e) => setLocalTTSInstructions(e.target.value)}
placeholder="Enter instructions for the TTS model"
className="h-24 resize-none"
/>
</div>
)}
<div className="pt-4 flex justify-end gap-2">
<Button
type="button"
variant="secondary"
size="md"
onClick={async () => {
const defaults = resolveProviderDefaults({
providerRef: runtimeConfig.defaultTtsProvider,
sharedProviders,
});
setLocalApiKey('');
setLocalBaseUrl('');
setLocalProviderRef(defaults.providerRef);
setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel);
setCustomModelInput('');
setLocalTTSInstructions(defaults.defaultInstructions);
}}
>
Reset
</Button>
<Button
data-testid="settings-save-button"
type="button"
variant="primary"
size="md"
disabled={!canSubmit || sharedProvidersLoading}
onClick={async () => {
const defaults = resolveProviderDefaults({
providerRef: selectedProviderRef,
providerType: selectedProviderType,
sharedProviders,
});
await updateConfig({
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
});
await updateConfigKey('providerRef', selectedProviderRef);
await updateConfigKey('providerType', selectedProviderType);
const finalModel = showAllProviderModels
? (selectedModelId === 'custom' ? customModelInput.trim() : modelValue)
: defaults.defaultModel;
await updateConfigKey('ttsModel', finalModel);
await updateConfigKey('ttsInstructions', localTTSInstructions);
// Push the change to the server immediately rather than waiting on
// the debounce, so a quick reload can't lose the save. Surface
// failures instead of silently dropping them.
try {
await flushUserPreferencesSync();
} catch (error) {
console.error('Failed to save TTS provider settings:', error);
toast.error('Failed to save provider settings. Please try again.');
return;
}
setIsOpen(false);
}}
>
Save
</Button>
</div>
</div>
)}
{/* Theme / Appearance Section */}
{activeSection === 'theme' && (
<div className="space-y-4">
{/* System */}
<div className="space-y-1.5">
{(() => {
const colors = getThemeColors(systemTheme.id);
const isActive = theme === systemTheme.id;
return (
<ThemeChoice
label={systemTheme.name}
colors={colors}
selected={isActive}
onClick={() => setTheme(systemTheme.id)}
className="w-full"
/>
);
})()}
</div>
{/* Custom theme */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-soft uppercase tracking-wide">Custom</label>
{(() => {
const colors = getThemeColors('custom');
const isActive = theme === 'custom';
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<ThemeChoice
label="Custom"
colors={colors}
selected={isActive}
onClick={() => {
setTheme('custom');
setIsCustomExpanded(true);
}}
className="flex-1"
/>
<IconButton
onClick={() => setIsCustomExpanded(!isCustomExpanded)}
tone="surface"
size="sm"
className="shrink-0"
style={{ color: colors.muted, backgroundColor: colors.base }}
aria-label={isCustomExpanded ? 'Collapse color picker' : 'Expand color picker'}
>
<svg className={`w-3.5 h-3.5 transition-transform duration-base ${isCustomExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</IconButton>
</div>
{isCustomExpanded && (
<div
className="rounded-lg border p-3 space-y-2"
style={{
backgroundColor: colors.background,
borderColor: isActive ? colors.accent : colors.offbase,
}}
>
<div className="flex flex-col gap-1">
{CUSTOM_COLOR_FIELDS.map(({ key, label }) => (
<div
key={key}
className="grid items-center rounded-md px-2 py-1"
style={{
backgroundColor: colors.base,
gridTemplateColumns: '5rem 1fr auto',
gap: '0.5rem',
}}
>
<span
className="text-xs font-medium truncate"
style={{ color: colors.foreground }}
>
{label}
</span>
<span
className="text-[10px] font-mono text-right"
style={{ color: colors.muted }}
>
{customColors[key]}
</span>
<ColorPicker
value={customColors[key]}
field={key}
label={label}
onChange={(color) => {
const updated = { ...customColors, [key]: color };
setCustomColors(updated);
applyCustomColors(updated);
}}
/>
</div>
))}
</div>
</div>
)}
</div>
);
})()}
</div>
{/* Light themes */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-soft uppercase tracking-wide">Light</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{lightThemes.map((t) => {
const colors = getThemeColors(t.id);
const isActive = theme === t.id;
return (
<ThemeChoice
key={t.id}
label={t.name}
colors={colors}
selected={isActive}
onClick={() => setTheme(t.id)}
/>
);
})}
</div>
</div>
{/* Dark themes */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-soft uppercase tracking-wide">Dark</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{darkThemes.map((t) => {
const colors = getThemeColors(t.id);
const isActive = theme === t.id;
return (
<ThemeChoice
key={t.id}
label={t.name}
colors={colors}
selected={isActive}
onClick={() => setTheme(t.id)}
/>
);
})}
</div>
</div>
</div>
)}
{/* Documents Section */}
{activeSection === 'docs' && (
<div className="space-y-5">
<div className={sectionShellClass}>
<h4 className={sectionHeadingClass}>Server Library</h4>
<Button
onClick={handleImportLibrary}
disabled={isBusy}
variant="outline"
size="md"
>
{isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'}
</Button>
</div>
<div className={sectionShellClass}>
<h4 className={sectionHeadingClass}>Cache & Data</h4>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleRefresh}
disabled={isBusy}
variant="outline"
size="md"
>
Refresh
</Button>
<Button
onClick={handleClearCache}
disabled={isBusy}
variant="outline"
size="md"
>
Clear cache
</Button>
</div>
</div>
</div>
)}
{/* Admin Section */}
{activeSection === 'admin' && isAdmin && (
<div className="space-y-4">
<SegmentedControl
value={adminSubTab}
options={[
{ value: 'providers', label: 'Shared providers' },
{ value: 'features', label: 'Site features' },
{ value: 'tasks', label: 'Tasks' },
]}
onChange={setAdminSubTab}
ariaLabel="Admin tab"
className="grid-cols-3"
/>
{adminSubTab === 'providers' && <AdminProvidersPanel />}
{adminSubTab === 'features' && <AdminFeaturesPanel />}
{adminSubTab === 'tasks' && <AdminTasksPanel />}
</div>
)}
{/* Account Section */}
{activeSection === 'account' && (
<div className="space-y-2">
{/* Session info */}
<div className="rounded-lg bg-background border border-line p-4 space-y-2">
<h4 className="text-sm font-medium text-foreground">Current Session</h4>
<div className="text-sm space-y-1">
<p className="text-soft">Logged in as:</p>
{session?.user ? (
<>
<p className="font-medium text-foreground">
{session.user.isAnonymous
? 'Anonymous'
: (session.user.name || session.user.email || 'Account')}
</p>
{!session.user.isAnonymous && (
<p className="text-xs text-soft font-mono">{session.user.email}</p>
)}
{session.user.isAnonymous && (
<p className="text-xs text-accent mt-1">Anonymous session</p>
)}
</>
) : (
<p className="font-medium text-foreground">No active session</p>
)}
</div>
</div>
{/* Export Data */}
{session?.user && (
<ChoiceTile
onClick={() => {
window.open('/api/user/export', '_blank');
}}
className="w-full rounded-lg bg-background p-4 text-left hover:bg-accent-wash"
>
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-surface-sunken flex items-center justify-center">
<DownloadIcon className="w-5 h-5 text-accent" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Export My Data</p>
<p className="text-xs text-soft">Download all your data as a ZIP file</p>
</div>
</ChoiceTile>
)}
{/* Actions */}
<div className="space-y-2">
{session?.user && !session.user.isAnonymous ? (
<>
<Button
onClick={handleSignOut}
variant="outline"
size="md"
>
Disconnect account
</Button>
<div className="pt-4 mt-4 border-t border-line-soft">
<label className="block text-sm font-medium text-danger mb-2">Danger Zone</label>
<Button
onClick={() => setShowDeleteAccountConfirm(true)}
variant="danger"
size="md"
>
Delete Account
</Button>
<p className="text-xs text-soft mt-2">
Permanently deletes your account and all data.
</p>
</div>
</>
) : (
<div className="pt-2 border-t border-line-soft">
<p className="text-sm text-soft mb-3">
{session?.user?.isAnonymous
? (runtimeConfig.enableUserSignups
? 'You are using an anonymous session. Sign up to save your progress permanently, your current data is automatically transferred.'
: 'You are using an anonymous session. New account sign-ups are currently disabled by the site administrator.')
: (runtimeConfig.enableUserSignups
? 'No active session. Please sign in or create an account.'
: 'No active session. Please sign in.')}
</p>
<div className="flex flex-wrap gap-2">
<Link href="/signin">
<Button variant="outline" size="md">
Connect
</Button>
</Link>
{runtimeConfig.enableUserSignups && (
<Link href="/signup">
<Button variant="primary" size="md">
Create account
</Button>
</Link>
)}
<Link href="/?redirect=false">
<Button variant="outline" size="md">
Back to landing page
</Button>
</Link>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</>
)}
</ModalFrame>
<ConfirmDialog
isOpen={showDeleteAccountConfirm}
onClose={() => setShowDeleteAccountConfirm(false)}
onConfirm={handleDeleteAccount}
title="Delete Account"
message="Are you sure you want to delete your account? This action cannot be undone and all your data will be lost."
confirmText="Delete Account"
isDangerous={true}
/>
<ProgressPopup
isOpen={showProgress}
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
onCancel={() => {
if (abortController) {
abortController.abort();
}
setShowProgress(false);
setProgress(0);
setIsImportingLibrary(false);
setStatusMessage('');
setAbortController(null);
}}
statusMessage={statusMessage}
operationType="library"
cancelText="Cancel"
/>
<DocumentSelectionModal
isOpen={isSelectionModalOpen}
onClose={() => !isBusy && setIsSelectionModalOpen(false)}
onConfirm={handleModalConfirm}
title={selectionModalProps.title}
confirmLabel={selectionModalProps.confirmLabel}
isProcessing={false}
defaultSelected={selectionModalProps.defaultSelected}
files={libraryDocuments}
isLoading={isLibraryDocumentsLoading}
errorMessage={libraryDocumentsErrorMessage}
/>
</>
);
}
function SettingsChangelogPanel({
appVersion,
manifestUrl,
onClose,
}: {
appVersion: string;
manifestUrl: string;
onClose: () => void;
}) {
const [manifest, setManifest] = useState<ChangelogManifestEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [bodies, setBodies] = useState<Record<string, ChangelogReleaseBody>>({});
const normalizedAppVersion = normalizeVersion(appVersion || '');
const isAbortError = (err: unknown): boolean => {
return err instanceof DOMException
? err.name === 'AbortError'
: !!(typeof err === 'object' && err && 'name' in err && (err as { name?: string }).name === 'AbortError');
};
useEffect(() => {
const controller = new AbortController();
async function loadManifest() {
setLoading(true);
setError(null);
try {
const entries = await fetchChangelogManifest(manifestUrl, controller.signal);
setManifest(entries);
const initialIndex = findCurrentVersionIndex(entries, normalizedAppVersion);
if (initialIndex >= 0) {
const entry = entries[initialIndex];
setExpanded((prev) => ({ ...prev, [entry.tag_name]: true }));
}
} catch (err) {
if (isAbortError(err)) return;
setError(err instanceof Error ? err.message : 'Failed to load changelog');
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
void loadManifest();
return () => controller.abort();
}, [manifestUrl, normalizedAppVersion]);
useEffect(() => {
const controller = new AbortController();
const tagsToLoad = manifest
.filter((entry) => expanded[entry.tag_name] && !bodies[entry.tag_name])
.map((entry) => entry.tag_name);
if (tagsToLoad.length === 0) return () => controller.abort();
async function loadBodies() {
await Promise.all(tagsToLoad.map(async (tag) => {
const entry = manifest.find((item) => item.tag_name === tag);
if (!entry) return;
try {
const body = await fetchChangelogReleaseBody(manifestUrl, entry.body_path, controller.signal);
setBodies((prev) => ({ ...prev, [tag]: body }));
} catch (err) {
if (isAbortError(err)) return;
// Keep entry expanded; inline fallback appears below.
}
}));
}
void loadBodies();
return () => controller.abort();
}, [expanded, manifest, manifestUrl, bodies]);
return (
<div className="h-[490px] flex flex-col bg-surface">
<div className="flex items-center gap-3 px-4 py-3 border-b border-line-soft bg-background">
<IconButton
onClick={onClose}
aria-label="Back to settings"
title="Back"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</IconButton>
<div className="min-w-0">
<h4 className="text-sm font-semibold text-foreground">Changelog</h4>
<p className="text-xs text-soft truncate">
{normalizedAppVersion
? `Current version: v${normalizedAppVersion}`
: 'Release history from GitHub'}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 pb-3">
{loading && (
<div className="py-3 text-sm text-soft">
Loading changelog
</div>
)}
{!loading && error && (
<div className="py-3 space-y-2 border-b border-line-soft">
<p className="text-sm text-foreground">Could not load changelog right now.</p>
<p className="text-xs text-soft break-words">{error}</p>
<a
href="https://github.com/richardr1126/openreader/releases"
target="_blank"
rel="noreferrer"
className="inline-flex text-xs font-medium text-accent hover:underline transition duration-base ease-standard transform"
>
Open GitHub Releases
</a>
</div>
)}
{!loading && !error && manifest.length === 0 && (
<div className="py-3 text-sm text-soft">
No releases found.
</div>
)}
{!loading && !error && manifest.map((entry) => {
const isCurrent = normalizedAppVersion && normalizeVersion(entry.tag_name) === normalizedAppVersion;
const body = bodies[entry.tag_name];
const isExpanded = !!expanded[entry.tag_name];
const normalizedTag = normalizeVersion(entry.tag_name);
const normalizedName = normalizeVersion(entry.name || '');
const showName = Boolean(entry.name) && normalizedName !== normalizedTag;
return (
<div key={entry.tag_name} className="border-b border-line-soft">
<button
type="button"
onClick={() => setExpanded((prev) => ({ ...prev, [entry.tag_name]: !isExpanded }))}
className="w-full rounded-md border border-transparent px-2 py-2 text-left flex items-center gap-2 transition duration-base ease-standard hover:border-accent-line hover:bg-accent-wash"
>
<ChevronRightIcon
className={`w-3.5 h-3.5 shrink-0 text-soft transition-transform ${
isExpanded ? 'rotate-90 text-foreground' : ''
}`}
/>
<div className="min-w-0 flex items-center gap-2 text-sm w-full">
<span className="font-semibold text-foreground shrink-0">{entry.tag_name}</span>
{entry.prerelease && (
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-surface-sunken text-soft shrink-0">
prerelease
</span>
)}
{isCurrent && (
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-surface-sunken text-accent shrink-0">
current
</span>
)}
{showName && (
<span className="text-xs text-soft truncate">
{entry.name}
</span>
)}
<span className="text-[11px] text-soft shrink-0">
{new Date(entry.published_at).toLocaleDateString()}
</span>
</div>
</button>
{isExpanded && (
<div className="pl-6 pr-1 pb-3 pt-1 space-y-2">
{body ? (
<div className="text-sm text-foreground leading-6 space-y-2 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_ul]:pl-5 [&_ol]:pl-5 [&_code]:bg-surface-sunken [&_code]:rounded [&_code]:px-1 [&_pre]:bg-surface-sunken [&_pre]:rounded [&_pre]:p-2 [&_pre]:overflow-x-auto [&_a]:text-accent [&_a]:hover:underline [&_a]:transition-colors">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{body.body || '_No release notes provided._'}
</ReactMarkdown>
</div>
) : (
<p className="text-xs text-soft">Loading release notes</p>
)}
<a
href={entry.html_url}
target="_blank"
rel="noreferrer"
className="inline-flex text-xs font-medium text-accent hover:underline transition duration-base ease-standard transform"
>
View on GitHub
</a>
</div>
)}
</div>
);
})}
</div>
</div>
);
}