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)/__snapshots__/page.test.tsx.snap b/apps/admin/src/app/(authenticated)/__snapshots__/page.test.tsx.snap new file mode 100644 index 00000000..f8f950b5 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/__snapshots__/page.test.tsx.snap @@ -0,0 +1,34 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Dashboard page > matches the dashboard structure snapshot 1`] = ` +
+
+

+ Site + + pulse + + . +

+

+ A vital-signs view of the workspace. Drafts, readers, and revenue — updated as the site lives and grows. +

+
+ + +
+`; 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/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 */} +