feat(release): publish minimal self-contained Magent source

This commit is contained in:
Magent release tooling
2026-09-19 16:58:12 +12:00
commit 5fa5d45535
272 changed files with 79305 additions and 0 deletions
+530
View File
@@ -0,0 +1,530 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type DiagnosticCatalogItem = {
key: string;
label: string;
category: string;
description: string;
live_safe: boolean;
target: string | null;
configured: boolean;
config_status: string;
config_detail: string;
};
type DiagnosticResult = {
key: string;
label: string;
category: string;
description: string;
target: string | null;
live_safe: boolean;
configured: boolean;
status: string;
message: string;
detail?: unknown;
checked_at?: string;
duration_ms?: number;
};
type DiagnosticsResponse = {
checks: DiagnosticCatalogItem[];
categories: string[];
generated_at: string;
};
type RunDiagnosticsResponse = {
results: DiagnosticResult[];
summary: {
total: number;
up: number;
down: number;
degraded: number;
not_configured: number;
disabled: number;
};
checked_at: string;
};
type RunMode = "safe" | "all" | "single";
type AdminDiagnosticsPanelProps = {
embedded?: boolean;
};
type DatabaseDiagnosticDetail = {
integrity_check?: string;
database_path?: string;
database_size_bytes?: number;
wal_size_bytes?: number;
shm_size_bytes?: number;
page_size_bytes?: number;
page_count?: number;
freelist_pages?: number;
allocated_bytes?: number;
free_bytes?: number;
row_counts?: Record<string, number>;
timings_ms?: Record<string, number>;
};
const REFRESH_INTERVAL_MS = 30000;
const STATUS_LABELS: Record<string, string> = {
idle: "Ready",
up: "Up",
down: "Down",
degraded: "Degraded",
disabled: "Disabled",
not_configured: "Not configured",
};
function formatCheckedAt(value?: string) {
if (!value) return "Not yet run";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
function formatDuration(value?: number) {
if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
return "Pending";
}
return `${value.toFixed(1)} ms`;
}
function statusLabel(status: string) {
return STATUS_LABELS[status] ?? status;
}
function formatBytes(value?: number) {
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
return "0 B";
}
if (value >= 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${value} B`;
}
function formatDetailLabel(value: string) {
return value.replace(/_/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
if (!detail || typeof detail !== "object" || Array.isArray(detail)) {
return null;
}
return detail as DatabaseDiagnosticDetail;
}
function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
if (values.length === 0) {
return null;
}
return (
<div className="diagnostic-detail-group">
<h4>{title}</h4>
<div className="diagnostic-detail-grid">
{values.map(([label, value]) => (
<div key={`${title}-${label}`} className="diagnostic-detail-item">
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</div>
);
}
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([]);
const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({});
const [runningKeys, setRunningKeys] = useState<string[]>([]);
const [autoRefresh, setAutoRefresh] = useState(true);
const [pageError, setPageError] = useState("");
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null);
const [emailRecipient, setEmailRecipient] = useState("");
const liveSafeKeys = useMemo(() => checks.filter((check) => check.live_safe).map((check) => check.key), [checks]);
const runDiagnostics = useCallback(
async (keys?: string[], mode: RunMode = "single") => {
const baseUrl = getApiBase();
const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key);
if (effectiveKeys.length === 0) {
return;
}
setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])));
setPageError("");
try {
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: effectiveKeys,
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
}),
});
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Diagnostics run failed: ${response.status}`);
}
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse;
const nextResults: Record<string, DiagnosticResult> = {};
for (const result of data.results ?? []) {
nextResults[result.key] = result;
}
setResultsByKey((current) => ({ ...current, ...nextResults }));
setLastRunAt(data.checked_at ?? new Date().toISOString());
setLastRunMode(mode);
} catch (error) {
console.error(error);
setPageError(error instanceof Error ? error.message : "Diagnostics run failed.");
} finally {
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)));
}
},
[checks, emailRecipient, router],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: Authorization bootstrap runs once for each router instance.
useEffect(() => {
let active = true;
const loadPage = async () => {
if (!getToken()) {
router.push("/login");
return;
}
try {
const baseUrl = getApiBase();
const authResponse = await authFetch(`${baseUrl}/auth/me`);
if (!authResponse.ok) {
if (authResponse.status === 401) {
clearToken();
router.push("/login");
return;
}
router.push("/");
return;
}
const me = await authResponse.json();
if (!active) return;
if (me?.role !== "admin") {
router.push("/");
return;
}
const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`);
if (!diagnosticsResponse.ok) {
const text = await diagnosticsResponse.text();
throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`);
}
const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse;
if (!active) return;
setChecks(data.checks ?? []);
setAuthorized(true);
setLoading(false);
const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key);
if (safeKeys.length > 0) {
void runDiagnostics(safeKeys, "safe");
}
} catch (error) {
console.error(error);
if (!active) return;
setPageError(error instanceof Error ? error.message : "Unable to load diagnostics.");
setLoading(false);
}
};
void loadPage();
return () => {
active = false;
};
}, [router]);
useEffect(() => {
if (!authorized || !autoRefresh || liveSafeKeys.length === 0) {
return;
}
const interval = window.setInterval(() => {
void runDiagnostics(liveSafeKeys, "safe");
}, REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(interval);
};
}, [authorized, autoRefresh, liveSafeKeys, runDiagnostics]);
if (loading) {
return <div className="admin-panel">Loading diagnostics...</div>;
}
if (!authorized) {
return null;
}
const orderedCategories: string[] = [];
for (const check of checks) {
if (!orderedCategories.includes(check.category)) {
orderedCategories.push(check.category);
}
}
const mergedResults = checks.map((check) => {
const result = resultsByKey[check.key];
if (result) {
return result;
}
return {
key: check.key,
label: check.label,
category: check.category,
description: check.description,
target: check.target,
live_safe: check.live_safe,
configured: check.configured,
status: check.configured ? "idle" : check.config_status,
message: check.configured ? "Ready to test." : check.config_detail,
checked_at: undefined,
duration_ms: undefined,
} satisfies DiagnosticResult;
});
const summary = {
total: mergedResults.length,
up: 0,
down: 0,
degraded: 0,
disabled: 0,
not_configured: 0,
idle: 0,
};
for (const result of mergedResults) {
const key = result.status as keyof typeof summary;
if (key in summary) {
summary[key] += 1;
}
}
return (
<div className={`diagnostics-page${embedded ? " diagnostics-page-embedded" : ""}`}>
<div className="admin-panel diagnostics-control-panel">
<div className="diagnostics-control-copy">
<h2>{embedded ? "Connectivity diagnostics" : "Control center"}</h2>
<p className="lede">
Check Magent and your connected services. Automatic refresh runs health checks only. Test messages are
managed in Notifications below.
</p>
</div>
<div className="diagnostics-control-actions">
<button
type="button"
className={autoRefresh ? "is-active" : ""}
onClick={() => setAutoRefresh((current) => !current)}
>
{autoRefresh ? "Disable auto refresh" : "Enable auto refresh"}
</button>
<button
type="button"
onClick={() => {
void runDiagnostics(liveSafeKeys, "safe");
}}
disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
>
Run live checks
</button>
<span className={`small-pill ${autoRefresh ? "is-positive" : ""}`}>
{autoRefresh ? "Auto refresh on" : "Auto refresh off"}
</span>
<span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : "No run yet"}</span>
</div>
</div>
<div className="admin-panel diagnostics-inline-summary">
<div className="diagnostics-inline-metric">
<span>Total</span>
<strong>{summary.total}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Up</span>
<strong>{summary.up}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Degraded</span>
<strong>{summary.degraded}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Down</span>
<strong>{summary.down}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Disabled</span>
<strong>{summary.disabled}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Not configured</span>
<strong>{summary.not_configured}</strong>
</div>
<div className="diagnostics-inline-last-run">Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}</div>
</div>
{pageError ? <div className="admin-panel diagnostics-error">{pageError}</div> : null}
{orderedCategories.map((category) => {
const categoryChecks = mergedResults.filter((check) => check.category === category);
return (
<div key={category} className="admin-panel diagnostics-category-panel">
<div className="diagnostics-category-header">
<div>
<h2>{category}</h2>
<p>
{category === "Notifications" ? "These tests can emit real messages." : "Safe live health checks."}
</p>
</div>
<span className="small-pill">{categoryChecks.length} checks</span>
</div>
{category === "Notifications" && (
<div className="diagnostics-notification-controls">
<label className="diagnostics-email-recipient">
<span>Test email recipient</span>
<input
type="email"
placeholder="Leave blank to use configured sender"
value={emailRecipient}
onChange={(event) => setEmailRecipient(event.target.value)}
/>
</label>
<p>Choose where the test email goes. Other channels use their configured destinations.</p>
<button
type="button"
disabled={runningKeys.length > 0 || categoryChecks.length === 0}
onClick={() =>
void runDiagnostics(
categoryChecks.map((check) => check.key),
"all",
)
}
>
Test all notification channels
</button>
</div>
)}
<div className="diagnostics-grid">
{categoryChecks.map((check) => {
const isRunning = runningKeys.includes(check.key);
return (
<article key={check.key} className={`diagnostic-card diagnostic-card-${check.status}`}>
<div className="diagnostic-card-top">
<div className="diagnostic-card-copy">
<div className="diagnostic-card-title-row">
<h3>{check.label}</h3>
<span className={`system-pill system-pill-${check.status}`}>{statusLabel(check.status)}</span>
</div>
<p>{check.description}</p>
</div>
<button
type="button"
className="system-test"
onClick={() => {
void runDiagnostics([check.key], "single");
}}
disabled={isRunning}
>
{check.live_safe ? "Ping" : "Send test"}
</button>
</div>
<div className="diagnostic-meta-grid">
<div className="diagnostic-meta-item">
<span>Target</span>
<strong>{check.target || "Not set"}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Latency</span>
<strong>{formatDuration(check.duration_ms)}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Mode</span>
<strong>{check.live_safe ? "Live safe" : "Manual only"}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Last checked</span>
<strong>{formatCheckedAt(check.checked_at)}</strong>
</div>
</div>
<div className={`diagnostic-message diagnostic-message-${check.status}`}>
<span className="system-dot" />
<span>{isRunning ? "Running diagnostic..." : check.message}</span>
</div>
{check.key === "database"
? (() => {
const detail = asDatabaseDiagnosticDetail(check.detail);
if (!detail) {
return null;
}
return (
<details className="diagnostic-detail-panel">
<summary>Database storage, tables and timings</summary>
{renderDatabaseMetricGroup("Storage", [
["Database file", formatBytes(detail.database_size_bytes)],
["WAL file", formatBytes(detail.wal_size_bytes)],
["Shared memory", formatBytes(detail.shm_size_bytes)],
["Allocated bytes", formatBytes(detail.allocated_bytes)],
["Free bytes", formatBytes(detail.free_bytes)],
["Page size", formatBytes(detail.page_size_bytes)],
["Page count", `${detail.page_count?.toLocaleString() ?? 0}`],
["Freelist pages", `${detail.freelist_pages?.toLocaleString() ?? 0}`],
])}
{renderDatabaseMetricGroup(
"Tables",
Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
formatDetailLabel(key),
value.toLocaleString(),
]),
)}
{renderDatabaseMetricGroup(
"Timings",
Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
formatDetailLabel(key),
`${value.toFixed(1)} ms`,
]),
)}
</details>
);
})()
: null}
</article>
);
})}
</div>
</div>
);
})}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import type { ReactNode } from "react";
import SettingsNavigation from "./SettingsNavigation";
import PageHeading from "./PageHeading";
type AdminShellProps = {
title: string;
subtitle?: string;
actions?: ReactNode;
rail?: ReactNode;
children: ReactNode;
};
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
return (
<div className="admin-shell admin-shell--top-nav">
<SettingsNavigation />
<main className="card admin-card">
<PageHeading title={title} description={subtitle} actions={actions} />
{children}
{rail && (
<details className="admin-supplemental">
<summary>Additional information</summary>
{rail}
</details>
)}
</main>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { isAdminPage } from "../lib/user-view-policy";
import { setUserViewPreview, useUserViewState } from "../lib/viewMode";
export default function AdminViewGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { enabled, ready } = useUserViewState();
if (!isAdminPage(pathname)) return children;
if (!ready)
return (
<main className="card" role="status">
Checking view mode...
</main>
);
if (!enabled) return children;
return (
<main className="card">
<h1>Administrator tools are hidden</h1>
<p>Configuration, user management and other admin tools are unavailable while previewing user view.</p>
<p>Your account is unchanged. Exit the preview to return to this page.</p>
<div className="config-inline-controls">
<a href="/">Go to My Requests</a>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view
</button>
</div>
</main>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { usePathname } from "next/navigation";
import BrandingLogo from "./BrandingLogo";
import HeaderActions from "./HeaderActions";
import HeaderIdentity from "./HeaderIdentity";
import GlobalSearch from "./GlobalSearch";
import SiteStatus from "./SiteStatus";
import UserViewBanner from "./UserViewBanner";
import WorkspaceNavigation from "./WorkspaceNavigation";
export default function ApplicationChrome() {
const pathname = usePathname();
if (
[
"/welcome",
"/coming-soon",
"/login",
"/setup",
"/forgot-password",
"/reset-password",
"/signup",
"/email-recaps",
"/newsletter-subscription",
].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">Your media operations</div>
</div>
</a>
</div>
<div className="header-right">
<HeaderIdentity />
</div>
<div className="header-nav">
<GlobalSearch />
<HeaderActions />
</div>
</header>
<WorkspaceNavigation />
<UserViewBanner />
<SiteStatus />
</>
);
}
+34
View File
@@ -0,0 +1,34 @@
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>
</div>
<header>
<h1 id="login-title">{title}</h1>
<p>{description}</p>
</header>
{children}
{footer && <footer>{footer}</footer>}
</section>
<p className="login-credit">Magent · Request. Watch. Enjoy.</p>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
export default function BrandingFavicon() {
useEffect(() => {
const href = "/api/branding/favicon.ico";
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
link.href = href;
}, []);
return null;
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
type BrandingLogoProps = {
className?: string;
alt?: string;
};
export default function BrandingLogo({ className, alt = "Magent logo" }: BrandingLogoProps) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
return (
<span className={`${className ?? ""} branding-logo-shell`} role="img" aria-label={alt}>
{!failed ? (
<img
className={loaded ? "is-loaded" : undefined}
src="/api/branding/logo.png"
alt=""
aria-hidden="true"
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
) : null}
{!loaded ? (
<svg aria-hidden="true" viewBox="0 0 64 64" focusable="false">
<defs>
<linearGradient id="magentLogoGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#7ed7ff" />
<stop offset="100%" stopColor="#c6c1ff" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="12" fill="#0b1328" />
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
<path d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z" fill="url(#magentLogoGlow)" />
</svg>
) : null}
</span>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { authFetch, getApiBase, getToken } from "../lib/auth";
import { canAccess, featureForPath, type FeatureAccess } from "../lib/features";
import { useEffectiveRole } from "../lib/viewMode";
import { isAdminPage } from "../lib/user-view-policy";
export function useFeatureUser() {
const pathname = usePathname();
const [state, setState] = useState<{
path: string;
user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null;
}>({ path: "", user: null });
const role = useEffectiveRole(state.user?.role);
useEffect(() => {
let active = true;
const load = async () => {
if (!getToken()) {
if (active) setState({ path: pathname, user: null });
return;
}
try {
const response = await authFetch(`${getApiBase()}/auth/me`);
const user = response.ok ? await response.json() : null;
if (active) setState({ path: pathname, user });
} catch {
if (active) setState({ path: pathname, user: null });
}
};
void load();
window.addEventListener("focus", load);
return () => {
active = false;
window.removeEventListener("focus", load);
};
}, [pathname]);
return { user: state.user ? { ...state.user, role: role ?? undefined } : null, ready: state.path === pathname };
}
export default function FeatureGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const feature = featureForPath(pathname);
if (isAdminPage(pathname, false)) {
if (!ready) return <main className="card">Checking administrator access...</main>;
if (user?.role !== "admin") {
return (
<main className="card">
<h1>Administrator access required</h1>
<p>Sign in with an administrator account to use configuration and administration tools.</p>
<a href="/login">Sign in</a>
</main>
);
}
return children;
}
if (!feature) return children;
if (!ready) return <main className="card">Loading account access...</main>;
if (!getToken()) return children;
if (!canAccess(user, feature))
return (
<main className="card">
<h1>Feature unavailable</h1>
<p>Your account does not have access to this feature. Ask an administrator if you need it enabled.</p>
<a href="/profile">Go to my profile</a>
</main>
);
return children;
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../lib/auth";
import { canAccess } from "../lib/features";
import { useFeatureUser } from "./FeatureGate";
type SearchResult = {
title: string;
year?: number | null;
type: "movie" | "tv";
tmdbId: number;
requestId?: number | null;
statusLabel?: string | null;
};
export default function GlobalSearch() {
const router = useRouter();
const { user, ready } = useFeatureUser();
const root = useRef<HTMLDivElement>(null);
const requestVersion = useRef(0);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [open, setOpen] = useState(false);
const [searching, setSearching] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const canSearch = canAccess(user, "new_requests") || canAccess(user, "issues");
const canOpenRequests = canAccess(user, "requests");
const canCreateRequests = canAccess(user, "new_requests");
useEffect(() => {
const close = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
useEffect(() => {
const term = query.trim();
requestVersion.current += 1;
const version = requestVersion.current;
if (term.length < 2 || !canSearch) {
setResults([]);
setSearching(false);
setMessage(null);
return;
}
setSearching(true);
setMessage(null);
const controller = new AbortController();
const timer = window.setTimeout(async () => {
try {
const params = new URLSearchParams({ query: term });
const response = await authFetch(`${getApiBase()}/requests/search?${params.toString()}`, {
signal: controller.signal,
cache: "no-store",
});
if (!response.ok) throw new Error("Search is unavailable right now.");
const payload = await response.json();
if (version !== requestVersion.current) return;
const mapped = (Array.isArray(payload?.results) ? payload.results : [])
.filter(
(item: Record<string, unknown>) =>
typeof item.type === "string" && ["movie", "tv"].includes(item.type) && Number(item.tmdbId) > 0,
)
.slice(0, 7)
.map(
(item: Record<string, unknown>): SearchResult => ({
title: String(item?.title || "Untitled"),
year: typeof item?.year === "number" ? item.year : null,
type: item.type === "tv" ? "tv" : "movie",
tmdbId: Number(item.tmdbId),
requestId: typeof item?.requestId === "number" ? item.requestId : null,
statusLabel: typeof item?.statusLabel === "string" ? item.statusLabel : null,
}),
);
setResults(mapped);
setMessage(mapped.length ? null : "No matching titles found.");
} catch (error) {
if (controller.signal.aborted || version !== requestVersion.current) return;
console.error(error);
setResults([]);
setMessage("Search is unavailable right now.");
} finally {
if (version === requestVersion.current) setSearching(false);
}
}, 280);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [query, canSearch]);
if (!ready || !user || !canSearch) return null;
const openResult = (result: SearchResult) => {
setOpen(false);
setQuery("");
if (result.requestId && canOpenRequests) {
router.push(`/requests/${result.requestId}`);
return;
}
if (canCreateRequests) {
const params = new URLSearchParams({ type: result.type, query: result.title });
router.push(`/new-requests?${params.toString()}`);
return;
}
router.push("/portal/issues");
};
const showResults = open && query.trim().length >= 2;
return (
<div className="global-search" ref={root}>
<search>
<form
onSubmit={(event) => {
event.preventDefault();
if (results[0]) openResult(results[0]);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m16 16 4 4" />
</svg>
<input
type="search"
role="combobox"
value={query}
placeholder="Search titles or requests"
aria-label="Search titles or requests"
aria-autocomplete="list"
aria-expanded={showResults}
aria-controls="global-search-results"
onFocus={() => setOpen(true)}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false);
}}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
/>
{searching && <i className="global-search-spinner" aria-hidden="true" />}
</form>
</search>
{showResults && (
<div className="global-search-results" id="global-search-results" role="listbox">
{results.map((result) => (
<button
type="button"
role="option"
aria-selected="false"
key={`${result.type}:${result.tmdbId}`}
onClick={() => openResult(result)}
>
<span>
<strong>{result.title}</strong>
<small>
{result.type === "tv" ? "TV show" : "Movie"}
{result.year ? ` · ${result.year}` : ""}
</small>
</span>
<b>
{result.requestId && canOpenRequests
? result.statusLabel || "View request"
: canCreateRequests
? "New request"
: "Report issue"}
</b>
</button>
))}
{!searching && message && <p role="status">{message}</p>}
</div>
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { usePathname } from "next/navigation";
import { canAccess, featureForPath } from "../lib/features";
import { useFeatureUser } from "./FeatureGate";
export default function HeaderActions() {
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const role = user?.role ?? null;
const showRequestsNav = canAccess(user, "new_requests");
if (!ready || !user) return null;
const roleItems =
role === null
? []
: role === "admin"
? [
{
href: "/profile/invites",
label: "Invites",
match: (path: string) => path.startsWith("/profile/invites"),
},
{
href: "/admin",
label: "Config",
match: (path: string) => path.startsWith("/admin") || path.startsWith("/users"),
},
]
: [
{
href: "/profile/invites",
label: "Invites",
match: (path: string) => path.startsWith("/profile/invites"),
},
];
const commonItems = [
...(showRequestsNav
? [
{
href: "/new-requests",
label: "New Requests",
match: (path: string) => path === "/new-requests",
},
]
: []),
{
href: "/",
label: "My Requests",
match: (path: string) => path === "/" || path.startsWith("/requests/"),
},
{
href: "/insights",
label: "My Stats",
match: (path: string) => path === "/insights" || path.startsWith("/insights/"),
},
{
href: "/portal/issues",
label: "Issues",
match: (path: string) => path === "/portal/issues" || path === "/admin/issues",
},
];
const items = [...commonItems, ...roleItems].filter((item) => canAccess(user, featureForPath(item.href)));
return (
<nav className="header-actions" aria-label="Primary">
{items.map((item) => {
const active = item.match(pathname);
return (
<a key={item.href} href={item.href} className={active ? "is-active" : undefined}>
{item.label}
</a>
);
})}
</nav>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken, logout } from "../lib/auth";
import { setUserViewPreview, useEffectiveRole, useUserViewPreview } from "../lib/viewMode";
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null);
const [buildNumber, setBuildNumber] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const viewAsUser = useUserViewPreview();
const visibleRole = useEffectiveRole(identity?.role);
useEffect(() => {
const token = getToken();
if (!token) {
setIdentity(null);
setBuildNumber(null);
return;
}
const load = async () => {
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/me`);
if (!response.ok) {
clearToken();
setIdentity(null);
return;
}
const data = await response.json();
if (data?.username) {
setIdentity({ username: data.username, role: data.role });
if (data.role !== "admin") {
setUserViewPreview(false);
}
}
const siteResponse = await fetch(`${baseUrl}/site/public`);
if (siteResponse.ok) {
const siteInfo = await siteResponse.json();
if (siteInfo?.buildNumber) {
setBuildNumber(siteInfo.buildNumber);
}
}
} catch (err) {
console.error(err);
setIdentity(null);
}
};
void load();
}, []);
if (!identity) {
return null;
}
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ""}`;
const initial = identity.username.slice(0, 1).toUpperCase();
const signOut = async () => {
setUserViewPreview(false);
await logout().catch(() => undefined);
clearToken();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
};
return (
<div className="signed-in-context">
{identity.role === "admin" ? (
<button
type="button"
className={`user-view-toggle ${viewAsUser ? "is-active" : ""}`}
aria-pressed={viewAsUser}
onClick={() => setUserViewPreview(!viewAsUser)}
>
{viewAsUser ? "Exit user view" : "View as user"}
</button>
) : null}
<div className="signed-in-menu">
<button
type="button"
className="avatar-button"
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="true"
aria-expanded={open}
title={label}
>
{initial}
</button>
{open && (
<div className="signed-in-dropdown">
<div className="signed-in-header">
Signed in as {label}
{viewAsUser ? <span>Previewing user view</span> : null}
</div>
<div className="signed-in-actions">
<a href="/welcome" onClick={() => setOpen(false)}>
Welcome page
</a>
<a href="/how-it-works" onClick={() => setOpen(false)}>
How it works
</a>
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
{visibleRole === "admin" ? (
<a href="/admin" onClick={() => setOpen(false)}>
Settings
</a>
) : null}
<button type="button" className="signed-in-signout" onClick={() => void signOut()}>
Sign out
</button>
</div>
{buildNumber ? <div className="signed-in-build">Build {buildNumber}</div> : null}
</div>
)}
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import "./invite-delivery.css";
export default function InviteDeliveryChoice({
value,
onChange,
}: {
value: "manual" | "email" | "" | null;
onChange: (method: "manual" | "email") => void;
}) {
return (
<fieldset className="invite-delivery-options">
<legend className="sr-only">Invite delivery method</legend>
{(["manual", "email"] as const).map((method) => (
<button key={method} type="button" aria-pressed={value === method} onClick={() => onChange(method)}>
<span className="delivery-choice-icon" aria-hidden="true">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
{method === "manual" ? (
<>
<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2" />
<path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2" />
</>
) : (
<>
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="m3 7 9 6 9-6" />
</>
)}
</svg>
</span>
<span className="delivery-choice-copy">
<strong>{method === "manual" ? "Copy a link" : "Send an email"}</strong>
<small>
{method === "manual"
? "They add their email when signing up."
: "One use, tied to the recipients email. You get the link too."}
</small>
</span>
<span className="delivery-choice-check" aria-hidden="true">
{value === method ? "✓" : ""}
</span>
</button>
))}
</fieldset>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function MagentMark() {
return (
<svg className="magent-mark" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect x=".5" y=".5" width="39" height="39" rx="11" fill="#242329" stroke="#45434f" />
<path d="M10 29V11h4l6 9 6-9h4v18h-4V18l-6 8-6-8v11h-4Z" fill="#dedaff" />
</svg>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
type PageHeadingProps = {
title: string;
description?: string;
eyebrow?: string;
leading?: ReactNode;
actions?: ReactNode;
};
/** A flat, shared page title. Panels belong to the content below it. */
export default function PageHeading({ title, description, eyebrow, leading, actions }: PageHeadingProps) {
return (
<header className="page-heading">
<div className="page-heading-main">
{leading && <div className="page-heading-leading">{leading}</div>}
<div className="page-heading-copy">
{eyebrow && <span className="page-heading-eyebrow">{eyebrow}</span>}
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
</div>
{actions && <div className="page-heading-actions">{actions}</div>}
</header>
);
}
+33
View File
@@ -0,0 +1,33 @@
export type RequestStage = "all" | "pending" | "in_progress" | "working" | "ready";
const REQUEST_STAGE_OPTIONS: ReadonlyArray<{ value: RequestStage; label: string }> = [
{ value: "all", label: "All" },
{ value: "pending", label: "Waiting" },
{ value: "in_progress", label: "In progress" },
{ value: "working", label: "Working" },
{ value: "ready", label: "Ready" },
];
export default function RequestStageFilter({
value,
onChange,
}: {
value: RequestStage;
onChange: (stage: RequestStage) => void;
}) {
return (
<nav className="request-filter-chips" aria-label="Filter requests by stage">
{REQUEST_STAGE_OPTIONS.map((option) => (
<button
type="button"
key={option.value}
className={value === option.value ? "is-active" : undefined}
onClick={() => onChange(option.value)}
>
{option.value === "working" ? <i aria-hidden="true" /> : null}
{option.label}
</button>
))}
</nav>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import "./resolution-choice.css";
export default function ResolutionChoice({
title,
busy,
onAnswer,
}: {
title: string;
busy: boolean;
onAnswer: (resolved: boolean) => void;
}) {
return (
<section className="resolution-choice" aria-labelledby="resolution-question" aria-busy={busy}>
<span className="section-kicker">Your answer is needed</span>
<h2 id="resolution-question">Is it fixed?</h2>
<p>{title}</p>
<p>Try the affected content in Jellyfin, then choose:</p>
<div className="resolution-choice-buttons">
<button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}>
<strong>YES</strong>
<span>It works close this issue</span>
</button>
<button id="no" type="button" className="resolution-no" disabled={busy} onClick={() => onAnswer(false)}>
<strong>NO</strong>
<span>Still broken keep it open</span>
</button>
</div>
{busy && <p role="status">Saving your answer</p>}
</section>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { CONFIG_GROUPS } from "../admin/configNavigation";
export default function SettingsNavigation() {
const pathname = usePathname();
const router = useRouter();
const current = CONFIG_GROUPS.flatMap((group) => group.items).find((item) => item.href === pathname);
if (pathname === "/admin") return null;
return (
<nav className="settings-top-navigation" aria-label="Settings navigation">
<a href="/admin"> All settings</a>
<label>
<span>Jump to</span>
<select
aria-label="Settings section"
value={current?.href ?? "/admin"}
onChange={(event) => router.push(event.target.value)}
>
<option value="/admin">Settings overview</option>
{CONFIG_GROUPS.map((group) => (
<optgroup label={group.title} key={group.title}>
{group.items.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</optgroup>
))}
</select>
</label>
</nav>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { requestJson } from "../lib/api-client";
import { authFetch } from "../lib/auth";
// Backup access stays available so a fresh installation can be restored before
// connecting any apps. This is navigation only; the API enforces admin access.
export default function SetupGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const bypass = pathname === "/setup" || pathname === "/admin/backups";
const [checked, setChecked] = useState(false);
useEffect(() => {
if (bypass) return;
const controller = new AbortController();
void requestJson<{ setup_required: boolean }>(
"/setup/status",
{ signal: controller.signal, cache: "no-store" },
authFetch,
)
.then((status) => {
if (controller.signal.aborted) return;
if (status.setup_required) router.replace("/setup");
else setChecked(true);
})
.catch(() => {
// Never hide an existing installation during an API outage or rollout.
if (!controller.signal.aborted) setChecked(true);
});
return () => controller.abort();
}, [bypass, router]);
if (bypass || checked) return children;
return (
<main className="card" role="status">
Checking installation...
</main>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState, type CSSProperties } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type BannerInfo = {
enabled: boolean;
message: string;
tone?: string;
backgroundColor?: string | null;
borderColor?: string | null;
};
type SiteInfo = {
buildNumber?: string;
banner?: BannerInfo;
};
const buildRequest = () => {
const token = getToken();
const baseUrl = getApiBase();
const url = token ? `${baseUrl}/site/info` : `${baseUrl}/site/public`;
const fetcher = token ? authFetch : fetch;
return { token, url, fetcher };
};
export default function SiteStatus() {
const [info, setInfo] = useState<SiteInfo | null>(null);
useEffect(() => {
let active = true;
const load = async () => {
try {
const { token, url, fetcher } = buildRequest();
const response = await fetcher(url);
if (!response.ok) {
if (response.status === 401 && token) {
clearToken();
}
return;
}
const data = await response.json();
if (!active) return;
setInfo(data);
} catch (err) {
console.error(err);
}
};
void load();
return () => {
active = false;
};
}, []);
const banner = info?.banner;
const tone = banner?.tone || "info";
const bannerStyle = {
"--site-banner-background-color": banner?.backgroundColor || undefined,
"--site-banner-border-color": banner?.borderColor || undefined,
} as CSSProperties;
return (
<>
{banner?.enabled && banner.message ? (
<div className={`site-banner site-banner--${tone}`} style={bannerStyle}>
{banner.message}
</div>
) : null}
</>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { setUserViewPreview, useUserViewPreview } from "../lib/viewMode";
export default function UserViewBanner() {
const enabled = useUserViewPreview();
if (!enabled) return null;
return (
<div className="user-view-banner" role="status">
<div>
<strong>User view</strong>
<span>
Admin controls are hidden. You are still using your own account and data; backend permissions are unchanged.
</span>
</div>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view
</button>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { usePathname } from "next/navigation";
import { getToken } from "../lib/auth";
import { canAccess, featureForPath } from "../lib/features";
import { useFeatureUser } from "./FeatureGate";
type NavigationItem = {
href: string;
label: string;
shortLabel: string;
icon: "dashboard" | "media" | "issues" | "invites" | "settings" | "stats";
adminOnly?: boolean;
match: (path: string) => boolean;
};
const NAVIGATION: NavigationItem[] = [
{
href: "/new-requests",
label: "New Request",
shortLabel: "New",
icon: "media",
match: (path) => path === "/new-requests",
},
{
href: "/",
label: "My Requests",
shortLabel: "Requests",
icon: "dashboard",
match: (path) => path === "/" || path.startsWith("/requests/"),
},
{
href: "/insights",
label: "My Stats",
shortLabel: "Stats",
icon: "stats",
match: (path) => path === "/insights" || path.startsWith("/insights/"),
},
{
href: "/portal/issues",
label: "Issues",
shortLabel: "Issues",
icon: "issues",
match: (path) => path.startsWith("/portal/issues"),
},
{
href: "/profile/invites",
label: "Invites",
shortLabel: "Invites",
icon: "invites",
match: (path) => path.startsWith("/profile/invites"),
},
{
href: "/admin",
label: "Configuration",
shortLabel: "Config",
icon: "settings",
adminOnly: true,
match: (path) => path.startsWith("/admin") || path.startsWith("/users"),
},
];
const HIDDEN_ROUTES = ["/login", "/signup", "/forgot-password", "/reset-password", "/how-it-works"];
function NavigationIcon({ name }: { name: NavigationItem["icon"] }) {
const paths: Record<NavigationItem["icon"], React.ReactNode> = {
stats: (
<>
<path d="M4 20h16M6 16v-5m6 5V4m6 12V8" />
</>
),
dashboard: (
<>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</>
),
media: (
<>
<rect x="3" y="5" width="18" height="15" rx="2" />
<path d="m8 3 2 4m4-4 2 4M3 10h18" />
<path d="m10 13 5 3-5 3z" />
</>
),
issues: (
<>
<path d="M12 3 2.8 19h18.4L12 3Z" />
<path d="M12 9v4m0 3h.01" />
</>
),
invites: (
<>
<circle cx="9" cy="8" r="3" />
<path d="M3.5 20v-2.2A4.8 4.8 0 0 1 8.3 13h1.4a4.8 4.8 0 0 1 3.8 1.9M17 8v6m-3-3h6" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
};
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
{paths[name]}
</svg>
);
}
export default function WorkspaceNavigation() {
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const role = user?.role;
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
return null;
}
const items = NAVIGATION.filter(
(item) => (!item.adminOnly || role === "admin") && canAccess(user, featureForPath(item.href)),
);
return (
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
{items.map((item) => (
<a key={item.href} href={item.href} className={item.match(pathname) ? "is-active" : undefined}>
<NavigationIcon name={item.icon} />
<span>{item.shortLabel}</span>
</a>
))}
</nav>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import ComingSoonPage from "../coming-soon/page";
import HowItWorksPage from "../how-it-works/page";
import AuthLayout from "./AuthLayout";
describe("portable default branding", () => {
it("uses Magent branding without replacing the supplied sign-in content", () => {
const html = renderToStaticMarkup(
<AuthLayout title="Welcome to our library" description="Use your account" footer="Local help">
<p>A custom message</p>
</AuthLayout>,
);
expect(html).toContain("Magent · Request. Watch. Enjoy.");
expect(html).toContain("Welcome to our library");
expect(html).toContain("A custom message");
expect(html).toContain("Local help");
expect(html).not.toMatch(/grizzlyflix/i);
expect(html).not.toContain(">Beta<");
});
it("shows a generic coming-soon page", () => {
const html = renderToStaticMarkup(<ComingSoonPage />);
expect(html).toContain("MAGENT");
expect(html).toContain("Your media member portal");
expect(html).not.toMatch(/grizzlyflix/i);
});
it("explains the Jellyfin integration without a deployment-specific service name", () => {
const html = renderToStaticMarkup(<HowItWorksPage />);
expect(html).toContain("Jellyfin is where you watch them.");
expect(html).not.toMatch(/grizzlyflix/i);
});
});
+15
View File
@@ -0,0 +1,15 @@
.invite-delivery-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.page .invite-delivery-options > button { display: flex; align-items: center; justify-content: flex-start; gap: 14px; min-height: 96px; padding: 18px; text-align: left; border-radius: 12px; background: var(--ops-panel-2, #202023) !important; border: 1px solid var(--ops-line, #444) !important; color: var(--ops-text, #eee) !important; text-transform: none; }
.page .invite-delivery-options > button[aria-pressed='true'] { background: #302d3e !important; border-color: #c7bdff !important; box-shadow: inset 0 0 0 1px #c7bdff; }
.invite-delivery-options > button:focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
.invite-delivery-options .delivery-choice-icon { display: grid; place-items: center; width: 42px; height: 42px; flex: 0 0 42px; border-radius: 10px; background: #ffffff09; color: #c7bdff; }
.delivery-choice-icon svg { width: 24px; height: 24px; }
.invite-delivery-options .delivery-choice-copy { display: grid; gap: 6px; min-width: 0; flex: 1; }
.delivery-choice-copy strong { font-size: .95rem; color: var(--ops-text, #eee); }
.delivery-choice-copy small { font-size: .8rem; font-weight: 400; line-height: 1.45; color: var(--ops-muted, #bbb); }
.invite-delivery-options .delivery-choice-check { flex: 0 0 20px; width: 20px; height: 20px; border: 1px solid var(--ops-line, #666); border-radius: 50%; display: grid; place-items: center; color: #c7bdff; font-size: .8rem; }
.invite-delivery-fields { align-items: start; }
.invite-delivery-fields > label { display: grid; align-content: start; gap: 8px; }
.invite-delivery-fields input { min-height: 48px; }
.invite-delivery-fields textarea { min-height: 88px; resize: vertical; }
@media (max-width: 640px) { .invite-delivery-options { grid-template-columns: 1fr; } }
+13
View File
@@ -0,0 +1,13 @@
.resolution-choice { padding: clamp(20px, 4vw, 36px); border: 1px solid var(--ops-border, #555); border-radius: 18px; background: var(--ops-surface, #202023); margin-bottom: 20px; }
.resolution-choice h2 { margin: 10px 0; font-size: clamp(2rem, 5vw, 3.25rem); line-height: 1.1; }
.resolution-choice p { line-height: 1.5; overflow-wrap: anywhere; }
.resolution-choice-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px; }
.resolution-choice-buttons button { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 130px; padding: 20px; border-radius: 14px; border: 2px solid transparent; text-transform: none; }
.resolution-choice-buttons button strong { font-size: 2.75rem; line-height: 1; color: inherit; }
.resolution-choice-buttons button span { color: inherit; opacity: 1; font-size: .9rem; }
/* Scoped overrides for the legacy global !important button palette. */
.page .resolution-choice-buttons button.resolution-yes { background: #b4f4d2 !important; color: #10261b !important; border-color: #b4f4d2 !important; }
.page .resolution-choice-buttons button.resolution-no { background: #ffc1c5 !important; color: #391318 !important; border-color: #ffc1c5 !important; }
.resolution-choice-buttons button:focus-visible { outline: 3px solid var(--ops-accent, #c7baff); outline-offset: 4px; }
.resolution-response-page { width: min(760px, 100%); margin: 20px auto; }
@media (max-width: 520px) { .resolution-choice-buttons { grid-template-columns: 1fr; } .resolution-choice-buttons button { min-height: 104px; } }