0.2.76 - Icon Packs
This commit is contained in:
+108
-54
@@ -1,7 +1,16 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '../lib/hooks';
|
||||
import { initials, num, presence, recent, watchTime, when } from '../lib/format';
|
||||
import { Banner, Empty, Loading, Note, PageHead, Tag, Tiles } from '../components/ui';
|
||||
import { ago, initials, num, presence, recent, watchTime, when } from '../lib/format';
|
||||
import {
|
||||
Banner,
|
||||
Card,
|
||||
EmptyRow,
|
||||
Loading,
|
||||
PageHead,
|
||||
TableWrap,
|
||||
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
|
||||
@@ -38,6 +47,23 @@ interface AccountsResponse {
|
||||
accounts: Account[] | null;
|
||||
}
|
||||
|
||||
/** seenAt is a timestamp as a number, with anything unreadable sorting last rather than
|
||||
* first — an invalid date yields NaN, and NaN comparisons would scatter those rows. */
|
||||
function seenAt(value: string | undefined): number {
|
||||
const at = value ? new Date(value).getTime() : 0;
|
||||
return Number.isFinite(at) ? at : 0;
|
||||
}
|
||||
|
||||
/** A dash, and it says why on hover. Watch time comes from Tracearr; a household running
|
||||
* none and a person it has never matched are both "not measured", never "none". */
|
||||
function NotMeasured() {
|
||||
return (
|
||||
<span className="muted" title="No Tracearr sessions matched to this person">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountsPage() {
|
||||
const { data, error, loading } = useQuery<AccountsResponse>('/admin/api/accounts', {
|
||||
pollMs: 60_000,
|
||||
@@ -53,17 +79,18 @@ export function AccountsPage() {
|
||||
const tracked = accounts.filter((account) => account.watchTime?.matched);
|
||||
const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0);
|
||||
|
||||
/* Most recently seen first, so the table means something without being sorted. Whoever
|
||||
is using Memby right now is the row an operator opening this page is looking for, and a
|
||||
person who has never signed in from a device sorts to the bottom rather than the top. */
|
||||
const rows = [...accounts].sort(
|
||||
(a, b) => seenAt(b.lastSeen) - seenAt(a.lastSeen),
|
||||
);
|
||||
|
||||
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 />
|
||||
) : (
|
||||
@@ -93,53 +120,80 @@ export function AccountsPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<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);
|
||||
const watched = account.watchTime;
|
||||
return (
|
||||
<Link className="list-row" key={account.id} to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
|
||||
<span className="list-main">
|
||||
<span className="avatar">{account.initials || initials(account.username)}</span>
|
||||
<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)}
|
||||
<Card title="People" icon="people" tone="note">
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Person</th>
|
||||
<th className="num">Devices</th>
|
||||
<th className="num">This week</th>
|
||||
<th className="num">This month</th>
|
||||
<th>Recommendations</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyRow columns={6}>
|
||||
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
|
||||
</EmptyRow>
|
||||
) : (
|
||||
rows.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);
|
||||
const watched = account.watchTime;
|
||||
return (
|
||||
<tr key={account.id}>
|
||||
<td>
|
||||
<span className="row tight">
|
||||
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
|
||||
<span className="avatar">{account.initials || initials(account.username)}</span>
|
||||
<Link
|
||||
className="table-row-link"
|
||||
to={`/admin/accounts/${encodeURIComponent(account.id)}`}
|
||||
>
|
||||
{account.username || 'Unnamed user'}
|
||||
</Link>
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
{num(list.length)}
|
||||
{/* Only where there is something to say. A sub-line under every
|
||||
row reading "0 active now" is a column of noise. */}
|
||||
{active ? <span className="table-sub">{num(active)} active now</span> : null}
|
||||
</td>
|
||||
{/* 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`
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="list-actions">
|
||||
{watched?.matched ? <Tag tone="data">{watchTime(watched.weekMs)}</Tag> : null}
|
||||
<Tag tone={state.tone}>{state.label}</Tag>
|
||||
<span className="crumb">Manage</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
something next to the month around it. Both are a dash rather
|
||||
than a zero for somebody Tracearr has never seen: "0 min" would
|
||||
have an operator investigating a person when the real answer is
|
||||
that nothing was ever asked. */}
|
||||
<td className="num">
|
||||
{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}
|
||||
</td>
|
||||
<td className="num muted">
|
||||
{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}
|
||||
</td>
|
||||
<td>
|
||||
<Tag tone={state.tone}>{state.label}</Tag>
|
||||
</td>
|
||||
<td className="nowrap muted" title={when(account.lastSeen)}>
|
||||
{ago(account.lastSeen)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user