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 >
)
}