openreader/src/components/SettingsModal.tsx
Richard R d4afa43abf refactor(ui): unify button usage in settings, admin, and doclist components
Replace native button elements with shared Button, ChoiceTile, and IconButton
components for consistent UI behavior and styling. Update classNames and
props to match new component APIs. Adjust FinderSidebar to use utility
function for conditional class merging.
2026-06-01 11:58:30 -06:00

1498 lines
67 KiB
TypeScript

'use client';
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
import {
Transition,
Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
} from '@headlessui/react';
import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, 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 { deleteDocuments, 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 { useSharedProviders } from '@/hooks/useSharedProviders';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import {
SidebarNav,
SidebarNavItem,
SegmentedControl,
Button,
ChoiceTile,
IconButton,
Input,
ModalFrame,
ModalTitle,
inputClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
} 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';
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 enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
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 [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false);
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 } = 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(() => {
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef);
setLocalProviderType(providerType);
setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions);
}, [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 handleDeleteDocs = async () => {
try {
await deleteDocuments();
await refreshDocuments().catch(() => { });
} catch (error) {
console.error('Delete failed:', error);
} finally {
setShowDeleteDocsConfirm(false);
}
};
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>
{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>
) : (
<Listbox
value={selectedProviderOption!}
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('');
}}
>
<ListboxButton className={listboxButtonClass}>
<span className="block truncate">
{selectedProviderOption?.name || 'Select Provider'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-standard duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
anchor="bottom start"
className={listboxOptionsClass}
>
{ttsProviders.map((provider) => (
<ListboxOption
key={provider.id}
className={({ active }) => listboxOptionClass(active)}
value={provider}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{provider.name}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
)}
</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"
className={inputClass}
/>
</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"
className={inputClass}
/>
</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">
<Listbox
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
onChange={(model) => {
if (model.id === 'custom') {
setModelValue(customModelInput);
} else {
setModelValue(model.id);
setCustomModelInput('');
}
}}
>
<ListboxButton className={listboxButtonClass}>
{selectedModel ? (
<span className="block">
<span className="block truncate">
{selectedModel.name}
</span>
{selectedModelVersion && (
<span className="block truncate text-xs text-soft">
{selectedModelVersion}
</span>
)}
</span>
) : (
<span className="block truncate">Select Model</span>
)}
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-standard duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
anchor="bottom start"
className={listboxOptionsClass}
>
{ttsModels.map((model) => (
<ListboxOption
key={model.id}
className={({ active }) => listboxOptionClass(active)}
value={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>
)}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
{supportsCustom && selectedModelId === 'custom' && (
<Input
type="text"
value={customModelInput}
onChange={(e) => {
setCustomModelInput(e.target.value);
setModelValue(e.target.value);
}}
placeholder="Enter custom model name"
className={inputClass}
/>
)}
</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={`${inputClass} 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}
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);
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' },
]}
onChange={setAdminSubTab}
ariaLabel="Admin tab"
className="grid-cols-2"
/>
{adminSubTab === 'providers' && <AdminProvidersPanel />}
{adminSubTab === 'features' && <AdminFeaturesPanel />}
</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>
{enableDestructiveDelete && (
<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={showDeleteDocsConfirm}
onClose={() => setShowDeleteDocsConfirm(false)}
onConfirm={handleDeleteDocs}
title="Delete All Data"
message="Are you sure you want to delete all documents from the server? This action cannot be undone."
confirmText="Delete"
isDangerous={true}
/>
<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">
<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>
);
}