Redesign beta Magent UI
Magent CI/CD / verify (push) Successful in 11m8s
Magent CI/CD / deploy-prod (push) Has been skipped
Magent CI/CD / deploy-beta (push) Successful in 18s

This commit is contained in:
2026-06-21 11:41:38 +12:00
parent e36da13264
commit e6b4f99ea7
12 changed files with 1891 additions and 40 deletions
+74 -2
View File
@@ -19,6 +19,12 @@ type ServiceOptions = {
qualityProfiles: { id: number; name: string; label: string }[] qualityProfiles: { id: number; name: string; label: string }[]
} }
type ServiceStatus = {
name: string
status: string
message?: string
}
const SECTION_LABELS: Record<string, string> = { const SECTION_LABELS: Record<string, string> = {
magent: 'Magent', magent: 'Magent',
general: 'General', general: 'General',
@@ -414,6 +420,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null) const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false) const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [liveStreamConnected, setLiveStreamConnected] = useState(false) const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([])
const [serviceStatusCheckedAt, setServiceStatusCheckedAt] = useState<string | null>(null)
const requestsSyncRef = useRef<any | null>(null) const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null) const artworkPrefetchRef = useRef<any | null>(null)
const computeProgressPercent = ( const computeProgressPercent = (
@@ -543,6 +551,21 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
}, []) }, [])
const loadServiceStatuses = useCallback(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services`)
if (!response.ok) {
return
}
const data = await response.json()
setServiceStatuses(Array.isArray(data?.services) ? data.services : [])
setServiceStatusCheckedAt(new Date().toISOString())
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
if (!getToken()) { if (!getToken()) {
@@ -550,7 +573,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return return
} }
try { try {
await loadSettings() await Promise.all([loadSettings(), loadServiceStatuses()])
if (section === 'cache' || section === 'artwork') { if (section === 'cache' || section === 'artwork') {
await loadArtworkPrefetchStatus() await loadArtworkPrefetchStatus()
await loadArtworkSummary() await loadArtworkSummary()
@@ -570,7 +593,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'radarr') { if (section === 'radarr') {
void loadOptions('radarr') void loadOptions('radarr')
} }
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section]) }, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section])
const groupedSettings = useMemo(() => { const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {} const groups: Record<string, AdminSetting[]> = {}
@@ -583,6 +606,22 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [settings]) }, [settings])
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const statusNamesBySection: Record<string, string[]> = {
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyfin: ['Jellyfin'],
sonarr: ['Sonarr'],
radarr: ['Radarr'],
prowlarr: ['Prowlarr'],
qbittorrent: ['qBittorrent', 'Qbittorrent'],
}
const statusNames = statusNamesBySection[section] ?? statusNamesBySection[settingsSection ?? ''] ?? []
const currentServiceStatus = serviceStatuses.find((service) =>
statusNames.some((name) => name.toLowerCase() === service.name.toLowerCase())
)
const currentServiceConfigured = currentServiceStatus
? currentServiceStatus.status !== 'not_configured'
: null
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications' const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
const isSiteGroupedSection = section === 'site' const isSiteGroupedSection = section === 'site'
const visibleSections = settingsSection ? [settingsSection] : [] const visibleSections = settingsSection ? [settingsSection] : []
@@ -1681,6 +1720,39 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
> >
{status && <div className="error-banner">{status}</div>} {status && <div className="error-banner">{status}</div>}
{currentServiceStatus ? (
<section className="admin-section admin-zone service-status-panel">
<div className="service-status-summary">
<span className={`system-dot system-dot-${currentServiceStatus.status}`} aria-hidden="true" />
<div>
<span className="section-kicker">Connection status</span>
<h2>{currentServiceStatus.name}</h2>
<p className="section-subtitle">
{currentServiceStatus.message ?? 'No service message was returned.'}
</p>
</div>
</div>
<div className="service-status-grid">
<div>
<span>Status</span>
<strong>{currentServiceStatus.status.replaceAll('_', ' ')}</strong>
</div>
<div>
<span>Configuration</span>
<strong>{currentServiceConfigured ? 'Configured' : 'Not configured'}</strong>
</div>
<div>
<span>Last checked</span>
<strong>
{serviceStatusCheckedAt ? new Date(serviceStatusCheckedAt).toLocaleString() : 'Not checked yet'}
</strong>
</div>
<button type="button" className="ghost-button" onClick={() => void loadServiceStatuses()}>
Refresh status
</button>
</div>
</section>
) : null}
{settingsSections.length > 0 ? ( {settingsSections.length > 0 ? (
<div className="admin-form admin-zone-stack"> <div className="admin-form admin-zone-stack">
{settingsSections {settingsSections
+5
View File
@@ -0,0 +1,5 @@
import PortalClient from '../../portal/PortalClient'
export default function AdminIssuesPage() {
return <PortalClient workspace="issue" />
}
+240 -6
View File
@@ -1,24 +1,258 @@
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
type ServiceState = {
name: string
status: string
message?: string
}
type RecentRequest = {
id: number
title?: string | null
year?: number | null
statusLabel?: string | null
requestedBy?: string | null
createdAt?: string | null
}
type PortalOverview = {
overview?: {
total_items?: number
total_comments?: number
by_kind?: Record<string, number>
by_status?: Record<string, number>
}
my_items?: number
}
const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const normalizeRecent = (items: any[]): RecentRequest[] =>
items
.filter((item) => item?.id)
.map((item) => ({
id: Number(item.id),
title: item.title ?? null,
year: item.year ?? null,
statusLabel: item.statusLabel ?? null,
requestedBy: item.requestedBy ?? null,
createdAt: item.createdAt ?? null,
}))
export default function AdminLandingPage() { export default function AdminLandingPage() {
const router = useRouter() const router = useRouter()
const [services, setServices] = useState<ServiceState[]>([])
const [serviceOverall, setServiceOverall] = useState('unknown')
const [recent, setRecent] = useState<RecentRequest[]>([])
const [portalOverview, setPortalOverview] = useState<PortalOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
setLoading(true)
setError(null)
try {
const baseUrl = getApiBase()
const [meResponse, serviceResponse, recentResponse, overviewResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/me`),
authFetch(`${baseUrl}/status/services`),
authFetch(`${baseUrl}/requests/recent?take=8&days=0`),
authFetch(`${baseUrl}/portal/overview`),
])
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
if (meResponse.status === 403) {
router.push('/')
return
}
const me = await meResponse.json()
if (me?.role !== 'admin') {
router.push('/')
return
}
if (serviceResponse.ok) {
const data = await serviceResponse.json()
setServiceOverall(data?.overall ?? 'unknown')
setServices(Array.isArray(data?.services) ? data.services : [])
}
if (recentResponse.ok) {
const data = await recentResponse.json()
setRecent(Array.isArray(data?.results) ? normalizeRecent(data.results) : [])
}
if (overviewResponse.ok) {
const data = await overviewResponse.json()
setPortalOverview(data)
}
} catch (err) {
console.error(err)
setError('Unable to load the operations dashboard.')
} finally {
setLoading(false)
}
}
void load()
}, [router])
const serviceCounts = useMemo(() => {
const up = services.filter((service) => service.status === 'up').length
const down = services.filter((service) => service.status === 'down').length
const degraded = services.filter((service) => service.status === 'degraded').length
const notConfigured = services.filter((service) => service.status === 'not_configured').length
return { up, down, degraded, notConfigured, total: services.length }
}, [services])
const issueCount = Number(portalOverview?.overview?.by_kind?.issue ?? 0)
const requestItemCount = Number(portalOverview?.overview?.by_kind?.request ?? 0)
const commentCount = Number(portalOverview?.overview?.total_comments ?? 0)
const rail = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Service ecosystem</span>
<div className="service-ecosystem">
{services.length === 0 ? (
<div className="status-banner">Service status is not available yet.</div>
) : (
services.map((service) => (
<a
key={service.name}
className="service-row"
href={`/admin/${service.name.toLowerCase().replace(/[^a-z0-9]/g, '')}`}
>
<span className={`system-dot system-dot-${service.status}`} />
<span>
<strong>{service.name}</strong>
<small>{service.message ?? 'No message reported'}</small>
</span>
<span className={`small-pill system-pill-${service.status}`}>{service.status}</span>
</a>
))
)}
</div>
</div>
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Quick actions</span>
<div className="quick-action-grid">
<a href="/admin/requests-all">Review requests</a>
<a href="/admin/issues">Manage issues</a>
<a href="/users">User directory</a>
<a href="/admin/logs">Activity log</a>
</div>
</div>
</div>
)
return ( return (
<AdminShell <AdminShell
title="Settings" title="Operations Center"
subtitle="Choose what you want to manage." subtitle="Live Magent controls, request movement, issue intake, and service health."
rail={rail}
actions={ actions={
<button type="button" onClick={() => router.push('/')}> <button type="button" onClick={() => router.push('/')}>
Back to requests View health
</button> </button>
} }
> >
<section className="admin-section"> {loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
<div className="status-banner"> {error ? <div className="error-banner">{error}</div> : null}
Pick a section from the left. Each page explains what it does and how it helps.
<section className="ops-metric-grid">
<div className="ops-metric-card">
<span className="section-kicker">Services online</span>
<strong>
{serviceCounts.up}/{serviceCounts.total || 0}
</strong>
<p>{serviceOverall === 'up' ? 'All configured services are responding.' : 'Some services need review.'}</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Recent requests</span>
<strong>{recent.length}</strong>
<p>Loaded from the live request cache.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Open issue items</span>
<strong>{issueCount}</strong>
<p>{commentCount} portal comments recorded.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Portal requests</span>
<strong>{requestItemCount}</strong>
<p>Tracked in the dedicated request workflow.</p>
</div>
</section>
<section className="admin-zone">
<div className="section-header">
<div>
<h2>Recent activity</h2>
<p className="section-subtitle">Live request cache entries, newest first.</p>
</div>
</div>
{recent.length === 0 ? (
<div className="status-banner">No recent requests were returned.</div>
) : (
<div className="admin-table dashboard-activity-table">
<div className="admin-table-head">
<span>Request</span>
<span>Status</span>
<span>User</span>
<span>Created</span>
</div>
{recent.map((row) => (
<button
key={row.id}
type="button"
className="admin-table-row"
onClick={() => router.push(`/requests/${row.id}`)}
>
<span>
{row.title || `Request #${row.id}`}
{row.year ? ` (${row.year})` : ''}
</span>
<span>{row.statusLabel || 'Unknown'}</span>
<span>{row.requestedBy || 'Unknown'}</span>
<span>{formatDateTime(row.createdAt)}</span>
</button>
))}
</div>
)}
</section>
<section className="admin-zone">
<div className="section-header">
<div>
<h2>Attention states</h2>
<p className="section-subtitle">Service states that affect request processing.</p>
</div>
</div>
<div className="ops-status-strip">
<span>{serviceCounts.down} down</span>
<span>{serviceCounts.degraded} degraded</span>
<span>{serviceCounts.notConfigured} not configured</span>
</div> </div>
</section> </section>
</AdminShell> </AdminShell>
+3 -3
View File
@@ -1,8 +1,8 @@
import './globals.css' import './globals.css'
import './ops-redesign.css'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import HeaderActions from './ui/HeaderActions' import HeaderActions from './ui/HeaderActions'
import HeaderIdentity from './ui/HeaderIdentity' import HeaderIdentity from './ui/HeaderIdentity'
import ThemeToggle from './ui/ThemeToggle'
import BrandingFavicon from './ui/BrandingFavicon' import BrandingFavicon from './ui/BrandingFavicon'
import BrandingLogo from './ui/BrandingLogo' import BrandingLogo from './ui/BrandingLogo'
import SiteStatus from './ui/SiteStatus' import SiteStatus from './ui/SiteStatus'
@@ -24,12 +24,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<BrandingLogo className="brand-logo brand-logo--header" /> <BrandingLogo className="brand-logo brand-logo--header" />
<div className="brand-stack"> <div className="brand-stack">
<div className="brand">Magent</div> <div className="brand">Magent</div>
<div className="tagline">Find and fix media requests fast.</div> <div className="tagline">GrizzlyFlix media operations</div>
</div> </div>
</a> </a>
</div> </div>
<div className="header-right"> <div className="header-right">
<ThemeToggle /> <span className="beta-chip" aria-label="Beta environment">Beta</span>
<HeaderIdentity /> <HeaderIdentity />
</div> </div>
<div className="header-nav"> <div className="header-nav">
+20 -7
View File
@@ -108,10 +108,17 @@ export default function LoginPage() {
})() })()
return ( return (
<main className="card auth-card"> <main className="auth-screen">
<BrandingLogo className="brand-logo brand-logo--login" /> <section className="auth-hero">
<h1>Sign in</h1> <div className="auth-mark">
<p className="lede">{loginHelpText}</p> <BrandingLogo className="brand-logo brand-logo--login" />
</div>
<div className="auth-title-block">
<span className="section-kicker">Secure access</span>
<h1>Magent operational gateway</h1>
<p>{loginHelpText}</p>
</div>
</section>
<form <form
onSubmit={(event) => { onSubmit={(event) => {
if (!primaryMode) { if (!primaryMode) {
@@ -121,23 +128,25 @@ export default function LoginPage() {
} }
void submit(event, primaryMode) void submit(event, primaryMode)
}} }}
className="auth-form" className="auth-form auth-panel"
> >
<label> <label>
Username <span>Username</span>
<input <input
value={username} value={username}
onChange={(event) => setUsername(event.target.value)} onChange={(event) => setUsername(event.target.value)}
autoComplete="username" autoComplete="username"
placeholder="Enter your username"
/> />
</label> </label>
<label> <label>
Password <span>Password</span>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password" autoComplete="current-password"
placeholder="Enter your password"
/> />
</label> </label>
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
@@ -171,6 +180,10 @@ export default function LoginPage() {
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? ( {!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
<div className="error-banner">Login is currently disabled. Contact an administrator.</div> <div className="error-banner">Login is currently disabled. Contact an administrator.</div>
) : null} ) : null}
<div className="auth-footnote">
<span className="live-dot" aria-hidden="true" />
Beta environment
</div>
</form> </form>
</main> </main>
) )
File diff suppressed because it is too large Load Diff
+34
View File
@@ -362,8 +362,42 @@ export default function HomePage() {
return date.toLocaleString() return date.toLocaleString()
} }
const serviceItems = servicesStatus?.services ?? []
const serviceUpCount = serviceItems.filter((service) => service.status === 'up').length
const serviceAttentionCount = serviceItems.filter((service) =>
['down', 'degraded', 'not_configured'].includes(service.status)
).length
const activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
}).length
return ( return (
<main className="card"> <main className="card">
<section className="ops-metric-grid">
<div className="ops-metric-card">
<span className="section-kicker">Service mesh</span>
<strong>
{serviceUpCount}/{serviceItems.length || 0}
</strong>
<p>{servicesLoading ? 'Checking services now.' : 'Configured services online.'}</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Attention</span>
<strong>{serviceAttentionCount}</strong>
<p>Services reporting down, degraded, or not configured.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Loaded requests</span>
<strong>{recent.length}</strong>
<p>Returned by the live request cache.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Active queue</span>
<strong>{activeRecentCount}</strong>
<p>Loaded requests still moving through the pipeline.</p>
</div>
</section>
<div className="layout-grid"> <div className="layout-grid">
<section className="recent centerpiece"> <section className="recent centerpiece">
<div className="system-status"> <div className="system-status">
+36 -4
View File
@@ -374,7 +374,7 @@ export default function RequestTimelinePage() {
if (loading) { if (loading) {
return ( return (
<main className="card"> <main className="card request-detail-page">
<div className="loading-center" role="status" aria-live="polite"> <div className="loading-center" role="status" aria-live="polite">
<div className="spinner" aria-hidden="true" /> <div className="spinner" aria-hidden="true" />
<div className="loading-text">Loading request timeline...</div> <div className="loading-text">Loading request timeline...</div>
@@ -384,11 +384,43 @@ export default function RequestTimelinePage() {
} }
if (loadError) { if (loadError) {
return <main className="card">{loadError}</main> return (
<main className="card request-detail-page">
<section className="request-error-state">
<span className="section-kicker">Request unavailable</span>
<h1>Unable to load this request</h1>
<p>{loadError}</p>
<div className="request-error-actions">
<button type="button" onClick={() => router.refresh()}>
Retry
</button>
<button type="button" className="ghost-button" onClick={() => router.push('/')}>
Back to health
</button>
</div>
</section>
</main>
)
} }
if (!snapshot) { if (!snapshot) {
return <main className="card">Unable to load this request.</main> return (
<main className="card request-detail-page">
<section className="request-error-state">
<span className="section-kicker">Request unavailable</span>
<h1>Unable to load this request</h1>
<p>The request API did not return a valid timeline payload.</p>
<div className="request-error-actions">
<button type="button" onClick={() => router.refresh()}>
Retry
</button>
<button type="button" className="ghost-button" onClick={() => router.push('/')}>
Back to health
</button>
</div>
</section>
</main>
)
} }
const summary = const summary =
@@ -437,7 +469,7 @@ export default function RequestTimelinePage() {
: friendlyState(snapshot.state) : friendlyState(snapshot.state)
return ( return (
<main className="card"> <main className="card request-detail-page">
<div className="request-header"> <div className="request-header">
<div className="request-header-main"> <div className="request-header-main">
{resolvedPoster && ( {resolvedPoster && (
+1
View File
@@ -22,6 +22,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
<main className="card admin-card"> <main className="card admin-card">
<div className="admin-header"> <div className="admin-header">
<div> <div>
<span className="section-kicker">Beta stream</span>
<h1>{title}</h1> <h1>{title}</h1>
{subtitle && <p className="lede">{subtitle}</p>} {subtitle && <p className="lede">{subtitle}</p>}
</div> </div>
+10
View File
@@ -3,6 +3,15 @@
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
const NAV_GROUPS = [ const NAV_GROUPS = [
{
title: 'Operations',
items: [
{ href: '/admin', label: 'Overview' },
{ href: '/', label: 'Health' },
{ href: '/portal/requests', label: 'Request portal' },
{ href: '/admin/issues', label: 'Issue tracking' },
],
},
{ {
title: 'Services', title: 'Services',
items: [ items: [
@@ -21,6 +30,7 @@ const NAV_GROUPS = [
{ href: '/admin/requests', label: 'Request sync' }, { href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/requests-all', label: 'All requests' }, { href: '/admin/requests-all', label: 'All requests' },
{ href: '/admin/cache', label: 'Cache Control' }, { href: '/admin/cache', label: 'Cache Control' },
{ href: '/admin/artwork', label: 'Artwork cache' },
], ],
}, },
{ {
+35 -5
View File
@@ -1,14 +1,44 @@
'use client'
import { useState } from 'react'
type BrandingLogoProps = { type BrandingLogoProps = {
className?: string className?: string
alt?: string alt?: string
} }
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) { export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
const [loaded, setLoaded] = useState(false)
const [failed, setFailed] = useState(false)
return ( return (
<img <span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
className={className} {!failed ? (
src="/api/branding/logo.png" <img
alt={alt} className={loaded ? 'is-loaded' : undefined}
/> src="/api/branding/logo.png"
alt=""
aria-hidden="true"
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
) : null}
{!loaded ? (
<svg aria-hidden="true" viewBox="0 0 64 64" focusable="false">
<defs>
<linearGradient id="magentLogoGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#7ed7ff" />
<stop offset="100%" stopColor="#c6c1ff" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="12" fill="#0b1328" />
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
<path
d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z"
fill="url(#magentLogoGlow)"
/>
</svg>
) : null}
</span>
) )
} }
+45 -13
View File
@@ -1,10 +1,13 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderActions() { export default function HeaderActions() {
const [signedIn, setSignedIn] = useState(false) const [signedIn, setSignedIn] = useState(false)
const [role, setRole] = useState<string | null>(null)
const pathname = usePathname()
useEffect(() => { useEffect(() => {
const token = getToken() const token = getToken()
@@ -19,9 +22,11 @@ export default function HeaderActions() {
if (!response.ok) { if (!response.ok) {
clearToken() clearToken()
setSignedIn(false) setSignedIn(false)
setRole(null)
return return
} }
await response.json() const data = await response.json()
setRole(data?.role ?? null)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
@@ -33,18 +38,45 @@ export default function HeaderActions() {
return null return null
} }
const items = [
{ href: '/', label: 'Health', icon: '01', match: (path: string) => path === '/' },
{
href: '/portal/requests',
label: 'Requests',
icon: '02',
match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'),
},
{
href: '/portal/issues',
label: 'Issues',
icon: '03',
match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
},
{
href: role === 'admin' ? '/users' : '/profile',
label: role === 'admin' ? 'Users' : 'Profile',
icon: '04',
match: (path: string) => path.startsWith('/users') || path.startsWith('/profile'),
},
{
href: role === 'admin' ? '/admin' : '/profile/invites',
label: role === 'admin' ? 'Config' : 'Invites',
icon: '05',
match: (path: string) => path.startsWith('/admin') || path.startsWith('/profile/invites'),
},
]
return ( return (
<div className="header-actions"> <nav className="header-actions" aria-label="Primary">
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a> {items.map((item) => {
<div className="header-actions-center"> const active = item.match(pathname)
<a href="/how-it-works">How it works</a> return (
</div> <a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
<div className="header-actions-right"> <span aria-hidden="true">{item.icon}</span>
<a href="/">Requests</a> {item.label}
<a href="/profile/invites">Invites</a> </a>
<a href="/portal/requests">Portal</a> )
<a href="/portal/issues">Issues</a> })}
</div> </nav>
</div>
) )
} }