382 lines
18 KiB
TypeScript
382 lines
18 KiB
TypeScript
|
|
import React, { useEffect, useState } from 'react'
|
||
|
|
import {
|
||
|
|
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 { HydrusClient, extractTitleFromTags } from '../api/hydrusClient'
|
||
|
|
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache'
|
||
|
|
import type { MediaSection, ServerSyncSummary, Track } from '../types'
|
||
|
|
|
||
|
|
const SYNC_SECTION_LIMIT = 2000
|
||
|
|
const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false }
|
||
|
|
const SYNC_SECTIONS: Array<{ id: MediaSection; label: string; predicate: string }> = [
|
||
|
|
{ id: 'audio', label: 'Audio', predicate: 'system:filetype = audio' },
|
||
|
|
{ id: 'video', label: 'Video', predicate: 'system:filetype = video' },
|
||
|
|
{ id: 'image', label: 'Image', predicate: 'system:filetype = image' },
|
||
|
|
{ id: 'application', label: 'Applications', predicate: 'system:filetype = application' },
|
||
|
|
]
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
const extractNamespaceValue = (tags: string[] | null | undefined, ns: string) => {
|
||
|
|
if (!tags || !Array.isArray(tags)) return null
|
||
|
|
const prefix = `${ns.toLowerCase()}:`
|
||
|
|
const values = tags
|
||
|
|
.filter((tag) => typeof tag === 'string' && tag.toLowerCase().startsWith(prefix))
|
||
|
|
.map((tag) => tag.slice(prefix.length).replace(/_/g, ' ').trim())
|
||
|
|
.filter(Boolean)
|
||
|
|
return values.sort((a, b) => b.length - a.length)[0] || null
|
||
|
|
}
|
||
|
|
|
||
|
|
const buildTrackCacheKey = (serverId?: string, fileId?: number) => (serverId && fileId != null ? `${serverId}:${fileId}` : '')
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setEditing(null)
|
||
|
|
setForm(DEFAULT_SERVER_FORM)
|
||
|
|
setLastTest(null)
|
||
|
|
setDetailsText(null)
|
||
|
|
setDetailsOpen(false)
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
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 client = new HydrusClient(server)
|
||
|
|
const cacheKey = buildLibraryCacheKey(servers)
|
||
|
|
const snapshot = await loadLibraryCache(cacheKey)
|
||
|
|
const mergedSearchCache = { ...(snapshot?.searchCache ?? {}) }
|
||
|
|
const mergedTrackMap: Record<string, Track> = {}
|
||
|
|
|
||
|
|
let localCounter = Date.now()
|
||
|
|
for (const track of snapshot?.tracks ?? []) {
|
||
|
|
const hydratedTrack: Track = { ...track, id: ++localCounter }
|
||
|
|
const key = buildTrackCacheKey(hydratedTrack.serverId, hydratedTrack.fileId)
|
||
|
|
if (key) mergedTrackMap[key] = hydratedTrack
|
||
|
|
}
|
||
|
|
|
||
|
|
const counts: ServerSyncSummary['counts'] = {}
|
||
|
|
|
||
|
|
for (const section of SYNC_SECTIONS) {
|
||
|
|
const searchTags = [section.predicate]
|
||
|
|
const ids = await client.searchFiles(searchTags, SYNC_SECTION_LIMIT)
|
||
|
|
counts[section.id] = ids.length
|
||
|
|
mergedSearchCache[`${server.id}|${section.id}|tracks|${JSON.stringify(searchTags)}`] = ids
|
||
|
|
|
||
|
|
if (ids.length === 0) continue
|
||
|
|
|
||
|
|
const tagMap = await client.getFilesTags(ids, 8)
|
||
|
|
const mediaInfoMap = section.id === 'application' ? await client.getFilesMediaInfo(ids, 6) : {}
|
||
|
|
for (const fileId of ids) {
|
||
|
|
const tags = tagMap[fileId] || []
|
||
|
|
const key = buildTrackCacheKey(server.id, fileId)
|
||
|
|
if (!key) continue
|
||
|
|
|
||
|
|
mergedTrackMap[key] = {
|
||
|
|
id: ++localCounter,
|
||
|
|
fileId,
|
||
|
|
serverId: server.id,
|
||
|
|
serverName: server.name || server.host,
|
||
|
|
title: extractTitleFromTags(tags) || '',
|
||
|
|
artist: extractNamespaceValue(tags, 'artist') || undefined,
|
||
|
|
album: extractNamespaceValue(tags, 'album') || undefined,
|
||
|
|
tags: tags.length ? tags : undefined,
|
||
|
|
url: client.getFileUrl(fileId),
|
||
|
|
thumbnail: client.getThumbnailUrl(fileId),
|
||
|
|
mimeType: mediaInfoMap[fileId]?.mimeType,
|
||
|
|
isVideo: mediaInfoMap[fileId]?.isVideo ?? (section.id === 'video' ? true : undefined),
|
||
|
|
mediaKind: section.id,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
await saveLibraryCache(cacheKey, Object.values(mergedTrackMap), mergedSearchCache)
|
||
|
|
|
||
|
|
const total = Object.values(counts).reduce((sum, value) => sum + (value || 0), 0)
|
||
|
|
const summary: ServerSyncSummary = {
|
||
|
|
updatedAt: Date.now(),
|
||
|
|
total,
|
||
|
|
counts,
|
||
|
|
message: `Synced ${total} cached items`,
|
||
|
|
}
|
||
|
|
|
||
|
|
updateServer(server.id, { syncSummary: summary })
|
||
|
|
setLastTest(summary.message ?? `Synced ${total} cached items`)
|
||
|
|
} 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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
|
||
|
|
{import.meta.env.DEV && (
|
||
|
|
<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 display</Typography>
|
||
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||
|
|
Choose the default Library layout so browsing controls can stay compact.
|
||
|
|
</Typography>
|
||
|
|
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: 2 }}>
|
||
|
|
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
|
||
|
|
<Select
|
||
|
|
labelId="settings-library-display-mode-label"
|
||
|
|
value={libraryDisplayMode}
|
||
|
|
label="Display"
|
||
|
|
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
|
||
|
|
>
|
||
|
|
<MenuItem value="grid">Grid</MenuItem>
|
||
|
|
<MenuItem value="table">Table</MenuItem>
|
||
|
|
</Select>
|
||
|
|
</FormControl>
|
||
|
|
|
||
|
|
<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={devOverlayEnabled} onChange={(event) => onDevOverlayEnabledChange(event.target.checked)} />}
|
||
|
|
label={devOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'}
|
||
|
|
sx={{ alignItems: 'flex-start', m: 0 }}
|
||
|
|
/>
|
||
|
|
</Box>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{!import.meta.env.DEV && (
|
||
|
|
<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 display</Typography>
|
||
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||
|
|
Choose the default Library layout so browsing controls can stay compact.
|
||
|
|
</Typography>
|
||
|
|
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240 }}>
|
||
|
|
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
|
||
|
|
<Select
|
||
|
|
labelId="settings-library-display-mode-label"
|
||
|
|
value={libraryDisplayMode}
|
||
|
|
label="Display"
|
||
|
|
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
|
||
|
|
>
|
||
|
|
<MenuItem value="grid">Grid</MenuItem>
|
||
|
|
<MenuItem value="table">Table</MenuItem>
|
||
|
|
</Select>
|
||
|
|
</FormControl>
|
||
|
|
</Box>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<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 } }}>
|
||
|
|
{syncingServerId === s.id ? 'Syncing...' : 'Sync cache'}
|
||
|
|
</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" />}
|
||
|
|
{s.syncSummary?.message && <Chip label={s.syncSummary.message} size="small" color="primary" variant="outlined" />}
|
||
|
|
{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>
|
||
|
|
{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>
|
||
|
|
)
|
||
|
|
}
|