Unify page layouts, compact headers and shared UI styling

This commit is contained in:
2026-09-06 19:52:39 +12:00
parent 4d67567d4c
commit dec1dd902c
27 changed files with 480 additions and 179 deletions
-9
View File
@@ -1,7 +1,4 @@
/* Top-navigation workspace and streamlined account screens. */
.page > main:not(.auth-screen):not(.auth-card):not(.login-page) { width: calc(100% - 64px); max-width: 1440px; margin: 32px auto 0; padding: 0; border: 0 !important; background: transparent !important; }
.page > .site-banner, .page > .user-view-banner { width: calc(100% - 64px); max-width: 1440px; margin: 16px auto 0; }
.admin-shell.admin-shell--top-nav { display: block; width: calc(100% - 64px); max-width: 1440px; margin: 24px auto 0; }
.admin-shell--top-nav > .admin-card { width: 100%; max-width: none; padding: 24px 0; }
.settings-top-navigation { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 0 20px; border-bottom: 1px solid var(--ops-line-soft); }
.settings-top-navigation a { color: var(--ops-muted); font-size: 13px; text-decoration: none; }
@@ -11,10 +8,7 @@
.admin-supplemental { margin-top: 28px; border-top: 1px solid var(--ops-line-soft); padding-top: 20px; }
.admin-supplemental > summary { cursor: pointer; color: var(--ops-muted); font-size: 13px; margin-bottom: 18px; }
.admin-supplemental .admin-rail-stack { display: block; max-width: 960px; }
.page > main.account-page { max-width: 920px !important; margin-top: 42px; }
.account-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 32px; }
.account-eyebrow { font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
.account-heading h1 { font-size: clamp(30px, 4vw, 40px); line-height: 1.2; margin: 7px 0 0; color: var(--ops-text); }
.account-identity { display: flex; align-items: center; gap: 12px; min-width: 0; }
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
.account-identity strong { font-size: 14px; overflow-wrap: anywhere; }
@@ -92,9 +86,6 @@ button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px sol
@keyframes account-appear { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .account-panel { animation: none; } }
@media (max-width: 680px) {
.page > main:not(.auth-screen):not(.auth-card):not(.login-page), .page > .site-banner, .page > .user-view-banner, .admin-shell.admin-shell--top-nav { width: calc(100% - 32px); }
.page > main.account-page { margin-top: 28px; }
.account-heading { align-items: flex-start; gap: 16px; margin-bottom: 24px; }
.account-identity strong { max-width: 130px; }
.account-avatar { display: none; }
.account-panel { padding: 22px 20px; }
+2 -2
View File
@@ -1188,8 +1188,8 @@ export default function AdminInviteManagementPage() {
return (
<AdminShell
title="Invites"
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
title="Invite policy & access"
subtitle="Manage account defaults, profiles, and invitations across Magent."
rail={inviteManagementRail}
>
<section className="admin-section">
-5
View File
@@ -109,11 +109,6 @@ export default function AdminRequestsAllPage() {
<AdminShell
title="All requests"
subtitle="Paginated view of every cached request."
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section">
<div className="admin-toolbar">
+2 -7
View File
@@ -116,14 +116,9 @@ export default function AdminSystemGuidePage() {
return (
<AdminShell
title="How it works"
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
title="System guide"
subtitle="Service connections, controls, and recovery paths."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section system-guide">
<div className="admin-panel">
+6 -9
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -106,14 +108,9 @@ export default function ChangelogPage() {
}, [groups, loading])
return (
<div className="page">
<section className="card changelog-card">
<div className="changelog-header">
<h1>Changelog</h1>
<p className="lede">Latest updates and release notes.</p>
</div>
{content}
</section>
</div>
<main className="card changelog-page">
<PageHeading title="Changelog" description="Whats new and improved in Magent." />
{content}
</main>
)
}
+5 -9
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
@@ -78,16 +80,10 @@ export default function FeedbackPage() {
}
return (
<main className="card">
<header className="how-hero">
<p className="eyebrow">Send feedback</p>
<h1>Help us improve Magent</h1>
<p className="lede">
Found a problem or have an idea? Send it here and we will see it right away.
</p>
</header>
<main className="card feedback-page">
<PageHeading title="Feedback" description="Share an idea or tell us what could work better." />
<form className="auth-form" onSubmit={submit}>
<form className="account-panel account-form feedback-form" onSubmit={submit}>
<label htmlFor="feedback-user">Your username</label>
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
+7 -13
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import AuthLayout from '../ui/AuthLayout'
import { getApiBase } from '../lib/auth'
export default function ForgotPasswordPage() {
@@ -46,14 +46,8 @@ export default function ForgotPasswordPage() {
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Forgot password</h1>
<p className="lede">
Enter the username or email you use for Jellyfin or Magent. If the account is eligible, a reset link
will be emailed to you.
</p>
<form className="auth-form" onSubmit={submit}>
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
<label>
Username or email
<input
@@ -63,10 +57,10 @@ export default function ForgotPasswordPage() {
placeholder="you@example.com"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{error && <div className="account-notice is-error" role="alert">{error}</div>}
{status && <div className="account-notice is-status" role="status">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading}>
<button type="submit" className="account-primary" disabled={loading}>
{loading ? 'Sending reset link…' : 'Send reset link'}
</button>
</div>
@@ -74,6 +68,6 @@ export default function ForgotPasswordPage() {
Back to sign in
</button>
</form>
</main>
</AuthLayout>
)
}
+3 -4
View File
@@ -512,10 +512,9 @@ button {
}
button span {
font-size: 12px;
text-transform: uppercase;
opacity: 0.8;
text-align: center;
font-size: inherit;
text-transform: inherit;
text-align: inherit;
}
.filters {
+3 -8
View File
@@ -1,16 +1,11 @@
'use client'
import PageHeading from '../ui/PageHeading'
export default function HowItWorksPage() {
return (
<main className="card how-page">
<header className="how-hero">
<p className="eyebrow">How it works</p>
<h1>How Magent works for users</h1>
<p className="lede">
Use Magent to find a request, watch it move through the pipeline, and know when it is
ready without constantly refreshing the page.
</p>
</header>
<PageHeading title="How it works" description="Request something to watch, follow its progress, and get help when you need it." />
<section className="how-flow">
<h2>What Magent is for</h2>
+1
View File
@@ -2,6 +2,7 @@ import './globals.css'
import './ops-redesign.css'
import './admin/config.css'
import './account.css'
import './workspace.css'
import type { ReactNode } from 'react'
import BrandingFavicon from './ui/BrandingFavicon'
import ApplicationChrome from './ui/ApplicationChrome'
+5 -9
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, type FormEvent } from 'react'
import { getApiBase, setToken } from '../lib/auth'
import MagentMark from '../ui/MagentMark'
import AuthLayout from '../ui/AuthLayout'
type LoginMode = 'jellyfin' | 'local'
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean }
@@ -77,10 +77,9 @@ export default function LoginPage() {
}
return (
<main className="login-page">
<section className="login-card" aria-labelledby="login-title">
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
<header><h1 id="login-title">Welcome back.</h1><p>Sign in to your media workspace.</p></header>
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></>
}>
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" aria-label="Sign-in account">
<button type="button" aria-pressed={selectedMode === 'jellyfin'} disabled={loading} onClick={() => { setMode('jellyfin'); setError('') }}>Grizzlyflix</button>
@@ -102,9 +101,6 @@ export default function LoginPage() {
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true"></span></button>
</form>
)}
{optionsReady && options.showSignupLink && <footer>Have an invite? <a href="/signup">Create an account <span aria-hidden="true"></span></a></footer>}
</section>
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
</main>
</AuthLayout>
)
}
+3 -12
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -318,18 +320,7 @@ export default function NewRequestClient() {
return (
<main className="card request-portal-page">
<header className="request-portal-hero">
<div>
<span className="section-kicker">New requests</span>
<h1>Find something worth watching.</h1>
<p>Choose what you want, find the right title, then tailor the request before it goes to Seerr.</p>
</div>
<div className="request-portal-route">
<span>Seerr</span><i aria-hidden="true" />
<span>{mediaType === 'tv' ? 'Sonarr' : mediaType === 'movie' ? 'Radarr' : 'Collector'}</span><i aria-hidden="true" />
<span>Grizzlyflix</span>
</div>
</header>
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
<ol className="request-master-stepper" aria-label="New request progress">
{['Type', 'Search', 'Select', 'Config', 'Submit'].map((label, index) => {
+11
View File
@@ -0,0 +1,11 @@
import Link from 'next/link'
import PageHeading from './ui/PageHeading'
export default function NotFound() {
return (
<main className="card">
<PageHeading title="Page not found" description="This link may have moved or no longer be available." />
<p><Link href="/"> Back to my requests</Link></p>
</main>
)
}
+4 -7
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from './ui/PageHeading'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
@@ -261,12 +263,7 @@ export default function HomePage() {
return (
<main className="card home-page">
<section className="home-command">
<div className="home-command-copy">
<span className="section-kicker">Media operations</span>
<h1>My requests</h1>
<p>Manage and track your media processing queue.</p>
</div>
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
@@ -279,7 +276,7 @@ export default function HomePage() {
<button type="submit">Find request</button>
</div>
</form>
</section>
} />
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
+7 -17
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -1463,23 +1465,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
return (
<main className={`card portal-page ${workspace === 'issue' ? 'issue-portal-page' : ''}`}>
<div className={`user-directory-panel-header ${workspace === 'issue' ? 'issue-portal-hero' : ''}`}>
<div>
{workspace === 'issue' ? <span className="section-kicker">Guided support</span> : null}
<h1>{workspace === 'request' ? 'Request portal' : 'What is going wrong?'}</h1>
<p className="lede">
{workspace === 'request'
? 'Search and track content requests through the delivery pipeline.'
: 'Choose the symptom and Magent will collect the right details, check the media server when relevant, and recommend the next action.'}
</p>
</div>
{workspace === 'issue' ? (
<div className="issue-hero-count">
<strong>{visibleKindCount}</strong>
<span>reported issues</span>
</div>
) : null}
</div>
<PageHeading
title={workspace === 'request' ? 'Request portal' : 'Issues'}
description={workspace === 'request' ? 'Search and track your content requests.' : 'Tell us what is wrong. Well guide you through the fix.'}
actions={workspace === 'issue' ? <span className="page-heading-meta">{visibleKindCount} reported {visibleKindCount === 1 ? 'issue' : 'issues'}</span> : undefined}
/>
{workspace === 'request' ? (
<section className="portal-workspace-switch">
+4 -8
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../../ui/PageHeading'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
@@ -225,14 +227,8 @@ export default function ProfileInvitesPage() {
if (loading) return <main className="card">Loading invite workspace</main>
return (
<main className="card">
<div className="user-directory-panel-header profile-page-header">
<div>
<span className="section-kicker">04 · Invites</span>
<h1>Invite someone to Grizzlyflix</h1>
<p className="lede">Create a secure invitation one simple decision at a time.</p>
</div>
</div>
<main className="card invites-page">
<PageHeading title="Invites" description="Invite someone to Grizzlyflix and manage the links you share." />
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
+5 -4
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../ui/PageHeading'
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -167,10 +169,9 @@ export default function ProfilePage() {
return (
<main className="account-page">
<header className="account-heading">
<div><span className="account-eyebrow">YOUR ACCOUNT</span><h1>My profile</h1></div>
{user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>}
</header>
<PageHeading title="My profile" description="Your contact details, security, and activity." actions={
user && <div className="account-identity"><span className="account-avatar" aria-hidden="true">{user.username.slice(0, 1).toUpperCase()}</span><div><strong>{user.username}</strong><span>{user.role === 'admin' ? 'Administrator' : 'Member'}</span></div></div>
} />
{loading ? <p className="account-empty" role="status">Loading your profile</p> : loadError ? (
<div className="account-empty"><p role="alert">{loadError}</p><button type="button" className="account-secondary" onClick={() => void loadProfile()}>Try again</button></div>
+13 -21
View File
@@ -1,5 +1,7 @@
'use client'
import PageHeading from '../../ui/PageHeading'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
@@ -502,15 +504,11 @@ export default function RequestTimelinePage() {
if (loadError || !snapshot) {
return (
<main className="card request-detail-page">
<section className="request-error-state">
<span className="section-kicker">Request unavailable</span>
<h1>We could not load this request</h1>
<p>{loadError ?? 'The request API did not return a valid status.'}</p>
<div className="request-error-actions">
<button type="button" onClick={() => window.location.reload()}>Retry</button>
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
</div>
</section>
<PageHeading title="We could not load this request" description={loadError ?? 'The request API did not return a valid status.'} />
<div className="request-error-actions">
<button type="button" onClick={() => window.location.reload()}>Retry</button>
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
</div>
</main>
)
}
@@ -714,18 +712,12 @@ export default function RequestTimelinePage() {
return (
<main className="card request-detail-page">
<div className="request-header">
<div className="request-header-main">
{resolvedPoster && (
<Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={90} height={135} sizes="90px" unoptimized />
)}
<div>
<span className="section-kicker">Request #{snapshot.request_id}</span>
<h1>{snapshot.title}</h1>
<div className="meta">{snapshot.request_type.toUpperCase()} {snapshot.year ?? ''}</div>
</div>
</div>
</div>
<PageHeading
title={snapshot.title}
eyebrow={`Request #${snapshot.request_id}`}
description={[snapshot.request_type === 'tv' ? 'TV show' : 'Movie', snapshot.year].filter(Boolean).join(' · ')}
leading={resolvedPoster && <Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={60} height={90} sizes="60px" unoptimized />}
/>
<section className="request-overview" aria-labelledby="request-status-heading">
<div className="request-overview-block request-overview-status">
+8 -11
View File
@@ -2,7 +2,7 @@
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import AuthLayout from '../ui/AuthLayout'
import { getApiBase } from '../lib/auth'
type ResetVerification = {
@@ -100,11 +100,8 @@ function ResetPasswordPageContent() {
verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Reset password</h1>
<p className="lede">Choose a new password for your account.</p>
<form className="auth-form" onSubmit={submit}>
<AuthLayout title="Reset password" description="Choose a new password of at least 8 characters.">
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
{verifying && <div className="status-banner">Checking password reset link</div>}
{!verifying && verification && (
<div className="status-banner">
@@ -132,10 +129,10 @@ function ResetPasswordPageContent() {
disabled={!verification || loading}
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{error && <div className="account-notice is-error" role="alert">{error}</div>}
{status && <div className="account-notice is-status" role="status">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={loading || verifying || !verification}>
<button type="submit" className="account-primary" disabled={loading || verifying || !verification}>
{loading ? 'Updating password…' : 'Reset password'}
</button>
</div>
@@ -143,13 +140,13 @@ function ResetPasswordPageContent() {
Back to sign in
</button>
</form>
</main>
</AuthLayout>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading password reset</main>}>
<Suspense fallback={<AuthLayout title="Reset password" description="Choose a new password for your account."><p role="status">Checking your reset link</p></AuthLayout>}>
<ResetPasswordPageContent />
</Suspense>
)
+12 -15
View File
@@ -2,7 +2,7 @@
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import AuthLayout from '../ui/AuthLayout'
import { clearToken, getApiBase, setToken } from '../lib/auth'
type InviteInfo = {
@@ -133,11 +133,8 @@ function SignupPageContent() {
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Create account</h1>
<p className="lede">Use an invite code from your admin to create your Jellyfin-backed Magent account.</p>
<form onSubmit={submit} className="auth-form">
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
<form onSubmit={submit} className="account-form login-form auth-flow-form">
<label>
Invite code
<div className="invite-lookup-row">
@@ -162,16 +159,16 @@ function SignupPageContent() {
<div className="invite-summary-row">
<strong>{invite.label || invite.code}</strong>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
{invite.is_usable ? 'Ready' : 'Unavailable'}
</span>
</div>
{invite.description && <p>{invite.description}</p>}
<div className="admin-meta-row">
<details className="auth-invite-details"><summary>Invite details</summary><div className="admin-meta-row">
<span>Code: {invite.code}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Profile: {invite.profile?.name || 'None'}</span>
</div>
</div></details>
</div>
)}
<label>
@@ -200,24 +197,24 @@ function SignupPageContent() {
autoComplete="new-password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{error && <div className="account-notice is-error" role="alert">{error}</div>}
{status && <div className="account-notice is-status" role="status">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
<button type="submit" className="account-primary" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account'}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
Back to sign in
</button>
</form>
</main>
</AuthLayout>
)
}
export default function SignupPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading sign-up</main>}>
<Suspense fallback={<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix."><p role="status">Loading sign-up</p></AuthLayout>}>
<SignupPageContent />
</Suspense>
)
+2 -8
View File
@@ -2,6 +2,7 @@
import type { ReactNode } from 'react'
import SettingsNavigation from './SettingsNavigation'
import PageHeading from './PageHeading'
type AdminShellProps = {
title: string
@@ -16,14 +17,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
<div className="admin-shell admin-shell--top-nav">
<SettingsNavigation />
<main className="card admin-card">
<div className="admin-header">
<div>
<span className="section-kicker">Configuration</span>
<h1>{title}</h1>
{subtitle && <p className="lede">{subtitle}</p>}
</div>
{actions}
</div>
<PageHeading title={title} description={subtitle} actions={actions} />
{children}
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>}
</main>
+1 -1
View File
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
export default function ApplicationChrome() {
const pathname = usePathname()
if (pathname === '/login') return null
if (['/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
return <>
<header className="header">
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
import MagentMark from './MagentMark'
export default function AuthLayout({ title, description, children, footer }: {
title: string
description: string
children: ReactNode
footer?: ReactNode
}) {
return (
<main className="login-page">
<section className="login-card" aria-labelledby="login-title">
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
<header><h1 id="login-title">{title}</h1><p>{description}</p></header>
{children}
{footer && <footer>{footer}</footer>}
</section>
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
</main>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
type PageHeadingProps = {
title: string
description?: string
eyebrow?: string
leading?: ReactNode
actions?: ReactNode
}
/** A flat, shared page title. Panels belong to the content below it. */
export default function PageHeading({ title, description, eyebrow, leading, actions }: PageHeadingProps) {
return (
<header className="page-heading">
<div className="page-heading-main">
{leading && <div className="page-heading-leading">{leading}</div>}
<div className="page-heading-copy">
{eyebrow && <span className="page-heading-eyebrow">{eyebrow}</span>}
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
</div>
{actions && <div className="page-heading-actions">{actions}</div>}
</header>
)
}
+174
View File
@@ -0,0 +1,174 @@
/* Shared page rhythm. Feature styles own their content, not the outer shell. */
:root {
--workspace-width: 1440px;
--workspace-gutter: 32px;
--workspace-gap: 24px;
--ops-radius: 8px;
--ops-radius-lg: 12px;
}
.page > main:not(.login-page),
.admin-shell.admin-shell--top-nav {
width: calc(100% - var(--workspace-gutter) * 2);
max-width: var(--workspace-width) !important;
margin: 32px auto 0;
padding: 0;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
animation: none;
}
.page > main:not(.login-page) {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--workspace-gap);
align-content: start;
}
.admin-shell.admin-shell--top-nav { display: block; }
.admin-shell--top-nav > .admin-card { grid-template-columns: minmax(0, 1fr); gap: var(--workspace-gap); border-radius: 0 !important; animation: none; }
.admin-card > * { min-width: 0; }
.admin-card .admin-section, .invite-admin-stack { grid-template-columns: minmax(0, 1fr); min-width: 0; }
.admin-section > *, .invite-admin-stack > * { min-width: 0; }
.page > .site-banner, .page > .user-view-banner {
width: calc(100% - var(--workspace-gutter) * 2);
max-width: var(--workspace-width);
margin: 16px auto 0;
border-radius: 8px;
}
.header { border-radius: 0 !important; }
/* A single, calm heading treatment across every workspace page. */
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-width: 0;
margin: 0;
padding: 0 0 24px;
border: 0;
border-bottom: 1px solid var(--ops-line-soft);
border-radius: 0;
background: transparent;
box-shadow: none;
}
.page-heading-main { display: flex; align-items: center; gap: 18px; min-width: 0; }
.page-heading-copy { display: grid; gap: 8px; min-width: 0; }
.page-heading h1 {
margin: 0;
color: var(--ops-text);
font: 700 clamp(26px, 2.5vw, 32px)/1.2 "DM Sans", "Segoe UI", sans-serif;
text-transform: none;
overflow-wrap: anywhere;
}
.page-heading-copy > p { margin: 0; max-width: 64ch; color: var(--ops-muted); font-size: 14px; line-height: 1.6; }
.page-heading-eyebrow { color: var(--ops-faint); font: 11px "JetBrains Mono", monospace; }
.page-heading-leading { flex-shrink: 0; }
.page-heading-leading .request-poster { display: block; width: 60px; height: 90px; margin: 0; border-radius: 8px; object-fit: cover; }
.page-heading-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 10px; min-width: 0; flex-shrink: 0; max-width: 50%; }
.page-heading-actions .lede { margin: 0; }
.page-heading { grid-column: 1 / -1; }
.page > main.issue-portal-page { grid-template-columns: minmax(0, 1fr) minmax(310px, 360px); }
.issue-reports-column { top: 80px; height: calc(100dvh - 104px); }
.page-heading-meta { color: var(--ops-faint); font-size: 13px; }
.page-heading .home-search { width: min(440px, 100%); }
.page-heading .home-search > label { font: 500 12px "DM Sans", sans-serif; text-transform: none; }
.page-heading .home-search-row { grid-template-columns: minmax(0, 1fr) auto; }
/* Keep small forms readable without moving their page title off the shared grid. */
.account-page > .account-tabs, .account-page > .account-panel { width: 100%; max-width: 920px; margin: 0; }
.feedback-form { width: 100%; max-width: 760px; }
.feedback-form label:not(:first-child) { margin-top: 12px; }
.feedback-form :is(select, textarea) { width: 100%; min-width: 0; padding: 12px; border-radius: 8px; font-size: 14px; }
.feedback-form button[type=submit] { justify-self: start; margin-top: 12px; }
.how-page > .how-flow, .changelog-page > .changelog-groups { width: 100%; max-width: 1120px; }
.how-flow > h2 { margin: 0 0 16px; font-size: 20px; }
.how-card { background: var(--ops-panel); border-color: var(--ops-line); border-radius: 12px; box-shadow: none; }
.how-card p { margin: 0; color: var(--ops-muted); line-height: 1.7; }
.changelog-group { padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.changelog-group:first-child { padding-top: 24px; border-top: 1px solid var(--ops-line); }
.changelog-group h2 { font-size: 18px; }
.changelog-list { margin-bottom: 0; color: var(--ops-muted); line-height: 1.7; }
/* Shared hierarchy: readable form labels, restrained section titles and corners. */
:is(.request-flow-heading, .issue-flow-heading, .invite-flow-heading, .home-section-heading, .request-journey-heading) h2 { font-size: 21px; line-height: 1.3; }
:is(.request-flow-stage, .issue-flow, .issue-reports-column, .invite-flow-step, .profile-invites-list, .account-panel) { border-radius: 12px; }
.invites-page > .profile-invites-section { margin: 0; padding: 0; border: 0; background: transparent !important; }
.invites-page .invite-flow-heading > div > .eyebrow { display: none; }
.invites-page .profile-invites-list { margin-top: 12px; padding: 20px; border: 1px solid var(--ops-line); }
.invite-flow-fields label > span:first-child, .invite-flow-field-grid label > span:first-child { font-size: 13px; text-transform: none; }
.invite-admin-tabbar .admin-segmented { flex-wrap: wrap; width: auto; max-width: 100%; }
.invite-admin-tabbar .admin-segmented { padding: 0; gap: 4px 18px; border: 0; border-bottom: 1px solid var(--ops-line-soft); border-radius: 0; background: transparent; }
.invite-admin-tabbar .admin-segmented button { min-height: 44px; padding: 10px 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0 !important; background: transparent !important; color: var(--ops-muted); }
.invite-admin-tabbar .admin-segmented button[aria-selected=true] { border-bottom-color: #c7bdff; color: #dedaff; }
.invite-operations-strip > button { justify-content: stretch; justify-items: start; border-color: var(--ops-line) !important; background: var(--ops-panel) !important; }
.invite-operations-strip > button:hover { border-color: var(--ops-primary-2) !important; }
.request-stage-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.request-stage-grid > .request-stage { grid-column: auto; }
:is(.request-flow-stage, .issue-flow, .profile-invites-section, .admin-card) label:not(.setting-switch) {
font-family: "DM Sans", "Segoe UI", sans-serif;
font-size: 13px;
text-transform: none;
}
:is(.request-flow-stage, .issue-flow, .profile-invites-section, .admin-card) label > span { text-transform: none; }
:is(.request-flow-stage, .issue-flow, .profile-invites-section) :is(input:not([type=checkbox]):not([type=radio]), select, textarea) { border-radius: 8px; font: 14px/1.5 "DM Sans", sans-serif; }
:is(.request-flow-stage, .issue-flow, .profile-invites-section) input:not([type=checkbox]):not([type=radio]),
:is(.request-flow-stage, .issue-flow, .profile-invites-section) select { min-height: 44px; }
.auth-flow-form > label { display: grid; gap: 8px; }
.auth-flow-form > label:not(:first-child) { margin-top: 10px; }
.auth-flow-form .auth-actions { display: grid; margin-top: 12px; }
.auth-flow-form .invite-lookup-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.auth-flow-form .invite-lookup-row button { font-size: 12px; padding: 10px; }
.auth-flow-form .invite-summary { background: var(--ops-panel-2); padding: 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; overflow-wrap: anywhere; }
.auth-invite-details { font-size: 12px; color: var(--ops-muted); margin-top: 10px; }
.auth-invite-details summary { cursor: pointer; }
.auth-invite-details .admin-meta-row { margin-top: 10px; }
.page main button[type=submit]:not(.ghost-button),
.page .request-submit-bar > button,
.page .issue-resolution-card > button {
min-height: 42px;
padding: 11px 18px;
border-color: #c7bdff !important;
border-radius: 8px;
background: #c7bdff !important;
color: #1c172c !important;
font: 700 13px "DM Sans", sans-serif;
text-transform: none;
box-shadow: none;
}
.page main button[type=submit]:disabled { opacity: .4; }
.page button.danger-button { border-color: #86464f !important; background: #361f25 !important; color: #ffc0c5 !important; }
.page :is(button, input, select, textarea) { font-family: "DM Sans", "Segoe UI", sans-serif; }
.page :focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
.login-card { border-radius: 12px; }
.login-card header p { line-height: 1.6; }
.login-card .status-banner { font-size: 13px; line-height: 1.6; }
@media (max-width: 1100px) {
.page > main.issue-portal-page { grid-template-columns: minmax(0, 1fr); }
.issue-reports-column { height: auto; }
.request-stage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 980px) {
.page-heading { flex-wrap: wrap; align-items: flex-start; gap: 18px; }
.page-heading-actions { justify-content: flex-start; max-width: 100%; }
.home-page .page-heading-actions { width: 100%; }
.page-heading .home-search { width: 100%; }
}
@media (max-width: 680px) {
: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; }
.page-heading-copy > p { font-size: 13px; }
.page-heading-main { gap: 14px; }
.page-heading-leading .request-poster { width: 48px; height: 72px; }
.page-heading-actions > .admin-inline-actions { flex-wrap: wrap; }
.changelog-group { padding: 20px; }
.request-stage-grid { grid-template-columns: minmax(0, 1fr); }
.invite-operations-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (prefers-reduced-motion: reduce) {
.page *, .page *::before, .page *::after { animation: none !important; scroll-behavior: auto !important; }
}