379 lines
15 KiB
TypeScript
Executable file
379 lines
15 KiB
TypeScript
Executable file
// OVE — Settings tab (v6.5): appearance controls — base theme + highlight
|
|
// color pickers. Everything is per-visitor (localStorage only, nothing on
|
|
// the server) and applies instantly. The Native tab that previously lived
|
|
// in this slot is parked (see control-panel.tsx) and returns unchanged
|
|
// when the project nears completion.
|
|
//
|
|
// v7.7: the Music Services log in / "log out of all" section is PARKED
|
|
// (MUSIC_SERVICES_UI below). Stream-a-Link already plays for everyone
|
|
// through the operator's built-in house account, so the section was noise —
|
|
// it returns (reworked) in a later update. Nothing server-side is affected
|
|
// by this flag: saved visitor logins keep riding along with resolves, and
|
|
// the house account keeps working exactly as before.
|
|
|
|
'use client'
|
|
|
|
import { useSyncExternalStore, useState, useEffect } from 'react'
|
|
import { Check, LogIn, LogOut, Music4, Palette, RotateCcw } from 'lucide-react'
|
|
import { useTheme } from 'next-themes'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { ServiceLoginDialog } from '@/components/ava/service-login-dialog'
|
|
import { SERVICES, type ServiceId } from '@/lib/music-services'
|
|
import {
|
|
clearAllMusicSessions,
|
|
listMusicSessions,
|
|
type SessionSummary,
|
|
} from '@/lib/music-sessions'
|
|
import {
|
|
ACCENTS,
|
|
DEFAULT_ACCENT,
|
|
DEFAULT_BASE,
|
|
THEME_BASES,
|
|
applyAccent,
|
|
isAccentId,
|
|
storeDarkBase,
|
|
type AccentId,
|
|
} from '@/lib/theme'
|
|
import { useToast } from '@/hooks/use-toast'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
/**
|
|
* v7.7 — Music Services login UI parked. The built-in house account already
|
|
* powers Stream-a-Link for every visitor, so the log in / "log out of all"
|
|
* section is hidden until it is reworked (the underlying session storage,
|
|
* resolve auth attach and house-account behavior all keep working).
|
|
*/
|
|
const MUSIC_SERVICES_UI = false
|
|
|
|
const emptySubscribe = () => () => {}
|
|
function useMounted() {
|
|
return useSyncExternalStore(emptySubscribe, () => true, () => false)
|
|
}
|
|
|
|
/** Re-read data-accent whenever applyAccent() mutates it on <html>. */
|
|
function subscribeToAccent(onChange: () => void) {
|
|
const obs = new MutationObserver(onChange)
|
|
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-accent'] })
|
|
return () => obs.disconnect()
|
|
}
|
|
const getAccentAttribute = () => document.documentElement.getAttribute('data-accent')
|
|
|
|
export function SettingsTab() {
|
|
const { resolvedTheme, setTheme } = useTheme()
|
|
const mounted = useMounted()
|
|
const isLight = mounted && resolvedTheme === 'light'
|
|
const { toast } = useToast()
|
|
|
|
// Current accent — the data-accent attribute on <html> is the source of
|
|
// truth (set pre-paint by the boot script, mutated by applyAccent). The
|
|
// server snapshot returns null so the first client render matches SSR
|
|
// ("unselected") and the selection appears right after hydration.
|
|
const accentAttr = useSyncExternalStore(subscribeToAccent, getAccentAttribute, () => null)
|
|
const accent: AccentId | null = isAccentId(accentAttr) ? accentAttr : null
|
|
|
|
const activeBase = mounted ? resolvedTheme : null
|
|
const activeAccentMeta = ACCENTS.find((a) => a.id === accent)
|
|
// The little accent chip inside each base preview follows the current
|
|
// highlight (its light-theme variant for the white base card).
|
|
const chipFor = (baseIsLight: boolean) =>
|
|
(baseIsLight ? activeAccentMeta?.lightMain : activeAccentMeta?.main) ?? '#60a5fa'
|
|
|
|
function pickBase(id: (typeof THEME_BASES)[number]['id']) {
|
|
setTheme(id)
|
|
const base = THEME_BASES.find((b) => b.id === id)
|
|
if (base?.dark) storeDarkBase(id)
|
|
// Pair the outline base with the BLUE highlight — v7.2's default showcase
|
|
// pairing (the same look a new visitor gets); the visitor can re-pick any
|
|
// highlight afterwards.
|
|
if (id === 'outline' && accent !== 'blue') applyAccent('blue')
|
|
}
|
|
|
|
function pickAccent(id: AccentId) {
|
|
applyAccent(id)
|
|
}
|
|
|
|
function resetToDefaults() {
|
|
setTheme(DEFAULT_BASE)
|
|
storeDarkBase(DEFAULT_BASE)
|
|
applyAccent(DEFAULT_ACCENT)
|
|
}
|
|
|
|
// ---- music service logins (v6.6; UI parked in v7.7) ----
|
|
const [sessions, setSessions] = useState<SessionSummary | null>(null)
|
|
const [loginOpen, setLoginOpen] = useState(false)
|
|
const [chipService, setChipService] = useState<ServiceId | null>(null)
|
|
const [reloadTick, setReloadTick] = useState(0)
|
|
// v6.9: which services have the operator's shared "house" account
|
|
// configured (GET /api/resolve — booleans only, no cookie data)
|
|
const [house, setHouse] = useState<Partial<Record<ServiceId, boolean>> | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!MUSIC_SERVICES_UI) return
|
|
let cancelled = false
|
|
void listMusicSessions().then((s) => {
|
|
if (!cancelled) setSessions(s)
|
|
})
|
|
fetch('/api/resolve')
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((d) => {
|
|
if (!cancelled && d && typeof d === 'object' && typeof d.house === 'object') {
|
|
setHouse(d.house as Partial<Record<ServiceId, boolean>>)
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [reloadTick])
|
|
|
|
const loggedInIds = Object.keys(sessions?.loggedIn ?? {}) as ServiceId[]
|
|
const anyLoggedIn = loggedInIds.length > 0
|
|
const houseNames = house
|
|
? SERVICES.filter((s) => house[s.id]).map((s) => s.name.split(' / ')[0])
|
|
: []
|
|
|
|
function refreshSessions() {
|
|
setReloadTick((t) => t + 1)
|
|
}
|
|
|
|
async function logoutAll() {
|
|
await clearAllMusicSessions()
|
|
refreshSessions()
|
|
toast({
|
|
title: 'Logged out of all music services',
|
|
description: 'Every saved login cookie was removed from this browser.',
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="flex items-center gap-2 text-base font-semibold">
|
|
<Palette className="size-4 text-emerald-400" aria-hidden />
|
|
Appearance
|
|
</h3>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<section aria-label="Base theme">
|
|
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">Base theme</h4>
|
|
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{THEME_BASES.map((b) => {
|
|
const selected = activeBase === b.id
|
|
const chip = chipFor(!b.dark)
|
|
return (
|
|
<button
|
|
key={b.id}
|
|
type="button"
|
|
onClick={() => pickBase(b.id)}
|
|
aria-pressed={selected}
|
|
className={cn(
|
|
'flex flex-col gap-2.5 rounded-xl border p-3 text-left transition-colors',
|
|
selected
|
|
? 'glow-segment border-emerald-400/60 bg-emerald-400/10'
|
|
: 'border-zinc-800 bg-zinc-900/40 hover:border-zinc-700 hover:bg-zinc-800/40',
|
|
)}
|
|
>
|
|
{/* mini preview -- static base swatch + live accent chip */}
|
|
<div
|
|
className="h-14 w-full rounded-lg border p-1.5"
|
|
style={{ background: b.swatch.bg, borderColor: b.swatch.border }}
|
|
aria-hidden
|
|
>
|
|
<div
|
|
className="flex h-full items-center gap-1.5 rounded-md border px-2"
|
|
style={{ background: b.swatch.panel, borderColor: b.swatch.border }}
|
|
>
|
|
<span className="size-2.5 shrink-0 rounded-full" style={{ background: chip }} />
|
|
<span
|
|
className="h-1.5 w-8 shrink-0 rounded-full opacity-80"
|
|
style={{ background: chip }}
|
|
/>
|
|
<span
|
|
className="ml-auto h-4 w-10 shrink-0 rounded-[4px]"
|
|
style={{ background: chip }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-1.5 text-sm font-semibold">
|
|
{b.name}
|
|
{selected && <span className="text-emerald-300">•</span>}
|
|
</div>
|
|
<p className="mt-0.5 text-[11px] leading-snug text-zinc-500">{b.blurb}</p>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section aria-label="Highlight color">
|
|
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
|
Highlight color
|
|
</h4>
|
|
<div className="mt-3 grid grid-cols-6 gap-1">
|
|
{ACCENTS.map((a) => {
|
|
const selected = accent === a.id
|
|
const dot = isLight ? a.lightMain : a.main
|
|
return (
|
|
<button
|
|
key={a.id}
|
|
type="button"
|
|
onClick={() => pickAccent(a.id)}
|
|
aria-pressed={selected}
|
|
title={`${a.name} highlight`}
|
|
className="flex flex-col items-center gap-1.5 rounded-lg p-1.5 transition-colors hover:bg-zinc-800/50"
|
|
>
|
|
<span
|
|
className="grid size-9 place-items-center rounded-full transition-transform hover:scale-105"
|
|
style={{
|
|
background: dot,
|
|
boxShadow: selected
|
|
? `0 0 0 2px var(--color-zinc-950), 0 0 0 4px ${dot}, 0 0 12px rgb(var(--accent-rgb) / 0.45)`
|
|
: undefined,
|
|
}}
|
|
>
|
|
{selected && <Check className="size-4" style={{ color: a.deep }} aria-hidden />}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
'text-[10px] font-medium leading-none',
|
|
selected ? 'text-emerald-300' : 'text-zinc-400',
|
|
)}
|
|
>
|
|
{a.name}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
{MUSIC_SERVICES_UI && (
|
|
<>
|
|
<Separator />
|
|
|
|
<section aria-label="Music services">
|
|
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
|
<Music4 className="size-3.5" aria-hidden />
|
|
Music services
|
|
</h4>
|
|
<p className="mt-1.5 text-[11px] leading-relaxed text-zinc-500">
|
|
Log in to your own accounts -- the Link button then opens your playlists and liked music
|
|
with your account, so you never get stuck behind bot-detection walls. Login cookies are
|
|
encrypted and stored on this computer only.
|
|
</p>
|
|
|
|
{houseNames.length > 0 && (
|
|
<p className="mt-2 flex items-start gap-1.5 rounded-md border border-emerald-500/25 bg-emerald-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-emerald-300">
|
|
<Check className="mt-0.5 size-3 shrink-0" aria-hidden />
|
|
Built-in account active -- {houseNames.join(', ')} links play for everyone, no login
|
|
needed. Log in only to reach your own playlists, liked music and uploads.
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
{SERVICES.map((s) => {
|
|
const active = Boolean(sessions?.loggedIn[s.id])
|
|
return (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setChipService(s.id)
|
|
setLoginOpen(true)
|
|
}}
|
|
title={active ? `${s.name} -- logged in. Click to manage.` : `${s.name} -- click to log in`}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors',
|
|
active
|
|
? 'border-emerald-400/50 bg-emerald-400/10 text-emerald-200'
|
|
: 'border-zinc-800 bg-zinc-900/40 text-zinc-400 hover:border-zinc-700 hover:text-zinc-200',
|
|
)}
|
|
>
|
|
<span
|
|
className="size-2 shrink-0 rounded-full"
|
|
style={{ background: s.brandColor }}
|
|
aria-hidden
|
|
/>
|
|
{s.name}
|
|
{s.streamStatus === 'coming-soon' && (
|
|
<span className="rounded bg-amber-400/15 px-1 py-px text-[8px] font-semibold uppercase tracking-wider text-amber-300">
|
|
soon
|
|
</span>
|
|
)}
|
|
{active ? (
|
|
<Check className="size-3 text-emerald-300" aria-hidden />
|
|
) : (
|
|
<span className="text-[9px] text-zinc-600" aria-hidden>
|
|
--
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{sessions?.unavailable && (
|
|
<p className="mt-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-amber-300">
|
|
This browser context is not secure (https), so login cookies cannot be stored here.
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<Button
|
|
onClick={() => {
|
|
setChipService(null)
|
|
setLoginOpen(true)
|
|
}}
|
|
className="h-9 gap-1.5 bg-emerald-400 font-semibold text-emerald-950 hover:bg-emerald-300"
|
|
>
|
|
<LogIn className="size-3.5" aria-hidden />
|
|
Log in
|
|
</Button>
|
|
{anyLoggedIn && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => void logoutAll()}
|
|
className="h-9 gap-1.5 border-zinc-700 text-xs"
|
|
title="Remove every saved music login cookie from this browser"
|
|
>
|
|
<LogOut className="size-3.5" aria-hidden />
|
|
Log out of all
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={resetToDefaults}
|
|
className="h-8 gap-1.5 border-zinc-700 text-xs"
|
|
>
|
|
<RotateCcw className="size-3.5" aria-hidden />
|
|
Reset to defaults
|
|
</Button>
|
|
</div>
|
|
|
|
{MUSIC_SERVICES_UI && (
|
|
<ServiceLoginDialog
|
|
open={loginOpen}
|
|
onOpenChange={setLoginOpen}
|
|
initialService={chipService}
|
|
sessions={sessions}
|
|
houseServices={house}
|
|
onChanged={refreshSessions}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|