2026-03-26 13:10:00 -07:00
import React , { useEffect , useMemo , useRef , useState } from 'react'
2026-03-26 03:26:37 -07:00
import {
2026-03-26 13:10:00 -07:00
Alert ,
2026-03-26 03:26:37 -07:00
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'
2026-04-14 23:14:41 -07:00
import { buildLibraryCacheKey , getLibraryCacheStats , pruneLibraryCache } from '../libraryCache'
import { syncLibraryCache } from '../librarySync'
2026-03-26 03:26:37 -07:00
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 < Server | null >( null )
const [ form , setForm ] = useState < Omit < Server , 'id' | 'lastTest' >>( DEFAULT_SERVER_FORM )
const [ testing , setTesting ] = useState ( false )
const [ syncingServerId , setSyncingServerId ] = useState < string | null >( null )
const [ lastTest , setLastTest ] = useState < string | null >( null )
const [ detailsOpen , setDetailsOpen ] = useState ( false )
const [ detailsText , setDetailsText ] = useState < string | null >( null )
2026-03-26 13:10:00 -07:00
const [ draftLibraryDisplayMode , setDraftLibraryDisplayMode ] = useState < 'grid' | 'table' > ( libraryDisplayMode )
const [ draftDevOverlayEnabled , setDraftDevOverlayEnabled ] = useState ( devOverlayEnabled )
const [ syncCompletionNotices , setSyncCompletionNotices ] = useState < Record < string , string >>({})
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 < number | null >( null )
const syncNoticeTimeoutsRef = useRef < Record < string , number >>({})
const currentCacheKey = useMemo (() => buildLibraryCacheKey ( servers ), [ servers ])
2026-03-26 03:26:37 -07:00
useEffect (() => {
setEditing ( null )
setForm ( DEFAULT_SERVER_FORM )
setLastTest ( null )
setDetailsText ( null )
setDetailsOpen ( false )
}, [])
2026-03-26 13:10:00 -07:00
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 )
}
2026-03-26 03:26:37 -07:00
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 {
2026-04-14 23:14:41 -07:00
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' )
2026-03-26 03:26:37 -07:00
updateServer ( server . id , { syncSummary : summary })
2026-04-14 23:14:41 -07:00
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
2026-03-26 13:10:00 -07:00
const completionMessage = addedCount === 0 && removedCount === 0
2026-04-14 23:14:41 -07:00
? `Sync complete. No file changes. ${ summary . total } cached items.`
2026-03-26 13:10:00 -07:00
: `Sync complete. Added ${ addedCount } files, removed ${ removedCount } files.`
2026-04-14 23:14:41 -07:00
setLastTest ( `Sync complete. ${ summary . total } cached items.` )
2026-03-26 13:10:00 -07:00
setSyncCompletionNotices (( current ) => ({ ... current , [ server . id ] : completionMessage }))
2026-04-14 23:14:41 -07:00
await refreshCacheStats ( result . cacheKey )
2026-03-26 13:10:00 -07:00
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 )
2026-03-26 03:26:37 -07:00
} 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 )
}
}
2026-03-26 13:10:00 -07:00
useEffect (() => {
void refreshCacheStats ( currentCacheKey )
}, [ currentCacheKey ])
const handleSavePreferences = () => {
if ( draftLibraryDisplayMode !== libraryDisplayMode ) {
onLibraryDisplayModeChange ( draftLibraryDisplayMode )
}
if ( draftDevOverlayEnabled !== devOverlayEnabled ) {
onDevOverlayEnabledChange ( draftDevOverlayEnabled )
}
}
2026-03-26 03:26:37 -07:00
return (
< Box sx = {{ p : { xs : 1 , sm : 2 , lg : 3 }, minHeight : '100%' , bgcolor : 'background.default' }}>
< Box sx = {{ width : '100%' , maxWidth : 1280 , mx : 'auto' }}>
< Box sx = {{ display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , gap : 1 , mb : 3 }}>
< Box sx = {{ display : 'flex' , alignItems : 'center' }}>
< IconButton onClick = {() => onClose && onClose ()} aria-label = "back" size = "large" sx = {{ mr : 1 }}>
< ArrowBackIcon />
</ IconButton >
< Typography variant = "h6" > Hydrus Servers </ Typography >
</ Box >
</ Box >
2026-03-26 13:10:00 -07:00
< Box sx = {{ border : '1px solid rgba(255,255,255,0.08)' , borderRadius : 2 , bgcolor : 'background.paper' , p : { xs : 1.5 , sm : 2 }, mb : { xs : 2 , lg : 3 } }}>
< Box sx = {{ display : 'flex' , alignItems : 'flex-start' , justifyContent : 'space-between' , gap : 2 , mb : 1.5 , flexWrap : 'wrap' }}>
< Box >
< Typography variant = "subtitle1" sx = {{ mb : 0.5 }}> Interface preferences </ Typography >
< Typography variant = "body2" color = "text.secondary" >
Save Library layout and development UI changes together .
</ Typography >
</ Box >
< Button variant = "contained" onClick = { handleSavePreferences } disabled = { ! preferencesDirty }>
Save preferences
</ Button >
2026-03-26 03:26:37 -07:00
</ Box >
2026-03-26 13:10:00 -07:00
< FormControl size = "small" sx = {{ minWidth : 180 , maxWidth : 240 , mb : import.meta.env.DEV ? 2 : 0 }}>
< InputLabel id = "settings-library-display-mode-label" > Display </ InputLabel >
< Select
labelId = "settings-library-display-mode-label"
value = { draftLibraryDisplayMode }
label = "Display"
onChange = {( event ) => setDraftLibraryDisplayMode ( event . target . value as 'grid' | 'table' )}
>
< MenuItem value = "grid" > Grid </ MenuItem >
< MenuItem value = "table" > Table </ MenuItem >
</ Select >
</ FormControl >
{ import . meta . env . DEV && (
<>
< Typography variant = "subtitle1" sx = {{ mb : 0.5 }}> Developer tools </ Typography >
< Typography variant = "body2" color = "text.secondary" sx = {{ mb : 1.5 }}>
Control development - only UI that can get in the way on smaller screens .
</ Typography >
< FormControlLabel
control = {< Switch checked = { draftDevOverlayEnabled } onChange = {( event ) => setDraftDevOverlayEnabled ( event . target . checked )} />}
label = { draftDevOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled' }
sx = {{ alignItems : 'flex-start' , m : 0 }}
/>
</>
)}
</ Box >
< Box sx = {{ border : '1px solid rgba(255,255,255,0.08)' , borderRadius : 2 , bgcolor : 'background.paper' , p : { xs : 1.5 , sm : 2 }, mb : { xs : 2 , lg : 3 } }}>
< Typography variant = "subtitle1" sx = {{ mb : 0.5 }}> Library cache </ Typography >
< Typography variant = "body2" color = "text.secondary" sx = {{ mb : 1.5 }}>
The app keeps one active IndexedDB snapshot and automatically removes older sync cache snapshots .
</ Typography >
< Box sx = {{ display : 'flex' , gap : 1 , flexWrap : 'wrap' }}>
< Chip label = { `Storage: ${ cacheStorageText } ` } size = "small" color = "primary" variant = "outlined" />
< Chip label = { `Tracks: ${ cacheTrackCount } ` } size = "small" variant = "outlined" />
< Chip label = { `Searches: ${ cacheSearchCount } ` } size = "small" variant = "outlined" />
< Chip label = { `Snapshots kept: ${ cacheSnapshotCount } ` } size = "small" variant = "outlined" />
2026-03-26 03:26:37 -07:00
</ Box >
2026-03-26 13:10:00 -07:00
< Typography variant = "caption" color = "text.secondary" sx = {{ display : 'block' , mt : 1 }}>
{ cacheUpdatedAt ? `Cache updated: ${ new Date ( cacheUpdatedAt ). toLocaleString () } ` : 'Cache has not been written yet.' }
</ Typography >
</ Box >
2026-03-26 03:26:37 -07:00
< Grid container spacing = {{ xs : 2 , lg : 3 }} alignItems = "flex-start" >
< Grid item xs = { 12 } lg = { 5 }>
< Box sx = {{ border : '1px solid rgba(255,255,255,0.08)' , borderRadius : 2 , bgcolor : 'background.paper' , p : { xs : 1.5 , sm : 2 } }}>
< Box sx = {{ display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , mb : 1 }}>
< Typography variant = "subtitle1" > Configured servers </ Typography >
< Button startIcon = {< AddIcon />} onClick = { startAdd } size = "large" className = "touch-target" >
Add
</ Button >
</ Box >
< List >
{ servers . length === 0 && < Typography color = "text.secondary" > No servers configured yet </ Typography >}
{ servers . map (( s ) => (
< ListItem key = { s . id } sx = {{ flexWrap : 'wrap' , alignItems : 'flex-start' , px : 0 , py : 1.25 , borderBottom : '1px solid rgba(255,255,255,0.06)' }}>
< ListItemText primary = { s . name || s . host } secondary = { s . host } sx = {{ mr : 2 }} />
< Box sx = {{ display : 'flex' , gap : 1 , mt : { xs : 1 , md : 0 }, flexWrap : 'wrap' , width : { xs : '100%' , md : 'auto' } }}>
< Button size = "large" onClick = {() => setActiveServerId ( s . id )} className = "touch-target" sx = {{ flexGrow : { xs : 1 , sm : 0 } }}>
{ s . id === activeServerId ? 'Active' : 'Set active' }
</ Button >
< Button variant = "outlined" size = "large" onClick = {() => handleTestExisting ( s )} startIcon = {< PlayArrowIcon />} className = "touch-target" sx = {{ flexGrow : { xs : 1 , sm : 0 } }}>
Test
</ Button >
< Button variant = "outlined" size = "large" onClick = {() => handleSyncServer ( s )} startIcon = {< CloudDownloadIcon />} disabled = { syncingServerId === s . id } className = "touch-target" sx = {{ flexGrow : { xs : 1 , sm : 0 } }}>
2026-03-26 13:10:00 -07:00
{ syncingServerId === s . id ? 'Syncing...' : typeof s . syncSummary ? . total === 'number' ? `Sync cache ( ${ s . syncSummary . total } )` : 'Sync cache' }
2026-03-26 03:26:37 -07:00
</ Button >
< IconButton size = "large" aria-label = "edit" onClick = {() => startEdit ( s )}>
< EditIcon />
</ IconButton >
< IconButton size = "large" aria-label = "delete" onClick = {() => handleDelete ( s )}>
< DeleteIcon />
</ IconButton >
</ Box >
< Box sx = {{ display : 'flex' , gap : 1 , flexWrap : 'wrap' , width : '100%' , mt : 1 }}>
{ s . lastTest && s . lastTest . message && < Chip label = { s . lastTest . message } size = "small" />}
2026-03-26 13:10:00 -07:00
{ typeof s . syncSummary ? . total === 'number' && < Chip label = { `Cached: ${ s . syncSummary . total } items` } size = "small" color = "primary" variant = "outlined" />}
{ s . syncSummary ? . message && < Chip label = { s . syncSummary . message } size = "small" color = "error" variant = "outlined" />}
2026-03-26 03:26:37 -07:00
{ s . syncSummary ? . counts && Object . entries ( s . syncSummary . counts ). map (([ section , count ]) => (
< Chip key = { ` ${ s . id } - ${ section } ` } label = { ` ${ section } : ${ count } ` } size = "small" variant = "outlined" />
))}
{ s . forceApiKeyInQuery && < Chip label = "API key in query" size = "small" />}
</ Box >
2026-03-26 13:10:00 -07:00
{ syncCompletionNotices [ s . id ] && (
< Alert severity = "success" sx = {{ width : '100%' , mt : 1 , py : 0 }}>
{ syncCompletionNotices [ s . id ]}
</ Alert >
)}
2026-03-26 03:26:37 -07:00
{ s . syncSummary ? . updatedAt && (
< Typography variant = "caption" color = "text.secondary" sx = {{ width : '100%' , mt : 1 }}>
Last sync : { new Date ( s . syncSummary . updatedAt ). toLocaleString ()}
</ Typography >
)}
</ ListItem >
))}
</ List >
</ Box >
</ Grid >
< Grid item xs = { 12 } lg = { 7 }>
< Box sx = {{ border : '1px solid rgba(255,255,255,0.08)' , borderRadius : 2 , bgcolor : 'background.paper' , p : { xs : 1.5 , sm : 2 } }}>
< Typography variant = "subtitle1" >{ editing ? 'Edit server' : 'Add new server' }</ Typography >
< Box sx = {{ mt : 2 , display : 'flex' , flexDirection : 'column' , gap : 2 }}>
< TextField label = "Name (optional)" value = { form . name } onChange = {( e ) => setForm ({ ... form , name : e.target.value })} fullWidth />
< TextField label = "Host (IP or host, e.g. 192.168.1.128)" value = { form . host } onChange = {( e ) => setForm ({ ... form , host : e.target.value })} fullWidth />
< TextField label = "Port (optional)" value = { form . port as any ?? '' } onChange = {( e ) => setForm ({ ... form , port : e.target.value })} fullWidth />
< TextField label = "API Key (optional)" value = { form . apiKey } onChange = {( e ) => setForm ({ ... form , apiKey : e.target.value })} fullWidth />
< FormControlLabel control = {< Switch checked = { !! form . ssl } onChange = {( e ) => setForm ({ ... form , ssl : e.target.checked })} />} label = "Use HTTPS (SSL)" />
< FormControlLabel control = {< Switch checked = { !! form . forceApiKeyInQuery } onChange = {( e ) => setForm ({ ... form , forceApiKeyInQuery : e.target.checked })} />} label = "Send API key in query parameter" />
< Box sx = {{ display : 'flex' , gap : 1 , mt : 1 , flexWrap : 'wrap' }}>
< Button variant = "contained" onClick = { handleSave } disabled = { ! form . host } size = "large" className = "touch-target" sx = {{ flexGrow : { xs : 1 , sm : 0 } }}>
Save
</ Button >
< Button variant = "outlined" onClick = { handleTestForm } disabled = { ! form . host || testing } startIcon = {< PlayArrowIcon />} size = "large" className = "touch-target" sx = {{ flexGrow : { xs : 1 , sm : 0 } }}>
{ testing ? 'Testing...' : 'Test connection' }
</ Button >
< Button onClick = {() => { setEditing ( null ); setForm ( DEFAULT_SERVER_FORM ); setLastTest ( null ) }} size = "large" className = "touch-target" sx = {{ flexGrow : { xs : 1 , sm : 0 } }}>
Reset
</ Button >
</ Box >
{ lastTest && (
< Box sx = {{ mt : 1 }}>
< Typography variant = "caption" > Last test : { lastTest }</ Typography >
< Box sx = {{ mt : 1 }}>
< Button size = "small" onClick = {() => {
const obj = editing ? . lastTest ?? servers . find (( s ) => s . id === editing ? . id ) ? . lastTest ?? null
setDetailsText ( obj ? JSON . stringify ( obj , null , 2 ) : lastTest )
setDetailsOpen ( true )
}}> Show details </ Button >
</ Box >
</ Box >
)}
< Dialog open = { detailsOpen } onClose = {() => setDetailsOpen ( false )} fullWidth maxWidth = "md" >
< DialogTitle > Connection details </ DialogTitle >
< DialogContent >
< Box component = "pre" sx = {{ whiteSpace : 'pre-wrap' , fontFamily : 'monospace' , fontSize : 12 }}>{ detailsText }</ Box >
</ DialogContent >
< DialogActions >
< Button onClick = {() => setDetailsOpen ( false )}> Close </ Button >
</ DialogActions >
</ Dialog >
</ Box >
</ Box >
</ Grid >
</ Grid >
</ Box >
</ Box >
)
}