Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7ddce22
feat: add sortOrder and group properties to manifest collections and …
ahliweb May 14, 2026
1437e87
feat: implement sidebar configuration, collapsible submenus, and dyna…
ahliweb May 14, 2026
b9b5f8a
feat: implement drag-and-drop collection reordering with API and UI s…
ahliweb May 14, 2026
08558a9
refactor: add reorder collections functionality with updated UI compo…
ahliweb May 14, 2026
6c825d3
feat: implement automatic collection-to-menu synchronization and add …
ahliweb May 14, 2026
f174781
feat: add menu-sidebar sync engine and corresponding API endpoints
ahliweb May 14, 2026
5749892
feat: implement collection ordering and grouping, fix menu-sync local…
ahliweb May 14, 2026
92306a5
feat: add sidebar menu tree with collection grouping, plugin subgroup…
ahliweb May 14, 2026
6f6287a
style: format
emdashbot[bot] May 14, 2026
845dda5
fix: add sort_order and group columns to CollectionTable type
ahliweb May 14, 2026
715047a
fix(core): add sortOrder and group to Collection type and registry ma…
ahliweb May 14, 2026
a6001d7
style: format
emdashbot[bot] May 14, 2026
ae6ee47
Merge branch 'main' into sidebar-menu-tree
ahliweb May 14, 2026
f9bfba9
Merge branch 'main' into sidebar-menu-tree
ahliweb May 14, 2026
e291090
chore: add Vitest snapshot attachments for admin package tests
ahliweb May 14, 2026
ab4ecdd
fix: address bot review comments on PR #1024
ahliweb May 14, 2026
11db696
style: format
emdashbot[bot] May 14, 2026
4877bfc
fix: address second round of bot review comments on PR #1024
ahliweb May 14, 2026
6cbd948
style: format
emdashbot[bot] May 14, 2026
5fd85ba
Merge branch 'main' into sidebar-menu-tree
ahliweb May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/sidebar-menu-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@emdash-cms/admin": minor
"emdash": minor
---

Adds sidebar menu tree with collection grouping, plugin subgroups, and public menu sync. Collections can now be organized into collapsible sidebar groups via a new `group` field and ordered with `sortOrder`. Plugin admin pages support the same grouping and custom icon resolution (25+ Phosphor icons). The sidebar supports one level of nested submenus and can hide unused core features via `hideCoreFeatures` / `hideCollections` config. New menu sync engine auto-populates public menus from sidebar structure on collection creation, with preview (`GET /_emdash/api/menus/:name/sync-diff`) and apply (`POST /_emdash/api/menus/:name/sync`) endpoints. Drag-and-drop reordering UI added to the Content Types admin page.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
176 changes: 176 additions & 0 deletions packages/admin/src/components/CollectionReorderDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Button, Dialog } from "@cloudflare/kumo";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { useLingui } from "@lingui/react/macro";
import { DotsSixVertical, X } from "@phosphor-icons/react";
import * as React from "react";

import { cn } from "../lib/utils";

interface CollectionOrderItem {
slug: string;
label: string;
sortOrder: number;
}

interface CollectionReorderDialogProps {
open: boolean;
onClose: () => void;
collections: CollectionOrderItem[];
onReorder: (collections: Array<{ slug: string; sortOrder: number }>) => Promise<void>;
}

function SortableCollectionRow({ item }: { item: CollectionOrderItem }) {
const { t } = useLingui();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.slug,
});

const style: React.CSSProperties = {
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
transition: transition ?? undefined,
opacity: isDragging ? 0.5 : 1,
};

return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md bg-kumo-base border",
isDragging && "border-kumo-brand shadow-sm",
)}
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-kumo-subtle hover:text-kumo-default p-1"
aria-label={t`Drag to reorder`}
>
<DotsSixVertical className="size-4" />
</button>
<span className="flex-1 text-sm font-medium">{item.label}</span>
<code className="text-xs text-kumo-subtle bg-kumo-tint px-1.5 py-0.5 rounded">
{item.slug}
</code>
</div>
);
}

/**
* Dialog for reordering collections via drag-and-drop.
*/
export function CollectionReorderDialog({
open,
onClose,
collections,
onReorder,
}: CollectionReorderDialogProps) {
const { t } = useLingui();
const [order, setOrder] = React.useState<CollectionOrderItem[]>([]);
const [saving, setSaving] = React.useState(false);

React.useEffect(() => {
if (open) {
setOrder(
collections.toSorted((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label)),
);
}
}, [open, collections]);

const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;

setOrder((prev) => {
const oldIndex = prev.findIndex((c) => c.slug === active.id);
const newIndex = prev.findIndex((c) => c.slug === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
};

const handleSave = async () => {
setSaving(true);
try {
await onReorder(order.map((c, i) => ({ slug: c.slug, sortOrder: i })));
onClose();
} finally {
setSaving(false);
}
};

return (
<Dialog.Root open={open} onOpenChange={(v: boolean) => !v && onClose()}>
<Dialog className="p-6 sm:max-w-md">
<div className="flex items-start justify-between gap-4">
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
{t`Reorder Collections`}
</Dialog.Title>
<Dialog.Close
render={(props) => (
<Button
{...props}
variant="ghost"
shape="square"
aria-label={t`Close`}
className="absolute end-4 top-4"
>
<X className="h-4 w-4" />
</Button>
)}
/>
</div>
<Dialog.Description className="text-sm text-kumo-subtle">
{t`Drag and drop to change the order of collections in the sidebar.`}
</Dialog.Description>

<div className="space-y-1 mt-4 max-h-80 overflow-y-auto">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={order.map((c) => c.slug)}
strategy={verticalListSortingStrategy}
>
{order.map((item) => (
<SortableCollectionRow key={item.slug} item={item} />
))}
</SortableContext>
</DndContext>
</div>

<div className="flex justify-end gap-2 mt-6">
<Button variant="secondary" onClick={onClose} disabled={saving}>
{t`Cancel`}
</Button>
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? t`Saving...` : t`Save Order`}
</Button>
</div>
</Dialog>
</Dialog.Root>
);
}
41 changes: 37 additions & 4 deletions packages/admin/src/components/ContentTypeList.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { Badge, Button } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Plus, Pencil, Trash, Database, FileText, Warning, Check } from "@phosphor-icons/react";
import {
Plus,
Pencil,
Trash,
Database,
FileText,
Warning,
Check,
ArrowsVertical,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import * as React from "react";

import type { SchemaCollection, OrphanedTable } from "../lib/api";
import { cn } from "../lib/utils";
import { CollectionReorderDialog } from "./CollectionReorderDialog";
import { ConfirmDialog } from "./ConfirmDialog";
import { RouterLinkButton } from "./RouterLinkButton.js";

Expand All @@ -16,6 +26,7 @@ export interface ContentTypeListProps {
isLoading?: boolean;
onDelete?: (slug: string) => void;
onRegisterOrphan?: (slug: string) => void;
onReorder?: (collections: Array<{ slug: string; sortOrder: number }>) => Promise<void>;
}

/**
Expand All @@ -27,9 +38,11 @@ export function ContentTypeList({
isLoading,
onDelete,
onRegisterOrphan,
onReorder,
}: ContentTypeListProps) {
const { t } = useLingui();
const [deleteTarget, setDeleteTarget] = React.useState<SchemaCollection | null>(null);
const [reorderOpen, setReorderOpen] = React.useState(false);
const hasOrphans = orphanedTables && orphanedTables.length > 0;

return (
Expand All @@ -40,9 +53,18 @@ export function ContentTypeList({
<h1 className="text-2xl font-bold">{t`Content Types`}</h1>
<p className="text-kumo-subtle text-sm">{t`Define the structure of your content`}</p>
</div>
<RouterLinkButton to="/content-types/new" icon={<Plus />}>
{t`New Content Type`}
</RouterLinkButton>
<div className="flex items-center gap-2">
<Button
variant="secondary"
icon={<ArrowsVertical />}
onClick={() => setReorderOpen(true)}
>
{t`Reorder`}
</Button>
<RouterLinkButton to="/content-types/new" icon={<Plus />}>
{t`New Content Type`}
</RouterLinkButton>
</div>
</div>

{/* Orphaned Tables Warning */}
Expand Down Expand Up @@ -156,6 +178,17 @@ export function ContentTypeList({
}
}}
/>

<CollectionReorderDialog
open={reorderOpen}
onClose={() => setReorderOpen(false)}
collections={collections.map((c) => ({
slug: c.slug,
label: c.label,
sortOrder: c.sortOrder ?? 0,
}))}
onReorder={onReorder ?? (async () => {})}
/>
</div>
);
}
Expand Down
17 changes: 15 additions & 2 deletions packages/admin/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,25 @@ import { WelcomeModal } from "./WelcomeModal";
export interface ShellProps {
children: React.ReactNode;
manifest: {
collections: Record<string, { label: string }>;
collections: Record<
string,
{
label: string;
sortOrder?: number;
group?: string;
}
>;
plugins: Record<
string,
{
package?: string;
adminPages?: Array<{ path: string; label?: string; icon?: string }>;
adminPages?: Array<{
path: string;
label?: string;
icon?: string;
group?: string;
sortOrder?: number;
}>;
}
>;
taxonomies: Array<{
Expand Down
Loading
Loading