refactor(web): extract PageShell and apply to every tab
Single component now owns the page chrome — wrapper width, padding, vertical rhythm, and the page-header row (hidden-on-mobile H1 + an optional right-aligned action slot). Dashboard, Accounts, Reminders, Activity, and Settings all use it, replacing five copies of the same \`<div className=\"max-w-5xl mx-auto px-4 ...\">\` markup. Settings was previously \`max-w-2xl\` and \`container mx-auto\`; it now matches the other tabs at 5xl so the chrome stays consistent. Covered by 5 SSR tests (header order, responsive justify utilities, wrapper class, action-optional path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e38b9ac7b6
commit
a5cb8cea46
@ -30,6 +30,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageShell } from "@/components/page-shell";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { listActivityRuns } from "@/lib/queries";
|
||||
import {
|
||||
@ -182,11 +183,10 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
const hasAny = runs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Hidden on mobile — the top header already shows "Activity". */}
|
||||
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Activity</h1>
|
||||
{hasAny && !showingArchived && (
|
||||
<PageShell
|
||||
title="Activity"
|
||||
action={
|
||||
hasAny && !showingArchived ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
|
||||
@ -213,9 +213,9 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
|
||||
packed into a phone-width row left every label squeezed to
|
||||
~50px. Wrap the list in an overflow-x scroller so each tab
|
||||
@ -417,6 +417,6 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { getDashboardStats } from "@/lib/queries";
|
||||
import { PageShell } from "@/components/page-shell";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time helpers (no external dep, server-safe)
|
||||
@ -168,10 +169,7 @@ export default async function DashboardPage() {
|
||||
const hasRuns = stats.recentRuns.length > 0;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-8">
|
||||
{/* Hidden on mobile — the top header already shows "Dashboard". */}
|
||||
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||
|
||||
<PageShell title="Dashboard">
|
||||
{/* Stat cards — click to drill into the corresponding tab */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatCard
|
||||
@ -352,6 +350,6 @@ export default async function DashboardPage() {
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageShell } from "@/components/page-shell";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { listAccounts, listReminders } from "@/lib/queries";
|
||||
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
|
||||
@ -180,10 +181,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
const hasAnyFilter = Boolean(sp.q || sp.accountId);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-end sm:justify-between gap-4">
|
||||
{/* Hidden on mobile — the top header already shows "Reminders". */}
|
||||
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Reminders</h1>
|
||||
<PageShell
|
||||
title="Reminders"
|
||||
action={
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
@ -195,8 +195,8 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
<span className="hidden sm:inline">New Reminder</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
<ReminderFilterBar
|
||||
accounts={accounts.map((a) => ({ id: a.id, label: a.label }))}
|
||||
/>
|
||||
@ -357,6 +357,6 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,14 +3,12 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { NotificationsToggle } from "@/components/notifications-toggle";
|
||||
import { PageShell } from "@/components/page-shell";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const op = await getSeededOperator();
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl space-y-6 p-4 sm:p-6">
|
||||
{/* Hidden on mobile — the top header already shows "Settings". */}
|
||||
<h1 className="hidden sm:block text-2xl font-semibold">Settings</h1>
|
||||
|
||||
<PageShell title="Settings">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Operator</CardTitle>
|
||||
@ -53,7 +51,7 @@ export default async function SettingsPage() {
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
cm WhatsApp Bot · self-hosted
|
||||
</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageShell } from "@/components/page-shell";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -29,10 +30,9 @@ interface AccountsListViewProps {
|
||||
*/
|
||||
export function AccountsListView({ accounts }: AccountsListViewProps) {
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-end sm:justify-between gap-4">
|
||||
{/* Hidden on mobile — the top header already shows "Accounts". */}
|
||||
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Accounts</h1>
|
||||
<PageShell
|
||||
title="Accounts"
|
||||
action={
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
@ -44,8 +44,8 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
||||
<span className="hidden sm:inline">Add Account</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
{accounts.length > 0 ? (
|
||||
<div
|
||||
data-testid="accounts-grid"
|
||||
@ -122,6 +122,6 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
59
apps/web/src/components/page-shell.test.tsx
Normal file
59
apps/web/src/components/page-shell.test.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { PageShell } from "./page-shell";
|
||||
|
||||
describe("PageShell", () => {
|
||||
it("renders the title in an H1 hidden on mobile, visible on desktop", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PageShell title="Accounts">
|
||||
<p>body</p>
|
||||
</PageShell>,
|
||||
);
|
||||
expect(html).toMatch(/<h1[^>]*hidden sm:block[^>]*>Accounts<\/h1>/);
|
||||
});
|
||||
|
||||
it("renders the action slot to the right of the H1", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PageShell title="Reminders" action={<button>Add</button>}>
|
||||
<p>body</p>
|
||||
</PageShell>,
|
||||
);
|
||||
// H1 must come before the action button in document order.
|
||||
const h1Idx = html.indexOf("<h1");
|
||||
const buttonIdx = html.indexOf("<button");
|
||||
expect(h1Idx).toBeGreaterThan(-1);
|
||||
expect(buttonIdx).toBeGreaterThan(h1Idx);
|
||||
});
|
||||
|
||||
it("flexes the header row with right-alignment on mobile and space-between on desktop", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PageShell title="x" action={<span>cta</span>}>
|
||||
<p />
|
||||
</PageShell>,
|
||||
);
|
||||
// The header row needs both responsive utilities so the action
|
||||
// button sits on the right when the H1 is hidden.
|
||||
expect(html).toMatch(/justify-end sm:justify-between/);
|
||||
});
|
||||
|
||||
it("renders children inside the standard wrapper width and padding", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PageShell title="x">
|
||||
<p data-testid="body">hello</p>
|
||||
</PageShell>,
|
||||
);
|
||||
expect(html).toMatch(/max-w-5xl/);
|
||||
expect(html).toMatch(/px-4 py-6 sm:px-6 sm:py-8/);
|
||||
expect(html).toContain('data-testid="body"');
|
||||
});
|
||||
|
||||
it("works without an action slot — only the H1 + children render", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PageShell title="Settings">
|
||||
<p>just a card</p>
|
||||
</PageShell>,
|
||||
);
|
||||
expect(html).toContain("Settings");
|
||||
expect(html).toContain("just a card");
|
||||
});
|
||||
});
|
||||
33
apps/web/src/components/page-shell.tsx
Normal file
33
apps/web/src/components/page-shell.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageShellProps {
|
||||
/** Title shown in the desktop H1. The mobile top bar already shows
|
||||
* the same string, so the H1 is hidden below `sm:`. */
|
||||
title: string;
|
||||
/** Optional right-aligned action slot — usually a primary CTA
|
||||
* button. When omitted, the header row collapses to just the H1
|
||||
* on desktop and renders nothing on mobile. */
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard chrome for every top-level tab. Owns the wrapper width,
|
||||
* page padding, vertical rhythm, and the page-header row (hidden H1
|
||||
* on mobile + optional action button on the right). Every tab uses
|
||||
* this so the header pattern stays consistent without each page
|
||||
* repeating the same wrapper markup.
|
||||
*/
|
||||
export function PageShell({ title, action, children }: PageShellProps) {
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-end sm:justify-between gap-4">
|
||||
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user