1415 lines
63 KiB
TypeScript
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'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>
|
|
);
|
|
}
|