import React, { useEffect, useMemo, useRef, useState } from 'react' import { Alert, Box, FormControl, InputLabel, Grid, Typography, List, ListItem, ListItemText, Button, IconButton, TextField, Switch, FormControlLabel, Chip, Dialog, DialogTitle, DialogContent, DialogActions, MenuItem, Select } from '@mui/material' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import DeleteIcon from '@mui/icons-material/Delete' import PlayArrowIcon from '@mui/icons-material/PlayArrow' import EditIcon from '@mui/icons-material/Edit' import AddIcon from '@mui/icons-material/Add' import CloudDownloadIcon from '@mui/icons-material/CloudDownload' import type { LibraryPrimaryAction } from '../appPreferences' import type { Server } from '../context/ServersContext' import { useServers } from '../context/ServersContext' import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache' import { syncLibraryCache } from '../librarySync' import { APP_THEME_PRESETS, type AppThemeId } from '../themes' import { formatBytes } from '../utils/formatBytes' type ServerForm = Omit & { endpoint: string } const DEFAULT_SERVER_FORM: ServerForm = { name: '', endpoint: '', apiKey: '', ssl: false, forceApiKeyInQuery: false } function buildServerEndpointValue(server: Pick) { const rawHost = (server.host || '').trim() if (!rawHost) return '' let endpoint = rawHost if (!/^https?:\/\//i.test(endpoint)) { endpoint = `${server.ssl ? 'https' : 'http'}://${endpoint}` } try { const parsed = new URL(endpoint) if (server.port && !parsed.port) parsed.port = String(server.port) const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/$/, '') : '' return `${parsed.origin}${path}` } catch { return `${rawHost}${server.port ? `:${server.port}` : ''}` } } function normalizeServerForm(form: ServerForm) { return { ...form, name: (form.name || '').trim(), endpoint: (form.endpoint || '').trim(), apiKey: (form.apiKey || '').replace(/\s+/g, ''), } } function validateServerForm(form: ServerForm) { const normalized = normalizeServerForm(form) if (!normalized.endpoint) { return { normalized, config: null, error: 'Server URL is required.' } } try { let candidate = normalized.endpoint if (!/^https?:\/\//i.test(candidate)) { candidate = `${normalized.ssl ? 'https' : 'http'}://${candidate}` } const parsed = new URL(candidate) if (!parsed.hostname) { return { normalized, config: null, error: 'Server URL is invalid. Use an IP, hostname, or full URL.' } } if (parsed.port) { const portNumber = Number(parsed.port) if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) { return { normalized, config: null, error: 'Port must be between 1 and 65535.' } } } if (normalized.apiKey && !/^[a-f0-9]{64}$/i.test(normalized.apiKey)) { return { normalized, config: null, error: 'API key should be a 64-character hexadecimal key. Any pasted spaces or line breaks were removed automatically.' } } const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/$/, '') : '' const config: Omit = { name: normalized.name, host: `${parsed.protocol}//${parsed.hostname}${path}`, port: parsed.port || undefined, apiKey: normalized.apiKey, ssl: parsed.protocol === 'https:', forceApiKeyInQuery: normalized.forceApiKeyInQuery, syncSummary: form.syncSummary, } return { normalized: { ...normalized, ssl: config.ssl }, config, error: null } } catch { return { normalized, config: null, error: 'Server URL is invalid. Use an IP, hostname, or full URL.' } } } type SettingsPageProps = { onClose?: () => void devOverlayEnabled: boolean onDevOverlayEnabledChange: (enabled: boolean) => void appTheme: AppThemeId onAppThemeChange: (theme: AppThemeId) => void libraryDisplayMode: 'grid' | 'table' onLibraryDisplayModeChange: (mode: 'grid' | 'table') => void libraryPrimaryAction: LibraryPrimaryAction onLibraryPrimaryActionChange: (action: LibraryPrimaryAction) => void } export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, appTheme, onAppThemeChange, libraryDisplayMode, onLibraryDisplayModeChange, libraryPrimaryAction, onLibraryPrimaryActionChange }: SettingsPageProps) { const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers() const isFirstServerSetup = servers.length === 0 const [editing, setEditing] = useState(null) const [form, setForm] = useState(DEFAULT_SERVER_FORM) const [testing, setTesting] = useState(false) const [syncingServerId, setSyncingServerId] = useState(null) const [lastTest, setLastTest] = useState(null) const [detailsOpen, setDetailsOpen] = useState(false) const [detailsText, setDetailsText] = useState(null) const [draftAppTheme, setDraftAppTheme] = useState(appTheme) const [draftLibraryDisplayMode, setDraftLibraryDisplayMode] = useState<'grid' | 'table'>(libraryDisplayMode) const [draftLibraryPrimaryAction, setDraftLibraryPrimaryAction] = useState(libraryPrimaryAction) const [draftDevOverlayEnabled, setDraftDevOverlayEnabled] = useState(devOverlayEnabled) const [syncCompletionNotices, setSyncCompletionNotices] = useState>({}) const [cacheStorageText, setCacheStorageText] = useState('0 B') const [cacheTrackCount, setCacheTrackCount] = useState(0) const [cacheSearchCount, setCacheSearchCount] = useState(0) const [cacheSnapshotCount, setCacheSnapshotCount] = useState(0) const [cacheUpdatedAt, setCacheUpdatedAt] = useState(null) const syncNoticeTimeoutsRef = useRef>({}) const currentCacheKey = useMemo(() => buildLibraryCacheKey(servers), [servers]) useEffect(() => { setEditing(null) setForm(DEFAULT_SERVER_FORM) setLastTest(null) setDetailsText(null) setDetailsOpen(false) }, []) useEffect(() => { setDraftAppTheme(appTheme) }, [appTheme]) useEffect(() => { setDraftLibraryDisplayMode(libraryDisplayMode) }, [libraryDisplayMode]) useEffect(() => { setDraftLibraryPrimaryAction(libraryPrimaryAction) }, [libraryPrimaryAction]) useEffect(() => { setDraftDevOverlayEnabled(devOverlayEnabled) }, [devOverlayEnabled]) useEffect(() => { return () => { Object.values(syncNoticeTimeoutsRef.current).forEach((timeoutId) => window.clearTimeout(timeoutId)) } }, []) const preferencesDirty = draftAppTheme !== appTheme || draftLibraryDisplayMode !== libraryDisplayMode || draftLibraryPrimaryAction !== libraryPrimaryAction || draftDevOverlayEnabled !== devOverlayEnabled const refreshCacheStats = async (cacheKey: string) => { if (!cacheKey) { setCacheStorageText('0 B') setCacheTrackCount(0) setCacheSearchCount(0) setCacheSnapshotCount(0) setCacheUpdatedAt(null) return } await pruneLibraryCache(cacheKey) const stats = await getLibraryCacheStats(cacheKey) setCacheStorageText(formatBytes(stats.activeBytes) || '0 B') setCacheTrackCount(stats.trackCount) setCacheSearchCount(stats.searchEntryCount) setCacheSnapshotCount(stats.snapshotCount) setCacheUpdatedAt(stats.updatedAt) } const startAdd = () => { setEditing(null) setForm(DEFAULT_SERVER_FORM) setLastTest(null) } const startEdit = (s: Server) => { setEditing(s) setForm({ name: s.name || '', endpoint: buildServerEndpointValue(s), apiKey: s.apiKey || '', ssl: !!s.ssl, forceApiKeyInQuery: !!s.forceApiKeyInQuery, syncSummary: s.syncSummary }) setLastTest(s.lastTest ? `${s.lastTest.message} (${new Date(s.lastTest.timestamp).toLocaleString()})` : null) } const handleSave = () => { const { normalized, config, error } = validateServerForm(form) if (error) { setLastTest(error) return } setForm(normalized) if (!config) return if (editing) { updateServer(editing.id, config) } else { const srv = addServer(config) setActiveServerId(srv.id) } onClose && onClose() } const handleDelete = (s: Server) => { if (confirm(`Delete server ${s.name || s.host}?`)) { removeServer(s.id) } } const handleTestExisting = async (s: Server) => { setTesting(true) try { const res = await testServerById(s.id) setLastTest(`${res.message}`) } catch (e: any) { setLastTest(`Error: ${e?.message ?? String(e)}`) } finally { setTesting(false) } } const handleTestForm = async () => { const { normalized, config, error } = validateServerForm(form) if (error) { setLastTest(error) return } setForm(normalized) if (!config) return setTesting(true) try { const res = await testServerConfig(config) setLastTest(`${res.message}`) } catch (e: any) { setLastTest(`Error: ${e?.message ?? String(e)}`) } finally { setTesting(false) } } const handleSyncServer = async (server: Server) => { setSyncingServerId(server.id) try { const result = await syncLibraryCache(servers, { targetServerIds: [server.id] }) const summary = result.summaries[server.id] if (!summary) throw new Error('Sync did not return a server summary') updateServer(server.id, { syncSummary: summary }) if (summary.message) { setLastTest(summary.message) await refreshCacheStats(result.cacheKey) return } const addedCount = result.addedCounts[server.id] ?? 0 const removedCount = result.removedCounts[server.id] ?? 0 const completionMessage = addedCount === 0 && removedCount === 0 ? `Sync complete. No file changes. ${summary.total} cached items.` : `Sync complete. Added ${addedCount} files, removed ${removedCount} files.` setLastTest(`Sync complete. ${summary.total} cached items.`) setSyncCompletionNotices((current) => ({ ...current, [server.id]: completionMessage })) await refreshCacheStats(result.cacheKey) if (syncNoticeTimeoutsRef.current[server.id]) { window.clearTimeout(syncNoticeTimeoutsRef.current[server.id]) } syncNoticeTimeoutsRef.current[server.id] = window.setTimeout(() => { setSyncCompletionNotices((current) => { const next = { ...current } delete next[server.id] return next }) delete syncNoticeTimeoutsRef.current[server.id] }, 8000) } catch (error: any) { const message = error?.message ?? String(error) updateServer(server.id, { syncSummary: { updatedAt: Date.now(), total: 0, counts: {}, message: `Sync failed: ${message}`, }, }) setLastTest(`Sync failed: ${message}`) } finally { setSyncingServerId(null) } } useEffect(() => { void refreshCacheStats(currentCacheKey) }, [currentCacheKey]) const handleSavePreferences = () => { if (draftAppTheme !== appTheme) { onAppThemeChange(draftAppTheme) } if (draftLibraryDisplayMode !== libraryDisplayMode) { onLibraryDisplayModeChange(draftLibraryDisplayMode) } if (draftLibraryPrimaryAction !== libraryPrimaryAction) { onLibraryPrimaryActionChange(draftLibraryPrimaryAction) } if (draftDevOverlayEnabled !== devOverlayEnabled) { onDevOverlayEnabledChange(draftDevOverlayEnabled) } } return ( {!isFirstServerSetup && ( onClose && onClose()} aria-label="back" size="large" sx={{ mr: 1 }}> )} {isFirstServerSetup ? 'Connect to Hydrus' : 'Hydrus Servers'} {isFirstServerSetup && ( Add your Hydrus Client API host, port, and API key before browsing the library. )} {!isFirstServerSetup && ( <> Interface preferences Personalize the look and default actions without affecting your cached library. Theme Display Track tap {APP_THEME_PRESETS.map((theme) => ( ))} {APP_THEME_PRESETS.find((theme) => theme.id === draftAppTheme)?.description} {import.meta.env.DEV && ( <> Developer tools Control development-only UI that can get in the way on smaller screens. setDraftDevOverlayEnabled(event.target.checked)} />} label={draftDevOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'} sx={{ alignItems: 'flex-start', m: 0 }} /> )} Library cache The app keeps one active IndexedDB snapshot and automatically removes older sync cache snapshots. {cacheUpdatedAt ? `Cache updated: ${new Date(cacheUpdatedAt).toLocaleString()}` : 'Cache has not been written yet.'} )} {!isFirstServerSetup && ( Configured servers {servers.length === 0 && No servers configured yet} {servers.map((s) => ( startEdit(s)}> handleDelete(s)}> {s.lastTest && s.lastTest.message && } {typeof s.syncSummary?.total === 'number' && } {s.syncSummary?.message && } {s.syncSummary?.counts && Object.entries(s.syncSummary.counts).map(([section, count]) => ( ))} {s.forceApiKeyInQuery && } {syncCompletionNotices[s.id] && ( {syncCompletionNotices[s.id]} )} {s.syncSummary?.updatedAt && ( Last sync: {new Date(s.syncSummary.updatedAt).toLocaleString()} )} ))} )} {editing ? 'Edit server' : isFirstServerSetup ? 'Add your first server' : 'Add new server'} Paste the same Hydrus Client API address and API key you use elsewhere. {isFirstServerSetup && ( Nothing is preconfigured. Enter your Hydrus server details below, test the connection, then save the server to unlock the library. )} setForm({ ...form, name: e.target.value })} fullWidth /> setForm({ ...form, endpoint: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} helperText="Paste the full Hydrus Client API address here. Port can be included in the same field." /> setForm({ ...form, apiKey: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} helperText="Hex API key. Pasted spaces or line breaks are ignored." /> setForm({ ...form, ssl: e.target.checked })} />} label="Use HTTPS (SSL)" /> setForm({ ...form, forceApiKeyInQuery: e.target.checked })} />} label="Send API key in query parameter" /> {lastTest && ( Last test: {lastTest} )} setDetailsOpen(false)} fullWidth maxWidth="md"> Connection details {detailsText} ) }