openvisengine/src/components/ava/settings-tab.tsx
2026-11-04 05:50:06 +00:00

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>
)
}