import React, { useEffect, useState } from 'react'; import { Layout } from '../components/Layout'; import { useNavigate } from 'react-router-dom'; import * as api from '../api'; import type { Collection } from '../types'; import { Upload, Moon, Sun, Info, Archive } from 'lucide-react'; import { ConfirmModal } from '../components/ConfirmModal'; import { importLegacyFiles } from '../utils/importUtils'; import { useTheme } from '../context/ThemeContext'; import { useAuth } from '../context/AuthContext'; export const Settings: React.FC = () => { const [collections, setCollections] = useState([]); const navigate = useNavigate(); const { theme, toggleTheme } = useTheme(); const { authEnabled, user } = useAuth(); const [legacyDbImportConfirmation, setLegacyDbImportConfirmation] = useState<{ isOpen: boolean; file: File | null; info: null | { drawings: number; collections: number; legacyLatestMigration: string | null; currentLatestMigration: string | null; }; }>({ isOpen: false, file: null, info: null }); const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); const [importSuccess, setImportSuccess] = useState<{ isOpen: boolean; message: React.ReactNode }>({ isOpen: false, message: '' }); const [legacyDbImportLoading, setLegacyDbImportLoading] = useState(false); const [authToggleLoading, setAuthToggleLoading] = useState(false); const [authToggleError, setAuthToggleError] = useState(null); const [authToggleConfirm, setAuthToggleConfirm] = useState<{ isOpen: boolean; nextEnabled: boolean | null }>({ isOpen: false, nextEnabled: null, }); const [backupExportExt, setBackupExportExt] = useState<'excalidash' | 'excalidash.zip'>('excalidash'); const [backupImportConfirmation, setBackupImportConfirmation] = useState<{ isOpen: boolean; file: File | null; info: null | { formatVersion: number; exportedAt: string; excalidashBackendVersion: string | null; collections: number; drawings: number; }; }>({ isOpen: false, file: null, info: null }); const [backupImportLoading, setBackupImportLoading] = useState(false); const [backupImportSuccess, setBackupImportSuccess] = useState(false); const [backupImportError, setBackupImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); const appVersion = import.meta.env.VITE_APP_VERSION || 'Unknown version'; const buildLabel = import.meta.env.VITE_APP_BUILD_LABEL; useEffect(() => { const fetchCollections = async () => { try { const data = await api.getCollections(); setCollections(data); } catch (err) { console.error('Failed to fetch collections:', err); } }; fetchCollections(); }, []); const setAuthEnabled = async (enabled: boolean) => { setAuthToggleLoading(true); setAuthToggleError(null); try { const response = await api.api.post<{ authEnabled: boolean; bootstrapRequired?: boolean }>( '/auth/auth-enabled', { enabled }, ); if (response.data.authEnabled) { // Auth enabled -> prompt admin bootstrap via register. window.location.href = '/register'; return; } // Auth disabled -> reload to drop auth gating. window.location.reload(); } catch (err: unknown) { let message = 'Failed to update authentication setting'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setAuthToggleError(message); } finally { setAuthToggleLoading(false); } }; const confirmToggleAuthEnabled = () => { if (authEnabled === null) return; if (authToggleLoading) return; setAuthToggleConfirm({ isOpen: true, nextEnabled: !authEnabled }); }; const exportBackup = async () => { try { const extQuery = backupExportExt === 'excalidash.zip' ? '?ext=zip' : ''; const response = await api.api.get(`/export/excalidash${extQuery}`, { responseType: 'blob' }); const blob = new Blob([response.data], { type: 'application/zip' }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; const date = new Date().toISOString().split('T')[0]; link.download = backupExportExt === 'excalidash.zip' ? `excalidash-backup-${date}.excalidash.zip` : `excalidash-backup-${date}.excalidash`; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); } catch (err: unknown) { console.error('Backup export failed:', err); setBackupImportError({ isOpen: true, message: 'Failed to export backup. Please try again.' }); } }; const verifyBackupFile = async (file: File) => { setBackupImportLoading(true); try { const formData = new FormData(); formData.append('archive', file); const response = await api.api.post<{ valid: boolean; formatVersion: number; exportedAt: string; excalidashBackendVersion: string | null; collections: number; drawings: number; }>('/import/excalidash/verify', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setBackupImportConfirmation({ isOpen: true, file, info: { formatVersion: response.data.formatVersion, exportedAt: response.data.exportedAt, excalidashBackendVersion: response.data.excalidashBackendVersion ?? null, collections: response.data.collections, drawings: response.data.drawings, }, }); } catch (err: unknown) { console.error('Backup verify failed:', err); let message = 'Failed to verify backup file.'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setBackupImportError({ isOpen: true, message }); } finally { setBackupImportLoading(false); } }; const verifyLegacyDbFile = async (file: File) => { setLegacyDbImportLoading(true); try { const formData = new FormData(); formData.append('db', file); const response = await api.api.post<{ valid: boolean; drawings: number; collections: number; latestMigration: string | null; currentLatestMigration: string | null; }>('/import/sqlite/legacy/verify', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setLegacyDbImportConfirmation({ isOpen: true, file, info: { drawings: response.data.drawings, collections: response.data.collections, legacyLatestMigration: response.data.latestMigration ?? null, currentLatestMigration: response.data.currentLatestMigration ?? null, }, }); } catch (err: unknown) { console.error('Legacy DB verify failed:', err); let message = 'Failed to verify legacy database file.'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setImportError({ isOpen: true, message }); } finally { setLegacyDbImportLoading(false); } }; const handleCreateCollection = async (name: string) => { await api.createCollection(name); const newCollections = await api.getCollections(); setCollections(newCollections); }; const handleEditCollection = async (id: string, name: string) => { setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c)); await api.updateCollection(id, name); }; const handleDeleteCollection = async (id: string) => { setCollections(prev => prev.filter(c => c.id !== id)); await api.deleteCollection(id); }; const handleSelectCollection = (id: string | null | undefined) => { if (id === undefined) navigate('/'); else if (id === null) navigate('/collections?id=unorganized'); else navigate(`/collections?id=${id}`); }; return (

Settings

{authToggleError && (

{authToggleError}

)}

Export Backup

Exports an `.excalidash` archive (zip) organized by collections

{ const file = (e.target.files || [])[0]; if (!file) return; await verifyBackupFile(file); e.target.value = ''; }} />

Version Info

{appVersion} {buildLabel && ( {buildLabel} )}
Advanced / Legacy
{ const files = Array.from(e.target.files || []); if (files.length === 0) return; const databaseFile = files.find(f => f.name.endsWith('.sqlite') || f.name.endsWith('.db')); if (databaseFile) { if (files.length > 1) { setImportError({ isOpen: true, message: 'Please import legacy database files separately from other files.' }); e.target.value = ''; return; } await verifyLegacyDbFile(databaseFile); e.target.value = ''; return; } const result = await importLegacyFiles(files, null, () => { }); if (result.failed > 0) { setImportError({ isOpen: true, message: `Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}` }); } else { setImportSuccess({ isOpen: true, message: `Imported ${result.success} file(s).` }); } e.target.value = ''; }} />
This will merge legacy data into your account (it will not replace the server database).
{legacyDbImportConfirmation.info && (
Drawings: {legacyDbImportConfirmation.info.drawings}
Collections: {legacyDbImportConfirmation.info.collections}
Legacy migration: {legacyDbImportConfirmation.info.legacyLatestMigration || 'Unknown'}
Current migration: {legacyDbImportConfirmation.info.currentLatestMigration || 'Unknown'}
)} } confirmText="Merge Import" cancelText="Cancel" onConfirm={async () => { const file = legacyDbImportConfirmation.file; if (!file) return; setLegacyDbImportConfirmation({ isOpen: false, file: null, info: null }); const formData = new FormData(); formData.append('db', file); try { const response = await api.api.post<{ success: boolean; collections: { created: number; updated: number; idConflicts: number }; drawings: { created: number; updated: number; idConflicts: number }; }>('/import/sqlite/legacy', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setImportSuccess({ isOpen: true, message: `Legacy DB imported. Collections: +${response.data.collections.created} / ~${response.data.collections.updated}. Drawings: +${response.data.drawings.created} / ~${response.data.drawings.updated}.`, }); } catch (err: unknown) { console.error(err); let message = 'Failed to import legacy database.'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setImportError({ isOpen: true, message }); } }} onCancel={() => setLegacyDbImportConfirmation({ isOpen: false, file: null, info: null })} /> setImportError({ isOpen: false, message: '' })} onCancel={() => setImportError({ isOpen: false, message: '' })} /> setImportSuccess({ isOpen: false, message: '' })} onCancel={() => setImportSuccess({ isOpen: false, message: '' })} /> { const nextEnabled = authToggleConfirm.nextEnabled; setAuthToggleConfirm({ isOpen: false, nextEnabled: null }); if (typeof nextEnabled !== 'boolean') return; await setAuthEnabled(nextEnabled); }} onCancel={() => setAuthToggleConfirm({ isOpen: false, nextEnabled: null })} /> { const file = backupImportConfirmation.file; if (!file) return; setBackupImportConfirmation({ ...backupImportConfirmation, isOpen: false }); setBackupImportLoading(true); try { const formData = new FormData(); formData.append('archive', file); await api.api.post('/import/excalidash', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); setBackupImportConfirmation({ isOpen: false, file: null, info: null }); setBackupImportSuccess(true); } catch (err: unknown) { console.error('Backup import failed:', err); let message = 'Failed to import backup.'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setBackupImportError({ isOpen: true, message }); setBackupImportConfirmation({ isOpen: false, file: null, info: null }); } finally { setBackupImportLoading(false); } }} onCancel={() => setBackupImportConfirmation({ isOpen: false, file: null, info: null })} /> setBackupImportSuccess(false)} onCancel={() => setBackupImportSuccess(false)} /> setBackupImportError({ isOpen: false, message: '' })} onCancel={() => setBackupImportError({ isOpen: false, message: '' })} />
); };