From df9f30b62611d31af4bc373116139c60992a19b7 Mon Sep 17 00:00:00 2001 From: Mohamed Tayeb Mokni Date: Mon, 25 May 2026 15:48:32 +0200 Subject: [PATCH 1/5] feat(admin): brand-restyle sidebar + top header (Living systems) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar adopts the forest-dark surface from docs/design/ui_kits/admin/index.html — org switcher chip with GoNext wordmark + plan dot, sectioned nav (Workspace · Content · Studio · Operator), Lucide icons that retune to emerald-bright on the active row, and the radial-glow "Grow to Agency" upgrade card. Adds an emerald initials avatar + sign-out trigger in the foot. A new carries the GoNext wordmark (italic "Next" accent), an "Admin" eyebrow, a "view site" anchor, and a notifications bell — matching the 54px topbar in the design prototype. The legacy Sidebar.test.tsx contract (collapse toggle + 13 canonical destinations) is preserved without modification. Add recharts as a dependency so the dashboard data-viz can be built without each call site bringing its own chart library. Signed-off-by: Mohamed Tayeb Mokni --- apps/admin/package.json | 1 + .../(authenticated)/_components/Sidebar.tsx | 296 ++++--- .../(authenticated)/_components/TopHeader.tsx | 50 ++ apps/admin/src/app/(authenticated)/layout.tsx | 8 +- apps/admin/src/app/globals.css | 444 +++++++++- pnpm-lock.yaml | 777 +++++++----------- 6 files changed, 956 insertions(+), 620 deletions(-) create mode 100644 apps/admin/src/app/(authenticated)/_components/TopHeader.tsx diff --git a/apps/admin/package.json b/apps/admin/package.json index 6df6e24e..af09ff37 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -31,6 +31,7 @@ "next": "^15.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "recharts": "^3.8.1", "sonner": "^1.7.1", "tailwind-merge": "^2.5.5" }, diff --git a/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx b/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx index 6ac0f4b6..cc6a7538 100644 --- a/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx +++ b/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx @@ -1,39 +1,55 @@ 'use client'; /** - * Sidebar — collapsible primary navigation for the admin shell. + * Sidebar — primary navigation, brand "Living systems" treatment. * - * The link set matches the canonical admin information architecture - * (see docs/05-admin-information-architecture.md). Sections whose feature - * code has not yet landed (Pages, Comments, Media) link to "Coming soon" - * placeholder routes so the nav stays visible and the IA is testable in - * isolation from the feature rollout. + * Replaces the scaffold sidebar with the forest-dark surface from + * `docs/design/ui_kits/admin/index.html`: * - * The collapsed state is local to this component for the scaffold; once the - * user-preferences store lands (issue #43) it should be lifted there so the - * choice persists across sessions. + * - 248px wide forest (#0E1A14) surface, forest-border hairlines. + * - Org-switch chip at the top (logo-mark + workspace name + plan + * dot). Cosmetic for now; the workspace switcher lands in its + * own issue. + * - Grouped nav with section heads (Workspace · Content · Studio). + * Lucide icons render in emerald-bright when the section is + * active. + * - "Grow to Agency" upgrade card with the brand's organic-glow + * radial gradient and emerald CTA. Static copy at this stage; + * once the billing surface lands it can become dismissable. + * - Side-foot user chip: emerald avatar (display-type initials), + * name + email, log-out trigger. * - * Visual treatment follows docs/design/HANDOFF.md "admin dashboard" - * shell: forest-ink sidebar surface, --r-md hairlines, Lucide icons - * paired with Geist labels, and the GoNext wordmark logo in place of - * the original "GoNext" plain-text label. The icon-rendering hooks - * use `aria-hidden` so screen readers still see only the link's - * label, preserving the accessible-name contract the existing tests - * pin. + * The visible nav set is the canonical IA from + * `docs/05-admin-information-architecture.md`. Pages that the + * scaffold doesn't yet ship still point at their target route; the + * target page just renders the placeholder until the feature lands. + * + * Auth-related behaviour (collapsing, search) is preserved from the + * pre-restyle sidebar — collapsed state is local for now, GlobalSearch + * still mounts under the org switcher. */ import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { useState, type ComponentType, type ReactElement, type SVGProps } from 'react'; +import { + useState, + type ComponentType, + type ReactElement, + type SVGProps, +} from 'react'; import { Activity, + Bell, ChevronLeft, ChevronRight, + ChevronsUpDown, CornerDownRight, + ExternalLink, FileText, Gauge, ImageIcon, Layout, LineChart, + LogOut, MessageSquare, Palette, Plug, @@ -47,56 +63,65 @@ import { GlobalSearch } from '../../../components/GlobalSearch'; type LucideIcon = ComponentType>; -type NavItem = { +interface NavItem { href: string; label: string; Icon: LucideIcon; -}; + /** Right-aligned count badge (cosmetic for now — wired in a follow-up). */ + count?: string; +} + +interface NavSection { + head: string; + items: readonly NavItem[]; +} -const NAV_ITEMS: readonly NavItem[] = [ - { href: '/', label: 'Dashboard', Icon: Gauge }, - { href: '/posts', label: 'Posts', Icon: FileText }, - { href: '/pages', label: 'Pages', Icon: Layout }, - { href: '/comments', label: 'Comments', Icon: MessageSquare }, - { href: '/media', label: 'Media', Icon: ImageIcon }, - { href: '/users', label: 'Users', Icon: Users }, - // Search is the full-page result view that the sidebar GlobalSearch - // overlay routes to on Enter-without-focus. We expose it as a nav - // entry so deep-linked /search?q=… URLs still slot into the IA; - // most users will reach it through the always-visible search input. - { href: '/search', label: 'Search', Icon: Search }, - { href: '/plugins', label: 'Plugins', Icon: Plug }, - // Marketplace is the browse + install entry point sibling to the - // installed-plugins manager. Adminstrators land here to discover new - // plugins; the install flow on this surface reuses the same - // CapabilityReview component as the manual install path. - { href: '/marketplace', label: 'Marketplace', Icon: Store }, - // Appearance → Site Editor surface (issue #428). The link points - // at the lite cut today; v0.2 expands the same section with full - // template editing. - { href: '/appearance/site-editor', label: 'Appearance', Icon: Palette }, - // Appearance funnels into the Theme Customizer surface (#355). The - // link points straight at /appearance/customizer because that's the - // only landing inside the section today; an index page lands when - // theme installation arrives. - { href: '/appearance/customizer', label: 'Customize', Icon: Sliders }, - // Redirects is the WordPress-parity 301/302/307/308 admin surface. - // Operators manage literal and regex rules; the API middleware - // serves matched paths with the configured status BEFORE the - // renderer sees them. - { href: '/redirects', label: 'Redirects', Icon: CornerDownRight }, - { href: '/settings', label: 'Settings', Icon: Settings }, - // System Status is the operator surface (issue #221). It sits at the - // bottom of the IA so it doesn't compete with the content-authoring - // sections that occupy the rest of the sidebar. Access is gated - // server-side via the system_read capability; the link itself is - // visible to every signed-in user (a non-admin clicking through gets - // a 403 on the API call and the page renders the error state). - { href: '/status', label: 'System Status', Icon: Activity }, - // Performance is the Core Web Vitals operator surface fed by the - // in-house RUM beacon (issue #132). Same access posture as System - // Status: visible in the nav, server-side capability-gated. - { href: '/performance', label: 'Performance', Icon: LineChart }, +/** + * Grouped IA. Section heads mirror the ui_kit prototype: + * • Workspace — overview + pulse (the brand's analytics surface). + * • Content — posts, pages, media, comments, users (CRUD). + * • Studio — appearance, customizer, marketplace, plugins, + * redirects, search, settings + operator surfaces + * (status, performance). + * Counts are cosmetic placeholders until the real aggregate endpoint + * lands — see issue #76. + */ +const NAV_SECTIONS: readonly NavSection[] = [ + { + head: 'Workspace', + items: [ + { href: '/', label: 'Dashboard', Icon: Gauge }, + ], + }, + { + head: 'Content', + items: [ + { href: '/posts', label: 'Posts', Icon: FileText }, + { href: '/pages', label: 'Pages', Icon: Layout }, + { href: '/comments', label: 'Comments', Icon: MessageSquare }, + { href: '/media', label: 'Media', Icon: ImageIcon }, + { href: '/users', label: 'Users', Icon: Users }, + ], + }, + { + head: 'Studio', + items: [ + { href: '/appearance/site-editor', label: 'Appearance', Icon: Palette }, + { href: '/appearance/customizer', label: 'Customize', Icon: Sliders }, + { href: '/marketplace', label: 'Marketplace', Icon: Store }, + { href: '/plugins', label: 'Plugins', Icon: Plug }, + { href: '/redirects', label: 'Redirects', Icon: CornerDownRight }, + { href: '/search', label: 'Search', Icon: Search }, + { href: '/settings', label: 'Settings', Icon: Settings }, + ], + }, + { + head: 'Operator', + items: [ + { href: '/status', label: 'System Status', Icon: Activity }, + { href: '/performance', label: 'Performance', Icon: LineChart }, + ], + }, ]; function isActive(currentPath: string, href: string): boolean { @@ -107,7 +132,7 @@ function isActive(currentPath: string, href: string): boolean { } /** - * Inline-renders the GoNext wordmark SVG instead of pulling it via + * Inline-renders the GoNext wordmark instead of pulling it via * so the mark inherits `currentColor` * for paper-on-forest contrast without shipping a second asset just * for the dark sidebar. @@ -115,29 +140,8 @@ function isActive(currentPath: string, href: string): boolean { function WordmarkLogo(): ReactElement { return ( - - Go - - - Next - + Go + Next ); } @@ -151,10 +155,27 @@ export function Sidebar(): ReactElement { className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'} aria-label="Primary navigation" > -
- + {/* Org switcher — workspace + plan dot. Collapse toggle sits + to the right so the sidebar can fold to icon-only without + losing access to the workspace switcher. */} +
+ + {!collapsed && ( + <> + + Your workspace + Studio plan + +
+ {!collapsed && (
)} -
-
-

Activity

-

- Recent edits, publishes, and comments. -

-
+ {/* ─── Stat tiles ─── */} +
+ {STAT_TILES.map((tile) => ( + + ))} +
-
-

Quick draft

-

- Start a new post without leaving the dashboard. -

-
+ {/* ─── Reader-flow forest pulse card ─── */} +
+ {/* Organic radial-glow on a dark surface — the brand's + signature backdrop pattern. */} + -
-

News

-

- Release notes and project updates from the GoNext team. + {/* ─── Histogram + sidebar ─── */} +

+
+
+
+
+ Reader sessions · 24h +
+ + Where they linger. + +
+
+ + count + + + 7,842 sessions + +
+
+
+ +
+

+ Lavender bars are the session-length distribution; peaks in + emerald mark where most + readers spend their time.

-
+
+ +
+
+ Recent activity +
+ + What just happened. + +
    +
  • + + + + Deploy succeeded · + v1.2.4 → production + + 14 routes invalidated · TTFB unchanged + + +
  • +
  • + + + + Reader from Tokyo opened + Drip vs. pour-over + + Mobile Safari · 3rd session + + +
  • +
  • + + + + New draft Holiday hours picked up by Mara + + Auto-save · 12 minutes ago + + +
  • +
+
); diff --git a/apps/admin/src/components/ui/brand-chart.tsx b/apps/admin/src/components/ui/brand-chart.tsx new file mode 100644 index 00000000..878a8fd1 --- /dev/null +++ b/apps/admin/src/components/ui/brand-chart.tsx @@ -0,0 +1,312 @@ +'use client'; + +/** + * BrandChart — recharts wrapper that ports the brand "Living systems" + * data-viz language onto a real charting library. + * + * Why a wrapper? + * The brand has a strong opinion on chart appearance — emerald and + * lavender bars on a paper surface; emerald-bright peaks; soft + * gridlines; Geist Mono axis ticks. Threading those choices into + * every chart call site would duplicate the brand contract. + * This module concentrates the choices in one place so the canvas + * stays consistent across the dashboard, pulse, and any + * per-resource report screens that ship later. + * + * Two primitives are exported: + * + * + * Vertical bar chart on a paper-2 card. Bars are lavender by + * default; rows flagged `accent: 'emerald'` swap to emerald-bright + * — matching the "peak bars" pattern from + * docs/design/ui_kits/admin/pulse.html. + * + * + * Time-series line on paper. The primary line is emerald; an + * optional conversions overlay renders in lavender. A soft + * gradient fills under the primary line. + * + * Both primitives use a 240px default height and stretch to fill + * their container. They render inside ResponsiveContainer so they + * adapt to whatever card width they're dropped into. + */ +import * as React from 'react'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { cn } from '@/lib/utils'; + +// Token-mirrored colour constants. These mirror docs/design/colors_and_type.css +// — when the tokens move the constants here must move too. We intentionally +// hard-code them here rather than reading CSS variables because Recharts +// computes colours during a layout pass that happens before the cascade +// resolves on the SVG primitives. +const EMERALD = '#10B981'; +const EMERALD_BRIGHT = '#34D399'; +const EMERALD_DEEP = '#047857'; +const LAVENDER = '#A78BFA'; +const LAVENDER_DEEP = '#7C3AED'; +const PAPER_3 = '#E6E1D2'; +const BORDER = '#D9D2C0'; +const FG_MUTED = '#4A5C52'; +const FG_SUBTLE = '#6B7B72'; +const INK = '#0E1A14'; + +export interface BarChartDatum { + name: string; + value: number; + /** When 'emerald', the bar renders in emerald-bright — peak emphasis. */ + accent?: 'lavender' | 'emerald'; +} + +export interface BarChartSurfaceProps { + data: BarChartDatum[]; + /** Pixel height for the chart canvas. Default 240. */ + height?: number; + /** Optional accessible label for the canvas. */ + ariaLabel?: string; + className?: string; +} + +export function BarChartSurface({ + data, + height = 240, + ariaLabel, + className, +}: BarChartSurfaceProps): React.ReactElement { + return ( +
+ + + + + + + + {data.map((entry, index) => ( + + ))} + + + +
+ ); +} + +export interface LineChartDatum { + name: string; + value: number; + conversions?: number; +} + +export interface LineChartSurfaceProps { + data: LineChartDatum[]; + height?: number; + /** When true, draw a lavender conversions overlay using `data.conversions`. */ + showConversions?: boolean; + ariaLabel?: string; + className?: string; +} + +export function LineChartSurface({ + data, + height = 240, + showConversions = false, + ariaLabel, + className, +}: LineChartSurfaceProps): React.ReactElement { + const gradientId = React.useId(); + return ( +
+ + + + + + + + + + + + + + {showConversions ? ( + + ) : null} + + +
+ ); +} + +/** + * Sparkline — micro bar chart for inline stat tiles. Pure SVG (no + * recharts) so it stays cheap to render dozens at a time. Bars are + * paper-4 with the highest bar(s) in emerald (or lavender if + * `accent='lavender'`). + */ +export interface SparklineProps { + /** Array of bar heights as percentages (0 to 100). */ + values: number[]; + /** Highlight the top N tallest bars in the accent colour. Default 1. */ + peakCount?: number; + /** Colour used for peak bars. Default emerald. */ + accent?: 'emerald' | 'lavender'; + className?: string; + ariaLabel?: string; +} + +export function Sparkline({ + values, + peakCount = 1, + accent = 'emerald', + className, + ariaLabel, +}: SparklineProps): React.ReactElement { + // Find the N highest values so we know which bars get the accent. + const peakSet = React.useMemo(() => { + const sorted = [...values] + .map((v, i) => ({ v, i })) + .sort((a, b) => b.v - a.v) + .slice(0, Math.max(0, peakCount)) + .map((x) => x.i); + return new Set(sorted); + }, [values, peakCount]); + + const peakColour = accent === 'emerald' ? EMERALD : LAVENDER; + return ( +
+ {values.map((value, index) => ( + + ))} +
+ ); +} From 104ba363ead168efc2779b85d0f8ac3eea87be2d Mon Sep 17 00:00:00 2001 From: Mohamed Tayeb Mokni Date: Mon, 25 May 2026 15:53:38 +0200 Subject: [PATCH 3/5] feat(admin): brand-restyle posts list ("All *posts*.") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts the brand display-type header from docs/design/ui_kits/admin/index.html — "All *posts*." with the italic-serif accent on "posts", a sub-line in fg-muted, and the Import / New post action cluster on the right. The actions migrate to the shadcn + + + }> diff --git a/apps/admin/src/app/(authenticated)/posts/posts.module.css b/apps/admin/src/app/(authenticated)/posts/posts.module.css index 50de4768..6342070f 100644 --- a/apps/admin/src/app/(authenticated)/posts/posts.module.css +++ b/apps/admin/src/app/(authenticated)/posts/posts.module.css @@ -1,90 +1,129 @@ /* * Scoped styles for the Posts list screen. * - * The admin scaffold is intentionally pre-Tailwind (see globals.css and - * docs/05-admin-api.md §2.3) — when the design-system extraction lands - * (issue #34) these primitives migrate to design tokens. For now we keep - * them local to the route so any future rename / tear-down is a single - * directory delete. + * "Living systems" brand restyle: cream paper-2 panel, emerald + * filter-chip on active, paper-3 row hover. Mirrors the table + * treatment in `docs/design/ui_kits/admin/index.html`. */ .toolbar { display: flex; flex-wrap: wrap; - gap: var(--space-3); + gap: var(--s-3); align-items: center; - margin-bottom: var(--space-4); - padding: var(--space-3); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); + padding: 14px 18px; + background: var(--paper-2); + border: 1px solid var(--border); + border-bottom: 0; + border-radius: var(--r-lg) var(--r-lg) 0 0; } .search { flex: 1 1 240px; min-width: 180px; - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 8px 12px; font: inherit; + font-size: var(--t-sm); + color: var(--ink); + background: var(--paper); + transition: border-color var(--dur) var(--ease), + box-shadow var(--dur) var(--ease); +} + +.search:hover { + border-color: var(--border-strong); } .search:focus { - outline: 2px solid var(--color-accent); - outline-offset: -1px; - border-color: var(--color-accent); + outline: none; + border-color: var(--emerald); + box-shadow: var(--sh-focus); +} + +.search::placeholder { + color: var(--fg-faint); } .chipGroup { display: inline-flex; flex-wrap: wrap; - gap: var(--space-2); + gap: 4px; + padding: 2px; + background: var(--paper-3); + border-radius: var(--r-md); } .chip { background: transparent; - border: 1px solid var(--color-border); - border-radius: 999px; - padding: 4px var(--space-3); - font-size: 13px; - color: var(--color-text); + border: 1px solid transparent; + border-radius: var(--r-sm); + padding: 5px 12px; + font-family: var(--font-sans); + font-size: var(--t-xs); + font-weight: 500; + color: var(--fg-muted); + cursor: pointer; + transition: background var(--dur) var(--ease), color var(--dur) var(--ease); } .chip:hover { - border-color: var(--color-accent); + color: var(--ink); } .chipActive { - background: var(--color-accent); - color: white; - border-color: var(--color-accent); + background: var(--emerald-soft); + color: var(--emerald-deep); + border-color: transparent; + box-shadow: var(--sh-xs); } .chipActive:hover { - background: var(--color-accent-hover); - border-color: var(--color-accent-hover); + background: var(--emerald-soft); + color: var(--emerald-deep); } .bulkBar { display: inline-flex; align-items: center; - gap: var(--space-2); + gap: var(--s-2); } .bulkSelect { - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: var(--space-1) var(--space-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 6px 10px; font: inherit; - background: var(--color-surface); + font-size: var(--t-sm); + background: var(--paper); + color: var(--ink); +} + +.bulkSelect:focus { + outline: none; + border-color: var(--emerald); + box-shadow: var(--sh-focus); } .bulkApply { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 4px var(--space-3); - font-size: 13px; + background: var(--paper-2); + color: var(--ink); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 5px 14px; + font-family: var(--font-sans); + font-size: var(--t-xs); + font-weight: 500; + cursor: pointer; + box-shadow: var(--sh-xs); + transition: background var(--dur) var(--ease), + border-color var(--dur) var(--ease); +} + +.bulkApply:hover:not(:disabled) { + background: var(--paper-3); + border-color: var(--border-strong); } .bulkApply:disabled { @@ -93,27 +132,45 @@ } .tableWrap { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); + background: var(--paper); + border: 1px solid var(--border); + border-top: 1px solid var(--border); + border-radius: 0 0 var(--r-lg) var(--r-lg); overflow-x: auto; } .table { width: 100%; border-collapse: collapse; - font-size: 14px; + font-size: var(--t-sm); + background: var(--paper); } .table thead { - background: var(--color-bg); + background: var(--paper-2); } .table th, .table td { text-align: left; - padding: var(--space-3); - border-bottom: 1px solid var(--color-border); + padding: 13px 18px; + border-bottom: 1px solid var(--border-subtle); +} + +.table th { + font-family: var(--font-sans); + font-size: var(--t-xs); + font-weight: 500; + color: var(--fg-subtle); + letter-spacing: 0; +} + +.table tbody tr { + transition: background var(--dur-fast) var(--ease); +} + +.table tbody tr:hover { + background: var(--paper-3); } .table tbody tr:last-child td { @@ -125,8 +182,10 @@ border: 0; padding: 0; font: inherit; - font-weight: 600; - color: var(--color-text); + font-family: var(--font-sans); + font-size: var(--t-xs); + font-weight: 500; + color: var(--fg-subtle); cursor: pointer; display: inline-flex; align-items: center; @@ -134,75 +193,112 @@ } .sortButton:hover { - color: var(--color-accent); + color: var(--emerald-deep); } .sortArrow { font-size: 10px; - color: var(--color-text-muted); + color: var(--fg-subtle); } +/* ─── Status badges — token-driven ─── */ .badge { - display: inline-block; - padding: 2px var(--space-2); - border-radius: 999px; - font-size: 12px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--r-sm); + font-family: var(--font-sans); + font-size: var(--t-xs); font-weight: 500; - background: var(--color-bg); - color: var(--color-text-muted); - border: 1px solid var(--color-border); + background: var(--paper-3); + color: var(--fg-muted); + border: 1px solid var(--border); + line-height: 1.5; +} + +.badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + opacity: 0.9; } .badgePublished { - background: #e6f4ea; - color: #137333; - border-color: #c1e3cb; + background: var(--success-soft); + color: var(--success); + border-color: transparent; } .badgeDraft { - background: #fef7e0; - color: #92740c; - border-color: #f7e6a2; + background: var(--paper-3); + color: var(--fg-muted); + border-color: var(--border); } .badgeTrash { - background: #fce8e6; - color: #c5221f; - border-color: #f4c7c3; + background: var(--danger-soft); + color: var(--danger); + border-color: transparent; +} + +.badgeScheduled { + background: var(--lavender-soft); + color: var(--lavender-deep); + border-color: transparent; } .empty { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: var(--space-8); + background: var(--paper-2); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: var(--s-8); text-align: center; + box-shadow: var(--sh-xs); } .empty h2 { - font-size: 18px; - margin-bottom: var(--space-2); + font-family: var(--font-display); + font-weight: 800; + font-size: var(--t-xl); + margin: 0 0 var(--s-2); + color: var(--ink); } .empty p { - color: var(--color-text-muted); - margin: 0 0 var(--space-4); + color: var(--fg-muted); + margin: 0 0 var(--s-4); } .error { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-left: 3px solid #c5221f; - border-radius: var(--radius); - padding: var(--space-6); + background: var(--paper-2); + border: 1px solid var(--border); + border-left: 3px solid var(--danger); + border-radius: var(--r-lg); + padding: var(--s-6); +} + +.error h2 { + font-family: var(--font-display); + font-weight: 700; + font-size: var(--t-lg); + margin: 0 0 var(--s-2); + color: var(--ink); } .skeletonRow { height: 18px; - margin: var(--space-3) 0; - background: linear-gradient(90deg, #eef0f3 0%, #f6f7f9 50%, #eef0f3 100%); + margin: var(--s-3) 0; + background: linear-gradient( + 90deg, + var(--paper-2) 0%, + var(--paper-3) 50%, + var(--paper-2) 100% + ); background-size: 200% 100%; - border-radius: 4px; + border-radius: var(--r-xs); animation: skeletonShimmer 1.4s ease-in-out infinite; } @@ -218,33 +314,44 @@ .loadMoreWrap { display: flex; justify-content: center; - margin-top: var(--space-4); + margin-top: var(--s-4); } .headerBar { display: flex; justify-content: space-between; align-items: center; - margin-bottom: var(--space-4); + margin-bottom: var(--s-4); } .primaryAction { - background: var(--color-accent); - color: white; - border: 0; - border-radius: var(--radius); - padding: var(--space-2) var(--space-4); - font-weight: 500; + background: var(--emerald); + color: var(--emerald-ink); + border: 1px solid var(--emerald); + border-radius: var(--r-md); + padding: 9px 16px; + font-family: var(--font-display); + font-weight: 700; + font-size: var(--t-sm); text-decoration: none; - font-size: 14px; display: inline-block; + transition: background var(--dur) var(--ease), color var(--dur) var(--ease); + box-shadow: var(--sh-xs); } .primaryAction:hover { - background: var(--color-accent-hover); + background: var(--emerald-deep); + color: var(--paper); text-decoration: none; } .titleCell a { font-weight: 500; + color: var(--ink); + text-decoration: none; +} + +.titleCell a:hover { + color: var(--emerald-deep); + text-decoration: none; } From d73217ef5f04f3bedee8b503ac6f9b6fb5b94aae Mon Sep 17 00:00:00 2001 From: Mohamed Tayeb Mokni Date: Mon, 25 May 2026 15:57:47 +0200 Subject: [PATCH 4/5] feat(admin): post detail + pages list/detail (brand restyle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the per-resource edit-metadata pages for posts and pages, and turns the pages list from a "Coming soon" placeholder into a real brand-styled CRUD surface. • /posts/[id] — metadata-only edit surface (the block editor opens via /posts/[id]/edit, which arrives in N3). Italic-accent headline ("Edit *post*."), a 1fr/320px split with a paper-2 main column (Headline-styled title input + slug + excerpt) and a sidebar inspector with Status / Schedule / Categories & tags / SEO panels. Geist labels, emerald-soft success badges, lavender for scheduled, ink for private — all token-driven. • /pages — mirror of the posts list. Italic-accent headline ("All *pages*."), an emerald-soft active filter chip strip on paper-3, hover-paper-3 table rows. Seed data until the type=page endpoint ships (issue #76). • /pages/[id] — sibling of posts/[id], with the page-specific metadata trim (no scheduling-as-publication, no taxonomy). Each new page gets a snapshot test that pins the page-head and inspector structure. The existing posts client-island tests are untouched. Signed-off-by: Mohamed Tayeb Mokni --- .../[id]/__snapshots__/page.test.tsx.snap | 96 ++++++ .../(authenticated)/pages/[id]/page.test.tsx | 50 +++ .../app/(authenticated)/pages/[id]/page.tsx | 248 ++++++++++++++ .../pages/__snapshots__/page.test.tsx.snap | 50 +++ .../app/(authenticated)/pages/page.test.tsx | 55 +++ .../src/app/(authenticated)/pages/page.tsx | 235 ++++++++++++- .../[id]/__snapshots__/page.test.tsx.snap | 96 ++++++ .../(authenticated)/posts/[id]/page.test.tsx | 65 ++++ .../app/(authenticated)/posts/[id]/page.tsx | 315 ++++++++++++++++++ 9 files changed, 1205 insertions(+), 5 deletions(-) create mode 100644 apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap create mode 100644 apps/admin/src/app/(authenticated)/pages/[id]/page.test.tsx create mode 100644 apps/admin/src/app/(authenticated)/pages/[id]/page.tsx create mode 100644 apps/admin/src/app/(authenticated)/pages/__snapshots__/page.test.tsx.snap create mode 100644 apps/admin/src/app/(authenticated)/pages/page.test.tsx create mode 100644 apps/admin/src/app/(authenticated)/posts/[id]/__snapshots__/page.test.tsx.snap create mode 100644 apps/admin/src/app/(authenticated)/posts/[id]/page.test.tsx create mode 100644 apps/admin/src/app/(authenticated)/posts/[id]/page.tsx diff --git a/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap b/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap new file mode 100644 index 00000000..4e2a42ea --- /dev/null +++ b/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap @@ -0,0 +1,96 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Page detail page > matches the page-head snapshot 1`] = ` +
+ + + Back to pages + +
+
+

+ Edit + + page + + . +

+

+ Update the slug, visibility, and SEO blurb. + + + # + about + +

+
+
+ + Cancel + + +
+
+
+`; diff --git a/apps/admin/src/app/(authenticated)/pages/[id]/page.test.tsx b/apps/admin/src/app/(authenticated)/pages/[id]/page.test.tsx new file mode 100644 index 00000000..5850591b --- /dev/null +++ b/apps/admin/src/app/(authenticated)/pages/[id]/page.test.tsx @@ -0,0 +1,50 @@ +/** + * Page detail tests — sibling of posts/[id]/page.test.tsx. + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('next/navigation', () => ({ + useParams: () => ({ id: 'about' }), + usePathname: () => '/pages/about', + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + refresh: vi.fn(), + }), + useSearchParams: () => new URLSearchParams(), +})); + +import PageDetailPage from './page'; + +describe('Page detail page', () => { + it('renders the italic-accent headline', () => { + render(); + const h1 = screen.getByRole('heading', { level: 1 }); + expect(h1.textContent).toMatch(/Edit\s+page\./); + expect(h1.querySelector('em')?.textContent).toBe('page'); + }); + + it('renders the inspector sidebar', () => { + render(); + expect( + screen.getByLabelText('Page metadata inspector'), + ).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Status' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Metadata' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /SEO/i })).toBeInTheDocument(); + }); + + it('renders the back link to /pages', () => { + render(); + const back = screen.getByRole('link', { name: /Back to pages/i }); + expect(back).toHaveAttribute('href', '/pages'); + }); + + it('matches the page-head snapshot', () => { + const { container } = render(); + const head = container.querySelector('[data-testid="page-detail"] > div'); + expect(head).toMatchSnapshot(); + }); +}); diff --git a/apps/admin/src/app/(authenticated)/pages/[id]/page.tsx b/apps/admin/src/app/(authenticated)/pages/[id]/page.tsx new file mode 100644 index 00000000..20c1277d --- /dev/null +++ b/apps/admin/src/app/(authenticated)/pages/[id]/page.tsx @@ -0,0 +1,248 @@ +/** + * Page detail / edit-metadata — sibling of posts/[id]. + * + * Pages share the post-type infrastructure (docs/05-admin-api.md §3.1) + * but the metadata surface trims the bits that don't apply to + * evergreen content (no scheduling-as-publication, no category + * taxonomy by default). The brand surface stays identical so the IA + * is predictable. + * + * The block editor for pages opens via the same per-resource route + * (/pages/[id]/edit); this page is intentionally the metadata-only + * view so editors can quickly toggle visibility, change the URL, or + * tweak SEO without entering edit mode. + */ +'use client'; + +import type { ReactElement } from 'react'; +import { useState } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { + Calendar, + ChevronLeft, + Eye, + Globe, + Save, + User, +} from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Headline } from '@/components/ui/headline'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +type PageStatus = 'draft' | 'publish' | 'private'; + +export default function PageDetailPage(): ReactElement { + const params = useParams<{ id: string }>(); + const pageId = params?.id ?? 'new'; + + const [title, setTitle] = useState('Untitled page'); + const [slug, setSlug] = useState('/untitled-page'); + const [status, setStatus] = useState('draft'); + + return ( +
+
+ +
+ +
+ {/* Main editor column */} +
+
+
+ + setTitle(e.target.value)} + className="w-full bg-transparent font-display text-3xl font-bold leading-tight tracking-tight text-ink outline-none placeholder:text-fg-faint focus:outline-none" + placeholder="What's this page about?" + /> +
+ +
+ + setSlug(e.target.value)} + className="font-mono" + /> +
+
+ +
+ + Block editor. + +

+ Pages typically have layout-heavy content. Open the block editor + to compose hero sections, columns, and embeds. +

+ +
+
+ + {/* Sidebar inspector */} +