Add private Jellystat viewing stats to Magent beta
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m16s

This commit is contained in:
2026-09-08 11:25:05 +12:00
parent a3b5759708
commit 2976145dd8
24 changed files with 1010 additions and 3 deletions
+18
View File
@@ -34,6 +34,7 @@ const SECTION_LABELS: Record<string, string> = {
seerr: 'Seerr',
jellyseerr: 'Seerr',
jellyfin: 'Jellyfin',
jellystat: 'Jellystat',
artwork: 'Artwork cache',
cache: 'Request cache',
sonarr: 'Sonarr',
@@ -98,6 +99,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
seerr: 'Connect Seerr where users submit content requests.',
jellyseerr: 'Connect Seerr where users submit content requests.',
jellyfin: 'Jellyfin connection, public playback links, user sync, and availability checks.',
jellystat: 'Connect Jellystat so users can see their personal viewing stats in Magent.',
artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
@@ -119,6 +121,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
seerr: 'jellyseerr',
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
jellystat: 'jellystat',
artwork: null,
sonarr: 'sonarr',
radarr: 'radarr',
@@ -305,6 +308,14 @@ const STANDARD_SECTION_GROUPS: Record<
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
},
],
jellystat: [
{
key: 'jellystat-connection',
title: 'Connection',
description: 'Use the Jellystat instance connected to the same Jellyfin server as Magent. Create a Jellystat API key in its settings, then save and test the connection.',
keys: ['jellystat_base_url', 'jellystat_api_key'],
},
],
jellyfin: [
{
key: 'jellyfin-connection',
@@ -483,6 +494,8 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_notify_webhook_url: 'Generic webhook URL',
jellyfin_base_url: 'Internal server URL',
jellyfin_api_key: 'Administrator API key',
jellystat_base_url: 'Internal server URL',
jellystat_api_key: 'Jellystat API key',
jellyfin_public_url: 'Public playback URL',
jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr',
sonarr_base_url: 'Sonarr server URL',
@@ -578,6 +591,7 @@ type SectionFeedback = {
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
'seerr-connection': 'seerr',
'jellyfin-connection': 'jellyfin',
'jellystat-connection': 'jellystat',
'sonarr-connection': 'sonarr',
'radarr-connection': 'radarr',
'bazarr-connection': 'bazarr',
@@ -807,6 +821,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyfin: ['Jellyfin'],
jellystat: ['Jellystat'],
sonarr: ['Sonarr'],
radarr: ['Radarr'],
bazarr: ['Bazarr'],
@@ -995,6 +1010,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellystat_base_url: 'Jellystat address reachable by Magent, including any base path. Example: http://jellystat:3000.',
jellystat_api_key: 'API key created in Jellystat. Stored privately by Magent and never sent to users browsers.',
jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
@@ -1076,6 +1093,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellystat_base_url: 'http://jellystat:3000',
jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767',
+1
View File
@@ -5,6 +5,7 @@ const ALLOWED_SECTIONS = new Set([
'seerr',
'jellyseerr',
'jellyfin',
'jellystat',
'artwork',
'sonarr',
'radarr',
+1
View File
@@ -5,6 +5,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
{ title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [
{ href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' },
{ href: '/admin/jellystat', label: 'Jellystat', description: 'Personal viewing statistics', symbol: 'JS', service: 'Jellystat' },
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
+133
View File
@@ -0,0 +1,133 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../lib/auth'
import PageHeading from '../ui/PageHeading'
import './stats.css'
type Breakdown = { name: string; minutes: number }
type Day = { date: string; minutes: number }
type Stats = {
state: 'ready' | 'not_configured' | 'unlinked'
is_admin: boolean
days: number
updated_at?: string
summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number }
daily?: Day[]
top_titles?: { title: string; type: string; minutes: number; plays: number }[]
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string }[]
clients?: Breakdown[]
methods?: Breakdown[]
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] }
}
const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 })
const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
function ViewingChart({ daily }: { daily: Day[] }) {
const [selected, setSelected] = useState<number | null>(null)
const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1
const bars: { start: string; end: string; minutes: number }[] = []
for (let i = 0; i < daily.length; i += bucket) {
const group = daily.slice(i, i + bucket)
bars.push({ start: group[0].date, end: group[group.length - 1].date, minutes: group.reduce((sum, day) => sum + day.minutes, 0) })
}
const peak = Math.max(1, ...bars.map((bar) => bar.minutes))
const active = selected === null ? null : bars[selected]
return (
<section className="stats-panel stats-viewing" aria-labelledby="viewing-title">
<div className="stats-panel-heading"><div><h2 id="viewing-title">Your viewing rhythm</h2><p>{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC</p></div><span className="stats-unit">Minutes</span></div>
<div className="stats-chart-detail" aria-live="polite">{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}</div>
<div className="stats-chart">
<div className="stats-chart-scale" aria-hidden="true"><span>{number(peak)}</span><span>{number(peak / 2)}</span><span>0</span></div>
<div className="stats-chart-bars">
{bars.map((bar, index) => <button type="button" className={selected === index ? 'is-selected' : ''} key={bar.start} aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ''}: ${number(bar.minutes)} minutes`} aria-pressed={selected === index} onClick={() => setSelected(index)} onFocus={() => setSelected(index)}><span style={{ height: `${bar.minutes > 0 ? Math.max(2, bar.minutes / peak * 100) : 1}%` }} /></button>)}
</div>
</div>
<div className="stats-chart-axis" aria-hidden="true"><span>{bars[0] && dateLabel(bars[0].start)}</span><span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span></div>
</section>
)
}
function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
return <section className="stats-panel"><div className="stats-panel-heading"><h2>{title}</h2></div>{rows.length ? <div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{number(row.minutes)} min</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> : <p className="stats-muted">Your next watch will start the story here.</p>}</section>
}
export default function InsightsPage() {
const router = useRouter()
const [days, setDays] = useState(30)
const [data, setData] = useState<Stats | null>(null)
const [busy, setBusy] = useState(true)
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
const load = useCallback(async (signal: AbortSignal) => {
setBusy(true)
setError('')
setData(null)
try {
const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal })
if (response.status === 401) { router.replace('/login?next=%2Finsights'); return }
if (response.status === 403) throw new Error('Your account cannot access viewing stats. Please contact an administrator.')
if (!response.ok) {
const result = await response.json().catch(() => ({}))
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your viewing stats are temporarily unavailable. Please try again shortly.')
}
const result = await response.json() as Stats
if (!signal.aborted) setData(result)
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your stats.')
} finally {
if (!signal.aborted) setBusy(false)
}
}, [days, router])
useEffect(() => {
const controller = new AbortController()
void load(controller.signal)
return () => controller.abort()
}, [load, revision])
const summary = data?.summary
return (
<main className="stats-page">
<PageHeading title="My Stats" description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all." actions={<button className="ghost-button" type="button" disabled={busy} onClick={() => setRevision((value) => value + 1)}>{busy ? 'Loading…' : 'Refresh stats'}</button>} />
<div className="stats-toolbar">
<fieldset className="stats-period"><legend className="stats-sr-only">Stats period</legend>{[7, 30, 90, 365].map((value) => <button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>{value === 365 ? 'Past year' : `${value} days`}</button>)}</fieldset>
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat{data?.updated_at && <span> · Updated {new Date(data.updated_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>}</p>
</div>
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Gathering your stats</h2><p>Fetching your viewing history from Jellystat.</p></div>}
{error && <div className="stats-state" role="alert"><h2>Stats couldnt load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button></div>}
{data?.state === 'not_configured' && <section className="stats-state"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Your viewing story starts here</h2><p>{data.is_admin ? 'Connect your Jellystat instance to bring personal viewing stats into Magent.' : 'Viewing stats will appear here once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your administrator to sync Jellyfin users.</p></section>}
{summary && <>
<section className="stats-metrics" aria-label="Viewing totals">
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{number(summary.minutes / 60)} hours across {number(summary.plays)} plays</small></article>
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small></article>
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small></article>
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests</small></article>
</section>
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history in this period yet. Try a longer period, or come back after your next watch.</div>}
<div className="stats-main-grid">
<ViewingChart key={`${days}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel stats-highlights"><div className="stats-panel-heading"><h2>A little watch history</h2></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.current_streak}<small> days</small></span><div><strong>Current streak</strong><p>Consecutive viewing days through today or yesterday.</p></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Your best streak in this period.</p></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Time for a story</strong><p>Days with at least a minute watched.</p></div></div>
</section>
</div>
<div className="stats-three-grid">
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><span className="stats-rank">{String(index + 1).padStart(2, '0')}</span><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your favourites will find their place here.</p>}</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<BreakdownCard title="How you streamed" rows={data.methods ?? []} />
</div>
</>}
{data && <div className="stats-main-grid">
{summary && <section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>Recently watched</h2><span className="stats-unit">Latest 20 plays</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><div className={`stats-media-icon stats-media-icon-${play.type}`} aria-hidden="true">{play.type === 'episode' ? 'TV' : play.type === 'movie' ? 'MV' : '▶'}</div><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.client}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded by Jellystat will appear here.</p>}</section>}
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in the past {days} days</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length > 0 ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true"></span></a></li>)}</ul> : <p className="stats-muted">Something on your watchlist? <a href="/new-requests">Make a request.</a></p>}</section>
</div>}
{summary && <p className="stats-footnote">Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays, including unfinished watches. Movies are identified from Jellystats movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.</p>}
</main>
)
}
+95
View File
@@ -0,0 +1,95 @@
.stats-page { padding-bottom: 32px !important; }
.stats-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.stats-period { display: flex; padding: 4px; margin: 0; min-width: 0; gap: 4px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); }
.stats-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.stats-period button { min-height: 38px; padding: 8px 16px; border: 0; border-radius: 6px; background: transparent !important; color: var(--ops-muted) !important; font-size: 13px; text-transform: none; }
.stats-period button[aria-pressed=true] { background: #c7bdff !important; color: #211a36 !important; font-weight: 700; }
.stats-source { margin: 0; font-size: 12px; color: var(--ops-muted); }
.stats-source-dot { display: inline-block; height: 6px; width: 6px; margin-right: 8px; border-radius: 50%; background: var(--ops-faint); }
.stats-source-dot.is-ready { background: #95d5b2; }
.stats-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.stats-metric { display: grid; align-content: start; gap: 12px; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.stats-metric > span { font-size: 13px; color: var(--ops-muted); }
.stats-metric > strong { font: 600 clamp(28px, 3vw, 42px)/1.15 "DM Sans", sans-serif; color: var(--ops-text); letter-spacing: -.03em; }
.stats-metric > small { color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
.stats-metric-accent { border-color: #655987; background: linear-gradient(135deg, #2f2940, var(--ops-panel)); }
.stats-metric-accent > strong { color: #d5cbff; }
.stats-main-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 24px; }
.stats-three-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.stats-panel { min-width: 0; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.stats-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 24px; }
.stats-panel h2 { margin: 0; color: var(--ops-text); font-size: 17px; font-weight: 600; }
.stats-panel-heading p { margin: 8px 0 0; color: var(--ops-faint); font-size: 12px; }
.stats-panel-heading a { font-size: 12px; white-space: nowrap; color: #c7bdff; }
.stats-unit { color: var(--ops-faint); font-size: 11px; white-space: nowrap; }
.stats-chart-detail { min-height: 28px; color: var(--ops-muted); font-size: 12px; }
.stats-chart { height: 180px; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; margin-top: 12px; }
.stats-chart-scale { display: flex; flex-direction: column; justify-content: space-between; text-align: right; font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
.stats-chart-bars { display: flex; align-items: stretch; gap: clamp(2px, .5vw, 8px); background: repeating-linear-gradient(to top, var(--ops-line-soft) 0px, var(--ops-line-soft) 1px, transparent 1px, transparent 50%); }
.stats-chart-bars button { display: flex; align-items: flex-end; justify-content: center; padding: 0; min-width: 0; flex: 1; border: 0; background: transparent !important; border-radius: 3px; }
.stats-chart-bars button > span { display: block; width: 100%; max-width: 44px; background: #9085b8; border-radius: 3px 3px 0 0; }
.stats-chart-bars button:is(:hover, :focus-visible, .is-selected) > span { background: #d1c6ff; }
.stats-chart-axis { display: flex; justify-content: space-between; padding-left: 50px; margin-top: 12px; color: var(--ops-faint); font-size: 11px; }
.stats-highlight { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: 16px; align-items: center; padding: 19px 0; border-top: 1px solid var(--ops-line-soft); }
.stats-highlight:first-of-type { border-top: 0; }
.stats-highlight-number { font-size: 28px; color: #d1c6ff; font-weight: 600; }
.stats-highlight-number small { font-size: 11px; color: var(--ops-faint); font-weight: 400; }
.stats-highlight strong { font-size: 13px; color: var(--ops-text); }
.stats-highlight p { margin: 6px 0 0; color: var(--ops-faint); font-size: 12px; line-height: 1.5; }
.stats-top-titles { list-style: none; margin: 0; padding: 0; display: grid; gap: 20px; }
.stats-top-titles li { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.stats-rank { font: 11px "JetBrains Mono", monospace; color: var(--ops-faint); }
.stats-top-titles strong { display: block; font-size: 13px; font-weight: 500; color: var(--ops-text); overflow-wrap: anywhere; }
.stats-top-titles small { display: block; margin-top: 5px; font-size: 11px; color: var(--ops-faint); }
.stats-top-titles li > span:last-child { text-align: right; font-size: 13px; color: var(--ops-muted); }
.stats-breakdown { display: grid; gap: 24px; }
.stats-breakdown-label { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 12px; }
.stats-breakdown-label span { color: var(--ops-muted); overflow-wrap: anywhere; }
.stats-breakdown-label strong { white-space: nowrap; font-size: 11px; color: var(--ops-faint); font-weight: 400; }
.stats-meter { height: 5px; background: var(--ops-line-soft); border-radius: 5px; overflow: hidden; }
.stats-meter > span { display: block; height: 100%; background: #a497c9; border-radius: 5px; }
.stats-history-list { display: grid; }
.stats-history-list article { display: flex; align-items: center; gap: 14px; padding: 15px 0; border-top: 1px solid var(--ops-line-soft); }
.stats-history-list article:first-child { padding-top: 0; border-top: 0; }
.stats-media-icon { display: grid; place-items: center; flex: 0 0 40px; height: 48px; border-radius: 6px; background: #373043; color: #cfc1eb; font: 10px "JetBrains Mono", monospace; }
.stats-media-icon-movie { background: #3c322c; color: #e5bfa8; }
.stats-history-title { flex: 1; min-width: 0; }
.stats-history-title strong { display: block; color: var(--ops-text); font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
.stats-history-title small, .stats-history-title > span { display: block; color: var(--ops-faint); font-size: 11px; line-height: 1.6; margin-top: 3px; overflow-wrap: anywhere; }
.stats-history-title > span { font-size: 10px; }
.stats-history-time { display: grid; gap: 8px; text-align: right; flex-shrink: 0; }
.stats-history-time strong { font-size: 12px; color: var(--ops-muted); font-weight: 500; }
.stats-history-time time { font-size: 11px; color: var(--ops-faint); }
.stats-requests { align-self: start; }
.stats-request-total { display: flex; align-items: center; gap: 16px; }
.stats-request-total > strong { font-size: 36px; color: var(--ops-text); }
.stats-request-total > span { max-width: 15ch; color: var(--ops-muted); font-size: 12px; line-height: 1.6; }
.stats-request-counts { display: flex; justify-content: space-between; gap: 8px; padding: 20px 0; margin-top: 16px; border-block: 1px solid var(--ops-line-soft); }
.stats-request-counts > span { font-size: 11px; color: var(--ops-faint); }
.stats-request-counts strong { display: block; margin-bottom: 8px; color: var(--ops-text); font-size: 18px; font-weight: 500; }
.stats-request-list { list-style: none; margin: 10px 0 0; padding: 0; }
.stats-request-list a { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; color: var(--ops-muted); font-size: 12px; text-decoration: none; overflow-wrap: anywhere; }
.stats-request-list a:hover { color: #d1c6ff; }
.stats-request-list a > span { color: var(--ops-faint); }
.stats-state { display: grid; justify-items: center; gap: 14px; padding: 56px 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); text-align: center; }
.stats-state h2 { margin: 0; font-size: 22px; color: var(--ops-text); }
.stats-state p { margin: 0; max-width: 60ch; font-size: 14px; color: var(--ops-muted); line-height: 1.8; }
.stats-state-symbol { margin-bottom: 8px; color: #c7bdff; font-size: 36px; }
.stats-action { display: inline-block; margin-top: 8px; padding: 12px 20px; background: #c7bdff; color: #211a36; border-radius: 8px; font-size: 13px; font-weight: 600; text-decoration: none; }
.stats-notice { padding: 16px 20px; border: 1px solid var(--ops-line); border-radius: 8px; color: var(--ops-muted); font-size: 13px; line-height: 1.6; }
.stats-muted, .stats-footnote { color: var(--ops-faint); font-size: 12px; line-height: 1.8; }
.stats-footnote { margin: 0; }
@media (max-width: 1100px) {
.stats-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stats-main-grid { grid-template-columns: minmax(0, 1.5fr) minmax(260px, 1fr); }
.stats-three-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stats-three-grid > :first-child { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
.stats-main-grid, .stats-three-grid { grid-template-columns: minmax(0, 1fr); gap: 20px; }
.stats-metric { padding: 18px; gap: 10px; }
.stats-panel { padding: 20px; }
.stats-period { width: 100%; }
.stats-period button { flex: 1; padding-inline: 8px; }
.stats-metrics { gap: 12px; }
}
+1 -1
View File
@@ -71,7 +71,7 @@ export default function LoginPage() {
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
setToken('cookie')
const next = new URLSearchParams(window.location.search).get('next') || ''
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome')
window.location.assign(next === '/insights' || /^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome')
} catch {
setError('Could not reach Magent. Check your connection and try again.')
} finally { setLoading(false) }
+5
View File
@@ -75,6 +75,11 @@ export default function HeaderActions() {
]
const commonItems = [
{
href: '/insights',
label: 'My Stats',
match: (path: string) => path === '/insights',
},
{
href: '/',
label: 'My Requests',
+4 -2
View File
@@ -8,12 +8,13 @@ type NavigationItem = {
href: string
label: string
shortLabel: string
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings'
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' | 'stats'
adminOnly?: boolean
match: (path: string) => boolean
}
const NAVIGATION: NavigationItem[] = [
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' },
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
@@ -25,6 +26,7 @@ const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password
function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
const paths: Record<NavigationItem['icon'], React.ReactNode> = {
stats: <><path d="M4 20h16M6 16v-5m6 5V4m6 12V8" /></>,
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
media: <><rect x="3" y="5" width="18" height="15" rx="2" /><path d="m8 3 2 4m4-4 2 4M3 10h18" /><path d="m10 13 5 3-5 3z" /></>,
issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>,
@@ -67,7 +69,7 @@ export default function WorkspaceNavigation() {
return (
<>
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
{items.slice(0, 5).map((item) => (
{items.map((item) => (
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
<NavigationIcon name={item.icon} /><span>{item.shortLabel}</span>
</a>
+2
View File
@@ -158,6 +158,8 @@
.page-heading .home-search { width: 100%; }
}
@media (max-width: 680px) {
.workspace-mobile-nav { padding-inline: 4px; gap: 0; }
.workspace-mobile-nav a { min-width: 0; flex: 1; padding-inline: 3px; }
:root { --workspace-gutter: 16px; --workspace-gap: 20px; }
.page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; }
.page-heading { padding-bottom: 20px; }