updated library and settings page

This commit is contained in:
2026-05-03 21:03:02 -07:00
parent d58686bce9
commit 38e825cf28
2 changed files with 269 additions and 126 deletions
+69 -40
View File
@@ -34,56 +34,84 @@ import { useServers } from '../context/ServersContext'
import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache'
import { syncLibraryCache } from '../librarySync'
import { APP_THEME_PRESETS, type AppThemeId } from '../themes'
const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false }
type ServerForm = Omit<Server, 'id' | 'lastTest' | 'host' | 'port'> & {
endpoint: string
}
function normalizeServerForm(form: Omit<Server, 'id' | 'lastTest'>) {
const DEFAULT_SERVER_FORM: ServerForm = { name: '', endpoint: '', apiKey: '', ssl: false, forceApiKeyInQuery: false }
function buildServerEndpointValue(server: Pick<Server, 'host' | 'port' | 'ssl'>) {
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(),
host: (form.host || '').trim(),
port: typeof form.port === 'string' ? form.port.trim() || undefined : form.port,
endpoint: (form.endpoint || '').trim(),
apiKey: (form.apiKey || '').replace(/\s+/g, ''),
}
}
function validateServerForm(form: Omit<Server, 'id' | 'lastTest'>) {
function validateServerForm(form: ServerForm) {
const normalized = normalizeServerForm(form)
if (!normalized.host) {
return { normalized, error: 'Host is required.' }
if (!normalized.endpoint) {
return { normalized, config: null, error: 'Server URL is required.' }
}
try {
let candidate = normalized.host
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, error: 'Host is invalid. Use an IP, hostname, or full URL.' }
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<Server, 'id' | 'lastTest'> = {
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, error: 'Host is invalid. Use an IP, hostname, or full URL.' }
return { normalized, config: null, error: 'Server URL is invalid. Use an IP, hostname, or full URL.' }
}
if (normalized.port !== undefined) {
const portText = String(normalized.port)
if (!/^\d+$/.test(portText)) {
return { normalized, error: 'Port must contain digits only.' }
}
const portNumber = Number(portText)
if (portNumber < 1 || portNumber > 65535) {
return { normalized, error: 'Port must be between 1 and 65535.' }
}
}
if (normalized.apiKey && !/^[a-f0-9]{64}$/i.test(normalized.apiKey)) {
return { normalized, error: 'API key should be a 64-character hexadecimal key. Any pasted spaces or line breaks were removed automatically.' }
}
return { normalized, error: null }
}
type SettingsPageProps = {
@@ -102,7 +130,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers()
const isFirstServerSetup = servers.length === 0
const [editing, setEditing] = useState<Server | null>(null)
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
const [form, setForm] = useState<ServerForm>(DEFAULT_SERVER_FORM)
const [testing, setTesting] = useState(false)
const [syncingServerId, setSyncingServerId] = useState<string | null>(null)
const [lastTest, setLastTest] = useState<string | null>(null)
@@ -194,22 +222,23 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
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 })
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, error } = validateServerForm(form)
const { normalized, config, error } = validateServerForm(form)
if (error) {
setLastTest(error)
return
}
setForm(normalized)
if (!config) return
if (editing) {
updateServer(editing.id, normalized)
updateServer(editing.id, config)
} else {
const srv = addServer(normalized)
const srv = addServer(config)
setActiveServerId(srv.id)
}
onClose && onClose()
@@ -234,16 +263,17 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
}
const handleTestForm = async () => {
const { normalized, error } = validateServerForm(form)
const { normalized, config, error } = validateServerForm(form)
if (error) {
setLastTest(error)
return
}
setForm(normalized)
if (!config) return
setTesting(true)
try {
const res = await testServerConfig(normalized)
const res = await testServerConfig(config)
setLastTest(`${res.message}`)
} catch (e: any) {
setLastTest(`Error: ${e?.message ?? String(e)}`)
@@ -509,7 +539,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
<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' : isFirstServerSetup ? 'Add your first server' : 'Add new server'}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Use the same host, port, and API key you use for the Hydrus Client API.
Paste the same Hydrus Client API address and API key you use elsewhere.
</Typography>
{isFirstServerSetup && (
<Alert severity="info" sx={{ mt: 2 }}>
@@ -518,17 +548,16 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
)}
<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 or URL" placeholder="192.168.1.128, hydrus.local, or http://192.168.1.128" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} />
<TextField label="Port" placeholder="45869" value={form.port as any ?? ''} onChange={(e) => setForm({ ...form, port: e.target.value })} fullWidth inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} />
<TextField label="Server URL" placeholder="http://192.168.1.128:45869 or hydrus.local:45869" value={form.endpoint} onChange={(e) => 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." />
<TextField label="API Key (optional)" value={form.apiKey} onChange={(e) => setForm({ ...form, apiKey: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} helperText="Hex API key. Pasted spaces or line breaks are ignored." />
<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 } }}>
<Button variant="contained" onClick={handleSave} disabled={!form.endpoint} 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 } }}>
<Button variant="outlined" onClick={handleTestForm} disabled={!form.endpoint || 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 } }}>