React + Vite + TypeScript SPA covering the full ABS feature set (library browsing, item detail, metadata/cover editing, podcasts, player with session sync, admin: users/libraries/scanner/server settings). Dev uses a dynamic CORS proxy; production is served by server/index.mjs (static + reverse proxy to ABS_URL). Includes systemd unit and installer under deploy/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
3.3 KiB
TypeScript
94 lines
3.3 KiB
TypeScript
import { create } from 'zustand'
|
|
import { persist } from 'zustand/middleware'
|
|
|
|
export type AccentName = 'amber' | 'teal' | 'coral' | 'green' | 'blue'
|
|
|
|
export const ACCENTS: { name: AccentName; label: string; swatch: string }[] = [
|
|
{ name: 'amber', label: 'Amber', swatch: '#f59e0b' },
|
|
{ name: 'teal', label: 'Türkis', swatch: '#14b8a6' },
|
|
{ name: 'coral', label: 'Koralle', swatch: '#fb7185' },
|
|
{ name: 'green', label: 'Grün', swatch: '#22c55e' },
|
|
{ name: 'blue', label: 'Blau', swatch: '#3b82f6' },
|
|
]
|
|
|
|
export const PLAYBACK_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3] as const
|
|
|
|
interface SettingsState {
|
|
accent: AccentName
|
|
/** When set, a freely chosen hex color overrides the named preset. */
|
|
customAccent: string | null
|
|
defaultSpeed: number
|
|
sidebarCollapsed: boolean
|
|
setAccent: (accent: AccentName) => void
|
|
setCustomAccent: (hex: string) => void
|
|
setDefaultSpeed: (speed: number) => void
|
|
toggleSidebar: () => void
|
|
}
|
|
|
|
export const useSettingsStore = create<SettingsState>()(
|
|
persist(
|
|
(set) => ({
|
|
accent: 'amber',
|
|
customAccent: null,
|
|
defaultSpeed: 1,
|
|
sidebarCollapsed: false,
|
|
setAccent: (accent) => {
|
|
applyTheme(accent, null)
|
|
set({ accent, customAccent: null })
|
|
},
|
|
setCustomAccent: (hex) => {
|
|
applyTheme('amber', hex)
|
|
set({ customAccent: hex })
|
|
},
|
|
setDefaultSpeed: (defaultSpeed) => set({ defaultSpeed }),
|
|
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
|
}),
|
|
{
|
|
name: 'shelfless.settings',
|
|
onRehydrateStorage: () => (state) => {
|
|
if (state) applyTheme(state.accent, state.customAccent)
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
// ── Theme application ─────────────────────────────────────────────────────
|
|
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
let h = hex.trim().replace(/^#/, '')
|
|
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
|
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null
|
|
return {
|
|
r: parseInt(h.slice(0, 2), 16),
|
|
g: parseInt(h.slice(2, 4), 16),
|
|
b: parseInt(h.slice(4, 6), 16),
|
|
}
|
|
}
|
|
|
|
/** Pick readable foreground (near-black or near-white) for a given accent color. */
|
|
function onAccentFor(r: number, g: number, b: number): string {
|
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
return luminance > 0.6 ? '#0c0a09' : '#fafaf9'
|
|
}
|
|
|
|
/**
|
|
* Reflect the chosen accent onto the document. A named preset uses the
|
|
* `[data-accent]` CSS rules; a custom hex sets inline CSS variables (which win).
|
|
*/
|
|
export function applyTheme(accent: AccentName, customAccent: string | null) {
|
|
if (typeof document === 'undefined') return
|
|
const root = document.documentElement
|
|
const rgb = customAccent ? hexToRgb(customAccent) : null
|
|
|
|
if (customAccent && rgb) {
|
|
root.style.setProperty('--accent', customAccent)
|
|
root.style.setProperty('--accent-soft', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.14)`)
|
|
root.style.setProperty('--on-accent', onAccentFor(rgb.r, rgb.g, rgb.b))
|
|
root.setAttribute('data-accent', 'custom')
|
|
} else {
|
|
root.style.removeProperty('--accent')
|
|
root.style.removeProperty('--accent-soft')
|
|
root.style.removeProperty('--on-accent')
|
|
root.setAttribute('data-accent', accent)
|
|
}
|
|
}
|