From 2c3c3878ceefeeb41291cb62534dd248c291b23f Mon Sep 17 00:00:00 2001 From: Evgenii Nasyrov Date: Fri, 24 Apr 2026 07:25:06 +0100 Subject: [PATCH 1/3] feat(admin): full-screen entry editor layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bordered-form-inside-shell entry editor with a dedicated full-screen layout: sticky header + canvas + right rail. Modelled on WP Gutenberg / Builder.io / Ghost — the editor *is* the page, not a form embedded in one. **Layout shape** - New `_editor` route group, sibling to `_authenticated`. Same auth gate (hoisted into `requireAuthenticatedSession` in `lib/session.ts`) but no admin shell / left nav. - Canvas: centered max-w-3xl, borderless h1-styled title input, Tiptap content editor filling the rest. - Right rail: shadcn `Sidebar side="right" collapsible="offcanvas"`. Holds Permalink / Status / Excerpt cards plus every registered plugin meta box. **Entry URLs unchanged.** `_editor` is an underscore layout route, so `/entries/$slug/new` and `/entries/$slug/$id` render under the new layout without the browser URL moving. **`box.location` no longer honoured.** Every meta box lands in the right rail; `partitionBoxesByLocation` is gone. The manifest field stays declared so existing plugins don't break — it's just ignored. A follow-up commit turns the rail cards into collapsible accordion sections and formalises the deprecation. **Feature gating.** `PostEditorForm` now takes an `entryType.supports` list (`"title" | "editor" | "slug" | "excerpt"`) and renders canvas + rail pieces conditionally. `DEFAULT_ENTRY_SUPPORTS = ["title", "editor", "slug"]` matches WP's default (plus `slug`, which is load-bearing for routing). Entry types that omit `editor` get a muted placeholder in the canvas and drive everything from the rail — mirrors WP's Gutenberg fallback for `remove_post_type_support`. **Ships** - Accordion shadcn primitive added for the meta-box follow-up. - Test IDs preserved for e2e; `post-editor-{new,edit}-heading` collapse into `post-editor-headline` (the header caption). `meta-boxes-bottom` is gone — everything lives in `meta-boxes-sidebar` now. --- packages/admin/e2e/content.spec.ts | 26 +- .../components/editor/entry-editor-form.tsx | 530 +++++++++++------- .../admin/src/components/ui/accordion.tsx | 63 +++ packages/admin/src/lib/session.ts | 18 + packages/admin/src/routeTree.gen.ts | 111 ++-- packages/admin/src/routes/_authenticated.tsx | 15 +- packages/admin/src/routes/_editor.tsx | 26 + .../entries/$slug/$id.tsx | 119 ++-- .../entries/$slug/new.tsx | 69 ++- pnpm-lock.yaml | 21 + 10 files changed, 620 insertions(+), 378 deletions(-) create mode 100644 packages/admin/src/components/ui/accordion.tsx create mode 100644 packages/admin/src/routes/_editor.tsx rename packages/admin/src/routes/{_authenticated => _editor}/entries/$slug/$id.tsx (68%) rename packages/admin/src/routes/{_authenticated => _editor}/entries/$slug/new.tsx (70%) diff --git a/packages/admin/e2e/content.spec.ts b/packages/admin/e2e/content.spec.ts index 08c474f9d..d48e112ed 100644 --- a/packages/admin/e2e/content.spec.ts +++ b/packages/admin/e2e/content.spec.ts @@ -340,7 +340,7 @@ test.describe("/entries/$slug/new", () => { }); await page.goto("entries/posts/new"); - await expect(page.getByTestId("post-editor-new-heading")).toBeVisible(); + await expect(page.getByTestId("post-editor-headline")).toBeVisible(); // Auto-slug from title while slug is still unlocked. await page.getByTestId("post-editor-title-input").fill("Hello World"); @@ -393,7 +393,7 @@ test.describe("/entries/$slug/new", () => { }) => { await mockRpc(page, { "/auth/session": AUTHED_ADMIN }); await page.goto("entries/posts/new"); - await expect(page.getByTestId("post-editor-new-heading")).toBeVisible(); + await expect(page.getByTestId("post-editor-headline")).toBeVisible(); await expectNoAxeViolations(page); }); }); @@ -475,7 +475,7 @@ test.describe("/entries/$slug/$id", () => { }); await page.goto("entries/posts/7"); - await expect(page.getByTestId("post-editor-edit-heading")).toBeVisible(); + await expect(page.getByTestId("post-editor-headline")).toBeVisible(); // Existing title populates from the loaded post. await expect(page.getByTestId("post-editor-title-input")).toHaveValue( "Original title", @@ -499,25 +499,25 @@ test.describe("/entries/$slug/$id", () => { }); test.describe("meta-box sidebar", () => { - test("renders side + normal boxes, fields are typable", async ({ page }) => { + test("all meta boxes render in the rail, fields are typable", async ({ + page, + }) => { await mockManifest(page, MANIFEST_WITH_META_BOXES); await mockRpc(page, { "/auth/session": AUTHED_ADMIN }); await page.goto("entries/posts/new"); - // Both boxes render — one in the side rail, one in the main column. - await expect(page.getByTestId("meta-box-seo")).toBeVisible(); - await expect(page.getByTestId("meta-box-featured")).toBeVisible(); - // Side rail container picks up only side-context boxes. - const sideRail = page.getByTestId("meta-boxes-sidebar"); - await expect(sideRail.getByTestId("meta-box-featured")).toBeVisible(); - await expect(sideRail.getByTestId("meta-box-seo")).toHaveCount(0); + // Full-screen editor: every registered meta box lands in the right + // rail. `box.location` is no longer honoured — all boxes are rail. + const rail = page.getByTestId("meta-boxes-sidebar"); + await expect(rail.getByTestId("meta-box-seo")).toBeVisible(); + await expect(rail.getByTestId("meta-box-featured")).toBeVisible(); - // Text field in the normal box accepts input. + // Text field in the seo box accepts input. const metaTitle = page.getByTestId("meta-box-field-meta_title-input"); await metaTitle.fill("Hello meta"); await expect(metaTitle).toHaveValue("Hello meta"); - // Checkbox in the side box toggles. + // Checkbox in the featured box toggles. const featured = page.getByTestId("meta-box-field-is_featured-input"); await expect(featured).not.toBeChecked(); await featured.check(); diff --git a/packages/admin/src/components/editor/entry-editor-form.tsx b/packages/admin/src/components/editor/entry-editor-form.tsx index 7113590de..ae4d0dabd 100644 --- a/packages/admin/src/components/editor/entry-editor-form.tsx +++ b/packages/admin/src/components/editor/entry-editor-form.tsx @@ -4,6 +4,12 @@ import { useEffect, useState } from "react"; import { MetaBoxCard } from "@/components/meta-box/meta-box.js"; import { Alert, AlertDescription } from "@/components/ui/alert.js"; import { Button } from "@/components/ui/button.js"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.js"; import { Form, FormControl, @@ -13,9 +19,19 @@ import { FormMessage, } from "@/components/ui/form.js"; import { Input } from "@/components/ui/input.js"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar.js"; +import { cn } from "@/lib/utils"; import { valibotResolver } from "@hookform/resolvers/valibot"; import { useBlocker } from "@tanstack/react-router"; -import { useForm, useWatch } from "react-hook-form"; +import { ArrowLeft } from "lucide-react"; +import { useForm, useFormContext, useWatch } from "react-hook-form"; import * as v from "valibot"; import type { EntryMetaBoxManifestEntry } from "@plumix/core/manifest"; @@ -67,6 +83,19 @@ export const POST_EDITOR_STATUSES: readonly EntryStatus[] = [ "trash", ]; +/** + * Default feature set when `entryType.supports` is omitted — matches + * what the admin surface needs to render a usable entry editor. + * WordPress parity: `title` + `editor`. We add `slug` because it's + * load-bearing for routing; a plugin that genuinely wants slug off can + * opt out explicitly. + */ +export const DEFAULT_ENTRY_SUPPORTS: readonly string[] = [ + "title", + "editor", + "slug", +]; + interface PostEditorFormProps { readonly initialValues: PostEditorValues; /** Slug starts unlocked until user edits the slug field directly. For @@ -74,13 +103,20 @@ interface PostEditorFormProps { * URL. */ readonly slugLocked: boolean; readonly availableStatuses: readonly EntryStatus[]; + /** `entryType.supports` — gates which canvas + rail pieces render. + * Default resolved by the caller (use `DEFAULT_ENTRY_SUPPORTS`). */ + readonly supports: readonly string[]; /** * Meta boxes applicable to the post type being edited, already - * filtered by capability and sorted by priority. The form splits them - * into `bottom` (default, below the main editor) and `sidebar` - * (right rail) regions based on `box.location`. + * filtered by capability and sorted by priority. All boxes land in + * the right rail stacked below the built-in Permalink / Status / + * Excerpt cards — `box.location` is ignored. */ readonly metaBoxes: readonly EntryMetaBoxManifestEntry[]; + /** Caption shown next to the back button — e.g. `"New post"` or + * `"Edit post"`. The editor itself is the page, so there's no + * separate `

` above it. */ + readonly headline: string; readonly submitLabel: string; readonly isSubmitting: boolean; readonly serverError?: string | null; @@ -92,7 +128,9 @@ export function PostEditorForm({ initialValues, slugLocked: initialSlugLocked, availableStatuses, + supports, metaBoxes, + headline, submitLabel, isSubmitting, serverError, @@ -131,239 +169,315 @@ export function PostEditorForm({ disabled: isSubmitting, }); - const { bottom, sidebar } = partitionBoxesByLocation(metaBoxes); - const handleSubmit = form.handleSubmit((value) => { onSubmit(value); }); + const showTitle = supports.includes("title"); + const showEditor = supports.includes("editor"); + const showSlug = supports.includes("slug"); + const showExcerpt = supports.includes("excerpt"); + return (
+ {/* `contents` makes the form invisible in the flex flow so + SidebarProvider owns the layout; fields in the rail still + submit via Controller (rhf state, not DOM form semantics). */} -
-
- ( - - Title - - - - - - )} - /> - - ( - - Slug - - { - // Any direct edit to the slug input locks out the - // title-driven auto-derivation for the rest of this - // editor session. - setSlugLocked(true); - field.onChange(e); - }} - /> - - - - )} - /> - - ( - - Content - -
- { - field.onChange(json); - }} - disabled={isSubmitting} - ariaLabel="Entry content" - /> -
-
- -
- )} - /> + + +
+
+ + + {headline} + +
+
+ + +
+
- ( - - - Excerpt{" "} - (optional) - - -