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:
yiekheng 2026-05-10 15:19:17 +08:00
parent e38b9ac7b6
commit a5cb8cea46
7 changed files with 121 additions and 33 deletions

View File

@ -30,6 +30,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageShell } from "@/components/page-shell";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries"; import { listActivityRuns } from "@/lib/queries";
import { import {
@ -182,11 +183,10 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const hasAny = runs.length > 0; const hasAny = runs.length > 0;
return ( return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6"> <PageShell
<div className="flex items-center justify-between gap-4"> title="Activity"
{/* Hidden on mobile — the top header already shows "Activity". */} action={
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Activity</h1> hasAny && !showingArchived ? (
{hasAny && !showingArchived && (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive"> <Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
@ -213,9 +213,9 @@ export default async function ActivityPage({ searchParams }: PageProps) {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} ) : undefined
</div> }
>
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived) {/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
packed into a phone-width row left every label squeezed to packed into a phone-width row left every label squeezed to
~50px. Wrap the list in an overflow-x scroller so each tab ~50px. Wrap the list in an overflow-x scroller so each tab
@ -417,6 +417,6 @@ export default async function ActivityPage({ searchParams }: PageProps) {
</CardContent> </CardContent>
</Card> </Card>
)} )}
</div> </PageShell>
); );
} }

View File

@ -38,6 +38,7 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getDashboardStats } from "@/lib/queries"; import { getDashboardStats } from "@/lib/queries";
import { PageShell } from "@/components/page-shell";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Time helpers (no external dep, server-safe) // Time helpers (no external dep, server-safe)
@ -168,10 +169,7 @@ export default async function DashboardPage() {
const hasRuns = stats.recentRuns.length > 0; const hasRuns = stats.recentRuns.length > 0;
return ( return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-8"> <PageShell title="Dashboard">
{/* Hidden on mobile — the top header already shows "Dashboard". */}
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Dashboard</h1>
{/* Stat cards — click to drill into the corresponding tab */} {/* Stat cards — click to drill into the corresponding tab */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard <StatCard
@ -352,6 +350,6 @@ export default async function DashboardPage() {
</Card> </Card>
)} )}
</section> </section>
</div> </PageShell>
); );
} }

View File

@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageShell } from "@/components/page-shell";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listAccounts, listReminders } from "@/lib/queries"; import { listAccounts, listReminders } from "@/lib/queries";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence"; import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
@ -180,10 +181,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
const hasAnyFilter = Boolean(sp.q || sp.accountId); const hasAnyFilter = Boolean(sp.q || sp.accountId);
return ( return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6"> <PageShell
<div className="flex items-center justify-end sm:justify-between gap-4"> title="Reminders"
{/* Hidden on mobile — the top header already shows "Reminders". */} action={
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Reminders</h1>
<Button <Button
asChild asChild
size="lg" size="lg"
@ -195,8 +195,8 @@ export default async function RemindersPage({ searchParams }: PageProps) {
<span className="hidden sm:inline">New Reminder</span> <span className="hidden sm:inline">New Reminder</span>
</Link> </Link>
</Button> </Button>
</div> }
>
<ReminderFilterBar <ReminderFilterBar
accounts={accounts.map((a) => ({ id: a.id, label: a.label }))} accounts={accounts.map((a) => ({ id: a.id, label: a.label }))}
/> />
@ -357,6 +357,6 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</CardContent> </CardContent>
</Card> </Card>
)} )}
</div> </PageShell>
); );
} }

View File

@ -3,14 +3,12 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { NotificationsToggle } from "@/components/notifications-toggle"; import { NotificationsToggle } from "@/components/notifications-toggle";
import { PageShell } from "@/components/page-shell";
export default async function SettingsPage() { export default async function SettingsPage() {
const op = await getSeededOperator(); const op = await getSeededOperator();
return ( return (
<div className="container mx-auto max-w-2xl space-y-6 p-4 sm:p-6"> <PageShell title="Settings">
{/* Hidden on mobile — the top header already shows "Settings". */}
<h1 className="hidden sm:block text-2xl font-semibold">Settings</h1>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Operator</CardTitle> <CardTitle>Operator</CardTitle>
@ -53,7 +51,7 @@ export default async function SettingsPage() {
<p className="text-center text-xs text-muted-foreground"> <p className="text-center text-xs text-muted-foreground">
cm WhatsApp Bot · self-hosted cm WhatsApp Bot · self-hosted
</p> </p>
</div> </PageShell>
); );
} }

View File

@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react"; import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageShell } from "@/components/page-shell";
import { import {
Card, Card,
CardContent, CardContent,
@ -29,10 +30,9 @@ interface AccountsListViewProps {
*/ */
export function AccountsListView({ accounts }: AccountsListViewProps) { export function AccountsListView({ accounts }: AccountsListViewProps) {
return ( return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6"> <PageShell
<div className="flex items-center justify-end sm:justify-between gap-4"> title="Accounts"
{/* Hidden on mobile — the top header already shows "Accounts". */} action={
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Accounts</h1>
<Button <Button
asChild asChild
size="lg" size="lg"
@ -44,8 +44,8 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
<span className="hidden sm:inline">Add Account</span> <span className="hidden sm:inline">Add Account</span>
</Link> </Link>
</Button> </Button>
</div> }
>
{accounts.length > 0 ? ( {accounts.length > 0 ? (
<div <div
data-testid="accounts-grid" data-testid="accounts-grid"
@ -122,6 +122,6 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
</CardContent> </CardContent>
</Card> </Card>
)} )}
</div> </PageShell>
); );
} }

View 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");
});
});

View 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>
);
}