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. */
|
/* 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; }
|
.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 { 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; }
|
.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 { 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 > summary { cursor: pointer; color: var(--ops-muted); font-size: 13px; margin-bottom: 18px; }
|
||||||
.admin-supplemental .admin-rail-stack { display: block; max-width: 960px; }
|
.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-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 { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
|
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
|
||||||
.account-identity strong { font-size: 14px; overflow-wrap: anywhere; }
|
.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); } }
|
@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 (prefers-reduced-motion: reduce) { .account-panel { animation: none; } }
|
||||||
@media (max-width: 680px) {
|
@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-identity strong { max-width: 130px; }
|
||||||
.account-avatar { display: none; }
|
.account-avatar { display: none; }
|
||||||
.account-panel { padding: 22px 20px; }
|
.account-panel { padding: 22px 20px; }
|
||||||
|
|||||||
@@ -1188,8 +1188,8 @@ export default function AdminInviteManagementPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
title="Invites"
|
title="Invite policy & access"
|
||||||
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
|
subtitle="Manage account defaults, profiles, and invitations across Magent."
|
||||||
rail={inviteManagementRail}
|
rail={inviteManagementRail}
|
||||||
>
|
>
|
||||||
<section className="admin-section">
|
<section className="admin-section">
|
||||||
|
|||||||
@@ -109,11 +109,6 @@ export default function AdminRequestsAllPage() {
|
|||||||
<AdminShell
|
<AdminShell
|
||||||
title="All requests"
|
title="All requests"
|
||||||
subtitle="Paginated view of every cached request."
|
subtitle="Paginated view of every cached request."
|
||||||
actions={
|
|
||||||
<button type="button" onClick={() => router.push('/admin')}>
|
|
||||||
Back to settings
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<section className="admin-section">
|
<section className="admin-section">
|
||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
|
|||||||
@@ -116,14 +116,9 @@ export default function AdminSystemGuidePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
title="How it works"
|
title="System guide"
|
||||||
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
|
subtitle="Service connections, controls, and recovery paths."
|
||||||
rail={rail}
|
rail={rail}
|
||||||
actions={
|
|
||||||
<button type="button" onClick={() => router.push('/admin')}>
|
|
||||||
Back to settings
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<section className="admin-section system-guide">
|
<section className="admin-section system-guide">
|
||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import PageHeading from '../ui/PageHeading'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
@@ -106,14 +108,9 @@ export default function ChangelogPage() {
|
|||||||
}, [groups, loading])
|
}, [groups, loading])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<main className="card changelog-page">
|
||||||
<section className="card changelog-card">
|
<PageHeading title="Changelog" description="What’s new and improved in Magent." />
|
||||||
<div className="changelog-header">
|
{content}
|
||||||
<h1>Changelog</h1>
|
</main>
|
||||||
<p className="lede">Latest updates and release notes.</p>
|
|
||||||
</div>
|
|
||||||
{content}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import PageHeading from '../ui/PageHeading'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
||||||
@@ -78,16 +80,10 @@ export default function FeedbackPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card feedback-page">
|
||||||
<header className="how-hero">
|
<PageHeading title="Feedback" description="Share an idea or tell us what could work better." />
|
||||||
<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>
|
|
||||||
|
|
||||||
<form className="auth-form" onSubmit={submit}>
|
<form className="account-panel account-form feedback-form" onSubmit={submit}>
|
||||||
<label htmlFor="feedback-user">Your username</label>
|
<label htmlFor="feedback-user">Your username</label>
|
||||||
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
|
<input id="feedback-user" value={profile?.username ?? ''} readOnly />
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import BrandingLogo from '../ui/BrandingLogo'
|
import AuthLayout from '../ui/AuthLayout'
|
||||||
import { getApiBase } from '../lib/auth'
|
import { getApiBase } from '../lib/auth'
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
@@ -46,14 +46,8 @@ export default function ForgotPasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card auth-card">
|
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
|
||||||
<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}>
|
|
||||||
<label>
|
<label>
|
||||||
Username or email
|
Username or email
|
||||||
<input
|
<input
|
||||||
@@ -63,10 +57,10 @@ export default function ForgotPasswordPage() {
|
|||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="account-notice is-error" role="alert">{error}</div>}
|
||||||
{status && <div className="status-banner">{status}</div>}
|
{status && <div className="account-notice is-status" role="status">{status}</div>}
|
||||||
<div className="auth-actions">
|
<div className="auth-actions">
|
||||||
<button type="submit" disabled={loading}>
|
<button type="submit" className="account-primary" disabled={loading}>
|
||||||
{loading ? 'Sending reset link…' : 'Send reset link'}
|
{loading ? 'Sending reset link…' : 'Send reset link'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,6 +68,6 @@ export default function ForgotPasswordPage() {
|
|||||||
Back to sign in
|
Back to sign in
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</AuthLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,10 +512,9 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button span {
|
button span {
|
||||||
font-size: 12px;
|
font-size: inherit;
|
||||||
text-transform: uppercase;
|
text-transform: inherit;
|
||||||
opacity: 0.8;
|
text-align: inherit;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import PageHeading from '../ui/PageHeading'
|
||||||
|
|
||||||
export default function HowItWorksPage() {
|
export default function HowItWorksPage() {
|
||||||
return (
|
return (
|
||||||
<main className="card how-page">
|
<main className="card how-page">
|
||||||
<header className="how-hero">
|
<PageHeading title="How it works" description="Request something to watch, follow its progress, and get help when you need it." />
|
||||||
<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>
|
|
||||||
|
|
||||||
<section className="how-flow">
|
<section className="how-flow">
|
||||||
<h2>What Magent is for</h2>
|
<h2>What Magent is for</h2>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import './globals.css'
|
|||||||
import './ops-redesign.css'
|
import './ops-redesign.css'
|
||||||
import './admin/config.css'
|
import './admin/config.css'
|
||||||
import './account.css'
|
import './account.css'
|
||||||
|
import './workspace.css'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import BrandingFavicon from './ui/BrandingFavicon'
|
import BrandingFavicon from './ui/BrandingFavicon'
|
||||||
import ApplicationChrome from './ui/ApplicationChrome'
|
import ApplicationChrome from './ui/ApplicationChrome'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { getApiBase, setToken } from '../lib/auth'
|
import { getApiBase, setToken } from '../lib/auth'
|
||||||
import MagentMark from '../ui/MagentMark'
|
import AuthLayout from '../ui/AuthLayout'
|
||||||
|
|
||||||
type LoginMode = 'jellyfin' | 'local'
|
type LoginMode = 'jellyfin' | 'local'
|
||||||
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean }
|
type LoginOptions = { showJellyfinLogin: boolean; showLocalLogin: boolean; showForgotPassword: boolean; showSignupLink: boolean }
|
||||||
@@ -77,10 +77,9 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="login-page">
|
<AuthLayout title="Welcome back." description="Sign in to your media workspace." footer={
|
||||||
<section className="login-card" aria-labelledby="login-title">
|
optionsReady && options.showSignupLink && <>Have an invite? <a href="/signup">Create an account <span aria-hidden="true">↗</span></a></>
|
||||||
<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>
|
|
||||||
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
|
{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">
|
{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>
|
<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>
|
<button type="submit" className="account-primary login-submit" disabled={loading}>{loading ? 'Signing in…' : 'Sign in'}<span aria-hidden="true">→</span></button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{optionsReady && options.showSignupLink && <footer>Have an invite? <a href="/signup">Create an account <span aria-hidden="true">↗</span></a></footer>}
|
</AuthLayout>
|
||||||
</section>
|
|
||||||
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
|
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import PageHeading from '../ui/PageHeading'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
@@ -318,18 +320,7 @@ export default function NewRequestClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card request-portal-page">
|
<main className="card request-portal-page">
|
||||||
<header className="request-portal-hero">
|
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
|
||||||
<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>
|
|
||||||
|
|
||||||
<ol className="request-master-stepper" aria-label="New request progress">
|
<ol className="request-master-stepper" aria-label="New request progress">
|
||||||
{['Type', 'Search', 'Select', 'Config', 'Submit'].map((label, index) => {
|
{['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'
|
'use client'
|
||||||
|
|
||||||
|
import PageHeading from './ui/PageHeading'
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
|
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
|
||||||
@@ -261,12 +263,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card home-page">
|
<main className="card home-page">
|
||||||
<section className="home-command">
|
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
|
||||||
<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>
|
|
||||||
<form onSubmit={submit} className="home-search">
|
<form onSubmit={submit} className="home-search">
|
||||||
<label htmlFor="request-search">Title, year, or request number</label>
|
<label htmlFor="request-search">Title, year, or request number</label>
|
||||||
<div className="home-search-row">
|
<div className="home-search-row">
|
||||||
@@ -279,7 +276,7 @@ export default function HomePage() {
|
|||||||
<button type="submit">Find request</button>
|
<button type="submit">Find request</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
} />
|
||||||
|
|
||||||
{(searchError || searchResults.length > 0) && (
|
{(searchError || searchResults.length > 0) && (
|
||||||
<section className="home-search-results" aria-live="polite">
|
<section className="home-search-results" aria-live="polite">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import PageHeading from '../ui/PageHeading'
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
@@ -1463,23 +1465,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`card portal-page ${workspace === 'issue' ? 'issue-portal-page' : ''}`}>
|
<main className={`card portal-page ${workspace === 'issue' ? 'issue-portal-page' : ''}`}>
|
||||||
<div className={`user-directory-panel-header ${workspace === 'issue' ? 'issue-portal-hero' : ''}`}>
|
<PageHeading
|
||||||
<div>
|
title={workspace === 'request' ? 'Request portal' : 'Issues'}
|
||||||
{workspace === 'issue' ? <span className="section-kicker">Guided support</span> : null}
|
description={workspace === 'request' ? 'Search and track your content requests.' : 'Tell us what is wrong. We’ll guide you through the fix.'}
|
||||||
<h1>{workspace === 'request' ? 'Request portal' : 'What is going wrong?'}</h1>
|
actions={workspace === 'issue' ? <span className="page-heading-meta">{visibleKindCount} reported {visibleKindCount === 1 ? 'issue' : 'issues'}</span> : undefined}
|
||||||
<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>
|
|
||||||
|
|
||||||
{workspace === 'request' ? (
|
{workspace === 'request' ? (
|
||||||
<section className="portal-workspace-switch">
|
<section className="portal-workspace-switch">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import PageHeading from '../../ui/PageHeading'
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
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>
|
if (loading) return <main className="card">Loading invite workspace…</main>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card invites-page">
|
||||||
<div className="user-directory-panel-header profile-page-header">
|
<PageHeading title="Invites" description="Invite someone to Grizzlyflix and manage the links you share." />
|
||||||
<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>
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{status && <div className="status-banner">{status}</div>}
|
{status && <div className="status-banner">{status}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import PageHeading from '../ui/PageHeading'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
|
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
@@ -167,10 +169,9 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="account-page">
|
<main className="account-page">
|
||||||
<header className="account-heading">
|
<PageHeading title="My profile" description="Your contact details, security, and activity." actions={
|
||||||
<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>
|
||||||
{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>
|
|
||||||
|
|
||||||
{loading ? <p className="account-empty" role="status">Loading your profile…</p> : loadError ? (
|
{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>
|
<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'
|
'use client'
|
||||||
|
|
||||||
|
import PageHeading from '../../ui/PageHeading'
|
||||||
|
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
@@ -502,15 +504,11 @@ export default function RequestTimelinePage() {
|
|||||||
if (loadError || !snapshot) {
|
if (loadError || !snapshot) {
|
||||||
return (
|
return (
|
||||||
<main className="card request-detail-page">
|
<main className="card request-detail-page">
|
||||||
<section className="request-error-state">
|
<PageHeading title="We could not load this request" description={loadError ?? 'The request API did not return a valid status.'} />
|
||||||
<span className="section-kicker">Request unavailable</span>
|
<div className="request-error-actions">
|
||||||
<h1>We could not load this request</h1>
|
<button type="button" onClick={() => window.location.reload()}>Retry</button>
|
||||||
<p>{loadError ?? 'The request API did not return a valid status.'}</p>
|
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
|
||||||
<div className="request-error-actions">
|
</div>
|
||||||
<button type="button" onClick={() => window.location.reload()}>Retry</button>
|
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -714,18 +712,12 @@ export default function RequestTimelinePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card request-detail-page">
|
<main className="card request-detail-page">
|
||||||
<div className="request-header">
|
<PageHeading
|
||||||
<div className="request-header-main">
|
title={snapshot.title}
|
||||||
{resolvedPoster && (
|
eyebrow={`Request #${snapshot.request_id}`}
|
||||||
<Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={90} height={135} sizes="90px" unoptimized />
|
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 />}
|
||||||
<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>
|
|
||||||
|
|
||||||
<section className="request-overview" aria-labelledby="request-status-heading">
|
<section className="request-overview" aria-labelledby="request-status-heading">
|
||||||
<div className="request-overview-block request-overview-status">
|
<div className="request-overview-block request-overview-status">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Suspense, useEffect, useState } from 'react'
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import BrandingLogo from '../ui/BrandingLogo'
|
import AuthLayout from '../ui/AuthLayout'
|
||||||
import { getApiBase } from '../lib/auth'
|
import { getApiBase } from '../lib/auth'
|
||||||
|
|
||||||
type ResetVerification = {
|
type ResetVerification = {
|
||||||
@@ -100,11 +100,8 @@ function ResetPasswordPageContent() {
|
|||||||
verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
|
verification?.auth_provider === 'jellyfin' ? 'Jellyfin, Seerr, and Magent' : 'Magent'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card auth-card">
|
<AuthLayout title="Reset password" description="Choose a new password of at least 8 characters.">
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
|
||||||
<h1>Reset password</h1>
|
|
||||||
<p className="lede">Choose a new password for your account.</p>
|
|
||||||
<form className="auth-form" onSubmit={submit}>
|
|
||||||
{verifying && <div className="status-banner">Checking password reset link…</div>}
|
{verifying && <div className="status-banner">Checking password reset link…</div>}
|
||||||
{!verifying && verification && (
|
{!verifying && verification && (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
@@ -132,10 +129,10 @@ function ResetPasswordPageContent() {
|
|||||||
disabled={!verification || loading}
|
disabled={!verification || loading}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="account-notice is-error" role="alert">{error}</div>}
|
||||||
{status && <div className="status-banner">{status}</div>}
|
{status && <div className="account-notice is-status" role="status">{status}</div>}
|
||||||
<div className="auth-actions">
|
<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'}
|
{loading ? 'Updating password…' : 'Reset password'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,13 +140,13 @@ function ResetPasswordPageContent() {
|
|||||||
Back to sign in
|
Back to sign in
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</AuthLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
return (
|
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 />
|
<ResetPasswordPageContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Suspense, useEffect, useMemo, useState } from 'react'
|
import { Suspense, useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import BrandingLogo from '../ui/BrandingLogo'
|
import AuthLayout from '../ui/AuthLayout'
|
||||||
import { clearToken, getApiBase, setToken } from '../lib/auth'
|
import { clearToken, getApiBase, setToken } from '../lib/auth'
|
||||||
|
|
||||||
type InviteInfo = {
|
type InviteInfo = {
|
||||||
@@ -133,11 +133,8 @@ function SignupPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card auth-card">
|
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
<form onSubmit={submit} className="account-form login-form auth-flow-form">
|
||||||
<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">
|
|
||||||
<label>
|
<label>
|
||||||
Invite code
|
Invite code
|
||||||
<div className="invite-lookup-row">
|
<div className="invite-lookup-row">
|
||||||
@@ -162,16 +159,16 @@ function SignupPageContent() {
|
|||||||
<div className="invite-summary-row">
|
<div className="invite-summary-row">
|
||||||
<strong>{invite.label || invite.code}</strong>
|
<strong>{invite.label || invite.code}</strong>
|
||||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
{invite.is_usable ? 'Ready' : 'Unavailable'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{invite.description && <p>{invite.description}</p>}
|
{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>Code: {invite.code}</span>
|
||||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||||
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
|
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
|
||||||
<span>Profile: {invite.profile?.name || 'None'}</span>
|
<span>Profile: {invite.profile?.name || 'None'}</span>
|
||||||
</div>
|
</div></details>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label>
|
<label>
|
||||||
@@ -200,24 +197,24 @@ function SignupPageContent() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="account-notice is-error" role="alert">{error}</div>}
|
||||||
{status && <div className="status-banner">{status}</div>}
|
{status && <div className="account-notice is-status" role="status">{status}</div>}
|
||||||
<div className="auth-actions">
|
<div className="auth-actions">
|
||||||
<button type="submit" disabled={!canSubmit}>
|
<button type="submit" className="account-primary" disabled={!canSubmit}>
|
||||||
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
|
{loading ? 'Creating account…' : 'Create account'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
|
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
|
||||||
Back to sign in
|
Back to sign in
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</AuthLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
return (
|
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 />
|
<SignupPageContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import SettingsNavigation from './SettingsNavigation'
|
import SettingsNavigation from './SettingsNavigation'
|
||||||
|
import PageHeading from './PageHeading'
|
||||||
|
|
||||||
type AdminShellProps = {
|
type AdminShellProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -16,14 +17,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
|||||||
<div className="admin-shell admin-shell--top-nav">
|
<div className="admin-shell admin-shell--top-nav">
|
||||||
<SettingsNavigation />
|
<SettingsNavigation />
|
||||||
<main className="card admin-card">
|
<main className="card admin-card">
|
||||||
<div className="admin-header">
|
<PageHeading title={title} description={subtitle} actions={actions} />
|
||||||
<div>
|
|
||||||
<span className="section-kicker">Configuration</span>
|
|
||||||
<h1>{title}</h1>
|
|
||||||
{subtitle && <p className="lede">{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>}
|
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
|
|||||||
|
|
||||||
export default function ApplicationChrome() {
|
export default function ApplicationChrome() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
if (pathname === '/login') return null
|
if (['/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
|
||||||
return <>
|
return <>
|
||||||
<header className="header">
|
<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>
|
<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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Read-only visual contract review. Live writes are blocked; auth forms use fixtures.
|
||||||
|
// REVIEW_SESSION is a short-lived {name, token} JSON value supplied in memory.
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
||||||
|
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
|
||||||
|
const liveBase = process.env.REVIEW_LIVE_BASE || base
|
||||||
|
const output = process.env.REVIEW_DIR
|
||||||
|
const session = JSON.parse(process.env.REVIEW_SESSION)
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true })
|
||||||
|
const context = await browser.newContext()
|
||||||
|
const errors = []
|
||||||
|
const reports = []
|
||||||
|
const blocked = []
|
||||||
|
await context.addCookies([
|
||||||
|
{ name: session.name, value: session.token, url: base, httpOnly: true },
|
||||||
|
{ name: 'magent_logged_in', value: '1', url: base },
|
||||||
|
])
|
||||||
|
await context.route('**/api/**', async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const url = new URL(request.url())
|
||||||
|
if (url.pathname.includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': review\n\n' })
|
||||||
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
|
||||||
|
blocked.push(url.pathname)
|
||||||
|
return route.fulfill({ status: 409, json: { detail: 'Read-only UI review' } })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await route.fetch({ url: liveBase + url.pathname + url.search, headers: { ...request.headers(), cookie: session.name + '=' + session.token } })
|
||||||
|
await route.fulfill({ response })
|
||||||
|
} catch { await route.abort().catch(() => {}) }
|
||||||
|
})
|
||||||
|
const page = await context.newPage()
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
|
const inspect = async (path, width) => {
|
||||||
|
await page.setViewportSize({ width, height: 1000 })
|
||||||
|
await page.goto(base + path, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.locator('main .page-heading h1').waitFor({ timeout: 30000 })
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const heading = document.querySelector('.page-heading')
|
||||||
|
const style = getComputedStyle(heading)
|
||||||
|
const main = document.querySelector('main').getBoundingClientRect()
|
||||||
|
return { title: heading.querySelector('h1').textContent, headings: document.querySelectorAll('main h1').length,
|
||||||
|
font: getComputedStyle(heading.querySelector('h1')).fontSize, left: main.left, right: innerWidth - main.right,
|
||||||
|
overflow: document.documentElement.scrollWidth - innerWidth, radius: style.borderRadius, background: style.backgroundColor,
|
||||||
|
headerWidth: heading.getBoundingClientRect().width, mainWidth: main.width }
|
||||||
|
})
|
||||||
|
reports.push({ path, width, ...result })
|
||||||
|
if (output) await page.screenshot({ path: output + '/layout-' + width + path.replaceAll('/', '-') + '.png', fullPage: true, animations: 'disabled' })
|
||||||
|
if (result.overflow) console.log(await page.evaluate(() => [...document.querySelectorAll('main *')].filter((el) => el.getBoundingClientRect().right > innerWidth).slice(0, 15).map((el) => ({ tag: el.tagName, class: el.className, width: el.getBoundingClientRect().width }))))
|
||||||
|
assert.equal(result.headings, 1, `${path}: one page title`)
|
||||||
|
assert.equal(result.overflow, 0, `${path}: overflow at ${width}px`)
|
||||||
|
assert.equal(result.left, width <= 680 ? 16 : 32, `${path}: left alignment at ${width}px`)
|
||||||
|
assert.equal(result.right, result.left, `${path}: symmetric gutters`)
|
||||||
|
assert.equal(result.headerWidth, result.mainWidth, `${path}: heading spans the content width`)
|
||||||
|
assert.equal(result.radius, '0px', `${path}: flat page header`)
|
||||||
|
assert.equal(result.background, 'rgba(0, 0, 0, 0)', `${path}: no header panel`)
|
||||||
|
assert.equal(result.font, width === 1440 ? '32px' : '26px', `${path}: shared title scale`)
|
||||||
|
console.log(`PASS ${width}px ${path}`)
|
||||||
|
}
|
||||||
|
// Resolve real records through existing navigation; do not invent or create test records.
|
||||||
|
await page.goto(base)
|
||||||
|
const recent = page.locator('.recent-card').first()
|
||||||
|
await recent.waitFor({ timeout: 30000 })
|
||||||
|
await recent.click()
|
||||||
|
await page.waitForURL('**/requests/*')
|
||||||
|
const requestPath = new URL(page.url()).pathname
|
||||||
|
await page.goto(base + '/users')
|
||||||
|
const user = page.locator('a[href^="/users/"]').first()
|
||||||
|
await user.waitFor({ timeout: 30000 })
|
||||||
|
const userPath = await user.getAttribute('href')
|
||||||
|
const paths = ['/', '/new-requests', '/portal/issues', '/profile/invites', '/profile', requestPath,
|
||||||
|
'/admin', '/admin/seerr', '/admin/jellyfin', '/admin/sonarr', '/admin/radarr', '/admin/bazarr', '/admin/prowlarr',
|
||||||
|
'/admin/qbittorrent', '/admin/site', '/admin/notifications', '/admin/issue-workflow', '/admin/requests',
|
||||||
|
'/admin/general', '/admin/cache', '/admin/artwork', '/admin/logs', '/admin/maintenance', '/admin/invites',
|
||||||
|
'/admin/diagnostics', '/admin/system', '/admin/requests-all', '/users', userPath, '/how-it-works', '/changelog', '/feedback']
|
||||||
|
for (const width of [1440, 390]) for (const path of process.env.REVIEW_PATHS?.split(',') || paths) await inspect(path, width)
|
||||||
|
// Redirects must land on the same styled workspaces.
|
||||||
|
for (const path of ['/portal', '/portal/requests', '/admin/profiles', '/admin/issues']) await inspect(path, 390)
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 })
|
||||||
|
await page.goto(base + '/admin/invites')
|
||||||
|
for (const tab of ['Invite links', 'Profiles', 'Automation', 'Delivery', 'Lineage']) {
|
||||||
|
await page.getByRole('tab', { name: tab, exact: true }).click()
|
||||||
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0, `Invite ${tab} at ${width}px`)
|
||||||
|
}
|
||||||
|
await page.goto(base + requestPath)
|
||||||
|
await page.locator('.request-stage').first().waitFor()
|
||||||
|
const columns = await page.locator('.request-stage').evaluateAll((stages) => new Set(stages.map((el) => Math.round(el.getBoundingClientRect().left))).size)
|
||||||
|
assert.equal(columns, width === 1440 ? 3 : 1, `Pipeline columns at ${width}px`)
|
||||||
|
}
|
||||||
|
await inspect('/layout-review-page-not-found', 390)
|
||||||
|
await context.unrouteAll({ behavior: 'ignoreErrors' })
|
||||||
|
// Authentication layout and form contracts are isolated from all live services.
|
||||||
|
const calls = []
|
||||||
|
await context.route('**/api/**', (route) => {
|
||||||
|
const req = route.request()
|
||||||
|
const path = new URL(req.url()).pathname
|
||||||
|
if (req.method() !== 'GET') calls.push({ path, body: req.postDataJSON() })
|
||||||
|
const reply = (json) => route.fulfill({ status: 200, json })
|
||||||
|
if (path.endsWith('/reset/verify')) return reply({ status: 'valid', recipient_hint: 'm***@example.com', auth_provider: 'jellyfin' })
|
||||||
|
if (path.includes('/auth/invites/')) return reply({ invite: { code: 'review-only', label: 'Welcome', is_usable: true, enabled: true, description: 'Join Grizzlyflix.' } })
|
||||||
|
if (path.endsWith('/password/forgot')) return reply({ message: 'If your account is eligible, a reset link has been sent.' })
|
||||||
|
return reply({})
|
||||||
|
})
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 })
|
||||||
|
for (const path of ['/forgot-password', '/reset-password', '/reset-password?token=review-only', '/signup', '/signup?code=review-only']) {
|
||||||
|
await page.goto(base + path)
|
||||||
|
await page.locator('.login-card').waitFor()
|
||||||
|
assert.equal(await page.locator('.header').count(), 0)
|
||||||
|
assert.equal(await page.locator('.brand-logo--login').count(), 0)
|
||||||
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0, `Auth overflow: ${path} ${width}`)
|
||||||
|
if (output) await page.screenshot({ path: output + '/layout-auth-' + width + path.split('?')[0].replaceAll('/', '-') + (path.includes('?') ? '-valid' : '') + '.png', fullPage: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.goto(base + '/forgot-password')
|
||||||
|
await page.getByLabel('Username or email').fill('member@example.com')
|
||||||
|
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
|
||||||
|
await page.getByRole('status').filter({ hasText: 'reset link has been sent' }).waitFor()
|
||||||
|
assert.equal(calls.at(-1).body.identifier, 'member@example.com')
|
||||||
|
await page.goto(base + '/reset-password?token=review-only')
|
||||||
|
await page.getByLabel('New password', { exact: true }).fill('review-password')
|
||||||
|
await page.getByLabel('Confirm new password', { exact: true }).fill('does-not-match')
|
||||||
|
await page.getByRole('button', { name: 'Reset password', exact: true }).click()
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Passwords do not match' }).waitFor()
|
||||||
|
assert(!calls.some((call) => call.path.endsWith('/password/reset')))
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
if (output) fs.writeFileSync(output + '/layout-report.json', JSON.stringify({ reports, blocked, errors }, null, 2))
|
||||||
|
await context.unrouteAll({ behavior: 'ignoreErrors' })
|
||||||
|
await browser.close()
|
||||||
|
console.log(`PASS: ${reports.length} route/viewport checks; recovery forms tested with fixtures only`)
|
||||||
|
})().catch((error) => { console.error(error); process.exit(1) })
|
||||||
Reference in New Issue
Block a user