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,
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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