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 { Server } from '../context/ServersContext' import { useServers } from '../context/ServersContext' import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache' import { syncLibraryCache } from '../librarySync' const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false } type SettingsPageProps = { onClose?: () => void devOverlayEnabled: boolean onDevOverlayEnabledChange: (enabled: boolean) => void libraryDisplayMode: 'grid' | 'table' onLibraryDisplayModeChange: (mode: 'grid' | 'table') => void } export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, libraryDisplayMode, onLibraryDisplayModeChange }: SettingsPageProps) { const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers() 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 [draftLibraryDisplayMode, setDraftLibraryDisplayMode] = useState<'grid' | 'table'>(libraryDisplayMode) 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(() => { setDraftLibraryDisplayMode(libraryDisplayMode) }, [libraryDisplayMode]) useEffect(() => { setDraftDevOverlayEnabled(devOverlayEnabled) }, [devOverlayEnabled]) useEffect(() => { return () => { Object.values(syncNoticeTimeoutsRef.current).forEach((timeoutId) => window.clearTimeout(timeoutId)) } }, []) const preferencesDirty = draftLibraryDisplayMode !== libraryDisplayMode || draftDevOverlayEnabled !== devOverlayEnabled const formatBytes = (value: number) => { if (!value || value <= 0) return '0 B' const units = ['B', 'KB', 'MB', 'GB'] let size = value let unitIndex = 0 while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024 unitIndex += 1 } return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}` } 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)) setCacheTrackCount(stats.trackCount) setCacheSearchCount(stats.searchEntryCount) setCacheSnapshotCount(stats.snapshotCount) setCacheUpdatedAt(stats.updatedAt) } const startAdd = () => { setEditing(null) setForm(DEFAULT_SERVER_FORM) } const startEdit = (s: Server) => { setEditing(s) setForm({ name: s.name || '', host: s.host, port: s.port, apiKey: s.apiKey || '', ssl: !!s.ssl, forceApiKeyInQuery: !!s.forceApiKeyInQuery }) setLastTest(s.lastTest ? `${s.lastTest.message} (${new Date(s.lastTest.timestamp).toLocaleString()})` : null) } const handleSave = () => { if (editing) { updateServer(editing.id, form) } else { const srv = addServer(form) 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 () => { setTesting(true) try { const res = await testServerConfig(form) 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 (draftLibraryDisplayMode !== libraryDisplayMode) { onLibraryDisplayModeChange(draftLibraryDisplayMode) } if (draftDevOverlayEnabled !== devOverlayEnabled) { onDevOverlayEnabledChange(draftDevOverlayEnabled) } } return ( onClose && onClose()} aria-label="back" size="large" sx={{ mr: 1 }}> Hydrus Servers Interface preferences Save Library layout and development UI changes together. Display {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.'} 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' : 'Add new server'} setForm({ ...form, name: e.target.value })} fullWidth /> setForm({ ...form, host: e.target.value })} fullWidth /> setForm({ ...form, port: e.target.value })} fullWidth /> setForm({ ...form, apiKey: e.target.value })} fullWidth /> 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} ) }