updated library and settings page
This commit is contained in:
+69
-40
@@ -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 } }}>
|
||||
|
||||
Reference in New Issue
Block a user