change installation process
This commit is contained in:
+34
-2
@@ -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])
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user