Unify page layouts, compact headers and shared UI styling
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# Shared workspace layout
|
||||
|
||||
- Use `app/ui/PageHeading.tsx` for page titles. Keep the heading flat, with a short description and optional actions. Only record IDs belong in the optional eyebrow.
|
||||
- Admin pages use `AdminShell`, which supplies the same heading and settings navigation.
|
||||
- Authentication screens use `AuthLayout`; they do not render the signed-in navigation.
|
||||
- `app/workspace.css` owns page width, gutters, title sizes and shared spacing. Feature styles own the content inside those pages. Do not add new page-specific hero panels or outer width overrides.
|
||||
- Keep primary actions, secondary controls and destructive actions visually distinct. Do not fade or uppercase every span inside a button: cards also use buttons, often with nested text.
|
||||
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
|
||||
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
|
||||
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
|
||||
|
||||
## Browser checks
|
||||
|
||||
Build the frontend before reviewing. The scripts in `scripts/` run using Node and Playwright:
|
||||
|
||||
- `review_layout_ui.cjs`: page alignment, consistent headings, overflow, redirects, pipeline layout, invite tabs, and fixture-only recovery forms.
|
||||
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
|
||||
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
|
||||
|
||||
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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="What’s new and improved in Magent." />
|
||||
{content}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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. We’ll 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">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user