change installation process

This commit is contained in:
2026-04-18 18:33:10 -07:00
parent 3bd335cfb5
commit e9c72d13df
4 changed files with 167 additions and 133 deletions
+34 -2
View File
@@ -9,7 +9,7 @@ import DownloadsOverlay, { type DownloadOverlayItem } from './components/Downloa
import DownloadsPage from './pages/DownloadsPage'
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
import { ThemeProvider } from '@mui/material/styles'
import { ServersProvider, useServers } from './context/ServersContext'
import { ACTIVE_KEY, STORAGE_KEY, ServersProvider, useServers } from './context/ServersContext'
import { extractTitleFromTags, type HydrusFileDetails } from './api/hydrusClient'
import { addDevLog } from './debugLog'
import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
@@ -367,9 +367,21 @@ function StartupLibraryCacheSync() {
return null
}
function getInitialActivePage(): MediaSection | 'settings' | 'downloads' {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return 'settings'
const parsed = JSON.parse(raw)
return Array.isArray(parsed) && parsed.length > 0 ? 'audio' : 'settings'
} catch {
return 'settings'
}
}
function App() {
const initialUiPreferences = useMemo(() => loadUiPreferences(), [])
const [activePage, setActivePage] = useState<MediaSection | 'settings' | 'downloads'>('audio')
const [activePage, setActivePage] = useState<MediaSection | 'settings' | 'downloads'>(() => getInitialActivePage())
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery)
@@ -452,6 +464,26 @@ function App() {
}
}, [activePage])
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
const parsed = raw ? JSON.parse(raw) : []
const hasServers = Array.isArray(parsed) && parsed.length > 0
if (!hasServers && activePage !== 'settings') {
setActivePage('settings')
}
if (!hasServers) {
localStorage.removeItem(ACTIVE_KEY)
}
} catch {
if (activePage !== 'settings') {
setActivePage('settings')
}
}
}, [activePage])
useEffect(() => {
saveUiPreferences({ libraryQuery })
}, [libraryQuery])
+2 -24
View File
@@ -3,8 +3,8 @@ import type { ServerConfig, ConnectivityResult } from '../api/hydrusClient'
import { HydrusClient, makeId } from '../api/hydrusClient'
import type { ServerSyncSummary } from '../types'
const STORAGE_KEY = 'hydrus_servers_v1'
const ACTIVE_KEY = 'hydrus_active_id_v1'
export const STORAGE_KEY = 'hydrus_servers_v1'
export const ACTIVE_KEY = 'hydrus_active_id_v1'
export type Server = ServerConfig & {
lastTest?: (ConnectivityResult & { timestamp: number }) | null
@@ -73,28 +73,6 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
setActiveServerIdState(servers[0].id)
localStorage.setItem(ACTIVE_KEY, servers[0].id)
}
// Seed a local server if none exist yet (user-provided default IP)
if (servers.length === 0) {
const seedHost = '192.168.1.128'
const seedPort = '45869'
const seedName = 'Local Hydrus (192.168.1.128)'
const id = makeId()
const srv: Server = {
id,
name: seedName,
host: seedHost,
port: seedPort,
apiKey: '',
ssl: false,
forceApiKeyInQuery: false,
lastTest: { ok: false, message: 'Unauthenticated (401). Add API key to test', status: 401, searchOk: false, rangeSupported: false, timestamp: Date.now() }
}
setServers([srv])
setActiveServerIdState(id)
localStorage.setItem(STORAGE_KEY, JSON.stringify([srv]))
localStorage.setItem(ACTIVE_KEY, id)
}
}, [])
const setActiveServerId = useCallback((id: string | null) => {
+125 -101
View File
@@ -50,6 +50,7 @@ type SettingsPageProps = {
export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, appTheme, onAppThemeChange, libraryDisplayMode, onLibraryDisplayModeChange, libraryPrimaryAction, onLibraryPrimaryActionChange }: SettingsPageProps) {
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 [testing, setTesting] = useState(false)
@@ -264,110 +265,124 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
<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>
{!isFirstServerSetup && (
<IconButton onClick={() => onClose && onClose()} aria-label="back" size="large" sx={{ mr: 1 }}>
<ArrowBackIcon />
</IconButton>
)}
<Box>
<Typography variant="h6">{isFirstServerSetup ? 'Connect to Hydrus' : 'Hydrus Servers'}</Typography>
{isFirstServerSetup && (
<Typography variant="body2" color="text.secondary">
Add your Hydrus Client API host, port, and API key before browsing the library.
</Typography>
)}
</Box>
</Box>
</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 } }}>
<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">
Personalize the look and default actions without affecting your cached library.
{!isFirstServerSetup && (
<>
<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">
Personalize the look and default actions without affecting your cached library.
</Typography>
</Box>
<Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
Save preferences
</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, minmax(0, 240px))' }, gap: 1.5, mb: import.meta.env.DEV ? 2 : 0 }}>
<FormControl size="small">
<InputLabel id="settings-app-theme-label">Theme</InputLabel>
<Select
labelId="settings-app-theme-label"
value={draftAppTheme}
label="Theme"
onChange={(event) => setDraftAppTheme(event.target.value as AppThemeId)}
>
{APP_THEME_PRESETS.map((theme) => (
<MenuItem key={theme.id} value={theme.id}>{theme.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small">
<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>
<FormControl size="small">
<InputLabel id="settings-library-primary-action-label">Track tap</InputLabel>
<Select
labelId="settings-library-primary-action-label"
value={draftLibraryPrimaryAction}
label="Track tap"
onChange={(event) => setDraftLibraryPrimaryAction(event.target.value as LibraryPrimaryAction)}
>
<MenuItem value="details">Open details popup</MenuItem>
<MenuItem value="stream">Stream immediately</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: import.meta.env.DEV ? 2 : 0.5 }}>
{APP_THEME_PRESETS.map((theme) => (
<Chip key={theme.id} label={theme.label} color={draftAppTheme === theme.id ? 'primary' : 'default'} variant={draftAppTheme === theme.id ? 'filled' : 'outlined'} />
))}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: import.meta.env.DEV ? 0.5 : 0 }}>
{APP_THEME_PRESETS.find((theme) => theme.id === draftAppTheme)?.description}
</Typography>
{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" />
</Box>
<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>
<Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
Save preferences
</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, minmax(0, 240px))' }, gap: 1.5, mb: import.meta.env.DEV ? 2 : 0 }}>
<FormControl size="small">
<InputLabel id="settings-app-theme-label">Theme</InputLabel>
<Select
labelId="settings-app-theme-label"
value={draftAppTheme}
label="Theme"
onChange={(event) => setDraftAppTheme(event.target.value as AppThemeId)}
>
{APP_THEME_PRESETS.map((theme) => (
<MenuItem key={theme.id} value={theme.id}>{theme.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small">
<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>
<FormControl size="small">
<InputLabel id="settings-library-primary-action-label">Track tap</InputLabel>
<Select
labelId="settings-library-primary-action-label"
value={draftLibraryPrimaryAction}
label="Track tap"
onChange={(event) => setDraftLibraryPrimaryAction(event.target.value as LibraryPrimaryAction)}
>
<MenuItem value="details">Open details popup</MenuItem>
<MenuItem value="stream">Stream immediately</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: import.meta.env.DEV ? 2 : 0.5 }}>
{APP_THEME_PRESETS.map((theme) => (
<Chip key={theme.id} label={theme.label} color={draftAppTheme === theme.id ? 'primary' : 'default'} variant={draftAppTheme === theme.id ? 'filled' : 'outlined'} />
))}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: import.meta.env.DEV ? 0.5 : 0 }}>
{APP_THEME_PRESETS.find((theme) => theme.id === draftAppTheme)?.description}
</Typography>
{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" />
</Box>
<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>
</>
)}
<Grid container spacing={{ xs: 2, lg: 3 }} alignItems="flex-start">
{!isFirstServerSetup && (
<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 }}>
@@ -423,14 +438,23 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
</List>
</Box>
</Grid>
)}
<Grid item xs={12} lg={7}>
<Grid item xs={12} lg={isFirstServerSetup ? 8 : 7} sx={isFirstServerSetup ? { mx: 'auto' } : undefined}>
<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>
<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.
</Typography>
{isFirstServerSetup && (
<Alert severity="info" sx={{ mt: 2 }}>
Nothing is preconfigured. Enter your Hydrus server details below, test the connection, then save the server to unlock the library.
</Alert>
)}
<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="Host or IP" placeholder="192.168.1.128 or hydrus.local" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} fullWidth />
<TextField label="Port" placeholder="45869" 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" />