import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { api } from '../api/client'; import { useAction, useQuery } from '../lib/hooks'; import { useToast } from '../lib/toast'; import { initials, num, presence, recent, when } from '../lib/format'; import { Banner, Button, Card, Chip, Confirm, Empty, Field, Grid, Loading, PageHead, Tag, Toggle, } from '../components/ui'; import type { DeviceVersion } from '../api/types'; /* One person: their televisions, their recommendation setup and the settings that follow them to every set. The identity is in the URL rather than in a query string so the page can be linked, bookmarked and returned to after a sign-in. */ interface PreferenceOption { value: string; label: string; } interface PreferenceDefinition { key: string; name: string; description: string; area: string; kind: 'toggle' | 'choice' | 'number' | 'multi' | 'list'; options?: PreferenceOption[]; numbers?: number[]; unit?: string; } interface ThemeDefinition { id: string; name: string; description: string; palette?: Record; } interface AccountDevice { id: string; name: string; version: string; signedInAt: string; lastSeen: string; versions: DeviceVersion[] | null; } interface RecommendationState { prompted?: boolean; completed?: boolean; ratings?: { title: string; rating: number }[]; genres?: string[]; studios?: string[]; actors?: string[]; actresses?: string[]; directors?: string[]; contentTypes?: string[]; } interface AccountDetail { id: string; username: string; lastSeen: string; devices: AccountDevice[] | null; themes: string[] | null; recommendations?: RecommendationState; settings?: { saved?: boolean; revision?: number; source?: string; updatedAt?: string; preferences?: Record; }; } interface AccountsPayload { accounts: AccountDetail[] | null; catalogue: PreferenceDefinition[] | null; themes: ThemeDefinition[] | null; } /* The palette is written the way Android reads it, #AARRGGBB, and CSS reads #RRGGBBAA. The conversion lives here rather than on the wire because the television is the end that has to parse thousands of these and the console is the end that parses eight. */ function cssColour(value: string | undefined): string { const hex = String(value ?? '').replace('#', ''); if (hex.length !== 8) return `#${hex}`; return `#${hex.slice(2)}${hex.slice(0, 2)}`; } type Pending = | { kind: 'remove-device'; deviceId: string; name: string } | { kind: 'remove-account' } | { kind: 'reset-recommendations' } | { kind: 'cancel-prompt' } | { kind: 'reset-preferences' } | { kind: 'no-themes' }; export function AccountPage() { const { userId = '' } = useParams(); const navigate = useNavigate(); const { wrap } = useToast(); const { busy, run } = useAction(); const base = `/admin/api/accounts/${encodeURIComponent(userId)}`; const { data, error, loading, reload } = useQuery('/admin/api/accounts', { pollMs: 30_000, }); /* True while the operator has edited a form without saving. The page polls, and a redraw would take a half-finished change away mid-sentence — so a dirty form keeps what it has until it is saved, discarded or reloaded. The two forms track this separately, so saving one does not discard an unsaved edit to the other. */ const [prefs, setPrefs] = useState | null>(null); const [themes, setThemes] = useState(null); const [pending, setPending] = useState(null); const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null); const account = (data?.accounts ?? []).find((entry) => entry.id === userId); const catalogue = data?.catalogue ?? []; const themeCatalogue = data?.themes ?? []; useEffect(() => { if (prefs === null && account) setPrefs({ ...(account.settings?.preferences ?? {}) }); }, [account, prefs]); useEffect(() => { if (themes !== null || !account) return; // An empty list from the server means unrestricted, so it draws as every box ticked. // Storing "all" and "never configured" identically is deliberate — they are the same // decision — and this is the one place an operator would notice if it were not. const allowed = account.themes ?? []; setThemes(allowed.length === 0 ? themeCatalogue.map((theme) => theme.id) : allowed); }, [account, themes, themeCatalogue]); const areas = useMemo(() => { const grouped: { name: string; definitions: PreferenceDefinition[] }[] = []; for (const definition of catalogue) { let area = grouped.find((entry) => entry.name === definition.area); if (!area) grouped.push((area = { name: definition.area, definitions: [] })); area.definitions.push(definition); } return grouped; }, [catalogue]); const act = (key: string, fn: () => Promise, message: string, after?: () => void) => run(key, async () => { const ok = await wrap(fn, message); setPending(null); if (ok !== undefined) after?.(); await reload(); }); if (loading) { return ( <> ← All users} /> ); } if (!account) { return ( <> ← All users} /> This user is no longer signed in to Memby. ); } const devices = account.devices ?? []; const active = devices.filter((device) => recent(device.lastSeen)).length; const settings = account.settings ?? {}; const prompt = account.recommendations ?? {}; return ( <> ← All users} actions={ <> {initials(account.username)} {active ? {active} active now : idle} {account.id} } /> {devices.length === 0 ? ( No devices are signed in to this user. ) : (
{devices.map((device) => { const seen = presence(device.lastSeen); return (
{' '} {device.name || 'Memby TV'}

{device.version ? `Memby ${device.version}` : 'Legacy Memby client'} · {seen.label} · last seen {when(device.lastSeen)} · signed in {when(device.signedInAt)}

{/* Every build this set has been seen running. One television that has been through four releases is a different thing from four televisions, and the history is what says which of those this is. */} {(device.versions ?? []).length > 0 ? (
{(device.versions ?? []).map((entry) => ( {entry.version} {entry.version === device.version ? ' · now' : ''} ))}
) : null}
); })}
)}
setPending({ kind: 'reset-recommendations' })} > Clear stored choices ) : prompt.prompted ? ( ) : ( ) } >
{prompt.completed ? ( completed ) : prompt.prompted ? ( prompt queued ) : ( not invited )}
r{num(settings.revision)} · {settings.source || 'device'} · {when(settings.updatedAt)} ) : ( defaults · never synced ) } footer={ <> History and rollback → } > {areas.map((area) => (

{area.name}

{area.definitions.map((definition) => ( setPrefs((current) => ({ ...(current ?? {}), [definition.key]: next }))} /> ))}
))}
all schemes ) : ( {num((account.themes ?? []).length)} of {num(themeCatalogue.length)} ) } footer={ <> Seasonal themes are not listed. They apply to every television in the house for their dates and nobody can decline one — the only switch is Seasonal themes on the features page. } >
{themeCatalogue.map((theme) => ( ))}
{renaming ? ( setRenaming(null)} onConfirm={(name) => void act( 'rename', () => api.put(`${base}/devices/${encodeURIComponent(renaming.id)}`, { deviceName: name }), 'Device renamed.', () => setRenaming(null), ) } /> ) : null} {pending ? ( setPending(null)} onConfirm={() => { switch (pending.kind) { case 'remove-device': return void act( 'remove-device', () => api.del(`${base}/devices/${encodeURIComponent(pending.deviceId)}`), 'Device signed out.', ); case 'remove-account': return void act( 'remove-account', () => api.del(`${base}/sessions`), 'Memby access removed.', () => navigate('/admin/accounts'), ); case 'reset-recommendations': case 'cancel-prompt': return void act( 'reset-rec', () => api.del(`${base}/recommendations`), 'Recommendation choices cleared.', ); case 'reset-preferences': return void act( 'reset-prefs', () => api.del(`${base}/preferences`), 'Defaults restored.', () => setPrefs(null), ); case 'no-themes': return void act( 'themes', () => api.put(`${base}/themes`, { themes: [] }), 'Colour schemes saved.', () => setThemes(null), ); } }} /> ) : null} ); } function RecommendationChips({ prompt }: { prompt: RecommendationState }) { const ratings = prompt.ratings ?? []; const dimensions: [string, string[] | undefined][] = [ ['Genres', prompt.genres], ['Studios', prompt.studios], ['Actors', prompt.actors], ['Actresses', prompt.actresses], ['Directors', prompt.directors], ['Types', prompt.contentTypes], ]; const chips: ReactNode[] = [ ...ratings.map((rating) => ( {rating.title} · {num(rating.rating)} ★ )), ...dimensions.flatMap(([label, values]) => (values ?? []).map((value) => ( {label}: {value} )), ), ]; if (chips.length === 0) return No recommendation selections have been saved.; return
{chips}
; } function SettingControl({ definition, value, onChange, }: { definition: PreferenceDefinition; value: unknown; onChange: (next: unknown) => void; }) { if (definition.kind === 'toggle') { return ( ); } if (definition.kind === 'choice' || definition.kind === 'number') { // A number's unit comes from the catalogue. Assuming minutes was safe while the only // number was a time budget, and wrong the moment a second one counted anything else. const unit = definition.unit ?? ''; const options = definition.kind === 'number' ? (definition.numbers ?? []).map((amount) => ({ value: String(amount), label: amount === 0 ? 'No limit' : unit ? `${amount} ${unit}` : String(amount), })) : (definition.options ?? []); return ( ); } if (definition.kind === 'multi') { // Rendered in the viewer's own order, then whatever they have not selected. The order // of these rows is the order the launcher draws them in, so preserving it matters as // much as which are ticked. const selected = Array.isArray(value) ? (value as string[]) : []; const ordered = [ ...selected, ...(definition.options ?? []).map((option) => option.value).filter((option) => !selected.includes(option)), ]; return (
{definition.name} {definition.description}
{ordered.map((option) => { const match = (definition.options ?? []).find((entry) => entry.value === option); if (!match) return null; return ( onChange(on ? [...selected, option] : selected.filter((entry) => entry !== option)) } /> ); })}
); } // A free-form list of server row ids: one per line, which is also how the television // stores them. There is no vocabulary to offer, because these ids ship from the gateway // without an app release. const entries = Array.isArray(value) ? (value as string[]) : []; return (