Redesign beta Magent UI
This commit is contained in:
@@ -19,6 +19,12 @@ type ServiceOptions = {
|
||||
qualityProfiles: { id: number; name: string; label: string }[]
|
||||
}
|
||||
|
||||
type ServiceStatus = {
|
||||
name: string
|
||||
status: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
magent: 'Magent',
|
||||
general: 'General',
|
||||
@@ -414,6 +420,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
||||
const [maintenanceBusy, setMaintenanceBusy] = 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 artworkPrefetchRef = useRef<any | null>(null)
|
||||
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(() => {
|
||||
const load = async () => {
|
||||
if (!getToken()) {
|
||||
@@ -550,7 +573,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await loadSettings()
|
||||
await Promise.all([loadSettings(), loadServiceStatuses()])
|
||||
if (section === 'cache' || section === 'artwork') {
|
||||
await loadArtworkPrefetchStatus()
|
||||
await loadArtworkSummary()
|
||||
@@ -570,7 +593,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
if (section === 'radarr') {
|
||||
void loadOptions('radarr')
|
||||
}
|
||||
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
|
||||
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section])
|
||||
|
||||
const groupedSettings = useMemo(() => {
|
||||
const groups: Record<string, AdminSetting[]> = {}
|
||||
@@ -583,6 +606,22 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}, [settings])
|
||||
|
||||
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 isSiteGroupedSection = section === 'site'
|
||||
const visibleSections = settingsSection ? [settingsSection] : []
|
||||
@@ -1681,6 +1720,39 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}
|
||||
>
|
||||
{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 ? (
|
||||
<div className="admin-form admin-zone-stack">
|
||||
{settingsSections
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import PortalClient from '../../portal/PortalClient'
|
||||
|
||||
export default function AdminIssuesPage() {
|
||||
return <PortalClient workspace="issue" />
|
||||
}
|
||||
+240
-6
@@ -1,24 +1,258 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
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() {
|
||||
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 (
|
||||
<AdminShell
|
||||
title="Settings"
|
||||
subtitle="Choose what you want to manage."
|
||||
title="Operations Center"
|
||||
subtitle="Live Magent controls, request movement, issue intake, and service health."
|
||||
rail={rail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/')}>
|
||||
Back to requests
|
||||
View health
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<section className="admin-section">
|
||||
<div className="status-banner">
|
||||
Pick a section from the left. Each page explains what it does and how it helps.
|
||||
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
|
||||
{error ? <div className="error-banner">{error}</div> : null}
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</AdminShell>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import './globals.css'
|
||||
import './ops-redesign.css'
|
||||
import type { ReactNode } from 'react'
|
||||
import HeaderActions from './ui/HeaderActions'
|
||||
import HeaderIdentity from './ui/HeaderIdentity'
|
||||
import ThemeToggle from './ui/ThemeToggle'
|
||||
import BrandingFavicon from './ui/BrandingFavicon'
|
||||
import BrandingLogo from './ui/BrandingLogo'
|
||||
import SiteStatus from './ui/SiteStatus'
|
||||
@@ -24,12 +24,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<BrandingLogo className="brand-logo brand-logo--header" />
|
||||
<div className="brand-stack">
|
||||
<div className="brand">Magent</div>
|
||||
<div className="tagline">Find and fix media requests fast.</div>
|
||||
<div className="tagline">GrizzlyFlix media operations</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<ThemeToggle />
|
||||
<span className="beta-chip" aria-label="Beta environment">Beta</span>
|
||||
<HeaderIdentity />
|
||||
</div>
|
||||
<div className="header-nav">
|
||||
|
||||
@@ -108,10 +108,17 @@ export default function LoginPage() {
|
||||
})()
|
||||
|
||||
return (
|
||||
<main className="card auth-card">
|
||||
<main className="auth-screen">
|
||||
<section className="auth-hero">
|
||||
<div className="auth-mark">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<h1>Sign in</h1>
|
||||
<p className="lede">{loginHelpText}</p>
|
||||
</div>
|
||||
<div className="auth-title-block">
|
||||
<span className="section-kicker">Secure access</span>
|
||||
<h1>Magent operational gateway</h1>
|
||||
<p>{loginHelpText}</p>
|
||||
</div>
|
||||
</section>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
if (!primaryMode) {
|
||||
@@ -121,23 +128,25 @@ export default function LoginPage() {
|
||||
}
|
||||
void submit(event, primaryMode)
|
||||
}}
|
||||
className="auth-form"
|
||||
className="auth-form auth-panel"
|
||||
>
|
||||
<label>
|
||||
Username
|
||||
<span>Username</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
@@ -171,6 +180,10 @@ export default function LoginPage() {
|
||||
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
||||
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
||||
) : null}
|
||||
<div className="auth-footnote">
|
||||
<span className="live-dot" aria-hidden="true" />
|
||||
Beta environment
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -362,8 +362,42 @@ export default function HomePage() {
|
||||
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 (
|
||||
<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">
|
||||
<section className="recent centerpiece">
|
||||
<div className="system-status">
|
||||
|
||||
@@ -374,7 +374,7 @@ export default function RequestTimelinePage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="card">
|
||||
<main className="card request-detail-page">
|
||||
<div className="loading-center" role="status" aria-live="polite">
|
||||
<div className="spinner" aria-hidden="true" />
|
||||
<div className="loading-text">Loading request timeline...</div>
|
||||
@@ -384,11 +384,43 @@ export default function RequestTimelinePage() {
|
||||
}
|
||||
|
||||
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) {
|
||||
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 =
|
||||
@@ -437,7 +469,7 @@ export default function RequestTimelinePage() {
|
||||
: friendlyState(snapshot.state)
|
||||
|
||||
return (
|
||||
<main className="card">
|
||||
<main className="card request-detail-page">
|
||||
<div className="request-header">
|
||||
<div className="request-header-main">
|
||||
{resolvedPoster && (
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
||||
<main className="card admin-card">
|
||||
<div className="admin-header">
|
||||
<div>
|
||||
<span className="section-kicker">Beta stream</span>
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <p className="lede">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
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',
|
||||
items: [
|
||||
@@ -21,6 +30,7 @@ const NAV_GROUPS = [
|
||||
{ href: '/admin/requests', label: 'Request sync' },
|
||||
{ href: '/admin/requests-all', label: 'All requests' },
|
||||
{ href: '/admin/cache', label: 'Cache Control' },
|
||||
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
type BrandingLogoProps = {
|
||||
className?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
return (
|
||||
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
|
||||
{!failed ? (
|
||||
<img
|
||||
className={className}
|
||||
className={loaded ? 'is-loaded' : undefined}
|
||||
src="/api/branding/logo.png"
|
||||
alt={alt}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [signedIn, setSignedIn] = useState(false)
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
@@ -19,9 +22,11 @@ export default function HeaderActions() {
|
||||
if (!response.ok) {
|
||||
clearToken()
|
||||
setSignedIn(false)
|
||||
setRole(null)
|
||||
return
|
||||
}
|
||||
await response.json()
|
||||
const data = await response.json()
|
||||
setRole(data?.role ?? null)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
@@ -33,18 +38,45 @@ export default function HeaderActions() {
|
||||
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 (
|
||||
<div className="header-actions">
|
||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
||||
<div className="header-actions-center">
|
||||
<a href="/how-it-works">How it works</a>
|
||||
</div>
|
||||
<div className="header-actions-right">
|
||||
<a href="/">Requests</a>
|
||||
<a href="/profile/invites">Invites</a>
|
||||
<a href="/portal/requests">Portal</a>
|
||||
<a href="/portal/issues">Issues</a>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="header-actions" aria-label="Primary">
|
||||
{items.map((item) => {
|
||||
const active = item.match(pathname)
|
||||
return (
|
||||
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
|
||||
<span aria-hidden="true">{item.icon}</span>
|
||||
{item.label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user