Files
memby/admin-ui/src/pages/Accounts.tsx
T

151 lines
6.5 KiB
TypeScript
Raw Normal View History

2026-08-14 09:40:03 +12:00
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
2026-08-18 08:41:48 +12:00
import { initials, num, presence, recent, watchTime, when } from '../lib/format';
2026-08-14 09:40:03 +12:00
import { Banner, Empty, Loading, Note, PageHead, Tag, Tiles } from '../components/ui';
import type { KnownClient } from '../api/types';
/* A directory, and only a directory. Everything you can *do* to a person lives on their own
page: this list used to render a full seventeen-control settings editor for every account
at once, which meant the page grew with the household and an operator scrolled past four
other people's preferences to reach the one they came for. */
2026-08-18 08:41:48 +12:00
/* Watch time comes from Tracearr, and `matched` is the field that matters: a household
running no Tracearr, and a person Tracearr has never seen, both arrive as zeroes. Drawing
those as "0 min this week" would have an operator asking why somebody stopped watching
when the real answer is that nothing was asked. */
interface WatchTime {
matched: boolean;
tracearrUsername?: string;
weekMs: number;
monthMs: number;
totalMs: number;
weekSessions: number;
monthSessions: number;
lastWatchedAt?: string;
}
2026-08-14 09:40:03 +12:00
interface Account {
id: string;
username: string;
2026-08-17 11:41:36 +12:00
initials: string;
2026-08-14 09:40:03 +12:00
lastSeen: string;
devices: KnownClient[] | null;
recommendations?: { prompted?: boolean; completed?: boolean };
2026-08-18 08:41:48 +12:00
watchTime?: WatchTime;
2026-08-14 09:40:03 +12:00
}
interface AccountsResponse {
accounts: Account[] | null;
}
export function AccountsPage() {
const { data, error, loading } = useQuery<AccountsResponse>('/admin/api/accounts', {
pollMs: 60_000,
});
const accounts = data?.accounts ?? [];
const devices = accounts.flatMap((account) => account.devices ?? []);
const completed = accounts.filter((account) => account.recommendations?.completed).length;
const queued = accounts.filter(
(account) => account.recommendations?.prompted && !account.recommendations?.completed,
).length;
2026-08-18 08:41:48 +12:00
/* Summed from the same rows the list draws, so the tile and the column underneath it can
never disagree — a separate total query is how those two come apart. */
const tracked = accounts.filter((account) => account.watchTime?.matched);
const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0);
2026-08-14 09:40:03 +12:00
return (
<>
<PageHead title="Memby users" intro="Who uses Memby, and the devices they are signed in on." />
<Banner message={error} />
<Note tone="info">
This is the Memby user list, not the Emby user directory. A person appears here only after
signing in to the Memby app. Removing access signs their Memby devices out and does not delete
or change their Emby account.
</Note>
{loading ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' },
{ label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' },
{
label: 'active in the last quarter hour',
value: num(devices.filter((device) => recent(device.lastSeen)).length),
icon: 'pulse',
tone: 'ok',
},
{ label: 'recommendation setups completed', value: num(completed), icon: 'check', tone: 'ok' },
{ label: 'setup prompts queued', value: num(queued), icon: 'sparkle', tone: 'note' },
2026-08-18 08:41:48 +12:00
...(tracked.length
? [
{
label: 'watched by the household this week',
value: watchTime(weekMs),
icon: 'pulse' as const,
tone: 'data' as const,
},
]
: []),
2026-08-14 09:40:03 +12:00
]}
/>
<section className="card flush">
{accounts.length === 0 ? (
<Empty>
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
</Empty>
) : (
accounts.map((account) => {
const list = account.devices ?? [];
const active = list.filter((device) => recent(device.lastSeen)).length;
const state = account.recommendations?.completed
? { label: 'personalised', tone: 'ok' as const }
: account.recommendations?.prompted
? { label: 'prompt queued', tone: 'warn' as const }
: { label: 'not invited', tone: undefined };
const seen = presence(account.lastSeen);
2026-08-18 08:41:48 +12:00
const watched = account.watchTime;
2026-08-14 09:40:03 +12:00
return (
<Link className="list-row" key={account.id} to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
<span className="list-main">
2026-08-17 11:41:36 +12:00
<span className="avatar">{account.initials || initials(account.username)}</span>
2026-08-14 09:40:03 +12:00
<span>
<span className="list-title">
{account.username || 'Unnamed user'}
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
</span>
<span className="list-meta">
{num(list.length)} device{list.length === 1 ? '' : 's'}
{active ? ` · ${active} active now` : ''} · last seen {when(account.lastSeen)}
2026-08-18 08:41:48 +12:00
{/* The month sits beside the week because a quiet week only means
something next to the month around it. Both are omitted rather
than zeroed for somebody Tracearr has never seen. */}
{watched?.matched
? ` · watched ${watchTime(watched.weekMs)} this week, ${watchTime(watched.monthMs)} this month`
: ''}
2026-08-14 09:40:03 +12:00
</span>
</span>
</span>
<span className="list-actions">
2026-08-18 08:41:48 +12:00
{watched?.matched ? <Tag tone="data">{watchTime(watched.weekMs)}</Tag> : null}
2026-08-14 09:40:03 +12:00
<Tag tone={state.tone}>{state.label}</Tag>
<span className="crumb">Manage</span>
</span>
</Link>
);
})
)}
</section>
</>
)}
</>
);
}
/** The account page reads the same list to find the person it is about. */
export type { Account, AccountsResponse };