2026-08-14 09:40:03 +12:00
|
|
|
import { Link } from 'react-router-dom';
|
|
|
|
|
import { useQuery } from '../lib/hooks';
|
|
|
|
|
import { initials, num, presence, recent, when } from '../lib/format';
|
|
|
|
|
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. */
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
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' },
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<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);
|
|
|
|
|
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)}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span className="list-actions">
|
|
|
|
|
<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 };
|