added themes and theme selection to settings page
This commit is contained in:
+19
-9
@@ -1,19 +1,20 @@
|
||||
import React, { Suspense, lazy, useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import { ID3Writer } from 'browser-id3-writer'
|
||||
import { embedContainerAudioMetadata, supportsContainerMetadataEmbedding } from './audioMetadata'
|
||||
import { type LibraryPrimaryAction, loadUiPreferences, saveUiPreferences } from './appPreferences'
|
||||
import Library from './pages/Library'
|
||||
import Header from './components/Header'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import DownloadsOverlay, { type DownloadOverlayItem } from './components/DownloadsOverlay'
|
||||
import DownloadsPage from './pages/DownloadsPage'
|
||||
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles'
|
||||
import { ThemeProvider } from '@mui/material/styles'
|
||||
import { ServersProvider, useServers } from './context/ServersContext'
|
||||
import { extractTitleFromTags, type HydrusFileDetails } from './api/hydrusClient'
|
||||
import { addDevLog } from './debugLog'
|
||||
import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
|
||||
import { loadUiPreferences, saveUiPreferences } from './appPreferences'
|
||||
import { syncLibraryCache } from './librarySync'
|
||||
import { createAppTheme } from './themes'
|
||||
import type { MediaSection, Track } from './types'
|
||||
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||
@@ -369,15 +370,11 @@ function App() {
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
|
||||
const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery)
|
||||
const [libraryDisplayMode, setLibraryDisplayMode] = useState(initialUiPreferences.libraryDisplayMode)
|
||||
const [appThemeId, setAppThemeId] = useState(initialUiPreferences.appTheme)
|
||||
const [libraryPrimaryAction, setLibraryPrimaryAction] = useState<LibraryPrimaryAction>(initialUiPreferences.libraryPrimaryAction)
|
||||
const [devOverlayEnabled, setDevOverlayEnabled] = useState(initialUiPreferences.devOverlayEnabled)
|
||||
|
||||
const theme = useMemo(() => createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: '#1db954' },
|
||||
background: { default: '#0f1113', paper: '#151617' }
|
||||
}
|
||||
}), [])
|
||||
const theme = useMemo(() => createAppTheme(appThemeId), [appThemeId])
|
||||
|
||||
const mimeCacheRef = useRef<Record<string, MediaInfo>>({})
|
||||
const mimeRequestCacheRef = useRef<Record<string, Promise<MediaInfo>>>({})
|
||||
@@ -617,6 +614,14 @@ function App() {
|
||||
setLibraryDisplayMode(mode)
|
||||
saveUiPreferences({ libraryDisplayMode: mode })
|
||||
}, [])
|
||||
const handleAppThemeChange = useCallback((themeId: typeof appThemeId) => {
|
||||
setAppThemeId(themeId)
|
||||
saveUiPreferences({ appTheme: themeId })
|
||||
}, [appThemeId])
|
||||
const handleLibraryPrimaryActionChange = useCallback((action: LibraryPrimaryAction) => {
|
||||
setLibraryPrimaryAction(action)
|
||||
saveUiPreferences({ libraryPrimaryAction: action })
|
||||
}, [])
|
||||
const handleSidebarNavigate = useCallback((id: string) => {
|
||||
if (id === 'settings') setActivePage('settings')
|
||||
else if (id === 'downloads') setActivePage('downloads')
|
||||
@@ -832,8 +837,12 @@ function App() {
|
||||
onClose={closeSettings}
|
||||
devOverlayEnabled={devOverlayEnabled}
|
||||
onDevOverlayEnabledChange={handleDevOverlayEnabledChange}
|
||||
appTheme={appThemeId}
|
||||
onAppThemeChange={handleAppThemeChange}
|
||||
libraryDisplayMode={libraryDisplayMode}
|
||||
onLibraryDisplayModeChange={handleLibraryDisplayModeChange}
|
||||
libraryPrimaryAction={libraryPrimaryAction}
|
||||
onLibraryPrimaryActionChange={handleLibraryPrimaryActionChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
@@ -853,6 +862,7 @@ function App() {
|
||||
onPlayNow={playNow}
|
||||
onDownloadTrack={queueDownload}
|
||||
isTrackDownloading={isTrackDownloading}
|
||||
primaryTapAction={libraryPrimaryAction}
|
||||
query={libraryQuery}
|
||||
onQueryChange={setLibraryQuery}
|
||||
displayModePreference={libraryDisplayMode}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { MediaSection } from './types'
|
||||
import type { AppThemeId } from './themes'
|
||||
|
||||
export type LibraryPrimaryAction = 'details' | 'stream'
|
||||
|
||||
export type UiPreferences = {
|
||||
devOverlayEnabled: boolean
|
||||
libraryQuery: string
|
||||
libraryDisplayMode: 'grid' | 'table'
|
||||
appTheme: AppThemeId
|
||||
libraryPrimaryAction: LibraryPrimaryAction
|
||||
librarySortBy: string
|
||||
librarySortDirection: 'asc' | 'desc'
|
||||
librarySectionViews: Partial<Record<MediaSection, string>>
|
||||
@@ -15,6 +20,8 @@ const DEFAULT_UI_PREFERENCES: UiPreferences = {
|
||||
devOverlayEnabled: true,
|
||||
libraryQuery: '',
|
||||
libraryDisplayMode: 'grid',
|
||||
appTheme: 'hydrus-dark',
|
||||
libraryPrimaryAction: 'details',
|
||||
librarySortBy: 'artist',
|
||||
librarySortDirection: 'asc',
|
||||
librarySectionViews: {},
|
||||
|
||||
+142
-29
@@ -4,7 +4,7 @@ import { useTheme } from '@mui/material/styles'
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import { useServers } from '../context/ServersContext'
|
||||
import { HydrusClient, extractTitleFromTags, type HydrusFileDetails } from '../api/hydrusClient'
|
||||
import { loadUiPreferences, saveUiPreferences } from '../appPreferences'
|
||||
import { type LibraryPrimaryAction, loadUiPreferences, saveUiPreferences } from '../appPreferences'
|
||||
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache'
|
||||
import { LIBRARY_CACHE_SYNC_EVENT } from '../librarySync'
|
||||
import type { MediaSection, Track } from '../types'
|
||||
@@ -13,12 +13,14 @@ const NO_IMAGE_DATA_URL = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.o
|
||||
const RESULTS_PAGE_SIZE = 36
|
||||
const LONG_PRESS_DELAY_MS = 420
|
||||
const LOCAL_SEARCH_TOKEN_PATTERN = /(?:[^\s"]+:"(?:[^"\\]|\\.)*"|"(?:[^"\\]|\\.)*"|\S+)/g
|
||||
const SEARCH_NAMESPACE_PATTERN = /^([a-z0-9_+-]+):(.*)$/i
|
||||
|
||||
type Props = {
|
||||
mediaSection: MediaSection
|
||||
onPlayNow: (track: Track) => void | Promise<void>
|
||||
onDownloadTrack: (track: Track, details?: HydrusFileDetails | null) => void
|
||||
isTrackDownloading: (track: Track) => boolean
|
||||
primaryTapAction: LibraryPrimaryAction
|
||||
query: string
|
||||
onQueryChange: (value: string) => void
|
||||
displayModePreference: 'grid' | 'table'
|
||||
@@ -213,7 +215,7 @@ function getTrackKindLabel(track: Track, fallbackLabel: string) {
|
||||
return fallbackLabel
|
||||
}
|
||||
|
||||
export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTrackDownloading, query, onQueryChange, displayModePreference }: Props) {
|
||||
export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTrackDownloading, primaryTapAction, query, onQueryChange, displayModePreference }: Props) {
|
||||
const initialUiPreferences = useMemo(() => loadUiPreferences(), [])
|
||||
const initialSectionViews = useMemo(() => initialUiPreferences.librarySectionViews as Partial<Record<MediaSection, string>>, [initialUiPreferences])
|
||||
const [results, setResults] = useState<Track[]>([])
|
||||
@@ -348,19 +350,123 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
return normalizeSearchText(normalized)
|
||||
}
|
||||
|
||||
function normalizeSearchPattern(value: string) {
|
||||
let normalized = value.trim()
|
||||
if (normalized.startsWith('"') && normalized.endsWith('"')) {
|
||||
normalized = normalized.slice(1, -1)
|
||||
}
|
||||
|
||||
normalized = normalized
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\')
|
||||
|
||||
return normalizeSearchText(normalized)
|
||||
}
|
||||
|
||||
function matchesSearchValue(value: string | undefined | null, term: string) {
|
||||
if (!term) return true
|
||||
return normalizeSearchText(value).includes(term)
|
||||
}
|
||||
|
||||
function matchesNamespacedTag(tags: string[] | undefined, namespace: string, term: string) {
|
||||
function matchesExactSearchValue(value: string | undefined | null, term: string) {
|
||||
if (!term) return true
|
||||
return normalizeSearchText(value) === term
|
||||
}
|
||||
|
||||
function matchesPatternSearchValue(value: string | undefined | null, pattern: string) {
|
||||
if (!pattern) return true
|
||||
|
||||
const normalizedValue = normalizeSearchText(value)
|
||||
if (!pattern.includes('*')) return normalizedValue === pattern
|
||||
|
||||
const escapedPattern = pattern.replace(/[|\\{}()[\]^$+?.]/g, '\\$&').replace(/\*/g, '.*')
|
||||
return new RegExp(`^${escapedPattern}$`, 'i').test(normalizedValue)
|
||||
}
|
||||
|
||||
function matchesNamespacedTag(tags: string[] | undefined, namespace: string, term: string, exact = false) {
|
||||
if (!tags || tags.length === 0) return false
|
||||
|
||||
const prefix = `${namespace.toLowerCase()}:`
|
||||
return tags.some((tag) => {
|
||||
if (typeof tag !== 'string') return false
|
||||
if (!tag.toLowerCase().startsWith(prefix)) return false
|
||||
return matchesSearchValue(tag.slice(prefix.length), term)
|
||||
return exact
|
||||
? matchesPatternSearchValue(tag.slice(prefix.length), term)
|
||||
: matchesSearchValue(tag.slice(prefix.length), term)
|
||||
})
|
||||
}
|
||||
|
||||
function splitSearchClauses(rawQuery: string) {
|
||||
const clauses: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
let escapeNext = false
|
||||
|
||||
for (const char of rawQuery) {
|
||||
if (escapeNext) {
|
||||
current += char
|
||||
escapeNext = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
current += char
|
||||
escapeNext = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes
|
||||
current += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === ',' && !inQuotes) {
|
||||
const trimmed = current.trim()
|
||||
if (trimmed) clauses.push(trimmed)
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
const trimmed = current.trim()
|
||||
if (trimmed) clauses.push(trimmed)
|
||||
return clauses
|
||||
}
|
||||
|
||||
function matchesNamespacedClause(track: Track, namespace: string, rawValue: string) {
|
||||
const term = normalizeSearchPattern(rawValue)
|
||||
if (!term) return true
|
||||
|
||||
if (namespace === 'system') return matchesSystemPredicate(track, `${namespace}:${rawValue}`)
|
||||
if (namespace === 'title') return matchesPatternSearchValue(getTrackDisplayTitle(track), term)
|
||||
if (namespace === 'artist') return matchesPatternSearchValue(track.artist, term)
|
||||
if (namespace === 'album') return matchesPatternSearchValue(track.album, term)
|
||||
if (namespace === 'ext') return matchesPatternSearchValue(getTrackExtension(track), term.replace(/^\./, ''))
|
||||
|
||||
return matchesNamespacedTag(track.tags, namespace, term, true)
|
||||
}
|
||||
|
||||
function matchesGenericClause(track: Track, clause: string) {
|
||||
const tokens = clause.match(LOCAL_SEARCH_TOKEN_PATTERN)?.filter(Boolean) ?? []
|
||||
if (tokens.length === 0) return true
|
||||
|
||||
return tokens.every((token) => {
|
||||
const term = normalizeSearchTerm(token)
|
||||
if (!term) return true
|
||||
|
||||
const searchableValues = [
|
||||
getTrackDisplayTitle(track),
|
||||
track.artist,
|
||||
track.album,
|
||||
track.serverName,
|
||||
getTrackExtension(track),
|
||||
...(track.tags || []),
|
||||
]
|
||||
|
||||
return searchableValues.some((value) => matchesSearchValue(value, term))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -380,34 +486,18 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
const trimmedQuery = rawQuery.trim()
|
||||
if (!trimmedQuery) return true
|
||||
|
||||
const tokens = trimmedQuery.match(LOCAL_SEARCH_TOKEN_PATTERN)?.filter(Boolean) ?? []
|
||||
if (tokens.length === 0) return true
|
||||
const clauses = splitSearchClauses(trimmedQuery)
|
||||
if (clauses.length === 0) return true
|
||||
|
||||
return tokens.every((token) => {
|
||||
const separatorIndex = token.indexOf(':')
|
||||
if (separatorIndex > 0) {
|
||||
const namespace = token.slice(0, separatorIndex).toLowerCase()
|
||||
const term = normalizeSearchTerm(token.slice(separatorIndex + 1))
|
||||
|
||||
if (namespace === 'system') return matchesSystemPredicate(track, token)
|
||||
if (namespace === 'title') return matchesSearchValue(getTrackDisplayTitle(track), term)
|
||||
if (namespace === 'artist') return matchesSearchValue(track.artist, term)
|
||||
if (namespace === 'album') return matchesSearchValue(track.album, term)
|
||||
return matchesNamespacedTag(track.tags, namespace, term)
|
||||
return clauses.every((clause) => {
|
||||
const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN)
|
||||
if (namespaceMatch) {
|
||||
const namespace = namespaceMatch[1].toLowerCase()
|
||||
const rawValue = namespaceMatch[2]
|
||||
return matchesNamespacedClause(track, namespace, rawValue)
|
||||
}
|
||||
|
||||
const term = normalizeSearchTerm(token)
|
||||
if (!term) return true
|
||||
|
||||
const searchableValues = [
|
||||
getTrackDisplayTitle(track),
|
||||
track.artist,
|
||||
track.album,
|
||||
track.serverName,
|
||||
...(track.tags || []),
|
||||
]
|
||||
|
||||
return searchableValues.some((value) => matchesSearchValue(value, term))
|
||||
return matchesGenericClause(track, clause)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -779,9 +869,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
return
|
||||
}
|
||||
|
||||
if (primaryTapAction === 'details') {
|
||||
await openTrackDetails(track)
|
||||
return
|
||||
}
|
||||
|
||||
await handlePlayNow(track)
|
||||
}
|
||||
|
||||
const handleStreamTrack = async () => {
|
||||
if (!detailsTrack?.url) return
|
||||
const streamTrack = detailsData ? { ...detailsTrack, ...detailsData } : detailsTrack
|
||||
closeDetails()
|
||||
await onPlayNow(streamTrack)
|
||||
}
|
||||
|
||||
const handleOpenWeb = () => {
|
||||
if (!detailsTrack?.url || typeof window === 'undefined') return
|
||||
window.open(detailsTrack.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const handleDownloadTrack = () => {
|
||||
if (!detailsTrack?.url || detailsTrackDownloading) return
|
||||
onDownloadTrack(detailsTrack, detailsData)
|
||||
@@ -1304,6 +1411,12 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleOpenWeb} disabled={!detailsTrack?.url}>
|
||||
Web
|
||||
</Button>
|
||||
<Button onClick={() => void handleStreamTrack()} disabled={!detailsTrack?.url}>
|
||||
Stream
|
||||
</Button>
|
||||
<Button onClick={handleDownloadTrack} startIcon={<DownloadIcon />} disabled={!detailsTrack?.url || detailsLoading || detailsTrackDownloading}>
|
||||
{detailsTrackDownloading ? 'Downloading...' : 'Download'}
|
||||
</Button>
|
||||
|
||||
+78
-15
@@ -28,21 +28,27 @@ 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 { LibraryPrimaryAction } from '../appPreferences'
|
||||
import type { Server } from '../context/ServersContext'
|
||||
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 SettingsPageProps = {
|
||||
onClose?: () => void
|
||||
devOverlayEnabled: boolean
|
||||
onDevOverlayEnabledChange: (enabled: boolean) => void
|
||||
appTheme: AppThemeId
|
||||
onAppThemeChange: (theme: AppThemeId) => void
|
||||
libraryDisplayMode: 'grid' | 'table'
|
||||
onLibraryDisplayModeChange: (mode: 'grid' | 'table') => void
|
||||
libraryPrimaryAction: LibraryPrimaryAction
|
||||
onLibraryPrimaryActionChange: (action: LibraryPrimaryAction) => void
|
||||
}
|
||||
|
||||
export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, libraryDisplayMode, onLibraryDisplayModeChange }: 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 [editing, setEditing] = useState<Server | null>(null)
|
||||
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
|
||||
@@ -51,7 +57,9 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
||||
const [lastTest, setLastTest] = useState<string | null>(null)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [detailsText, setDetailsText] = useState<string | null>(null)
|
||||
const [draftAppTheme, setDraftAppTheme] = useState<AppThemeId>(appTheme)
|
||||
const [draftLibraryDisplayMode, setDraftLibraryDisplayMode] = useState<'grid' | 'table'>(libraryDisplayMode)
|
||||
const [draftLibraryPrimaryAction, setDraftLibraryPrimaryAction] = useState<LibraryPrimaryAction>(libraryPrimaryAction)
|
||||
const [draftDevOverlayEnabled, setDraftDevOverlayEnabled] = useState(devOverlayEnabled)
|
||||
const [syncCompletionNotices, setSyncCompletionNotices] = useState<Record<string, string>>({})
|
||||
const [cacheStorageText, setCacheStorageText] = useState('0 B')
|
||||
@@ -70,10 +78,18 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
||||
setDetailsOpen(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setDraftAppTheme(appTheme)
|
||||
}, [appTheme])
|
||||
|
||||
useEffect(() => {
|
||||
setDraftLibraryDisplayMode(libraryDisplayMode)
|
||||
}, [libraryDisplayMode])
|
||||
|
||||
useEffect(() => {
|
||||
setDraftLibraryPrimaryAction(libraryPrimaryAction)
|
||||
}, [libraryPrimaryAction])
|
||||
|
||||
useEffect(() => {
|
||||
setDraftDevOverlayEnabled(devOverlayEnabled)
|
||||
}, [devOverlayEnabled])
|
||||
@@ -84,7 +100,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
||||
}
|
||||
}, [])
|
||||
|
||||
const preferencesDirty = draftLibraryDisplayMode !== libraryDisplayMode || draftDevOverlayEnabled !== devOverlayEnabled
|
||||
const preferencesDirty = draftAppTheme !== appTheme || draftLibraryDisplayMode !== libraryDisplayMode || draftLibraryPrimaryAction !== libraryPrimaryAction || draftDevOverlayEnabled !== devOverlayEnabled
|
||||
|
||||
const formatBytes = (value: number) => {
|
||||
if (!value || value <= 0) return '0 B'
|
||||
@@ -226,10 +242,18 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
||||
}, [currentCacheKey])
|
||||
|
||||
const handleSavePreferences = () => {
|
||||
if (draftAppTheme !== appTheme) {
|
||||
onAppThemeChange(draftAppTheme)
|
||||
}
|
||||
|
||||
if (draftLibraryDisplayMode !== libraryDisplayMode) {
|
||||
onLibraryDisplayModeChange(draftLibraryDisplayMode)
|
||||
}
|
||||
|
||||
if (draftLibraryPrimaryAction !== libraryPrimaryAction) {
|
||||
onLibraryPrimaryActionChange(draftLibraryPrimaryAction)
|
||||
}
|
||||
|
||||
if (draftDevOverlayEnabled !== devOverlayEnabled) {
|
||||
onDevOverlayEnabledChange(draftDevOverlayEnabled)
|
||||
}
|
||||
@@ -252,7 +276,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Interface preferences</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Save Library layout and development UI changes together.
|
||||
Personalize the look and default actions without affecting your cached library.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
|
||||
@@ -260,18 +284,57 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: import.meta.env.DEV ? 2 : 0 }}>
|
||||
<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>
|
||||
<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 && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createTheme } from '@mui/material/styles'
|
||||
|
||||
export type AppThemeId = 'hydrus-dark' | 'ocean' | 'ember' | 'paper'
|
||||
|
||||
type ThemePreset = {
|
||||
id: AppThemeId
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const APP_THEME_PRESETS: ThemePreset[] = [
|
||||
{
|
||||
id: 'hydrus-dark',
|
||||
label: 'Hydrus Dark',
|
||||
description: 'Dark graphite with the original green accent.',
|
||||
},
|
||||
{
|
||||
id: 'ocean',
|
||||
label: 'Ocean',
|
||||
description: 'Deep blue panels with a cool cyan accent.',
|
||||
},
|
||||
{
|
||||
id: 'ember',
|
||||
label: 'Ember',
|
||||
description: 'Warm dark surfaces with a copper highlight.',
|
||||
},
|
||||
{
|
||||
id: 'paper',
|
||||
label: 'Paper Light',
|
||||
description: 'Clean light interface with ink-blue accents.',
|
||||
},
|
||||
]
|
||||
|
||||
export function createAppTheme(themeId: AppThemeId) {
|
||||
switch (themeId) {
|
||||
case 'ocean':
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: '#4cc9f0' },
|
||||
secondary: { main: '#90e0ef' },
|
||||
background: { default: '#07141d', paper: '#0d1e2b' },
|
||||
},
|
||||
})
|
||||
case 'ember':
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: '#ff7a3d' },
|
||||
secondary: { main: '#ffb26b' },
|
||||
background: { default: '#17110f', paper: '#241714' },
|
||||
},
|
||||
})
|
||||
case 'paper':
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: '#1f4d7a' },
|
||||
secondary: { main: '#6785a3' },
|
||||
background: { default: '#f2efe8', paper: '#fffdf8' },
|
||||
},
|
||||
})
|
||||
case 'hydrus-dark':
|
||||
default:
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: { main: '#1db954' },
|
||||
secondary: { main: '#6ee7a2' },
|
||||
background: { default: '#0f1113', paper: '#151617' },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user