0.2.68
This commit is contained in:
-12
File diff suppressed because one or more lines are too long
+12
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,9 +13,9 @@
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
<script type="module" crossorigin src="/admin/assets/index-B-5op1DD.js"></script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-C4RyKtIU.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-DSlxKU2t.css">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-Dyz7s7hT.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { EngagementPage } from './pages/Engagement';
|
||||
import { SearchesPage } from './pages/Searches';
|
||||
import { ViewsPage } from './pages/Views';
|
||||
import { MediaReportsPage } from './pages/MediaReports';
|
||||
import { CreditsPage } from './pages/Credits';
|
||||
|
||||
/* The console's routing table.
|
||||
*
|
||||
@@ -79,6 +80,7 @@ export function App() {
|
||||
<Route path="features" element={<FeaturesPage />} />
|
||||
<Route path="playback" element={<PlaybackPage />} />
|
||||
<Route path="subtitles" element={<SubtitlesPage />} />
|
||||
<Route path="credits" element={<CreditsPage />} />
|
||||
<Route path="updates" element={<UpdatesPage />} />
|
||||
|
||||
<Route path="tasks" element={<TasksPage />} />
|
||||
|
||||
@@ -216,6 +216,7 @@ export interface FeaturePolicy {
|
||||
|
||||
export interface AdminStatus {
|
||||
serverVersion: string;
|
||||
currentUser?: string;
|
||||
maintenance: Maintenance;
|
||||
updatePolicy: UpdatePolicy;
|
||||
library: LibraryStats;
|
||||
@@ -237,6 +238,53 @@ export interface AdminStatus {
|
||||
radarrReady: boolean;
|
||||
}
|
||||
|
||||
export interface CreditsSettings {
|
||||
candidateLimit: number;
|
||||
prefetchEpisodes: number;
|
||||
maxPrefetch: number;
|
||||
retryHours: number;
|
||||
}
|
||||
|
||||
export interface CreditsCandidate {
|
||||
itemId: string;
|
||||
seriesId: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
priority: number;
|
||||
reason: string;
|
||||
userCount: number;
|
||||
lastViewed: string;
|
||||
}
|
||||
|
||||
export interface CreditsScanHistory {
|
||||
id: number;
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
seriesName: string;
|
||||
seriesId: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
reason: string;
|
||||
priority: number;
|
||||
outcome: 'detected' | 'unchanged' | 'no_match' | 'failed';
|
||||
markerMs: number;
|
||||
confidence: number;
|
||||
method: string;
|
||||
frames: number;
|
||||
error: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface CreditsResponse {
|
||||
enabled: boolean;
|
||||
settings: CreditsSettings;
|
||||
queueDepth: number;
|
||||
pending: CreditsCandidate[];
|
||||
history: CreditsScanHistory[];
|
||||
}
|
||||
|
||||
export interface ViewsReport {
|
||||
today: { visits: number; viewers: number };
|
||||
lastWeek: { visits: number; viewers: number };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { matchPath, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Icon } from './Icon';
|
||||
import { OmniSearch } from './OmniSearch';
|
||||
@@ -133,12 +133,17 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const { version, online, checkedAt, status, setMaintenance } = useGateway();
|
||||
const { version, currentUser, online, checkedAt, loading, status, setMaintenance } = useGateway();
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [changingAvailability, setChangingAvailability] = useState(false);
|
||||
const [confirmingOffline, setConfirmingOffline] = useState(false);
|
||||
const location = useLocation();
|
||||
const offline = Boolean(status?.maintenance?.enabled);
|
||||
const account = useRef<HTMLDivElement>(null);
|
||||
const initial = Array.from(currentUser.trim())[0]?.toLocaleUpperCase('en-NZ') || 'A';
|
||||
const statusTone = status && online && !offline ? 'ok' : status || !loading ? 'bad' : 'checking';
|
||||
const statusLabel = offline ? 'offline' : status && online ? 'online' : loading ? 'checking' : 'not responding';
|
||||
|
||||
const toggleAvailability = async () => {
|
||||
if (changingAvailability || !status) return;
|
||||
@@ -154,6 +159,22 @@ export function Layout() {
|
||||
// it sits over the page that was just opened.
|
||||
useEffect(() => setRailOpen(false), [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountOpen) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!account.current?.contains(event.target as Node)) setAccountOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setAccountOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [accountOpen]);
|
||||
|
||||
// An open tablet drawer is modal navigation: keep the page underneath still and let
|
||||
// Escape close it. The media query decides whether the rail is overlaid; applying this
|
||||
// on desktop is harmless because the desktop rail cannot be opened by a hidden button.
|
||||
@@ -206,22 +227,46 @@ export function Layout() {
|
||||
<button
|
||||
type="button"
|
||||
className="topbar-status"
|
||||
data-tone={online && !offline ? 'ok' : 'bad'}
|
||||
data-tone={statusTone}
|
||||
aria-pressed={offline}
|
||||
disabled={!status || changingAvailability}
|
||||
title={offline ? 'Bring Memby back online' : 'Take Memby offline'}
|
||||
disabled={!status || !online || changingAvailability}
|
||||
title={!status ? 'Checking Memby status' : offline ? 'Bring Memby back online' : online ? 'Take Memby offline' : 'Memby is not responding'}
|
||||
aria-label={offline ? 'Memby is offline. Bring it online' : status && online ? 'Memby is online. Take it offline' : loading ? 'Checking Memby status' : 'Memby is not responding'}
|
||||
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
|
||||
>
|
||||
<span className="dot" />
|
||||
<b>{offline ? 'offline' : online ? 'online' : 'not responding'}</b>
|
||||
<b>{statusLabel}</b>
|
||||
<span>{offline ? 'click to bring back online' : checkedAt ? `updated ${time(checkedAt)}` : ''}</span>
|
||||
</button>
|
||||
<NotificationBell />
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" data-variant="quiet" data-size="sm" title="Sign out">
|
||||
<Icon name="logout" />
|
||||
<div className="account-menu" data-open={accountOpen || undefined} ref={account}>
|
||||
<button
|
||||
type="button"
|
||||
className="account-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountOpen}
|
||||
aria-label={`Signed in as ${currentUser}`}
|
||||
onClick={() => setAccountOpen((open) => !open)}
|
||||
>
|
||||
<span className="account-avatar" aria-hidden="true">{initial}</span>
|
||||
<span className="account-name">{currentUser}</span>
|
||||
<Icon name="caret" className="ico account-caret" />
|
||||
</button>
|
||||
</form>
|
||||
{accountOpen ? (
|
||||
<div className="account-panel" role="menu">
|
||||
<div className="account-identity">
|
||||
<span className="account-avatar account-avatar-large" aria-hidden="true">{initial}</span>
|
||||
<span><small>Signed in as</small><b>{currentUser}</b></span>
|
||||
</div>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" role="menuitem">
|
||||
<Icon name="logout" />
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const POLL_MS = 30_000;
|
||||
interface GatewayState {
|
||||
status: AdminStatus | undefined;
|
||||
version: string;
|
||||
currentUser: string;
|
||||
online: boolean;
|
||||
/** checkedAt is the evidence behind the verdict: a page that looks alive while its
|
||||
* numbers are twenty minutes old is the failure the bar exists to prevent. */
|
||||
@@ -41,7 +42,7 @@ const GatewayContext = createContext<GatewayState | null>(null);
|
||||
|
||||
export function GatewayProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AdminStatus>();
|
||||
const [online, setOnline] = useState(true);
|
||||
const [online, setOnline] = useState(false);
|
||||
const [checkedAt, setCheckedAt] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -104,6 +105,7 @@ export function GatewayProvider({ children }: { children: ReactNode }) {
|
||||
() => ({
|
||||
status,
|
||||
version: status?.serverVersion ?? '',
|
||||
currentUser: status?.currentUser?.trim() || 'Administrator',
|
||||
online,
|
||||
checkedAt,
|
||||
error,
|
||||
|
||||
@@ -229,6 +229,14 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Which providers a viewer may fetch a missing subtitle from.',
|
||||
icon: 'captions',
|
||||
},
|
||||
{
|
||||
id: 'credits',
|
||||
path: '/admin/credits',
|
||||
label: 'Credits detection',
|
||||
title: 'Credits detection',
|
||||
intro: 'Control predictive scanning and review every completed credits scan.',
|
||||
icon: 'clock',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { CreditsResponse, CreditsScanHistory, CreditsSettings } from '../api/types';
|
||||
import { Banner, Button, Card, EmptyRow, Field, Grid, Loading, Note, PageHead, TableWrap, Tag, Tiles } from '../components/ui';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { ago, duration, num, percent, when, type Tone } from '../lib/format';
|
||||
|
||||
const outcomeTone = (outcome: CreditsScanHistory['outcome']): Tone => {
|
||||
if (outcome === 'detected') return 'ok';
|
||||
if (outcome === 'failed') return 'bad';
|
||||
if (outcome === 'no_match') return 'warn';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const outcomeLabel = (outcome: CreditsScanHistory['outcome']): string =>
|
||||
outcome === 'no_match' ? 'no match' : outcome;
|
||||
|
||||
const reasonLabel = (reason: string): string => ({
|
||||
'live-playback': 'Live playback',
|
||||
'tracearr-next': 'Next episode',
|
||||
'tracearr-binge-prefetch': 'Binge look-ahead',
|
||||
'multi-user-demand': 'Multiple viewers',
|
||||
}[reason] ?? reason) || 'Unknown';
|
||||
|
||||
const episodeLabel = (season: number, episode: number): string =>
|
||||
season > 0 && episode > 0 ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` : 'Episode';
|
||||
|
||||
export function CreditsPage() {
|
||||
const query = useQuery<CreditsResponse>('/admin/api/credits?limit=150', { pollMs: 15_000 });
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
const [draft, setDraft] = useState<CreditsSettings>();
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!touched && query.data) setDraft(query.data.settings);
|
||||
}, [query.data, touched]);
|
||||
|
||||
const patch = (next: Partial<CreditsSettings>) => {
|
||||
setDraft((current) => current ? { ...current, ...next } : current);
|
||||
setTouched(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!draft) return;
|
||||
void run('save', async () => {
|
||||
const next = await wrap(
|
||||
() => api.put<CreditsResponse>('/admin/api/credits', draft),
|
||||
'Credits scanning settings saved.',
|
||||
);
|
||||
if (next) {
|
||||
query.set(next);
|
||||
setDraft(next.settings);
|
||||
setTouched(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const history = query.data?.history ?? [];
|
||||
const pending = query.data?.pending ?? [];
|
||||
const detected = history.filter((entry) => entry.outcome === 'detected').length;
|
||||
const noMatch = history.filter((entry) => entry.outcome === 'no_match').length;
|
||||
const failed = history.filter((entry) => entry.outcome === 'failed').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Credits detection"
|
||||
intro="Control how far ahead Memby scans and see why each episode was selected, what the detector found, and when it may be tried again."
|
||||
/>
|
||||
<Banner message={query.error} />
|
||||
|
||||
{query.loading || !draft ? <Loading /> : (
|
||||
<>
|
||||
{!query.data?.enabled ? (
|
||||
<Note tone="warn">
|
||||
Credits detection is disabled in the gateway environment. These settings will be retained for the next time it is enabled.
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
<Tiles tiles={[
|
||||
{ label: 'Waiting candidates', value: num(query.data?.queueDepth), icon: 'clock', tone: pending.length ? 'info' : undefined },
|
||||
{ label: 'Detected in this history', value: num(detected), icon: 'check', tone: 'ok' },
|
||||
{ label: 'No match', value: num(noMatch), icon: 'search', tone: noMatch ? 'warn' : undefined },
|
||||
{ label: 'Failed', value: num(failed), icon: 'alert', tone: failed ? 'bad' : undefined },
|
||||
]} />
|
||||
|
||||
<Grid cols="wide">
|
||||
<Card
|
||||
title="Candidate controls"
|
||||
intro="The worker remains single-file and scans one episode at a time. These values control what is allowed to wait and how far prediction looks ahead."
|
||||
icon="sliders"
|
||||
tone="note"
|
||||
footer={<Button variant="primary" icon="check" busy={busy === 'save'} disabled={!touched} onClick={save}>Save settings</Button>}
|
||||
>
|
||||
<div className="fields">
|
||||
<Field label="Candidate limit" hint="Maximum episodes waiting in the priority queue. Stronger candidates displace weaker ones when it is full.">
|
||||
<input type="number" min={1} max={100} value={draft.candidateLimit} onChange={(event) => patch({ candidateLimit: Number(event.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Ordinary look-ahead" hint="Episodes prepared ahead of a normally paced viewer.">
|
||||
<input type="number" min={1} max={10} value={draft.prefetchEpisodes} onChange={(event) => patch({ prefetchEpisodes: Number(event.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Maximum look-ahead" hint="Upper bound for fast binge viewing; must not be below the ordinary look-ahead.">
|
||||
<input type="number" min={draft.prefetchEpisodes} max={20} value={draft.maxPrefetch} onChange={(event) => patch({ maxPrefetch: Number(event.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Retry delay (hours)" hint="After any speculative attempt, keep that episode out of refreshes for this long. Set 0 to allow every refresh.">
|
||||
<input type="number" min={0} max={720} value={draft.retryHours} onChange={(event) => patch({ retryHours: Number(event.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="How selection works" icon="sparkle" tone="data">
|
||||
<p className="muted">
|
||||
Recent viewing predicts the next few episodes. Priority favours a programme playing now, then the next episode, fast viewing, and episodes several people are approaching.
|
||||
</p>
|
||||
<p className="muted">
|
||||
A completed speculative attempt enters the retry delay even when no marker was found. Live playback can still raise an immediate candidate because somebody is waiting for it.
|
||||
</p>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Card
|
||||
title="Waiting candidates"
|
||||
intro="The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as household viewing changes."
|
||||
icon="list"
|
||||
tone="info"
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead><tr><th>Episode</th><th>Reason</th><th className="num">Priority</th><th className="num">Viewers</th><th>Demand seen</th><th>Item ID</th></tr></thead>
|
||||
<tbody>
|
||||
{pending.length === 0 ? <EmptyRow columns={6}>No episodes are waiting to be scanned.</EmptyRow> : pending.map((candidate) => (
|
||||
<tr key={candidate.itemId}>
|
||||
<td>{episodeLabel(candidate.season, candidate.episode)}</td>
|
||||
<td><Tag tone="info">{reasonLabel(candidate.reason)}</Tag></td>
|
||||
<td className="num">{num(candidate.priority)}</td>
|
||||
<td className="num">{num(candidate.userCount)}</td>
|
||||
<td className="nowrap muted" title={when(candidate.lastViewed)}>{ago(candidate.lastViewed)}</td>
|
||||
<td className="mono muted">{candidate.itemId}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Scan history"
|
||||
intro="Completed worker attempts, newest first. Repeated item IDs make an ineffective retry delay visible immediately."
|
||||
icon="history"
|
||||
tone="note"
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead><tr><th>Finished</th><th>Programme</th><th>Selected because</th><th>Result</th><th>Marker</th><th>Evidence</th><th className="num">Took</th></tr></thead>
|
||||
<tbody>
|
||||
{history.length === 0 ? <EmptyRow columns={7}>No credits scans have completed yet.</EmptyRow> : history.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="nowrap muted" title={when(entry.finishedAt)}>{ago(entry.finishedAt)}</td>
|
||||
<td>
|
||||
<b>{entry.seriesName || entry.itemName || entry.itemId}</b>
|
||||
<span className="table-sub">{episodeLabel(entry.season, entry.episode)}{entry.itemName && entry.seriesName ? ` · ${entry.itemName}` : ''}</span>
|
||||
</td>
|
||||
<td><Tag tone="info">{reasonLabel(entry.reason)}</Tag><span className="table-sub">priority {entry.priority}</span></td>
|
||||
<td><Tag tone={outcomeTone(entry.outcome)}>{outcomeLabel(entry.outcome)}</Tag>{entry.error ? <span className="table-sub">{entry.error}</span> : null}</td>
|
||||
<td className="nowrap">{entry.markerMs > 0 ? duration(entry.markerMs) : '—'}</td>
|
||||
<td className="muted">{entry.method || 'visual'}{entry.confidence > 0 ? ` · ${percent(entry.confidence)}` : ''}{entry.frames > 0 ? ` · ${entry.frames} frames` : ''}</td>
|
||||
<td className="num muted">{duration(entry.durationMs)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+202
-3
@@ -220,6 +220,7 @@ a {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: background .16s ease, border-color .16s ease, color .16s ease;
|
||||
}
|
||||
.topbar-status:hover:not(:disabled),
|
||||
.topbar-status:focus-visible {
|
||||
@@ -238,6 +239,7 @@ a {
|
||||
}
|
||||
.topbar-status[data-tone="ok"] .dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-wash), 0 0 12px rgba(86, 211, 100, .34);
|
||||
}
|
||||
.topbar-status[data-tone="bad"] .dot {
|
||||
background: var(--danger);
|
||||
@@ -252,6 +254,111 @@ a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The account is an identity first and an exit second. Keeping sign-out in this menu
|
||||
prevents an unexplained door icon from competing with the operational controls. */
|
||||
.account-menu {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.account-trigger {
|
||||
max-width: 190px;
|
||||
height: 38px;
|
||||
padding: 0 9px 0 5px;
|
||||
border-color: var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
}
|
||||
.account-trigger:hover:not(:disabled),
|
||||
.account-menu[data-open="true"] .account-trigger {
|
||||
border-color: rgba(86, 211, 100, .5);
|
||||
background: var(--surface-hi);
|
||||
color: var(--text);
|
||||
}
|
||||
.account-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
flex: 0 0 27px;
|
||||
border: 1px solid rgba(86, 211, 100, .36);
|
||||
border-radius: 50%;
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-ink);
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.account-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.account-caret {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transition: transform .16s ease;
|
||||
}
|
||||
.account-menu[data-open="true"] .account-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.account-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: min(280px, calc(100vw - 24px));
|
||||
padding: 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, .55);
|
||||
z-index: 40;
|
||||
}
|
||||
.account-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 0;
|
||||
padding: 9px 10px 12px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.account-avatar-large {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-basis: 34px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.account-identity span:last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
.account-identity small,
|
||||
.account-identity b {
|
||||
display: block;
|
||||
}
|
||||
.account-identity small {
|
||||
color: var(--quiet);
|
||||
font-size: 11px;
|
||||
}
|
||||
.account-identity b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.account-panel form {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.account-panel form button {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
.account-panel form button:hover:not(:disabled) {
|
||||
background: var(--surface-hi);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar-version,
|
||||
.topbar-status span {
|
||||
@@ -575,6 +682,7 @@ a {
|
||||
|
||||
.page {
|
||||
width: min(calc(100vw - var(--rail)), 1920px);
|
||||
min-width: 0;
|
||||
margin: var(--top) 0 0 calc(var(--rail) + max(0px, (100vw - var(--rail) - 1920px) / 2));
|
||||
padding: clamp(24px, 2.4vw, 44px) clamp(22px, 3vw, 56px) 80px;
|
||||
}
|
||||
@@ -1088,6 +1196,16 @@ table {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
.topbar-status[data-tone="ok"] {
|
||||
border-color: rgba(86, 211, 100, .38);
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.topbar-status[data-tone="bad"] {
|
||||
border-color: rgba(229, 83, 75, .34);
|
||||
background: var(--danger-wash);
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
.page {
|
||||
width: 100%;
|
||||
padding: clamp(24px, 3vw, 34px) max(24px, calc(var(--safe-right) + 10px)) max(64px, calc(var(--safe-bottom) + 36px)) max(24px, calc(var(--safe-left) + 10px));
|
||||
@@ -1106,6 +1224,17 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.account-trigger {
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
.account-name,
|
||||
.account-caret {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.brand-word,
|
||||
.topbar-status b {
|
||||
@@ -1136,6 +1265,57 @@ table {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* A phone gets two deliberate rows: navigation and live controls above, search below.
|
||||
Compressing all six controls into one line made the search unusable at exactly the
|
||||
widths where it is the quickest way around a long navigation drawer. */
|
||||
@media (max-width: 680px) {
|
||||
:root {
|
||||
--top: calc(108px + var(--safe-top));
|
||||
}
|
||||
.topbar {
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: calc(var(--safe-top) + 8px) max(12px, var(--safe-right)) 8px max(12px, var(--safe-left));
|
||||
}
|
||||
.rail-toggle { order: 1; }
|
||||
.topbar-brand { order: 2; }
|
||||
.topbar-status {
|
||||
order: 3;
|
||||
margin-left: auto;
|
||||
}
|
||||
.bell { order: 4; }
|
||||
.account-menu { order: 5; }
|
||||
.topbar .omni {
|
||||
order: 6;
|
||||
flex: 1 0 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-basis: 32px;
|
||||
}
|
||||
.omni-input {
|
||||
height: 38px;
|
||||
}
|
||||
.account-panel,
|
||||
.bell-panel {
|
||||
position: fixed;
|
||||
top: calc(var(--top) + 8px);
|
||||
}
|
||||
.account-panel {
|
||||
right: max(12px, var(--safe-right));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 370px) {
|
||||
.tiles {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
th {
|
||||
padding: 0 12px 8px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
@@ -2610,7 +2790,7 @@ details summary {
|
||||
/* iPad is an operating surface, not a large phone. Cards gain enough separation to be
|
||||
parsed at arm's length, but data remains dense and two-column where portrait width can
|
||||
genuinely support it. */
|
||||
@media (min-width: 821px) and (max-width: 1400px) {
|
||||
@media (pointer: coarse) and (min-width: 821px) and (max-width: 1400px) {
|
||||
.page-head {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
@@ -2718,7 +2898,7 @@ details summary {
|
||||
}
|
||||
.card-head-actions {
|
||||
width: 100%;
|
||||
margin-left: 41px;
|
||||
margin-left: 0;
|
||||
}
|
||||
.card-foot > button,
|
||||
.card-foot > .btn {
|
||||
@@ -2815,7 +2995,7 @@ details summary {
|
||||
.topbar .rail-toggle,
|
||||
.topbar .bell-button,
|
||||
.topbar .topbar-status,
|
||||
.topbar form button {
|
||||
.topbar .account-trigger {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
@@ -2885,6 +3065,25 @@ details summary {
|
||||
}
|
||||
}
|
||||
|
||||
/* Short laptop screens benefit from vertical density, while retaining the same readable
|
||||
widths and full-size pointer targets. */
|
||||
@media (pointer: fine) and (min-width: 821px) and (max-height: 800px) {
|
||||
.page {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
.page-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.card-head {
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
tbody tr:hover td,
|
||||
.list-row:hover,
|
||||
|
||||
Reference in New Issue
Block a user