@murumets-ee/menus-ui 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +94 -0
- package/dist/index.d.mts +204 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/index.mjs.map +1 -0
- package/dist/pages.d.mts +20 -0
- package/dist/pages.d.mts.map +1 -0
- package/dist/pages.mjs +2 -0
- package/dist/pages.mjs.map +1 -0
- package/dist/plugin.d.mts +33 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/dist/plugin.mjs.map +1 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Elastic License 2.0 (ELv2)
|
|
2
|
+
|
|
3
|
+
URL: https://www.elastic.co/licensing/elastic-license
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
12
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
13
|
+
available, and prepare derivative works of the software, in each case subject
|
|
14
|
+
to the limitations and conditions below.
|
|
15
|
+
|
|
16
|
+
## Limitations
|
|
17
|
+
|
|
18
|
+
You may not provide the software to third parties as a hosted or managed
|
|
19
|
+
service, where the service provides users with access to any substantial set
|
|
20
|
+
of the features or functionality of the software.
|
|
21
|
+
|
|
22
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
23
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
24
|
+
software that is protected by the license key.
|
|
25
|
+
|
|
26
|
+
You may not alter, remove, or obscure any licensing, copyright, or other
|
|
27
|
+
notices of the licensor in the software. Any use of the licensor's trademarks
|
|
28
|
+
is subject to applicable law.
|
|
29
|
+
|
|
30
|
+
## Patents
|
|
31
|
+
|
|
32
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
33
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
34
|
+
sale, import and have imported the software, in each case subject to the
|
|
35
|
+
limitations and conditions in this license. This license does not cover any
|
|
36
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
37
|
+
the software. If you or your company make any written claim that the software
|
|
38
|
+
infringes or contributes to infringement of any patent, your patent license
|
|
39
|
+
for the software granted under these terms ends immediately. If your company
|
|
40
|
+
makes such a claim, your patent license ends immediately for work on behalf
|
|
41
|
+
of your company.
|
|
42
|
+
|
|
43
|
+
## Notices
|
|
44
|
+
|
|
45
|
+
You must ensure that anyone who gets a copy of any part of the software from
|
|
46
|
+
you also gets a copy of these terms.
|
|
47
|
+
|
|
48
|
+
If you modify the software, you must include in any modified copies of the
|
|
49
|
+
software prominent notices stating that you have modified the software.
|
|
50
|
+
|
|
51
|
+
## No Other Rights
|
|
52
|
+
|
|
53
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
54
|
+
these terms.
|
|
55
|
+
|
|
56
|
+
## Termination
|
|
57
|
+
|
|
58
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
59
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
60
|
+
with a notice of your violation, and you cease all violation of this license
|
|
61
|
+
no later than 30 days after you receive that notice, your licenses will be
|
|
62
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
63
|
+
reinstatement, any additional violation of these terms will cause your
|
|
64
|
+
licenses to terminate automatically and permanently.
|
|
65
|
+
|
|
66
|
+
## No Liability
|
|
67
|
+
|
|
68
|
+
As far as the law allows, the software comes as is, without any warranty or
|
|
69
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
70
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
71
|
+
legal claim.
|
|
72
|
+
|
|
73
|
+
## Definitions
|
|
74
|
+
|
|
75
|
+
The **licensor** is the entity offering these terms, and the **software** is
|
|
76
|
+
the software the licensor makes available under these terms, including any
|
|
77
|
+
portion of it.
|
|
78
|
+
|
|
79
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
80
|
+
|
|
81
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
82
|
+
organization that you work for, plus all organizations that have control over,
|
|
83
|
+
are under the control of, or are under common control with that organization.
|
|
84
|
+
**control** means ownership of substantially all the assets of an entity, or
|
|
85
|
+
the power to direct the management and policies of an entity (for example, by
|
|
86
|
+
voting right, contract, or otherwise). Control can be direct or indirect.
|
|
87
|
+
|
|
88
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
89
|
+
these terms.
|
|
90
|
+
|
|
91
|
+
**use** means anything you do with the software requiring one of your
|
|
92
|
+
licenses.
|
|
93
|
+
|
|
94
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
|
|
2
|
+
import { ReactElement } from "react";
|
|
3
|
+
import { MenuBlock as MenuBlock$1 } from "@murumets-ee/blocks/library";
|
|
4
|
+
import { navigationRenders } from "@murumets-ee/blocks/themes/default";
|
|
5
|
+
import { DraftInfo, LockHolder } from "@murumets-ee/admin-ui/content-editor";
|
|
6
|
+
import { MenuItem, MenuItemSummary } from "@murumets-ee/menus/schema";
|
|
7
|
+
import { BlockEditorSurfaceProps } from "@murumets-ee/blocks";
|
|
8
|
+
|
|
9
|
+
//#region src/menu-list/new-menu-dialog.d.ts
|
|
10
|
+
interface NewMenuDialogProps {
|
|
11
|
+
/** Admin API base (`/api/admin` unless overridden). */
|
|
12
|
+
readonly apiBasePath: string;
|
|
13
|
+
/** Locale-resolved admin base (e.g. `/en/admin`) — where to navigate after create. */
|
|
14
|
+
readonly adminBasePath: string;
|
|
15
|
+
}
|
|
16
|
+
declare function NewMenuDialog({
|
|
17
|
+
apiBasePath,
|
|
18
|
+
adminBasePath
|
|
19
|
+
}: NewMenuDialogProps): ReactElement;
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/tree-editor/menu-block-editor-surface.d.ts
|
|
22
|
+
type MenuBlockFields = (typeof MenuBlock$1)['fields'];
|
|
23
|
+
type MenuBlockSurfaceProps = BlockEditorSurfaceProps<MenuBlockFields>;
|
|
24
|
+
interface CreateMenuBlockEditorSurfaceOptions {
|
|
25
|
+
/** Admin API base (`/api/admin` unless overridden). */
|
|
26
|
+
apiBasePath?: string;
|
|
27
|
+
/** Admin app base for the "Open full editor" link (`/admin` unless overridden). */
|
|
28
|
+
adminBasePath?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the Tier 3 surface component. Most hosts want the pre-built
|
|
32
|
+
* `menuBlockEditorSurface` export below — this factory exists for hosts
|
|
33
|
+
* that mount the admin API or admin app shell under a non-default base
|
|
34
|
+
* path, mirroring `@murumets-ee/blocks/editor`'s `createFetchCommitter({
|
|
35
|
+
* endpoint })` override.
|
|
36
|
+
*/
|
|
37
|
+
declare function createMenuBlockEditorSurface(options?: CreateMenuBlockEditorSurfaceOptions): (props: MenuBlockSurfaceProps) => ReactElement;
|
|
38
|
+
/** The default-configured surface — what `@murumets-ee/menus-ui`'s `MenuBlock` export wires in. */
|
|
39
|
+
declare const menuBlockEditorSurface: (props: MenuBlockSurfaceProps) => ReactElement;
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/tree-editor/menu-block.d.ts
|
|
42
|
+
declare const MenuBlock: typeof MenuBlock$1 & {
|
|
43
|
+
customEditorSurface: typeof menuBlockEditorSurface;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The default theme-agnostic browse-mode render — re-exported so an app
|
|
47
|
+
* with its OWN custom theme (not `@murumets-ee/blocks/themes/default`'s
|
|
48
|
+
* full `covers` map) can still map `menu: MenuBlockRender` without writing
|
|
49
|
+
* one from scratch. Reads `ResolvedMenusContext` — see that context's
|
|
50
|
+
* JSDoc (`@murumets-ee/blocks/core`) for what populates it.
|
|
51
|
+
*
|
|
52
|
+
* Reached via `navigationRenders.menu` (not a named `MenuDefault` export) —
|
|
53
|
+
* `@murumets-ee/blocks/themes/default` only exports its per-domain render
|
|
54
|
+
* maps (`structuralRenders`, `marketingRenders`, …), never individual block
|
|
55
|
+
* renders; `navigationRenders` is that same convention's navigation-domain
|
|
56
|
+
* entry.
|
|
57
|
+
*/
|
|
58
|
+
declare const MenuBlockRender: typeof navigationRenders.menu;
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/tree-editor/types.d.ts
|
|
61
|
+
/**
|
|
62
|
+
* A content locale the editor may author for — the `enabled` subset of the
|
|
63
|
+
* request's effective locale registry (shipped `content()` defaults ⊕ DB
|
|
64
|
+
* override rows), in the registry's own display order. `[0]` is not
|
|
65
|
+
* necessarily the default locale; use {@link MenuTreeEditorProps.defaultLocale}
|
|
66
|
+
* for that.
|
|
67
|
+
*/
|
|
68
|
+
interface MenuEditorLocale {
|
|
69
|
+
code: string;
|
|
70
|
+
label: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The menu row being edited, projected to exactly what the editor needs.
|
|
74
|
+
*
|
|
75
|
+
* Every field is TOTAL — the page fills each nullable column with the same
|
|
76
|
+
* default the column itself declares (`maxDepth` → `DEFAULT_MENU_DEPTH`,
|
|
77
|
+
* `status` → `'draft'`), so the editor never has to render a "maybe absent"
|
|
78
|
+
* branch for a value that is in practice always set.
|
|
79
|
+
*/
|
|
80
|
+
interface MenuEditorMenu {
|
|
81
|
+
id: string;
|
|
82
|
+
/** Fetch key (`main`, `footer`) — the handle the frontend resolves by. */
|
|
83
|
+
handle: string;
|
|
84
|
+
title: string;
|
|
85
|
+
/** This menu's own nesting cap (1..`MAX_MENU_DEPTH`); the editor clamps drops to it. */
|
|
86
|
+
maxDepth: number;
|
|
87
|
+
/** `publishable()` status. */
|
|
88
|
+
status: 'draft' | 'published';
|
|
89
|
+
/** The stored tree, narrowed + bounded server-side (`normalizeMenuTree`). */
|
|
90
|
+
tree: MenuItem[];
|
|
91
|
+
/**
|
|
92
|
+
* Which content types this menu accepts links to (phase 07 §2.8), narrowed
|
|
93
|
+
* server-side by `normalizeMenuEntityTypes`. `null` = accepts every type —
|
|
94
|
+
* the default, and what every row written before the column existed reads
|
|
95
|
+
* back as.
|
|
96
|
+
*
|
|
97
|
+
* Values are entity NAMES, not the {@link MenuLinkEntityType} objects in
|
|
98
|
+
* {@link MenuTreeEditorProps.entityTypes}: that prop is the option UNIVERSE
|
|
99
|
+
* (everything this caller may link to), this is the menu's own selection out
|
|
100
|
+
* of it, and the two are deliberately independent — a whitelist may name a
|
|
101
|
+
* type the current caller can't see.
|
|
102
|
+
*/
|
|
103
|
+
entityTypes: string[] | null;
|
|
104
|
+
}
|
|
105
|
+
/** Props for the menu tree editor client island. */
|
|
106
|
+
interface MenuTreeEditorProps {
|
|
107
|
+
menu: MenuEditorMenu;
|
|
108
|
+
/**
|
|
109
|
+
* Live target info for every entity link in {@link MenuEditorMenu.tree},
|
|
110
|
+
* keyed `` `${entity}:${id}` `` — the same key
|
|
111
|
+
* `resolveMenuItemSummaries()` produces.
|
|
112
|
+
*
|
|
113
|
+
* A plain object rather than a `Map` so the RSC payload needs no special
|
|
114
|
+
* serialisation. **A MISSING key means "indeterminate"** (the entity type
|
|
115
|
+
* isn't registered, the signed-in admin has no `view` grant on it, or the
|
|
116
|
+
* read failed) — NOT broken. Only an explicit `exists: false` means the row
|
|
117
|
+
* is really gone; conflating the two would paint every item red whenever a
|
|
118
|
+
* plugin isn't loaded.
|
|
119
|
+
*/
|
|
120
|
+
summaries: Readonly<Record<string, MenuItemSummary>>;
|
|
121
|
+
/** Enabled content locales, in registry order (see {@link MenuEditorLocale}). */
|
|
122
|
+
locales: readonly MenuEditorLocale[];
|
|
123
|
+
/** The request's default content locale; always present in {@link locales}. */
|
|
124
|
+
defaultLocale: string;
|
|
125
|
+
/**
|
|
126
|
+
* Stored nodes the server refused to hand over (malformed shape, or past the
|
|
127
|
+
* hard tree bounds). `> 0` means a whole-tree save would PERSIST those
|
|
128
|
+
* removals, so the editor must warn before letting the user save.
|
|
129
|
+
*/
|
|
130
|
+
droppedItemCount: number;
|
|
131
|
+
/** Admin API base (`/api/admin` unless overridden) — save/publish + the anchors endpoint. */
|
|
132
|
+
apiBasePath: string;
|
|
133
|
+
/**
|
|
134
|
+
* Content types offered in the "Add link" picker's Content tab.
|
|
135
|
+
*
|
|
136
|
+
* Resolved server-side to the entities that (a) declare `searchable()` — the
|
|
137
|
+
* picker searches through `/api/admin/search/<entity>`, so a non-searchable
|
|
138
|
+
* type could never be picked anyway — and (b) the signed-in admin holds a
|
|
139
|
+
* `view` grant on. An empty array simply means the Content tab has nothing to
|
|
140
|
+
* offer; URL and email links still work.
|
|
141
|
+
*/
|
|
142
|
+
entityTypes: readonly MenuLinkEntityType[];
|
|
143
|
+
/**
|
|
144
|
+
* Whether the caller holds `menu:publish`. Gates the publish/unpublish
|
|
145
|
+
* control and the draft banner's "Publish Draft" action.
|
|
146
|
+
*
|
|
147
|
+
* A UX gate, not the security boundary: the API enforces `menu:update` on the
|
|
148
|
+
* PATCH and `menu:publish` on the draft-publish route regardless of what the
|
|
149
|
+
* client renders.
|
|
150
|
+
*/
|
|
151
|
+
canPublish: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* The pending draft for this menu, when one exists — server-loaded, exactly
|
|
154
|
+
* as `EntityEditPage` does for any `publishable()` entity. Drives
|
|
155
|
+
* `<DraftBanner>`.
|
|
156
|
+
*/
|
|
157
|
+
draft?: DraftInfo | undefined;
|
|
158
|
+
/**
|
|
159
|
+
* Server-acquired edit lock. `acquired: false` means someone else is editing;
|
|
160
|
+
* the editor renders read-only behind a `<LockBanner>`.
|
|
161
|
+
*/
|
|
162
|
+
lockResult?: {
|
|
163
|
+
acquired: boolean;
|
|
164
|
+
lock?: LockHolder | null | undefined;
|
|
165
|
+
} | undefined;
|
|
166
|
+
/**
|
|
167
|
+
* Called whenever the editor's "has unpersisted work" state flips.
|
|
168
|
+
*
|
|
169
|
+
* For hosts that embed this editor inside their own chrome and need to guard
|
|
170
|
+
* a destructive action of their own — the blocks editor's Tier 3 menu surface
|
|
171
|
+
* uses it so switching the menu picker (which REMOUNTS this component, and so
|
|
172
|
+
* discards its local tree) can confirm first. Omitted by the standalone
|
|
173
|
+
* `/admin/menu/[id]` page, which has nothing to guard beyond the
|
|
174
|
+
* `beforeunload` handler this component already installs.
|
|
175
|
+
*
|
|
176
|
+
* Pass a STABLE reference (`useCallback`) — it is an effect dependency.
|
|
177
|
+
*/
|
|
178
|
+
onDirtyChange?: ((dirty: boolean) => void) | undefined;
|
|
179
|
+
}
|
|
180
|
+
/** One content type the "Add link" picker may search. */
|
|
181
|
+
interface MenuLinkEntityType {
|
|
182
|
+
/** Entity name — the search route segment and the stored `link.entity`. */
|
|
183
|
+
name: string;
|
|
184
|
+
/** Human label for the picker's type selector. */
|
|
185
|
+
label: string;
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/tree-editor/menu-tree-editor.d.ts
|
|
189
|
+
declare function MenuTreeEditor({
|
|
190
|
+
menu,
|
|
191
|
+
summaries,
|
|
192
|
+
locales,
|
|
193
|
+
defaultLocale,
|
|
194
|
+
droppedItemCount,
|
|
195
|
+
apiBasePath,
|
|
196
|
+
entityTypes,
|
|
197
|
+
canPublish,
|
|
198
|
+
draft,
|
|
199
|
+
lockResult,
|
|
200
|
+
onDirtyChange
|
|
201
|
+
}: MenuTreeEditorProps): ReactElement;
|
|
202
|
+
//#endregion
|
|
203
|
+
export { type CreateMenuBlockEditorSurfaceOptions, MenuBlock, MenuBlockRender, type MenuEditorLocale, type MenuEditorMenu, type MenuLinkEntityType, MenuTreeEditor, type MenuTreeEditorProps, NewMenuDialog, createMenuBlockEditorSurface, menuBlockEditorSurface };
|
|
204
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/menu-list/new-menu-dialog.tsx","../src/tree-editor/menu-block-editor-surface.tsx","../src/tree-editor/menu-block.ts","../src/tree-editor/types.ts","../src/tree-editor/menu-tree-editor.tsx"],"mappings":";;;;;;;;;UAwCiB,kBAAA;;WAEN,WAAA;;WAEA,aAAa;AAAA;AAAA,iBAmBR,aAAA,CAAA;EAAgB,WAAA;EAAa;AAAA,GAAiB,kBAAA,GAAqB,YAAA;;;KCkC9E,eAAA,WAA0B,WAAkB;AAAA,KAC5C,qBAAA,GAAwB,uBAAuB,CAAC,eAAA;AAAA,UA4BpC,mCAAA;EElGV;EFoGL,WAAA;EEzF6B;EF2F7B,aAAa;AAAA;;;;;;;;iBAUC,4BAAA,CACd,OAAA,GAAS,mCAAA,IACP,KAAA,EAAO,qBAAA,KAA0B,YAAA;;cAgPxB,sBAAA,GAAsB,KAAA,EAhPxB,qBAAA,KAA0B,YAgP+B;;;cCvVvD,SAAA,SAAkB,WAAA;EAC7B,mBAAA,SAA4B,sBAAsB;AAAA;;;;ADyDH;AAAA;;;;AACmB;AA4BpE;;;;cCnEa,eAAA,SAAwB,iBAAA,CAAkB,IAA6B;;;;;;;;;;UCjCnE,gBAAA;EACf,IAAA;EACA,KAAK;AAAA;;AHmCwF;;;;ACsBZ;;;UE9ClE,cAAA;EACf,EAAA;EF0DG;EExDH,MAAA;EACA,KAAA;EFuD2B;EErD3B,QAAA;EFiFe;EE/Ef,MAAA;;EAEA,IAAA,EAAM,QAAQ;EFiFD;AAUf;;;;;;;;;;;EE9EE,WAAA;AAAA;;UAIe,mBAAA;EACf,IAAA,EAAM,cAAA;EF2T4D;;;;;;;AAAA;;;;ACvVpE;ECyCE,SAAA,EAAW,QAAA,CAAS,MAAA,SAAe,eAAA;;EAEnC,OAAA,WAAkB,gBAAA;ED3CW;EC6C7B,aAAA;ED5C4B;;AAAsB;AAmBpD;;EC+BE,gBAAA;ED/BmC;ECiCnC,WAAA;;;;AAlEF;;;;AAEO;AAWP;EA+DE,WAAA,WAAsB,kBAAA;;;;;;;;;EAStB,UAAA;EAjDA;;AAAW;AAIb;;EAmDE,KAAA,GAAQ,SAAA;EAlDF;;;;EAuDN,UAAA;IAEM,QAAA;IACA,IAAA,GAAO,UAAA;EAAA;EAAU;;;;;;;;;;;;EAevB,aAAA,KAAkB,KAAA;AAAA;;UAIH,kBAAA;EA3Bf;EA6BA,IAAA;EAxBA;EA0BA,KAAK;AAAA;;;iBCrBS,cAAA,CAAA;EACd,IAAA;EACA,SAAA;EACA,OAAA;EACA,aAAA;EACA,gBAAA;EACA,WAAA;EACA,WAAA;EACA,UAAA;EACA,KAAA;EACA,UAAA;EACA;AAAA,GACC,mBAAA,GAAsB,YAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import{Badge as e,Button as t,Checkbox as n,Dialog as r,DialogContent as i,DialogHeader as a,DialogTitle as o,DropdownMenu as s,DropdownMenuContent as c,DropdownMenuItem as l,DropdownMenuTrigger as u,Input as d,Label as f,Select as p,cn as m,toast as h}from"@murumets-ee/ui";import{useRouter as g}from"next/navigation";import{useCallback as _,useEffect as v,useMemo as y,useRef as b,useState as x}from"react";import{Fragment as S,jsx as C,jsxs as w}from"react/jsx-runtime";import{MenuBlock as T}from"@murumets-ee/blocks/library";import{navigationRenders as E}from"@murumets-ee/blocks/themes/default";import{move as D}from"@dnd-kit/helpers";import{DragDropProvider as O,PointerSensor as k}from"@dnd-kit/react";import{DraftBanner as ee,LockBanner as te,SavedAsDraftBanner as ne,useAdminPickLink as re,useContentEditor as ie}from"@murumets-ee/admin-ui/content-editor";import{ANCHOR_PATTERN as ae,MAX_ANCHOR_LENGTH as A,MAX_MENU_TREE_NODES as j,buildMenuTree as oe,collectDescendantIds as se,depthCapForDrag as ce,findMenuItem as M,flattenMenuTree as le,getDragDepth as ue,hideCollapsedRows as de,indentMenuItem as fe,insertMenuItem as pe,measureMenuTree as me,menuAcceptsEntityType as he,moveMenuItem as ge,outdentMenuItem as _e,projectDrop as ve,removeMenuItem as ye,replaceMenuItem as be,subtreeHeight as xe}from"@murumets-ee/menus/schema";import{ChevronDown as N,ChevronRight as P,GripVertical as F,Heading as Se,Link2 as Ce,MoreVertical as I,Plus as we}from"lucide-react";import{useSortable as Te}from"@dnd-kit/react/sortable";import{linkValueKey as Ee}from"@murumets-ee/blocks/links";function De(e){return e.toLowerCase().normalize(`NFKD`).replace(/[̀-ͯ]/g,``).replace(/[^a-z0-9]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,64)}function L({apiBasePath:e,adminBasePath:n}){let s=g(),[c,l]=x(!1),[u,p]=x(``),[m,h]=x(``),[_,v]=x(!1),[y,b]=x(!1),[T,E]=x(null),D=_?m:De(u),O=u.trim()!==``&&D!==``&&!y;return w(S,{children:[C(t,{type:`button`,size:`sm`,onClick:()=>l(!0),children:`New menu`}),C(r,{open:c,onOpenChange:l,children:w(i,{className:`max-w-md`,children:[C(a,{children:C(o,{children:`New menu`})}),w(`form`,{onSubmit:async t=>{if(t.preventDefault(),O){b(!0),E(null);try{let t=await fetch(`${e}/menu`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({title:u.trim(),handle:D})});if(!t.ok){let e=await t.json().catch(()=>({}));throw Error(e.error??`Could not create the menu (${t.status})`)}let r=await t.json();if(typeof r.id!=`string`)throw Error(`The menu was created but the server returned no id.`);l(!1),s.push(`${n}/menu/${r.id}`)}catch(e){E(e instanceof Error?e.message:`Could not create the menu`),b(!1)}}},className:`flex flex-col gap-3`,children:[w(`div`,{className:`flex flex-col gap-1.5`,children:[C(f,{htmlFor:`new-menu-title`,children:`Title`}),C(d,{id:`new-menu-title`,value:u,onChange:e=>p(e.target.value),placeholder:`Main navigation`})]}),w(`div`,{className:`flex flex-col gap-1.5`,children:[C(f,{htmlFor:`new-menu-handle`,children:`Handle`}),C(d,{id:`new-menu-handle`,value:D,onChange:e=>{v(!0),h(e.target.value)},placeholder:`main`}),C(`p`,{className:`text-xs text-muted-foreground`,children:`The key the frontend fetches this menu by. Lowercase, no spaces.`})]}),T&&C(`p`,{className:`text-sm text-destructive`,children:T}),w(`div`,{className:`flex justify-end gap-2 pt-1`,children:[C(t,{type:`button`,variant:`outline`,onClick:()=>l(!1),children:`Cancel`}),C(t,{type:`submit`,disabled:!O,loading:y,children:`Create`})]})]})]})})]})}function Oe(){let e=globalThis.crypto;if(e===void 0||typeof e.randomUUID!=`function`)throw Error("[menus-ui] adding a menu item requires `crypto.randomUUID()` but the host runtime does not provide it (e.g. a non-secure-context origin). Serve the admin over HTTPS/localhost, or upgrade to a supported runtime.");return e.randomUUID()}function ke(e){return e===void 0||e.kind!==`entity`?null:`${e.entity}:${e.id}`}function Ae(e,t){let n=ke(e.link);if(n===null)return`ok`;let r=t[n];return r===void 0?`unknown`:r.exists?r.published===!1?`unpublished`:`ok`:`broken`}function je(e,{locale:t,defaultLocale:n,liveTitle:r}){let i=R(e.label,t)??R(e.label,n);if(i!==void 0)return{text:i,source:`override`};if(r!==void 0&&r.trim()!==``)return{text:r,source:`live`};let a=e.link;return a?.kind===`url`?{text:a.url,source:`target`}:a?.kind===`email`?{text:a.email,source:`target`}:{text:``,source:`none`}}function R(e,t){let n=e?.[t];return n!==void 0&&n.trim()!==``?n:void 0}function Me(e,t,n){let r=e.label?.[t];if(n===``?r===void 0:r===n)return e;let i={...e.label};return n===``?delete i[t]:i[t]=n,{...e,label:Object.keys(i).length===0?null:i}}function Ne(e){if(e.kind!==`header`)return!1;let t=e.label;return t===null?!0:!Object.values(t).some(e=>e.trim()!==``)}function z(e,t,n){if(e.enabled?.[t]===!1==!n)return e;let r={...e.enabled};if(n?delete r[t]:r[t]=!1,Object.keys(r).length===0){let{enabled:t,...n}=e;return n}return{...e,enabled:r}}function Pe(e,t){return e.enabled?.[t]===!1}function Fe(e,t){if(!t){if(!(`newTab`in e))return e;let{newTab:t,...n}=e;return n}return e.newTab===!0?e:{...e,newTab:!0}}function B(e){return e===``?null:e.length>A?`An anchor can be at most ${A} characters.`:ae.test(e)?null:`Use letters, numbers, hyphens and underscores only — no “#”, spaces or dots.`}function V(e){let t=e.link;return t===void 0||t.kind!==`entity`?null:t.anchor??null}function H(e,t){let n=e.link;if(n===void 0||n.kind!==`entity`||n.anchor===t)return e;let r={kind:`entity`,entity:n.entity,id:n.id,anchor:t};return{...e,link:r}}function U(e){let t=e.link;if(t===void 0||t.kind!==`entity`||t.anchor===void 0)return e;let n={kind:`entity`,entity:t.entity,id:t.id};return{...e,link:n}}function Ie(e){if(!Array.isArray(e))return[];let t=[];for(let n of e){if(t.length>=200)break;if(typeof n!=`object`||!n)continue;let{anchor:e,label:r}=n;typeof e==`string`&&(B(e)!==null||e===``||t.push({anchor:e,label:typeof r==`string`&&r!==``?r:e}))}return t}function Le({target:e,value:n,apiBasePath:r,disabled:i,onSelect:a,onClear:o}){let[s,c]=x(`idle`),[l,u]=x([]),[m,h]=x(n??``),g=B(m),v=_(async()=>{c(`loading`);try{let t=new URLSearchParams({entity:e.entity,id:e.id}),n=await fetch(`${r}/menus/anchors?${t.toString()}`);if(!n.ok)throw Error(`anchors lookup failed (${n.status})`);u(Ie(await n.json())),c(`loaded`)}catch{u([]),c(`error`)}},[r,e.entity,e.id]),y=_(e=>{h(e),e===``?o():B(e)===null&&a(e)},[o,a]),b=n!==null&&!l.some(e=>e.anchor===n)?[{anchor:n,label:n},...l]:l;return w(`div`,{className:`space-y-1.5`,children:[C(f,{htmlFor:`menu-item-anchor`,children:`Anchor`}),w(`div`,{className:`flex items-center gap-2`,children:[w(p,{id:`menu-item-anchor-select`,"aria-label":`Anchor on the linked page`,value:n??``,disabled:i,onChange:e=>y(e.target.value),children:[C(`option`,{value:``,children:`— none —`}),b.map(e=>C(`option`,{value:e.anchor,children:e.label},e.anchor))]}),C(t,{type:`button`,size:`sm`,variant:`outline`,className:`shrink-0`,disabled:i||s===`loading`,loading:s===`loading`,onClick:()=>{v()},children:`Find`})]}),s===`loaded`&&l.length===0&&C(`p`,{className:`text-xs text-muted-foreground`,children:`No anchors found on the linked page. You can still type one below.`}),s===`error`&&C(`p`,{className:`text-xs text-destructive`,children:`Could not load anchors. You can still type one below.`}),C(d,{id:`menu-item-anchor`,value:m,disabled:i,placeholder:`section-id`,"aria-invalid":g!==null,"aria-describedby":g===null?void 0:`menu-item-anchor-error`,onChange:e=>y(e.target.value)}),g===null?w(`p`,{className:`text-xs text-muted-foreground`,children:[`Links to a block on the target page (`,C(`code`,{children:`#anchor`}),`).`]}):C(`p`,{id:`menu-item-anchor-error`,className:`text-xs text-destructive`,children:g})]})}function Re({item:e,locales:r,defaultLocale:i,liveTitle:a,apiBasePath:o,disabled:s,onChange:c,onRemove:l}){if(e===null)return C(`aside`,{className:`rounded-md border border-dashed p-4 text-sm text-muted-foreground`,children:`Select an item to edit it.`});let u=e.kind===`header`,p=e.link,h=p?.kind===`entity`?{entity:p.entity,id:p.id}:null;return w(`aside`,{className:`space-y-4 rounded-md border border-border p-4`,children:[w(`div`,{children:[C(`h2`,{className:`text-sm font-medium`,children:u?`Header`:`Link`}),C(`p`,{className:`truncate text-xs text-muted-foreground`,children:G(e)})]}),w(`fieldset`,{className:`space-y-2`,children:[C(`legend`,{className:`text-xs font-medium text-muted-foreground`,children:u?`Label`:`Label override`}),r.map(t=>w(`div`,{className:`space-y-1`,children:[C(f,{htmlFor:`menu-item-label-${t.code}`,className:`text-xs font-normal`,children:t.label}),C(d,{id:`menu-item-label-${t.code}`,value:e.label?.[t.code]??``,disabled:s,placeholder:W(e,t.code,i,a),onChange:n=>c(e.id,Me(e,t.code,n.target.value))})]},t.code)),Ne(e)&&C(`p`,{className:`text-xs text-destructive`,children:`A header needs a label in at least one language — it has nothing else to show.`})]}),w(`fieldset`,{className:`space-y-1.5`,children:[C(`legend`,{className:`text-xs font-medium text-muted-foreground`,children:`Visible in`}),C(`div`,{className:`flex flex-wrap gap-1.5`,children:r.map(t=>{let n=Pe(e,t.code);return C(`button`,{type:`button`,"aria-pressed":!n,"aria-label":`Visibility in ${t.label}`,title:n?`Hidden in ${t.label}`:`Visible in ${t.label}`,disabled:s,onClick:()=>c(e.id,z(e,t.code,n)),className:m(`rounded-full border px-2.5 py-0.5 text-xs transition-colors`,`disabled:cursor-not-allowed disabled:opacity-50`,n?`border-dashed border-border text-muted-foreground line-through`:`border-primary bg-primary/10 text-foreground`),children:t.code},t.code)})}),C(`p`,{className:`text-xs text-muted-foreground`,children:`One tree serves every language; a hidden item is simply not rendered there.`})]}),!u&&w(`div`,{className:`flex items-center gap-2`,children:[C(n,{id:`menu-item-new-tab`,checked:e.newTab===!0,disabled:s,onCheckedChange:t=>c(e.id,Fe(e,t===!0))}),C(f,{htmlFor:`menu-item-new-tab`,className:`text-sm font-normal`,children:`Open in a new tab`})]}),h!==null&&C(Le,{target:h,value:V(e),apiBasePath:o,disabled:s,onSelect:t=>c(e.id,H(e,t)),onClear:()=>c(e.id,U(e))},`${h.entity}:${h.id}`),C(t,{type:`button`,variant:`outline`,size:`sm`,disabled:s,onClick:()=>l(e.id),children:`Remove item`})]})}function W(e,t,n,r){let i=je(e,{locale:t,defaultLocale:n,liveTitle:r});return i.source===`live`?`live: ${i.text}`:i.text===``?`no override set`:i.text}function G(e){let t=e.link;if(t===void 0)return`Text-only — a dropdown parent with no link of its own.`;switch(t.kind){case`entity`:return`${t.entity} · ${t.id}`;case`url`:return t.url;default:return t.email}}const ze={ok:null,unknown:null,broken:{text:`Broken`,title:`The linked row no longer exists. Repoint or remove this item.`,variant:`destructive`},unpublished:{text:`Unpublished`,title:`The linked page exists but is not published, so visitors will not reach it.`,variant:`outline`}};function Be({row:n,index:r,label:i,status:a,hiddenInPreview:o,previewLocaleLabel:d,selected:f,collapsed:p,disabled:h,onSelect:g,onToggleCollapse:_,onMove:v,onIndent:y,onOutdent:b,onRemove:x}){let{ref:S,handleRef:T}=Te({id:n.id,index:r,data:{depth:n.depth,parentId:n.parentId},disabled:h,transition:{idle:!0}}),E=n.childCount>0,D=ze[a],O=n.data.kind===`header`,k=i.text===``?O?`Untitled header`:`Untitled item`:i.text;return C(`li`,{ref:S,"data-menu-item-id":n.id,"data-depth":n.depth,...o&&{"data-hidden-in-preview":`true`},style:{marginInlineStart:n.depth*24},className:`list-none`,children:w(`div`,{className:m(`flex items-center gap-1 rounded-md border bg-card px-1.5 py-1`,f?`border-primary ring-1 ring-primary`:`border-border`),children:[C(`button`,{type:`button`,ref:T,disabled:h,"aria-label":`Reorder ${k} (level ${n.depth+1})`,className:`cursor-grab rounded p-1 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40`,children:C(F,{className:`size-4`,"aria-hidden":!0})}),E?C(`button`,{type:`button`,"aria-label":p?`Expand ${k}`:`Collapse ${k}`,"aria-expanded":!p,onClick:()=>_(n.id),className:`rounded p-1 text-muted-foreground hover:text-foreground`,children:C(p?P:N,{className:`size-4`,"aria-hidden":!0})}):C(`span`,{className:`inline-block size-6`,"aria-hidden":!0}),w(`button`,{type:`button`,onClick:()=>g(n.id),...o&&{title:`Hidden in ${d}`},onKeyDown:e=>{if(!e.altKey||h)return;let t=Ve[e.key];t!==void 0&&(e.preventDefault(),t({id:n.id,onMove:v,onIndent:y,onOutdent:b}))},className:m(`flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-0.5 text-left hover:bg-accent/50`,o&&`opacity-50 line-through`),children:[O?C(Se,{className:`size-4 shrink-0 text-muted-foreground`,"aria-label":`Header item`}):C(Ce,{className:`size-4 shrink-0 text-muted-foreground`,"aria-label":`Link item`}),C(`span`,{className:m(`truncate text-sm`,i.source===`override`?`text-foreground`:`text-muted-foreground italic`),children:k}),E&&C(`span`,{className:`shrink-0 text-xs text-muted-foreground tabular-nums`,children:n.childCount}),D&&C(e,{variant:D.variant,title:D.title,className:`shrink-0 text-[10px]`,children:D.text})]}),w(s,{children:[C(u,{asChild:!0,children:C(t,{variant:`ghost`,size:`icon`,disabled:h,"aria-label":`Actions for ${k}`,children:C(I,{className:`size-4`,"aria-hidden":!0})})}),w(c,{align:`end`,children:[C(l,{onClick:()=>v(n.id,-1),children:`Move up`}),C(l,{onClick:()=>v(n.id,1),children:`Move down`}),C(l,{onClick:()=>y(n.id),children:`Indent`}),C(l,{onClick:()=>b(n.id),children:`Outdent`}),C(l,{onClick:()=>x(n.id),children:`Remove`})]})]})]})})}const Ve={ArrowUp:({id:e,onMove:t})=>t(e,-1),ArrowDown:({id:e,onMove:t})=>t(e,1),ArrowRight:({id:e,onIndent:t})=>t(e),ArrowLeft:({id:e,onOutdent:t})=>t(e)},He=[`entity`,`url`,`email`];function Ue(e){switch(e.kind){case`entity`:return e.anchor!==void 0&&e.anchor!==``?{kind:`entity`,entity:e.entity,id:e.id,anchor:e.anchor}:{kind:`entity`,entity:e.entity,id:e.id};case`url`:return{kind:`url`,url:e.url};case`email`:return{kind:`email`,email:e.email};default:return null}}function We(e){return e===void 0||e.kind!==`entity`?null:Ee(e.anchor===void 0?{kind:`entity`,entity:e.entity,id:e.id}:{kind:`entity`,entity:e.entity,id:e.id,anchor:e.anchor})}function Ge(e){return e.map(e=>`${e.id}>${e.parentId??``}`).join(`|`)}function K({menu:e,summaries:r,locales:i,defaultLocale:a,droppedItemCount:o,apiBasePath:s,entityTypes:c,canPublish:l,draft:u,lockResult:d,onDirtyChange:p}){let[g,S]=x(e.tree),[T,E]=x(e.tree),[ae,A]=x(null),[j,me]=x(e.status),[N,P]=x(null),[F,Ce]=x(()=>new Set),[I,Te]=x(a),[Ee,De]=x(null),[L,R]=x(e.entityTypes??[]),[Me,Ne]=x(e.entityTypes??[]),[z,Fe]=x(null),B=b(null),V=b(null),H=ie({entityName:`menu`,entityId:e.id,entityPath:`menu`,apiBasePath:s,canPublish:l,enableLocking:!0,...d!==void 0&&{lockResult:d},...u!==void 0&&{draft:u}}),{pickLink:U,dialog:Ie,resolvedLabels:Le}=re({contentEntities:y(()=>c.filter(e=>he(L,e.name)),[L,c]),locale:a}),W=H.isReadOnly,G=!Ye(L,Me),ze=g!==T||G,Ve=g===ae&&z!==null&&Ye(L,z),K=ze&&!Ve,qe=i.find(e=>e.code===I)?.label??I,q=L.filter(e=>!c.some(t=>t.name===e)).length,Qe=y(()=>le(g),[g]),$e=Ee??de(Qe,F),J=N===null?null:M(g,N),et=_(e=>{let t=ke(e),n=t===null?void 0:r[t]?.title;if(n!=null)return n;let i=We(e);return i===null?void 0:Le.get(i)},[Le,r]),Y=_(e=>{let t=e(g);t.ok?S(t.tree):t.reason===`max-depth`&&h.error(Ke)},[g]),tt=_((e,t)=>Y(n=>ge(n,e,t)),[Y]),nt=_(t=>Y(n=>fe(n,t,e.maxDepth)),[Y,e.maxDepth]),rt=_(e=>Y(t=>_e(t,e)),[Y]),it=_(e=>{S(t=>ye(t,e)),P(t=>t===e?null:t)},[]),at=_((e,t)=>{S(n=>be(n,e,t))},[]),ot=_(e=>{R(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e])},[]),st=_(e=>{Ce(t=>{let n=new Set(t);return n.delete(e)||n.add(e),n})},[]),X=_(e=>{S(t=>pe(t,e,N)),P(e.id)},[N]),ct=_(async()=>{let e=await U({kinds:He,title:`Add menu item`});if(e===null)return;let t=Ue(e);if(t===null){h.error(`That link type can’t be used in a menu.`);return}X({id:Oe(),kind:`link`,link:t,label:null,children:[]})},[X,U]),lt=_(()=>{X({id:Oe(),kind:`header`,label:{[a]:``},children:[]})},[X,a]),ut=y(()=>[k],[]),Z=_(e=>{B.current=e,De(e)},[]),dt=_(t=>{let n=t.operation.source;if(n==null)return;let r=String(n.id),i=le(g),a=i.find(e=>e.id===r);if(a===void 0)return;let o=M(g,r),s=se(i,r),c=de(i,F).filter(e=>!s.has(e.id)),l=new Set(c.map(e=>e.id)),u=i.filter(e=>!l.has(e.id));V.current={initialDepth:a.depth,depthCap:ce(e.maxDepth,o===null?1:xe(o)),stash:u,signature:Ge([...c,...u]),clamped:!1},Z(c)},[F,e.maxDepth,Z,g]),Q=_((e,t,n,r)=>{let i=ve({items:e,activeId:n,requestedDepth:t.initialDepth+ue(r.dragOperation.transform.x,24),depthCap:t.depthCap});return i.clampedByMaxDepth&&(t.clamped=!0),i},[]),ft=_((e,t)=>{let{source:n,target:r}=e.operation,i=V.current,a=B.current;if(!n||!r||i===null||a===null||n.id===r.id)return;e.preventDefault();let o=D(a,e),{depth:s,parentId:c}=Q(o,i,String(n.id),t);Z(o.map(e=>e.id===n.id?{...e,depth:s,parentId:c}:e))},[Q,Z]),pt=_((e,t)=>{let{source:n,target:r}=e.operation,i=V.current,a=B.current;if(!n||!r||i===null||a===null)return;let o=String(n.id),{depth:s,parentId:c}=Q(a,i,o,t),l=a.find(e=>e.id===o);l!==void 0&&(l.depth===s&&l.parentId===c||Z(a.map(e=>e.id===o?{...e,depth:s,parentId:c}:e)))},[Q,Z]),mt=_(e=>{let t=V.current,n=B.current;if(V.current=null,Z(null),e.canceled||t===null||n===null)return;let r=[...n,...t.stash];Ge(r)!==t.signature&&S(oe(r)),t.clamped&&h.warning(Ke)},[Z]),$=_(async t=>!Je(g,e.maxDepth)||o>0&&!window.confirm(Ze(o))||await H.save({tree:g,...G&&{entityTypes:Xe(L)},...t})===null?!1:(E(g),Ne(L),!0),[L,o,H.save,e.maxDepth,g,G]),ht=_(async()=>{await $({})&&h.success(`Menu saved`)},[$]),gt=_(async()=>{if(Je(g,e.maxDepth)){if(await H.saveDraft({tree:g,entityTypes:Xe(L)})===null){h.error(`Could not save draft`);return}A(g),Fe(L)}},[L,H.saveDraft,e.maxDepth,g]),_t=_(async()=>{let e=j===`published`?`draft`:`published`;await $({status:e})&&(me(e),h.success(e===`published`?`Menu published`:`Menu unpublished`))},[$,j]);return v(()=>{p?.(K)},[K,p]),v(()=>{if(!K)return;let e=e=>{e.preventDefault()};return window.addEventListener(`beforeunload`,e),()=>window.removeEventListener(`beforeunload`,e)},[K]),w(`div`,{className:`space-y-4`,children:[H.isLockedByOther&&H.lockHolder&&C(te,{lockHolder:H.lockHolder,onForceUnlock:H.forceUnlock,canForceUnlock:l}),u&&C(ee,{draft:u,onDiscard:H.discardDraft,onPublish:H.publishDraft,canPublish:l,isSubmitting:H.isSubmitting}),H.savedAsDraft&&C(ne,{}),o>0&&C(`div`,{className:`rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3`,children:w(`p`,{className:`text-sm text-destructive`,children:[o,` stored item`,o===1?``:`s`,` could not be read and`,` `,o===1?`is`:`are`,` not shown. Saving this menu would remove`,` `,o===1?`it`:`them`,` permanently.`]})}),H.submitError&&C(`div`,{className:`rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3`,children:C(`p`,{className:`text-sm text-destructive`,children:H.submitError})}),w(`div`,{className:`flex flex-wrap items-center gap-2`,children:[w(t,{type:`button`,size:`sm`,onClick:ct,disabled:W,children:[C(we,{className:`size-4`,"aria-hidden":!0}),` Add link`]}),w(t,{type:`button`,size:`sm`,variant:`outline`,onClick:lt,disabled:W,children:[C(Se,{className:`size-4`,"aria-hidden":!0}),` Add header`]}),w(`span`,{className:`ml-auto flex items-center gap-2`,children:[K&&C(`span`,{className:`text-sm text-muted-foreground`,children:`Unsaved changes`}),C(t,{type:`button`,size:`sm`,variant:`outline`,onClick:gt,disabled:W||H.isSubmitting,children:`Save draft`}),C(t,{type:`button`,size:`sm`,onClick:ht,disabled:W||H.isSubmitting||!ze,loading:H.isSubmitting,children:`Save`}),l&&C(t,{type:`button`,size:`sm`,variant:j===`published`?`outline`:`default`,onClick:_t,disabled:W||H.isSubmitting,children:j===`published`?`Unpublish`:`Publish`})]})]}),c.length>0&&w(`details`,{className:`rounded-md border px-3 py-2`,children:[C(`summary`,{className:`cursor-pointer text-sm font-medium`,children:`Menu settings`}),w(`fieldset`,{className:`mt-3`,disabled:W,children:[C(`legend`,{className:`text-sm font-medium`,children:`Content types`}),w(`p`,{className:`mb-2 text-xs text-muted-foreground`,children:[`Which content types the “Add link” picker offers for this menu.`,L.length===0?` Nothing selected — this menu accepts every content type.`:` Items already in the tree are never removed by narrowing this.`]}),C(`div`,{className:`flex flex-wrap gap-x-4 gap-y-2`,children:c.map(e=>{let t=`menu-entity-type-${e.name}`;return w(`div`,{className:`flex items-center gap-2`,children:[C(n,{id:t,checked:L.includes(e.name),disabled:W,onCheckedChange:()=>ot(e.name)}),C(f,{htmlFor:t,className:`text-sm font-normal`,children:e.label})]},e.name)})}),q>0&&w(`p`,{className:`mt-2 text-xs text-muted-foreground`,children:[q,` more content type`,q===1?` is`:`s are`,` whitelisted but not available to you. `,q===1?`It is`:`They are`,` kept when you save.`]})]})]}),w(`div`,{className:`grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]`,children:[w(`div`,{children:[i.length>1&&w(`fieldset`,{className:`mb-2 flex flex-wrap items-center gap-1.5`,children:[C(`legend`,{className:`float-left me-1.5 text-xs text-muted-foreground`,children:`Preview as`}),i.map(e=>{let t=e.code===I;return C(`button`,{type:`button`,"aria-pressed":t,"aria-label":`Preview in ${e.label}`,onClick:()=>Te(e.code),className:m(`rounded-full border px-2.5 py-0.5 text-xs transition-colors`,t?`border-primary bg-primary/10 text-foreground`:`border-border text-muted-foreground hover:text-foreground`),children:e.code},e.code)})]}),$e.length===0?C(`p`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:`This menu has no items yet. Add a link or a header to start.`}):C(O,{sensors:ut,onDragStart:dt,onDragOver:ft,onDragMove:pt,onDragEnd:mt,children:C(`ul`,{className:`flex flex-col gap-1`,children:$e.map((e,t)=>{let n=M(g,e.id)??{...e.data,children:[]};return C(Be,{row:e,index:t,label:je(n,{locale:I,defaultLocale:a,liveTitle:et(e.data.link)}),status:Ae(n,r),hiddenInPreview:Pe(n,I),previewLocaleLabel:qe,selected:N===e.id,collapsed:F.has(e.id),disabled:W,onSelect:P,onToggleCollapse:st,onMove:tt,onIndent:nt,onOutdent:rt,onRemove:it},e.id)})})}),w(`p`,{className:`mt-3 text-xs text-muted-foreground`,children:[`Drag a row’s handle to reorder; drag right to nest. Keyboard: ⌥↑ / ⌥↓ to move, ⌥→ / ⌥← to indent and outdent. Max nesting for this menu: `,e.maxDepth,`. Dimmed rows are hidden in the previewed language.`]})]}),C(Re,{item:J,locales:i,defaultLocale:a,liveTitle:J===null?void 0:et(J.link),apiBasePath:s,disabled:W,onChange:at,onRemove:it},J?.id??`none`)]}),Ie]})}const Ke=`That would nest deeper than this menu allows.`,qe=`A menu can hold at most ${j} items.`;function Je(e,t){let{depth:n,nodeCount:r}=me(e);return r>j?(h.error(qe),!1):n>t?(h.error(Ke),!1):!0}function Ye(e,t){return e.length===t.length&&e.every(e=>t.includes(e))}function Xe(e){return e.length===0?null:[...e]}function Ze(e){return`${e} stored item${e===1?``:`s`} could not be read and will be REMOVED permanently by this save. Continue?`}function q(e){if(typeof e!=`object`||!e||!(`items`in e))return[];let t=e.items;if(!Array.isArray(t))return[];let n=[];for(let e of t){if(typeof e!=`object`||!e)continue;let{id:t,handle:r,title:i}=e;typeof t!=`string`||typeof r!=`string`||r.length===0||n.push({id:t,handle:r,title:typeof i==`string`&&i.length>0?i:r})}return n}function Qe(e={}){let n=e.apiBasePath??`/api/admin`,r=e.adminBasePath??`/admin`;return function({values:e,onChange:i,onClose:a}){let o=typeof e.handle==`string`?e.handle:``,s=e.orientation===`vertical`?`vertical`:`horizontal`,[c,l]=x(null),[u,d]=x(!1);v(()=>{let e=!1;return fetch(`${n}/menu?limit=200&sort=title`).then(e=>e.ok?e.json():Promise.reject(e.status)).then(t=>{e||l(q(t))}).catch(()=>{e||d(!0)}),()=>{e=!0}},[]);let[f,m]=x(null),[h,g]=x(!1),[y,S]=x(null);v(()=>{if(o.length===0){m(null),S(null);return}let e=!1;return g(!0),S(null),fetch(`${n}/menus/editor-state?handle=${encodeURIComponent(o)}`).then(async e=>{if(!e.ok){let t=await e.json().catch(()=>({}));throw Error(t.error??`Could not load this menu (${e.status})`)}return e.json()}).then(t=>{e||m(t)}).catch(t=>{e||S(t instanceof Error?t.message:`Could not load this menu`)}).finally(()=>{e||g(!1)}),()=>{e=!0}},[o]);let T=b(!1),E=_(e=>{T.current=e},[]),D=_(e=>{e!==o&&(T.current&&!window.confirm(`This menu has unsaved changes. Switching to a different menu will discard them. Continue?`)||i(`handle`,e))},[o,i]),O=f?.menu.id,k=O?`${r}/menu/${O}`:null;return w(`div`,{className:`flex h-full flex-col`,children:[w(`div`,{className:`flex flex-wrap items-center gap-3 border-b border-border px-4 py-3`,children:[w(`div`,{className:`flex flex-col gap-1`,children:[C(`span`,{className:`text-xs font-medium text-muted-foreground`,children:`Menu`}),u?C(`span`,{className:`text-sm text-destructive`,children:`Could not load the menu list.`}):w(p,{"aria-label":`Menu`,value:o,disabled:c===null,onChange:e=>D(e.target.value),children:[C(`option`,{value:``,children:`— choose a menu —`}),(c??[]).map(e=>w(`option`,{value:e.handle,children:[e.title,` (`,e.handle,`)`]},e.id))]})]}),w(`div`,{className:`flex flex-col gap-1`,children:[C(`span`,{className:`text-xs font-medium text-muted-foreground`,children:`Orientation`}),C(`div`,{className:`flex overflow-hidden rounded-md border border-border`,children:[`horizontal`,`vertical`].map(e=>C(`button`,{type:`button`,"aria-pressed":s===e,onClick:()=>i(`orientation`,e),className:s===e?`bg-primary px-3 py-1.5 text-sm text-primary-foreground`:`bg-transparent px-3 py-1.5 text-sm text-foreground hover:bg-accent`,children:e===`horizontal`?`Horizontal`:`Vertical`},e))})]}),w(`span`,{className:`ml-auto flex items-center gap-2`,children:[k&&C(`a`,{href:k,target:`_blank`,rel:`noopener noreferrer`,className:`text-sm text-muted-foreground underline hover:text-foreground`,children:`Open full editor ↗`}),C(t,{type:`button`,variant:`outline`,size:`sm`,onClick:a,children:`Close`})]})]}),C(`div`,{className:`min-h-0 flex-1 overflow-auto p-4`,children:o.length===0?C(`p`,{className:`text-sm text-muted-foreground`,children:`Choose a menu above to edit its items here, or create one from the Menus admin screen first.`}):h?w(`p`,{className:`text-sm text-muted-foreground`,children:[`Loading “`,o,`”…`]}):y?C(`p`,{className:`text-sm text-destructive`,children:y}):f?C(K,{...$e(f,n),onDirtyChange:E},f.menu.id):null})]})}}function $e(e,t){return{menu:e.menu,summaries:e.summaries,locales:e.locales,defaultLocale:e.defaultLocale,droppedItemCount:e.droppedItemCount,apiBasePath:t,entityTypes:e.entityTypes,canPublish:e.canPublish,...e.draft!==void 0&&{draft:e.draft}}}const J=Qe(),et={...T,customEditorSurface:J},Y=E.menu;export{et as MenuBlock,Y as MenuBlockRender,K as MenuTreeEditor,L as NewMenuDialog,Qe as createMenuBlockEditorSurface,J as menuBlockEditorSurface};
|
|
3
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["CanonicalMenuBlock"],"sources":["../src/menu-list/new-menu-dialog.tsx","../src/tree-editor/generate-item-id.ts","../src/tree-editor/item-display.ts","../src/tree-editor/item-edits.ts","../src/tree-editor/anchor-picker.tsx","../src/tree-editor/item-inspector.tsx","../src/tree-editor/menu-tree-row.tsx","../src/tree-editor/picker-link.ts","../src/tree-editor/menu-tree-editor.tsx","../src/tree-editor/menu-block-editor-surface.tsx","../src/tree-editor/menu-block.ts"],"sourcesContent":["'use client'\n\n/**\n * \"New menu\" — the only UI path to CREATE a menu (routing PR10, Task 3a\n * fold-in).\n *\n * **Why it has to exist here.** The menus plugin contributes a `menu/[id]`\n * admin route, and the route table matches that `:id` pattern BEFORE the\n * generic entity family — so `/admin/menu/new` resolves to the menu EDITOR (a\n * uuid guard turns it into a clean 404), and the framework's `EntityCreatePage`\n * is unreachable at that URL. Same situation `media` + `media/[id]` already\n * has. Declaring a static `menu/new` route does not help either:\n * `buildAdminRouteTable` strips any route whose factory is an entity-family\n * one. A small dedicated dialog is the honest fix; without it a fresh install\n * can never create its first menu.\n *\n * Deliberately minimal: the two required columns (`title`, `handle`) and\n * nothing else. `maxDepth`, publish status and the tree are all edited in the\n * editor the dialog navigates to.\n *\n * **Security.** POSTs to the same authenticated admin API as every other\n * mutation — `menu:create` is enforced server-side by the api-handler's\n * firewall, and the same-origin fetch carries the `Origin` header\n * `assertOrigin` checks. The caller only sees this control when\n * `MenuListPage`'s server-side `menu:create` check passes, so the button is a\n * least-surprise affordance, not the gate.\n */\n\nimport {\n Button,\n Dialog,\n DialogContent,\n DialogHeader,\n DialogTitle,\n Input,\n Label,\n} from '@murumets-ee/ui'\nimport { useRouter } from 'next/navigation'\nimport { type FormEvent, type ReactElement, useState } from 'react'\n\nexport interface NewMenuDialogProps {\n /** Admin API base (`/api/admin` unless overridden). */\n readonly apiBasePath: string\n /** Locale-resolved admin base (e.g. `/en/admin`) — where to navigate after create. */\n readonly adminBasePath: string\n}\n\n/**\n * `title` → `handle` suggestion. Intentionally the same shape the slug field\n * uses for its own auto-fill (lowercase, non-alphanumerics to `-`, trimmed):\n * the handle is the fetch key the frontend resolves by, so it must stay a\n * simple, stable token. The admin can always overwrite it.\n */\nexport function suggestHandle(title: string): string {\n return title\n .toLowerCase()\n .normalize('NFKD')\n .replace(/[̀-ͯ]/g, '')\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 64)\n}\n\nexport function NewMenuDialog({ apiBasePath, adminBasePath }: NewMenuDialogProps): ReactElement {\n const router = useRouter()\n const [open, setOpen] = useState(false)\n const [title, setTitle] = useState('')\n const [handle, setHandle] = useState('')\n // `true` once the admin edits the handle by hand — from then on the title no\n // longer overwrites it.\n const [handleTouched, setHandleTouched] = useState(false)\n const [submitting, setSubmitting] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n const effectiveHandle = handleTouched ? handle : suggestHandle(title)\n const canSubmit = title.trim() !== '' && effectiveHandle !== '' && !submitting\n\n const onSubmit = async (event: FormEvent) => {\n event.preventDefault()\n if (!canSubmit) return\n setSubmitting(true)\n setError(null)\n try {\n const res = await fetch(`${apiBasePath}/menu`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ title: title.trim(), handle: effectiveHandle }),\n })\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as { error?: string }\n throw new Error(body.error ?? `Could not create the menu (${res.status})`)\n }\n const created = (await res.json()) as { id?: unknown }\n if (typeof created.id !== 'string') {\n throw new Error('The menu was created but the server returned no id.')\n }\n setOpen(false)\n router.push(`${adminBasePath}/menu/${created.id}`)\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Could not create the menu')\n setSubmitting(false)\n }\n // No `finally` — on success the navigation owns the screen, and clearing\n // the submitting flag first would flash an enabled button mid-navigation.\n }\n\n return (\n <>\n <Button type=\"button\" size=\"sm\" onClick={() => setOpen(true)}>\n New menu\n </Button>\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent className=\"max-w-md\">\n <DialogHeader>\n <DialogTitle>New menu</DialogTitle>\n </DialogHeader>\n <form onSubmit={onSubmit} className=\"flex flex-col gap-3\">\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"new-menu-title\">Title</Label>\n <Input\n id=\"new-menu-title\"\n value={title}\n onChange={(e) => setTitle(e.target.value)}\n placeholder=\"Main navigation\"\n />\n </div>\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"new-menu-handle\">Handle</Label>\n <Input\n id=\"new-menu-handle\"\n value={effectiveHandle}\n onChange={(e) => {\n setHandleTouched(true)\n setHandle(e.target.value)\n }}\n placeholder=\"main\"\n />\n <p className=\"text-xs text-muted-foreground\">\n The key the frontend fetches this menu by. Lowercase, no spaces.\n </p>\n </div>\n {error && <p className=\"text-sm text-destructive\">{error}</p>}\n <div className=\"flex justify-end gap-2 pt-1\">\n <Button type=\"button\" variant=\"outline\" onClick={() => setOpen(false)}>\n Cancel\n </Button>\n <Button type=\"submit\" disabled={!canSubmit} loading={submitting}>\n Create\n </Button>\n </div>\n </form>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n","'use client'\n\n/**\n * Generate a new menu item's stable id.\n *\n * Throws when `crypto.randomUUID` is unavailable rather than falling back to\n * `Math.random` — every supported runtime (browsers on a secure context, Node\n * ≥19) ships it, so a silent fallback would mask a too-old/insecure-context\n * host behind UUID-shape collisions instead of a diagnosable error. Mirrors\n * `@murumets-ee/blocks`' `generateBlockId()` (package-internal there, not\n * exported), which this codebase already treats as the reviewed answer to\n * this exact question.\n */\nexport function generateItemId(): string {\n const c = globalThis.crypto\n if (c === undefined || typeof c.randomUUID !== 'function') {\n throw new Error(\n '[menus-ui] adding a menu item requires `crypto.randomUUID()` but the host runtime does not provide it (e.g. a non-secure-context origin). Serve the admin over HTTPS/localhost, or upgrade to a supported runtime.',\n )\n }\n return c.randomUUID()\n}\n","/**\n * How one menu item RENDERS in the tree — its display label and its\n * needs-attention badge (plan/routing phase 09 §3, \"item rows show … resolved\n * label (current admin locale), target summary …, broken/unpublished badge\").\n *\n * Pure so both are unit-testable without mounting anything, and so Task 3b's\n * locale-preview switcher only has to pass a different `locale`.\n *\n * ## The indeterminate-vs-broken distinction (get this right)\n *\n * `resolveMenuItemSummaries()` returns a map keyed `` `${entity}:${id}` ``, and\n * a MISSING entry means **indeterminate** — the entity type isn't registered in\n * this app, or the signed-in admin holds no `view` grant on it, or the read\n * failed. Only an explicit `exists: false` means the row is really gone.\n * Rendering a missing entry as \"broken\" would paint every item red the moment a\n * plugin isn't loaded, which is exactly the false alarm that makes editors stop\n * trusting the badge. So: missing → {@link MenuItemStatus} `'unknown'` (no\n * badge), `exists: false` → `'broken'`.\n *\n * @module\n */\n\nimport type { MenuItem, MenuItemSummary, MenuLinkValue } from '@murumets-ee/menus/schema'\n\n/** Needs-attention state of one item's target. */\nexport type MenuItemStatus =\n /** Nothing to flag — a header, a url/email link, or a live published target. */\n | 'ok'\n /** The linked row is gone (`exists: false`). Flagged, never auto-removed. */\n | 'broken'\n /** The linked row exists but isn't published, so visitors won't reach it. */\n | 'unpublished'\n /** No summary was resolvable — see the module header. Deliberately unbadged. */\n | 'unknown'\n\n/**\n * The `resolveMenuItemSummaries` lookup key for a link value, or `null` when\n * the link has no entity target to look up.\n */\nexport function summaryKey(link: MenuLinkValue | undefined): string | null {\n if (link === undefined || link.kind !== 'entity') return null\n return `${link.entity}:${link.id}`\n}\n\n/** Classify an item's target for the row badge. */\nexport function resolveItemStatus(\n item: MenuItem,\n summaries: Readonly<Record<string, MenuItemSummary>>,\n): MenuItemStatus {\n const key = summaryKey(item.link)\n if (key === null) return 'ok'\n const summary = summaries[key]\n if (summary === undefined) return 'unknown'\n if (!summary.exists) return 'broken'\n if (summary.published === false) return 'unpublished'\n return 'ok'\n}\n\n/** Where a rendered label came from — drives the row's \"live title\" styling. */\nexport type MenuItemLabelSource =\n /** An explicit per-locale override the editor typed. */\n | 'override'\n /** The linked entity's live title (`label: null`, phase 07 §2.3). */\n | 'live'\n /** The raw link target (external URL / email address). */\n | 'target'\n /** Nothing resolvable — the caller renders a placeholder. */\n | 'none'\n\n/** Options for {@link resolveItemLabel}. */\nexport interface ResolveItemLabelOptions {\n /** Locale being previewed. */\n readonly locale: string\n /** Fallback locale when the previewed one has no override. */\n readonly defaultLocale: string\n /**\n * The linked entity's live title, when known — from the server-resolved\n * summaries, or from the link picker's own `resolvedLabels` for an item the\n * editor just added (no round-trip, no mount-time fetch).\n */\n readonly liveTitle?: string | undefined\n}\n\n/** Result of {@link resolveItemLabel}. */\nexport interface ResolvedItemLabel {\n readonly text: string\n readonly source: MenuItemLabelSource\n}\n\n/**\n * The text a row shows, in priority order: the override for the previewed\n * locale → the default-locale override → the live entity title → the raw\n * external target. Whitespace-only overrides are treated as absent: an emptied\n * input is \"no override\", not a blank label.\n */\nexport function resolveItemLabel(\n item: MenuItem,\n { locale, defaultLocale, liveTitle }: ResolveItemLabelOptions,\n): ResolvedItemLabel {\n const override = pickLabel(item.label, locale) ?? pickLabel(item.label, defaultLocale)\n if (override !== undefined) return { text: override, source: 'override' }\n\n if (liveTitle !== undefined && liveTitle.trim() !== '') {\n return { text: liveTitle, source: 'live' }\n }\n\n const link = item.link\n if (link?.kind === 'url') return { text: link.url, source: 'target' }\n if (link?.kind === 'email') return { text: link.email, source: 'target' }\n\n return { text: '', source: 'none' }\n}\n\nfunction pickLabel(label: Record<string, string> | null, locale: string): string | undefined {\n const value = label?.[locale]\n return value !== undefined && value.trim() !== '' ? value : undefined\n}\n","/**\n * Field-level edits to ONE menu item — the pure half of the inspector panel\n * (plan/routing phase 09 §4).\n *\n * Every function here takes a {@link MenuItem} and returns a NEW one; nothing\n * mutates, nothing touches tree structure (that is `@murumets-ee/menus/schema`\n * (`menu-tree-ops.ts`)), and nothing renders. The inspector is then a thin\n * controlled surface over these, and the map semantics below — the part that is\n * easy to get subtly wrong — are unit-testable with no DOM.\n *\n * ## The two map conventions (get these right)\n *\n * `label` and `enabled` are both `Record<locale, …>`, but they mean opposite\n * things when a key is ABSENT, and both are edited by DELETING rather than\n * writing a neutral value:\n *\n * - **`label`** — absent = \"no override for this locale\". `label: null` is the\n * stronger sentinel \"use the linked entity's LIVE title, per locale\"\n * (phase 07 §2.3), so emptying the last override collapses the map back to\n * `null` rather than leaving `{}`. Storing `''` instead would be a label that\n * renders as nothing; `resolveItemLabel`'s `pickLabel` already reads a\n * whitespace-only override as absent, so the two would disagree about what\n * \"empty\" means on the way in and out.\n * - **`enabled`** — absent = VISIBLE. Per-locale visibility is first-class in\n * BOTH directions (phase 08 §3 / D009), so hiding writes an explicit\n * `false` and re-showing DELETES the key. Writing `{[locale]: true}` would be\n * functionally identical to absent while adding a key every reader has to\n * normalise away, and every stored tree would slowly fill with them.\n *\n * `newTab` follows the same \"absent is the default\" rule: `false` is deleted,\n * never stored.\n *\n * @module\n */\n\nimport {\n ANCHOR_PATTERN,\n MAX_ANCHOR_LENGTH,\n type MenuItem,\n type MenuLinkValue,\n} from '@murumets-ee/menus/schema'\n\n/**\n * Set (or clear) the label override for one locale.\n *\n * An EMPTY string deletes that locale's key. A whitespace-only string is kept\n * verbatim — deleting on `.trim()` would make a typed space disappear from the\n * input mid-keystroke, and the render path already treats it as no override.\n */\nexport function setItemLabel(item: MenuItem, locale: string, value: string): MenuItem {\n const current = item.label?.[locale]\n if (value === '' ? current === undefined : current === value) return item\n const label: Record<string, string> = { ...item.label }\n if (value === '') delete label[locale]\n else label[locale] = value\n return { ...item, label: Object.keys(label).length === 0 ? null : label }\n}\n\n/**\n * Does this header have no usable label in ANY locale?\n *\n * A UX hint, deliberately STRICTER than the write boundary: the schema only\n * requires the map to hold at least one key, so `{en: ''}` passes it and would\n * persist a header that renders as nothing. Always `false` for a link item —\n * there, no label means \"use the live title\", which is the normal case.\n */\nexport function headerNeedsLabel(item: MenuItem): boolean {\n if (item.kind !== 'header') return false\n const label = item.label\n if (label === null) return true\n return !Object.values(label).some((value) => value.trim() !== '')\n}\n\n/** Toggle this item's visibility in one locale. `visible` deletes the key. */\nexport function setItemEnabled(item: MenuItem, locale: string, visible: boolean): MenuItem {\n const currentlyHidden = item.enabled?.[locale] === false\n if (currentlyHidden === !visible) return item\n\n const enabled: Record<string, boolean> = { ...item.enabled }\n if (visible) delete enabled[locale]\n else enabled[locale] = false\n\n if (Object.keys(enabled).length === 0) {\n // Splice the property out entirely rather than setting `undefined`: an own\n // key with an undefined value is invisible to `JSON.stringify` but very\n // visible to `Object.keys`, so leaving one would make \"is anything hidden?\"\n // answer differently before and after a save round-trip.\n const { enabled: _dropped, ...rest } = item\n return rest\n }\n return { ...item, enabled }\n}\n\n/** Is this item explicitly hidden in `locale`? Absent/`true` = visible. */\nexport function isHiddenInLocale(item: MenuItem, locale: string): boolean {\n return item.enabled?.[locale] === false\n}\n\n/** Set the \"open in a new tab\" flag. `false` deletes the key. */\nexport function setItemNewTab(item: MenuItem, newTab: boolean): MenuItem {\n if (!newTab) {\n if (!('newTab' in item)) return item\n const { newTab: _dropped, ...rest } = item\n return rest\n }\n if (item.newTab === true) return item\n return { ...item, newTab: true }\n}\n\n/**\n * Validate a hand-typed anchor against the SAME bounds `LinkValueSchema` will\n * apply at the write boundary, returning a human message or `null` when it is\n * acceptable. An empty string is acceptable — that is \"no anchor\".\n *\n * A UX nicety, NOT the security boundary: the server re-validates every PATCH\n * regardless (CLAUDE.md \"validate at the boundary\"). Its whole job is to refuse\n * a value inline instead of letting a whole-tree save 400 after the fact.\n *\n * The messages never echo the offending input back — the same rule the admin\n * api-handler's refine messages follow, so a pasted payload can't be reflected\n * into the DOM.\n */\nexport function validateAnchorInput(value: string): string | null {\n if (value === '') return null\n if (value.length > MAX_ANCHOR_LENGTH) {\n return `An anchor can be at most ${MAX_ANCHOR_LENGTH} characters.`\n }\n if (!ANCHOR_PATTERN.test(value)) {\n return 'Use letters, numbers, hyphens and underscores only — no “#”, spaces or dots.'\n }\n return null\n}\n\n/** This item's `#fragment`, or `null` when it has none (or can't have one). */\nexport function itemAnchor(item: MenuItem): string | null {\n const link = item.link\n if (link === undefined || link.kind !== 'entity') return null\n return link.anchor ?? null\n}\n\n/**\n * Point this item at a block anchor on its target.\n *\n * Only `entity` links carry one — `MenuLinkValue`'s other arms have no `anchor`\n * slot — so anything else is returned unchanged rather than silently reshaped.\n * The caller is expected to have validated `anchor` against `ANCHOR_PATTERN` /\n * `MAX_ANCHOR_LENGTH` first; this function is the write, not the gate.\n */\nexport function setItemAnchor(item: MenuItem, anchor: string): MenuItem {\n const link = item.link\n if (link === undefined || link.kind !== 'entity') return item\n if (link.anchor === anchor) return item\n const next: MenuLinkValue = { kind: 'entity', entity: link.entity, id: link.id, anchor }\n return { ...item, link: next }\n}\n\n/**\n * Remove this item's anchor.\n *\n * The key is SPLICED OUT, not set to `undefined` or `''`: `LinkValueSchema`\n * requires `.min(1)` when `anchor` is present, so an empty string would fail\n * validation at save, and an own `anchor: undefined` key survives every\n * in-memory comparison while vanishing across `JSON.stringify`.\n */\nexport function clearItemAnchor(item: MenuItem): MenuItem {\n const link = item.link\n if (link === undefined || link.kind !== 'entity' || link.anchor === undefined) return item\n const next: MenuLinkValue = { kind: 'entity', entity: link.entity, id: link.id }\n return { ...item, link: next }\n}\n","'use client'\n\n/**\n * The inspector's block-anchor field (plan/routing phase 08 §5 / 09 §4 + §7,\n * SD027) — a picker over `GET /api/admin/menus/anchors` PLUS a manual-string\n * input, both always visible.\n *\n * ## Why the list loads on a CLICK, not on selection\n *\n * Every anchor lookup is a real DB read of another entity's published layout.\n * Resolving them server-side would mean one lookup per entity item in the tree\n * — an `O(items)` fan-out on a page render, exactly what CLAUDE.md's\n * \"no unbounded fan-out\" rule forbids — and firing one automatically whenever\n * the selection changes would put a query behind every row click in an\n * interactive editor. So the request is bound to an explicit \"Find anchors\"\n * press: at most one in-flight lookup, only when the editor actually wants one,\n * and no `useEffect` fetching (CLAUDE.md \"Data Fetching\").\n *\n * ## Why the manual input is never hidden\n *\n * The endpoint answers `200 []` for EVERY negative case — no permission on the\n * target type, unknown entity, no block-tree field, draft/deleted row — because\n * one uniform response discloses nothing about what exists (`routes.ts`). That\n * is deliberate, so this component must not try to reverse-engineer WHY a list\n * came back empty; it shows the same empty state either way. Manual entry stays\n * available regardless (phase 07 §2.6), which is also the documented route to\n * an anchor on a page that isn't published yet.\n *\n * Typed input is validated against `ANCHOR_PATTERN` / `MAX_ANCHOR_LENGTH` —\n * the very constants `LinkValueSchema` uses — so a bad value is refused inline\n * instead of surfacing as a 400 on the whole-tree save. That is a UX nicety;\n * the server remains the boundary.\n */\n\nimport type { BlockAnchor } from '@murumets-ee/menus/schema'\nimport { Button, Input, Label, Select } from '@murumets-ee/ui'\nimport { type ReactElement, useCallback, useState } from 'react'\nimport { validateAnchorInput } from './item-edits.js'\n\n/**\n * Hard cap on rendered options. The endpoint already caps its own response, but\n * a client that renders whatever it is handed is one compromised/odd response\n * away from an unbounded DOM — and this list is a `<select>`, which stops being\n * usable long before it stops being renderable.\n */\nconst MAX_RENDERED_ANCHORS = 200\n\n/** The `entity`-link target an anchor belongs to. */\nexport interface AnchorTarget {\n readonly entity: string\n readonly id: string\n}\n\nexport interface AnchorPickerProps {\n readonly target: AnchorTarget\n /** The item's current anchor, or `null`. */\n readonly value: string | null\n /** Admin API base (`/api/admin` unless overridden). */\n readonly apiBasePath: string\n readonly disabled: boolean\n /** Commit a validated anchor. */\n readonly onSelect: (anchor: string) => void\n /** Remove the anchor entirely (splices the key out — see `item-edits.ts`). */\n readonly onClear: () => void\n}\n\ntype LoadState = 'idle' | 'loading' | 'loaded' | 'error'\n\n/** Narrow an untrusted JSON body to the anchors we are willing to render. */\nexport function parseAnchorResponse(body: unknown): BlockAnchor[] {\n if (!Array.isArray(body)) return []\n const out: BlockAnchor[] = []\n for (const entry of body as readonly unknown[]) {\n if (out.length >= MAX_RENDERED_ANCHORS) break\n if (entry === null || typeof entry !== 'object') continue\n const { anchor, label } = entry as { anchor?: unknown; label?: unknown }\n if (typeof anchor !== 'string') continue\n // Same gate the manual input passes through: never offer a value the write\n // boundary would then reject.\n if (validateAnchorInput(anchor) !== null || anchor === '') continue\n out.push({ anchor, label: typeof label === 'string' && label !== '' ? label : anchor })\n }\n return out\n}\n\nexport function AnchorPicker({\n target,\n value,\n apiBasePath,\n disabled,\n onSelect,\n onClear,\n}: AnchorPickerProps): ReactElement {\n const [state, setState] = useState<LoadState>('idle')\n const [anchors, setAnchors] = useState<readonly BlockAnchor[]>([])\n // The typed value. Local because it holds text that is not yet (or never)\n // committable — an invalid draft must not be written into the tree. Seeded\n // once; the picker is remounted (keyed on the target) whenever the selected\n // item changes, so there is no prop-into-state effect to keep in sync.\n const [draft, setDraft] = useState(value ?? '')\n\n // Derived during render, never state: it is a pure function of `draft`.\n const draftError = validateAnchorInput(draft)\n\n const load = useCallback(async () => {\n setState('loading')\n try {\n const query = new URLSearchParams({ entity: target.entity, id: target.id })\n const res = await fetch(`${apiBasePath}/menus/anchors?${query.toString()}`)\n if (!res.ok) throw new Error(`anchors lookup failed (${res.status})`)\n setAnchors(parseAnchorResponse(await res.json()))\n setState('loaded')\n } catch {\n // The message below is generic on purpose — a failed lookup says nothing\n // about the target, matching the endpoint's own uniform posture.\n setAnchors([])\n setState('error')\n }\n }, [apiBasePath, target.entity, target.id])\n\n const commit = useCallback(\n (next: string) => {\n setDraft(next)\n if (next === '') onClear()\n else if (validateAnchorInput(next) === null) onSelect(next)\n },\n [onClear, onSelect],\n )\n\n // The committed value may not be in the fetched list (typed by hand, or on a\n // draft-only section) — offer it so the `<select>` can always show what is\n // actually stored rather than silently snapping to \"none\".\n const options =\n value !== null && !anchors.some((a) => a.anchor === value)\n ? [{ anchor: value, label: value }, ...anchors]\n : anchors\n\n return (\n <div className=\"space-y-1.5\">\n <Label htmlFor=\"menu-item-anchor\">Anchor</Label>\n\n <div className=\"flex items-center gap-2\">\n <Select\n id=\"menu-item-anchor-select\"\n aria-label=\"Anchor on the linked page\"\n value={value ?? ''}\n disabled={disabled}\n onChange={(event) => commit(event.target.value)}\n >\n <option value=\"\">— none —</option>\n {options.map((entry) => (\n <option key={entry.anchor} value={entry.anchor}>\n {entry.label}\n </option>\n ))}\n </Select>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n className=\"shrink-0\"\n disabled={disabled || state === 'loading'}\n loading={state === 'loading'}\n onClick={() => {\n void load()\n }}\n >\n Find\n </Button>\n </div>\n\n {state === 'loaded' && anchors.length === 0 && (\n <p className=\"text-xs text-muted-foreground\">\n No anchors found on the linked page. You can still type one below.\n </p>\n )}\n {state === 'error' && (\n <p className=\"text-xs text-destructive\">\n Could not load anchors. You can still type one below.\n </p>\n )}\n\n <Input\n id=\"menu-item-anchor\"\n value={draft}\n disabled={disabled}\n placeholder=\"section-id\"\n aria-invalid={draftError !== null}\n aria-describedby={draftError !== null ? 'menu-item-anchor-error' : undefined}\n onChange={(event) => commit(event.target.value)}\n />\n {draftError !== null ? (\n <p id=\"menu-item-anchor-error\" className=\"text-xs text-destructive\">\n {draftError}\n </p>\n ) : (\n <p className=\"text-xs text-muted-foreground\">\n Links to a block on the target page (<code>#anchor</code>).\n </p>\n )}\n </div>\n )\n}\n","'use client'\n\n/**\n * The selected item's inspector panel (plan/routing phase 09 §4).\n *\n * Everything a menu item carries that is NOT tree structure is edited here:\n * the per-locale label override, per-locale visibility, \"open in a new tab\",\n * and — for an entity link — the block anchor. Structure (order, nesting,\n * add/remove) stays with the tree and `@murumets-ee/menus/schema`\n * (`menu-tree-ops.ts`).\n *\n * ## Shape\n *\n * Fully CONTROLLED off `item`. There is no local copy of any field and no\n * effect syncing one — every keystroke goes straight through `onChange(id,\n * next)` into the editor's single `MenuItem[]` state, so the row and the\n * inspector can never disagree and an edit dirties the menu by exactly the same\n * path a drag does. The one piece of local state lives in `<AnchorPicker>`,\n * where it genuinely belongs (a typed value that isn't valid yet must not be\n * written into the tree), and the editor keys this panel on the item id so that\n * state resets on selection change — React's own prescription instead of a\n * copy-props-to-state effect (CLAUDE.md \"State Coordination\").\n *\n * The per-field map semantics — when a key is written vs DELETED, and why the\n * two maps mean opposite things when absent — live in `./item-edits.ts`.\n */\n\nimport type { MenuItem } from '@murumets-ee/menus/schema'\nimport { Button, Checkbox, cn, Input, Label } from '@murumets-ee/ui'\nimport type { ReactElement } from 'react'\nimport { AnchorPicker } from './anchor-picker.js'\nimport { resolveItemLabel } from './item-display.js'\nimport {\n clearItemAnchor,\n headerNeedsLabel,\n isHiddenInLocale,\n itemAnchor,\n setItemAnchor,\n setItemEnabled,\n setItemLabel,\n setItemNewTab,\n} from './item-edits.js'\nimport type { MenuEditorLocale } from './types.js'\n\nexport interface ItemInspectorProps {\n /** The selected item, or `null` when nothing is selected. */\n readonly item: MenuItem | null\n /** Locales this menu may be authored for — one label input + one chip each. */\n readonly locales: readonly MenuEditorLocale[]\n /** Fallback locale for the label placeholder's \"what will actually render\". */\n readonly defaultLocale: string\n /**\n * The linked entity's live title when known — the placeholder an empty label\n * input shows (\"live: About us\"), so it is obvious that clearing a field\n * falls back to the target's own title rather than to nothing.\n */\n readonly liveTitle?: string | undefined\n /** Admin API base (`/api/admin` unless overridden) — the anchors endpoint. */\n readonly apiBasePath: string\n /** Read-only mode (someone else holds the edit lock). */\n readonly disabled: boolean\n /** Persist an edited item back into the tree. */\n readonly onChange: (id: string, next: MenuItem) => void\n /** Remove the selected item and its subtree. */\n readonly onRemove: (id: string) => void\n}\n\nexport function ItemInspector({\n item,\n locales,\n defaultLocale,\n liveTitle,\n apiBasePath,\n disabled,\n onChange,\n onRemove,\n}: ItemInspectorProps): ReactElement {\n if (item === null) {\n return (\n <aside className=\"rounded-md border border-dashed p-4 text-sm text-muted-foreground\">\n Select an item to edit it.\n </aside>\n )\n }\n\n const isHeader = item.kind === 'header'\n const link = item.link\n const entityTarget = link?.kind === 'entity' ? { entity: link.entity, id: link.id } : null\n\n return (\n <aside className=\"space-y-4 rounded-md border border-border p-4\">\n <div>\n <h2 className=\"text-sm font-medium\">{isHeader ? 'Header' : 'Link'}</h2>\n <p className=\"truncate text-xs text-muted-foreground\">{describeTarget(item)}</p>\n </div>\n\n {/* -------------------------------------------------------------- label */}\n <fieldset className=\"space-y-2\">\n <legend className=\"text-xs font-medium text-muted-foreground\">\n {isHeader ? 'Label' : 'Label override'}\n </legend>\n {locales.map((locale) => (\n <div key={locale.code} className=\"space-y-1\">\n <Label htmlFor={`menu-item-label-${locale.code}`} className=\"text-xs font-normal\">\n {locale.label}\n </Label>\n <Input\n id={`menu-item-label-${locale.code}`}\n value={item.label?.[locale.code] ?? ''}\n disabled={disabled}\n placeholder={labelPlaceholder(item, locale.code, defaultLocale, liveTitle)}\n onChange={(event) =>\n onChange(item.id, setItemLabel(item, locale.code, event.target.value))\n }\n />\n </div>\n ))}\n {headerNeedsLabel(item) && (\n <p className=\"text-xs text-destructive\">\n A header needs a label in at least one language — it has nothing else to show.\n </p>\n )}\n </fieldset>\n\n {/* --------------------------------------------------------- visibility */}\n <fieldset className=\"space-y-1.5\">\n <legend className=\"text-xs font-medium text-muted-foreground\">Visible in</legend>\n <div className=\"flex flex-wrap gap-1.5\">\n {locales.map((locale) => {\n const hidden = isHiddenInLocale(item, locale.code)\n return (\n <button\n key={locale.code}\n type=\"button\"\n aria-pressed={!hidden}\n aria-label={`Visibility in ${locale.label}`}\n title={hidden ? `Hidden in ${locale.label}` : `Visible in ${locale.label}`}\n disabled={disabled}\n onClick={() => onChange(item.id, setItemEnabled(item, locale.code, hidden))}\n className={cn(\n 'rounded-full border px-2.5 py-0.5 text-xs transition-colors',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n hidden\n ? 'border-dashed border-border text-muted-foreground line-through'\n : 'border-primary bg-primary/10 text-foreground',\n )}\n >\n {locale.code}\n </button>\n )\n })}\n </div>\n <p className=\"text-xs text-muted-foreground\">\n One tree serves every language; a hidden item is simply not rendered there.\n </p>\n </fieldset>\n\n {/* ------------------------------------------------------ link-only bits */}\n {!isHeader && (\n <div className=\"flex items-center gap-2\">\n <Checkbox\n id=\"menu-item-new-tab\"\n checked={item.newTab === true}\n disabled={disabled}\n onCheckedChange={(checked) => onChange(item.id, setItemNewTab(item, checked === true))}\n />\n <Label htmlFor=\"menu-item-new-tab\" className=\"text-sm font-normal\">\n Open in a new tab\n </Label>\n </div>\n )}\n\n {entityTarget !== null && (\n <AnchorPicker\n key={`${entityTarget.entity}:${entityTarget.id}`}\n target={entityTarget}\n value={itemAnchor(item)}\n apiBasePath={apiBasePath}\n disabled={disabled}\n onSelect={(anchor) => onChange(item.id, setItemAnchor(item, anchor))}\n onClear={() => onChange(item.id, clearItemAnchor(item))}\n />\n )}\n\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => onRemove(item.id)}\n >\n Remove item\n </Button>\n </aside>\n )\n}\n\n/**\n * What an EMPTY label input should hint at: exactly what that locale will\n * actually render once the field is blank.\n *\n * Reusing `resolveItemLabel` keeps the hint honest for free — clearing a\n * non-default locale falls back to the default-locale override, clearing that\n * falls back to the live entity title, and so on down the same chain the row\n * itself uses. Only the live-title case is prefixed, per phase 09 §4's\n * \"empty input = `live: <current title>` placeholder\".\n */\nfunction labelPlaceholder(\n item: MenuItem,\n locale: string,\n defaultLocale: string,\n liveTitle: string | undefined,\n): string {\n const resolved = resolveItemLabel(item, { locale, defaultLocale, liveTitle })\n if (resolved.source === 'live') return `live: ${resolved.text}`\n return resolved.text === '' ? 'no override set' : resolved.text\n}\n\n/** One line describing what the item points at (or that it points at nothing). */\nfunction describeTarget(item: MenuItem): string {\n const link = item.link\n if (link === undefined) return 'Text-only — a dropdown parent with no link of its own.'\n switch (link.kind) {\n case 'entity':\n return `${link.entity} · ${link.id}`\n case 'url':\n return link.url\n default:\n return link.email\n }\n}\n","'use client'\n\n/**\n * One row of the flattened menu tree (plan/routing phase 09 §3).\n *\n * Rendered flat and indented by `depth * indentation`; nesting is conveyed by\n * that indentation plus the row's own `parentId`. The row owns:\n *\n * - the `@dnd-kit` sortable binding (drag handle only — the row body stays\n * clickable for selection),\n * - the collapse/expand toggle for a branch,\n * - the keyboard fallback (`⌥↑ ⌥↓` reorder, `⌥← ⌥→` outdent/indent) and the\n * equivalent explicit actions in a kebab menu.\n *\n * **No dnd-kit `KeyboardSensor`.** `packages/blocks/src/editor/interactions/\n * dnd-provider.tsx` removed it deliberately (its Enter/Space activation on the\n * focused sortable swallowed the editor's own chord) in favour of real buttons,\n * and phase 09 §3 asks for exactly that shape here — \"the Drupal\n * show-row-weights lesson: DND-only editors fail accessibility\". So every\n * structural change is reachable from a real, announced control, and the\n * reorder logic is pure functions in `@murumets-ee/menus/schema`.\n *\n * **Markup shape.** Sibling buttons, never nested ones: `packages/blocks/src/\n * editor/interactions/use-block-drag.ts` documents the hydration error that\n * button-in-button produces. The row body IS the select button; the handle,\n * the chevron and the kebab sit beside it.\n */\n\nimport { useSortable } from '@dnd-kit/react/sortable'\nimport type { FlatMenuNode } from '@murumets-ee/menus/schema'\nimport {\n Badge,\n Button,\n cn,\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from '@murumets-ee/ui'\nimport { ChevronDown, ChevronRight, GripVertical, Heading, Link2, MoreVertical } from 'lucide-react'\nimport type { ReactElement } from 'react'\nimport type { MenuItemStatus, ResolvedItemLabel } from './item-display.js'\n\n/** Pixels of indentation per nesting level — also the drag's depth step. */\nexport const MENU_TREE_INDENTATION = 24\n\nexport interface MenuTreeRowProps {\n readonly row: FlatMenuNode\n /** Position in the RENDERED flat list — what `useSortable` keys reordering off. */\n readonly index: number\n readonly label: ResolvedItemLabel\n readonly status: MenuItemStatus\n /**\n * `enabled[previewLocale] === false` — the item is explicitly suppressed in\n * the locale currently being previewed (phase 09 §5). Rendered DIMMED, never\n * removed: an editor has to be able to see what is hidden.\n */\n readonly hiddenInPreview: boolean\n /** Display name of the previewed locale, for the \"hidden in …\" tooltip. */\n readonly previewLocaleLabel: string\n readonly selected: boolean\n readonly collapsed: boolean\n /** Read-only mode (someone else holds the edit lock). */\n readonly disabled: boolean\n readonly onSelect: (id: string) => void\n readonly onToggleCollapse: (id: string) => void\n readonly onMove: (id: string, delta: -1 | 1) => void\n readonly onIndent: (id: string) => void\n readonly onOutdent: (id: string) => void\n readonly onRemove: (id: string) => void\n}\n\n/**\n * Badge copy per status.\n *\n * The `title`s are deliberately about the TARGET, so they can never be read as\n * the other suppression signal — a row can be BOTH explicitly hidden in the\n * previewed locale AND pointing at an unpublished target, and phase 09 §5\n * requires the two to stay distinguishable in the tooltip (\"explicit toggle vs\n * target-unpublished\"). The dim treatment owns \"Hidden in <locale>\"; these own\n * \"the thing you linked to\".\n */\nconst STATUS_BADGE: Record<\n MenuItemStatus,\n { text: string; title: string; variant: 'destructive' | 'outline' } | null\n> = {\n ok: null,\n // A missing summary is INDETERMINATE, not broken (see `item-display.ts`) —\n // deliberately unbadged so an unloaded plugin doesn't paint the tree red.\n unknown: null,\n broken: {\n text: 'Broken',\n title: 'The linked row no longer exists. Repoint or remove this item.',\n variant: 'destructive',\n },\n unpublished: {\n text: 'Unpublished',\n title: 'The linked page exists but is not published, so visitors will not reach it.',\n variant: 'outline',\n },\n}\n\nexport function MenuTreeRow({\n row,\n index,\n label,\n status,\n hiddenInPreview,\n previewLocaleLabel,\n selected,\n collapsed,\n disabled,\n onSelect,\n onToggleCollapse,\n onMove,\n onIndent,\n onOutdent,\n onRemove,\n}: MenuTreeRowProps): ReactElement {\n const { ref, handleRef } = useSortable({\n id: row.id,\n index,\n // Read back in the drag handlers' projection step.\n data: { depth: row.depth, parentId: row.parentId },\n disabled,\n transition: { idle: true },\n })\n\n const hasChildren = row.childCount > 0\n const badge = STATUS_BADGE[status]\n const isHeader = row.data.kind === 'header'\n const text = label.text === '' ? (isHeader ? 'Untitled header' : 'Untitled item') : label.text\n\n return (\n <li\n ref={ref}\n data-menu-item-id={row.id}\n data-depth={row.depth}\n {...(hiddenInPreview && { 'data-hidden-in-preview': 'true' })}\n style={{ marginInlineStart: row.depth * MENU_TREE_INDENTATION }}\n className=\"list-none\"\n >\n <div\n className={cn(\n 'flex items-center gap-1 rounded-md border bg-card px-1.5 py-1',\n selected ? 'border-primary ring-1 ring-primary' : 'border-border',\n )}\n >\n <button\n type=\"button\"\n ref={handleRef}\n disabled={disabled}\n aria-label={`Reorder ${text} (level ${row.depth + 1})`}\n className=\"cursor-grab rounded p-1 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40\"\n >\n <GripVertical className=\"size-4\" aria-hidden />\n </button>\n\n {hasChildren ? (\n <button\n type=\"button\"\n aria-label={collapsed ? `Expand ${text}` : `Collapse ${text}`}\n aria-expanded={!collapsed}\n onClick={() => onToggleCollapse(row.id)}\n className=\"rounded p-1 text-muted-foreground hover:text-foreground\"\n >\n {collapsed ? (\n <ChevronRight className=\"size-4\" aria-hidden />\n ) : (\n <ChevronDown className=\"size-4\" aria-hidden />\n )}\n </button>\n ) : (\n <span className=\"inline-block size-6\" aria-hidden />\n )}\n\n {/*\n The row body is the select target AND the keyboard-shortcut host, so\n it is a real <button> (focusable, announced) rather than a div with a\n role — the sibling controls above/below keep the markup valid.\n */}\n <button\n type=\"button\"\n onClick={() => onSelect(row.id)}\n {...(hiddenInPreview && { title: `Hidden in ${previewLocaleLabel}` })}\n onKeyDown={(event) => {\n if (!event.altKey || disabled) return\n const handled = ALT_SHORTCUTS[event.key]\n if (handled === undefined) return\n event.preventDefault()\n handled({ id: row.id, onMove, onIndent, onOutdent })\n }}\n className={cn(\n 'flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-0.5 text-left hover:bg-accent/50',\n // Dimmed + struck through, NOT removed (phase 09 §5). Visually\n // distinct from the status badge beside it, which describes the\n // TARGET rather than this locale's visibility.\n hiddenInPreview && 'opacity-50 line-through',\n )}\n >\n {isHeader ? (\n <Heading className=\"size-4 shrink-0 text-muted-foreground\" aria-label=\"Header item\" />\n ) : (\n <Link2 className=\"size-4 shrink-0 text-muted-foreground\" aria-label=\"Link item\" />\n )}\n <span\n className={cn(\n 'truncate text-sm',\n label.source === 'override' ? 'text-foreground' : 'text-muted-foreground italic',\n )}\n >\n {text}\n </span>\n {hasChildren && (\n <span className=\"shrink-0 text-xs text-muted-foreground tabular-nums\">\n {row.childCount}\n </span>\n )}\n {badge && (\n <Badge variant={badge.variant} title={badge.title} className=\"shrink-0 text-[10px]\">\n {badge.text}\n </Badge>\n )}\n </button>\n\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"icon\"\n disabled={disabled}\n aria-label={`Actions for ${text}`}\n >\n <MoreVertical className=\"size-4\" aria-hidden />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuItem onClick={() => onMove(row.id, -1)}>Move up</DropdownMenuItem>\n <DropdownMenuItem onClick={() => onMove(row.id, 1)}>Move down</DropdownMenuItem>\n <DropdownMenuItem onClick={() => onIndent(row.id)}>Indent</DropdownMenuItem>\n <DropdownMenuItem onClick={() => onOutdent(row.id)}>Outdent</DropdownMenuItem>\n <DropdownMenuItem onClick={() => onRemove(row.id)}>Remove</DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n </li>\n )\n}\n\ninterface ShortcutArgs {\n readonly id: string\n readonly onMove: (id: string, delta: -1 | 1) => void\n readonly onIndent: (id: string) => void\n readonly onOutdent: (id: string) => void\n}\n\n/**\n * `⌥`-modified arrow keys, the same chord family the blocks editor uses for its\n * own move actions. Declared as data so the row body stays readable and the\n * mapping is greppable.\n */\nconst ALT_SHORTCUTS: Record<string, ((args: ShortcutArgs) => void) | undefined> = {\n ArrowUp: ({ id, onMove }) => onMove(id, -1),\n ArrowDown: ({ id, onMove }) => onMove(id, 1),\n ArrowRight: ({ id, onIndent }) => onIndent(id),\n ArrowLeft: ({ id, onOutdent }) => onOutdent(id),\n}\n","/**\n * The seam between `useAdminPickLink`'s four-kind `LinkValue` and a menu item's\n * three-kind {@link MenuLinkValue} (plan/routing phase 09 §4).\n *\n * `@murumets-ee/menus` deliberately narrows blocks' link union: there is no\n * `media` arm, because no phase gives a menu a media-href seam to resolve one\n * with — such an item would validate, persist, arm delete-protection on the\n * file, and then never render. So the picker is opened with `kinds` restricted\n * to the three menus accepts, and {@link toMenuLinkValue} is the belt-and-braces\n * narrowing for the value that comes back.\n *\n * The re-shaping is not ceremony: under this repo's `exactOptionalPropertyTypes`\n * an `anchor?: string` (blocks) is not assignable to `anchor?: string | undefined`\n * (menus, forced by Zod's `.optional()`), so the entity arm has to be rebuilt\n * rather than spread.\n *\n * @module\n */\n\nimport type { LinkKind, LinkValue } from '@murumets-ee/blocks/links'\nimport { linkValueKey } from '@murumets-ee/blocks/links'\nimport type { MenuLinkValue } from '@murumets-ee/menus/schema'\n\n/** The picker tabs a menu item may be created from — `media` is excluded. */\nexport const MENU_LINK_KINDS: readonly LinkKind[] = ['entity', 'url', 'email']\n\n/**\n * Narrow a picked `LinkValue` to what a menu item can store, or `null` when the\n * picker returned a kind menus doesn't accept.\n */\nexport function toMenuLinkValue(value: LinkValue): MenuLinkValue | null {\n switch (value.kind) {\n case 'entity':\n return value.anchor !== undefined && value.anchor !== ''\n ? { kind: 'entity', entity: value.entity, id: value.id, anchor: value.anchor }\n : { kind: 'entity', entity: value.entity, id: value.id }\n case 'url':\n return { kind: 'url', url: value.url }\n case 'email':\n return { kind: 'email', email: value.email }\n default:\n return null\n }\n}\n\n/**\n * The key `useAdminPickLink` caches a picked row's TITLE under\n * (`resolvedLabels`), computed from a stored menu link so the editor can render\n * a just-added item's real name at the next render — no round-trip, no\n * mount-time fetch.\n *\n * `null` for kinds the picker never captures a title for.\n */\nexport function pickerLabelKey(link: MenuLinkValue | undefined): string | null {\n if (link === undefined || link.kind !== 'entity') return null\n // Rebuilt rather than passed straight through — see the module header's\n // `exactOptionalPropertyTypes` note. `linkValueKey` stays the single source\n // of truth for the key format.\n return linkValueKey(\n link.anchor !== undefined\n ? { kind: 'entity', entity: link.entity, id: link.id, anchor: link.anchor }\n : { kind: 'entity', entity: link.entity, id: link.id },\n )\n}\n","'use client'\n\n/**\n * `<MenuTreeEditor>` — the `@dnd-kit` indent-to-nest menu tree\n * (plan/routing phase 09 §3–§4).\n *\n * ## Shape\n *\n * The menu is ONE `MenuItem[]` state object (phase 09 §3: \"editing state is\n * local (one tree object)\"). Everything the editor renders is derived from it\n * each render — there is no parallel flat store to keep in sync, and no\n * `useEffect` anywhere in this file that only copies state around.\n *\n * DND works on a FLATTENED view of that tree, because `@dnd-kit`'s sortable\n * primitive reorders a list. During a drag the flattened rows become live\n * working state (`dragRows`): drag-start lifts the dragged node's descendants\n * out of the list (and stashes any rows hidden by a collapsed branch),\n * drag-over/-move re-order and re-project it, and drag-end rebuilds ONE new\n * tree from the working rows plus the stash. The projection maths lives in\n * `@murumets-ee/menus/schema` (`menu-tree-flatten.ts`), adapted from dnd-kit's\n * own sortable-tree reference — see that module's header.\n *\n * `dragRows` is mirrored in a ref because the drag handlers must read the\n * freshest rows within a single gesture (several dnd events can fire before\n * React commits a render); the state copy is what renders.\n *\n * ## Keyboard\n *\n * dnd-kit's `KeyboardSensor` is deliberately NOT installed — `packages/blocks/\n * src/editor/interactions/dnd-provider.tsx` removed it for the same reason, and\n * phase 09 §3 wants real buttons anyway. `sensors={[PointerSensor]}` REPLACES\n * the default sensor set (which includes the keyboard one). Reordering by\n * keyboard goes through the row's ⌥-shortcuts and kebab menu into the pure\n * helpers in `@murumets-ee/menus/schema` (`menu-tree-ops.ts`).\n *\n * ## Save / publish\n *\n * Reuses `useContentEditor` exactly as `EntityForm` does: `save()` is the\n * single whole-tree PATCH (D008 — no per-item endpoints), `saveDraft()` writes\n * to the draft buffer, and the shared `<DraftBanner>` / `<LockBanner>` /\n * `<SavedAsDraftBanner>` render the surrounding states. `menuTreeValidation()`\n * re-validates server-side on every PATCH regardless of what the client\n * checked, so the client-side depth pre-flight below is a UX nicety, not the\n * boundary.\n *\n * ⚠️ Never import from `@murumets-ee/menus/admin` here (or anything it pulls\n * in): since PR10 Task 1 that entry reaches `@murumets-ee/core` →\n * `node:async_hooks`. Type-only imports are fine — they are erased at compile.\n */\n\nimport type { DragDropManager } from '@dnd-kit/dom'\nimport { move } from '@dnd-kit/helpers'\nimport {\n DragDropProvider,\n type DragEndEvent,\n type DragMoveEvent,\n type DragOverEvent,\n type DragStartEvent,\n PointerSensor,\n} from '@dnd-kit/react'\nimport {\n DraftBanner,\n LockBanner,\n SavedAsDraftBanner,\n useAdminPickLink,\n useContentEditor,\n} from '@murumets-ee/admin-ui/content-editor'\nimport {\n buildMenuTree,\n collectDescendantIds,\n depthCapForDrag,\n type FlatMenuNode,\n findMenuItem,\n flattenMenuTree,\n getDragDepth,\n hideCollapsedRows,\n indentMenuItem,\n insertMenuItem,\n MAX_MENU_TREE_NODES,\n type MenuItem,\n type MenuLinkValue,\n measureMenuTree,\n menuAcceptsEntityType,\n moveMenuItem,\n outdentMenuItem,\n projectDrop,\n removeMenuItem,\n replaceMenuItem,\n subtreeHeight,\n type TreeOpResult,\n} from '@murumets-ee/menus/schema'\nimport { Button, Checkbox, cn, Label, toast } from '@murumets-ee/ui'\nimport { Heading, Plus } from 'lucide-react'\nimport { type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { generateItemId } from './generate-item-id.js'\nimport { resolveItemLabel, resolveItemStatus, summaryKey } from './item-display.js'\nimport { isHiddenInLocale } from './item-edits.js'\nimport { ItemInspector } from './item-inspector.js'\nimport { MENU_TREE_INDENTATION, MenuTreeRow } from './menu-tree-row.js'\nimport { MENU_LINK_KINDS, pickerLabelKey, toMenuLinkValue } from './picker-link.js'\nimport type { MenuTreeEditorProps } from './types.js'\n\n/** Per-gesture state the drag handlers need but React must not re-render on. */\ninterface DragSession {\n /** The dragged row's depth when the gesture began — the projection's origin. */\n readonly initialDepth: number\n /** Deepest legal depth for THIS drag (menu cap minus the subtree's height). */\n readonly depthCap: number\n /** Rows lifted out of the sortable list: the dragged subtree + collapsed rows. */\n readonly stash: readonly FlatMenuNode[]\n /** Structure signature at drag start — lets drag-end skip a no-op gesture. */\n readonly signature: string\n /** Set when the menu's `maxDepth` clamped a drop — one toast at drag end. */\n clamped: boolean\n}\n\n/**\n * Everything a drag can change about a tree: sibling ORDER (list position) and\n * PARENTAGE. Comparing this before/after lets drag-end skip `setTree` when the\n * gesture changed nothing — otherwise a click on the drag handle (which\n * dnd-kit still reports as a completed drag) would mint a new tree object and\n * light up \"Unsaved changes\" for no reason.\n */\nfunction rowSignature(rows: readonly FlatMenuNode[]): string {\n return rows.map((row) => `${row.id}>${row.parentId ?? ''}`).join('|')\n}\n\nexport function MenuTreeEditor({\n menu,\n summaries,\n locales,\n defaultLocale,\n droppedItemCount,\n apiBasePath,\n entityTypes,\n canPublish,\n draft,\n lockResult,\n onDirtyChange,\n}: MenuTreeEditorProps): ReactElement {\n const [tree, setTree] = useState<MenuItem[]>(menu.tree)\n // The last tree the server accepted onto the LIVE row (via Save or\n // Publish/Unpublish). `tree !== savedTree` IS the dirty flag that gates the\n // Save button — derived during render, never an effect that copies one\n // state into another.\n const [savedTree, setSavedTree] = useState<MenuItem[]>(menu.tree)\n // The last tree successfully persisted as a DRAFT — distinct from\n // `savedTree` on purpose. A draft save does NOT touch the live row (Save\n // must stay enabled afterward, since the live row genuinely still differs),\n // but the work itself is no longer at risk of being lost, so it should\n // clear the \"Unsaved changes\" label and the beforeunload guard. `null` means\n // \"no draft saved this session\" (distinct from \"saved the initial tree\").\n const [draftSavedTree, setDraftSavedTree] = useState<MenuItem[] | null>(null)\n const [status, setStatus] = useState<'draft' | 'published'>(menu.status)\n const [selectedId, setSelectedId] = useState<string | null>(null)\n const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set())\n // Which locale the TREE is previewed as (phase 09 §5). A pure render-time\n // lens over the same `MenuItem[]`: it changes which override each row shows\n // and which rows render dimmed, and nothing else — no re-fetch, no effect,\n // and nothing about what a save sends.\n const [previewLocale, setPreviewLocale] = useState(defaultLocale)\n const [dragRows, setDragRows] = useState<readonly FlatMenuNode[] | null>(null)\n // The per-menu `entityTypes` whitelist (phase 07 §2.8) as the admin is\n // editing it. `[]` = \"accepts every content type\" — the same thing `null`\n // means in the column, which is why the save normalises one to the other\n // (see `@murumets-ee/menus`' `menu-entity-types.ts`).\n //\n // Seeded VERBATIM from the stored value, including any type this caller\n // isn't offered a checkbox for (an unloaded plugin, or one they hold no\n // `view` grant on). Those survive a save instead of being silently dropped —\n // an authored whitelist entry must not disappear because of who opened the\n // editor.\n const [acceptedTypes, setAcceptedTypes] = useState<readonly string[]>(menu.entityTypes ?? [])\n const [savedAcceptedTypes, setSavedAcceptedTypes] = useState<readonly string[]>(\n menu.entityTypes ?? [],\n )\n // The selection last captured by a DRAFT save — the whitelist's counterpart\n // to `draftSavedTree`, and needed for the same reason: a draft save makes the\n // work safe to navigate away from, and the guard below must account for BOTH\n // halves of the editor's state or it clears while a change is still at risk.\n const [draftSavedTypes, setDraftSavedTypes] = useState<readonly string[] | null>(null)\n\n const dragRowsRef = useRef<readonly FlatMenuNode[] | null>(null)\n const sessionRef = useRef<DragSession | null>(null)\n\n const editor = useContentEditor({\n entityName: 'menu',\n entityId: menu.id,\n // Explicit: the default would be `pluralize('menu')` = `menus`, which is\n // the PLUGIN route prefix (`/api/admin/menus/anchors`). Entity CRUD lives\n // at the singular `menu` (see `buildEntityMap`), and keeping the two URL\n // spaces apart is worth one extra prop.\n entityPath: 'menu',\n apiBasePath,\n canPublish,\n // A menu is one structure shared by every locale (D009), so its draft +\n // lock are locale-INVARIANT. Passing no `locale` makes the server resolve\n // the `'_'` snapshot key for both, regardless of the admin's content-locale\n // switch.\n enableLocking: true,\n ...(lockResult !== undefined && { lockResult }),\n ...(draft !== undefined && { draft }),\n })\n\n // What the \"Add link\" picker's Content tab offers: the caller's available\n // types NARROWED by this menu's whitelist (phase 09 §3 — \"scoped per the\n // menus config (`entityTypes` whitelist)\"). Derived during render from the\n // LIVE selection, so unchecking a type narrows the picker immediately rather\n // than after a save. An empty selection means no restriction, so the full\n // list passes through.\n const offeredEntityTypes = useMemo(\n () => entityTypes.filter((type) => menuAcceptsEntityType(acceptedTypes, type.name)),\n [acceptedTypes, entityTypes],\n )\n\n const { pickLink, dialog, resolvedLabels } = useAdminPickLink({\n contentEntities: offeredEntityTypes,\n locale: defaultLocale,\n })\n\n const readOnly = editor.isReadOnly\n // Compared as a SET, not by reference or order: re-checking a type the admin\n // just unchecked restores the same whitelist, and reporting that as a change\n // would leave Save enabled with nothing to save.\n const typesDirty = !sameTypes(acceptedTypes, savedAcceptedTypes)\n const isDirty = tree !== savedTree || typesDirty\n // Whether there is genuinely UNPERSISTED work — false once EITHER a live save\n // or a draft save has captured the current state, even though `isDirty`\n // (live-row parity) can still be true after a draft-only save. Both halves\n // must match: a draft that captured the tree says nothing about a whitelist\n // edited afterwards.\n const draftHoldsCurrentWork =\n tree === draftSavedTree && draftSavedTypes !== null && sameTypes(acceptedTypes, draftSavedTypes)\n const hasUnpersistedWork = isDirty && !draftHoldsCurrentWork\n const previewLocaleLabel =\n locales.find((locale) => locale.code === previewLocale)?.label ?? previewLocale\n\n // Whitelisted types this caller isn't offered a checkbox for — surfaced so a\n // preserved-but-invisible entry doesn't read as data loss (or as a stale\n // \"accepts everything\" when the list looks empty).\n const hiddenAcceptedTypeCount = acceptedTypes.filter(\n (name) => !entityTypes.some((type) => type.name === name),\n ).length\n\n const baseRows = useMemo(() => flattenMenuTree(tree), [tree])\n const rows = dragRows ?? hideCollapsedRows(baseRows, collapsed)\n const selectedItem = selectedId === null ? null : findMenuItem(tree, selectedId)\n\n /**\n * The linked entity's live title, when known: the server-resolved summary\n * first, then the link picker's own cache for an item added in this session\n * (no round-trip, no mount-time fetch). Shared by the rows and the inspector\n * so both name a target the same way.\n */\n const liveTitleFor = useCallback(\n (link: MenuLinkValue | undefined): string | undefined => {\n const key = summaryKey(link)\n const title = key === null ? undefined : summaries[key]?.title\n if (title !== undefined && title !== null) return title\n const pickedKey = pickerLabelKey(link)\n return pickedKey === null ? undefined : resolvedLabels.get(pickedKey)\n },\n [resolvedLabels, summaries],\n )\n\n // ---------------------------------------------------------------------------\n // Structural edits (keyboard + kebab menu). All pure — see the tree helpers\n // in `@murumets-ee/menus/schema`.\n // ---------------------------------------------------------------------------\n\n const applyOp = useCallback(\n (op: (current: readonly MenuItem[]) => TreeOpResult) => {\n // Deliberately NOT inside a `setTree` updater: updaters must stay pure\n // (React may run them twice under StrictMode / a discarded render), and\n // this one needs to raise a toast on refusal. Reading `tree` from the\n // closure is safe — every caller is a user-event handler, so the render\n // that produced this callback is the one on screen.\n const result = op(tree)\n if (result.ok) setTree(result.tree)\n else if (result.reason === 'max-depth') toast.error(DEPTH_MESSAGE)\n },\n [tree],\n )\n\n const handleMove = useCallback(\n (id: string, delta: -1 | 1) => applyOp((current) => moveMenuItem(current, id, delta)),\n [applyOp],\n )\n const handleIndent = useCallback(\n (id: string) => applyOp((current) => indentMenuItem(current, id, menu.maxDepth)),\n [applyOp, menu.maxDepth],\n )\n const handleOutdent = useCallback(\n (id: string) => applyOp((current) => outdentMenuItem(current, id)),\n [applyOp],\n )\n const handleRemove = useCallback((id: string) => {\n setTree((current) => removeMenuItem(current, id))\n setSelectedId((current) => (current === id ? null : current))\n }, [])\n // The inspector's write path. Deliberately the SAME `setTree` every\n // structural edit uses, so a field edit dirties the menu (and reaches the\n // whole-tree PATCH) through one path — no parallel dirty tracking.\n const handleItemChange = useCallback((id: string, next: MenuItem) => {\n setTree((current) => replaceMenuItem(current, id, next))\n }, [])\n const handleToggleAcceptedType = useCallback((name: string) => {\n setAcceptedTypes((current) =>\n current.includes(name) ? current.filter((entry) => entry !== name) : [...current, name],\n )\n }, [])\n const handleToggleCollapse = useCallback((id: string) => {\n setCollapsed((current) => {\n const next = new Set(current)\n if (!next.delete(id)) next.add(id)\n return next\n })\n }, [])\n\n // ---------------------------------------------------------------------------\n // Add\n // ---------------------------------------------------------------------------\n\n const addItem = useCallback(\n (item: MenuItem) => {\n // Inserted as the next SIBLING of the selection so repeated adds build a\n // list in the order the editor clicked them; with nothing selected it\n // appends at the root.\n setTree((current) => insertMenuItem(current, item, selectedId))\n setSelectedId(item.id)\n },\n [selectedId],\n )\n\n const handleAddLink = useCallback(async () => {\n const value = await pickLink({ kinds: MENU_LINK_KINDS, title: 'Add menu item' })\n if (value === null) return\n const link = toMenuLinkValue(value)\n if (link === null) {\n // Unreachable through the UI (`kinds` excludes media) — but the picker is\n // a shared seam, so refuse rather than store an item that could never\n // render. See `./picker-link.ts`.\n toast.error('That link type can’t be used in a menu.')\n return\n }\n // `label: null` = \"use the linked entity's live title, per locale\"\n // (phase 07 §2.3). The picker's `resolvedLabels` supplies that title for\n // display immediately — no round-trip, no mount-time fetch.\n addItem({ id: generateItemId(), kind: 'link', link, label: null, children: [] })\n }, [addItem, pickLink])\n\n const handleAddHeader = useCallback(() => {\n // An EMPTY label is deliberate: the write-side schema only requires a label\n // map with at least one locale key, and blocking the add would make the\n // flow impossible (you can't type into an inspector for an item that\n // doesn't exist yet). Task 3b's inspector is where the text is entered.\n addItem({\n id: generateItemId(),\n kind: 'header',\n label: { [defaultLocale]: '' },\n children: [],\n })\n }, [addItem, defaultLocale])\n\n // ---------------------------------------------------------------------------\n // Drag\n // ---------------------------------------------------------------------------\n\n const sensors = useMemo(() => [PointerSensor], [])\n\n const setDragRowsNow = useCallback((next: readonly FlatMenuNode[] | null) => {\n dragRowsRef.current = next\n setDragRows(next)\n }, [])\n\n const handleDragStart = useCallback(\n (event: DragStartEvent) => {\n const source = event.operation.source\n if (source === null || source === undefined) return\n const activeId = String(source.id)\n\n const full = flattenMenuTree(tree)\n const active = full.find((candidate) => candidate.id === activeId)\n if (active === undefined) return\n\n const item = findMenuItem(tree, activeId)\n const descendants = collectDescendantIds(full, activeId)\n const working = hideCollapsedRows(full, collapsed).filter((row) => !descendants.has(row.id))\n const workingIds = new Set(working.map((row) => row.id))\n // Everything NOT in the sortable list — the dragged subtree plus any rows\n // a collapsed branch is hiding. Re-appended at drag end so a collapsed\n // branch can never be lost by a drag.\n const stash = full.filter((row) => !workingIds.has(row.id))\n\n sessionRef.current = {\n initialDepth: active.depth,\n depthCap: depthCapForDrag(menu.maxDepth, item === null ? 1 : subtreeHeight(item)),\n stash,\n signature: rowSignature([...working, ...stash]),\n clamped: false,\n }\n setDragRowsNow(working)\n },\n [collapsed, menu.maxDepth, setDragRowsNow, tree],\n )\n\n /** Shared projection step for drag-over and drag-move. */\n const project = useCallback(\n (\n rowsNow: readonly FlatMenuNode[],\n session: DragSession,\n anchorId: string,\n manager: DragDropManager,\n ) => {\n const requestedDepth =\n session.initialDepth +\n getDragDepth(manager.dragOperation.transform.x, MENU_TREE_INDENTATION)\n const projected = projectDrop({\n items: rowsNow,\n activeId: anchorId,\n requestedDepth,\n depthCap: session.depthCap,\n })\n if (projected.clampedByMaxDepth) session.clamped = true\n return projected\n },\n [],\n )\n\n const handleDragOver = useCallback(\n (event: DragOverEvent, manager: DragDropManager) => {\n const { source, target } = event.operation\n const session = sessionRef.current\n const rowsNow = dragRowsRef.current\n if (!source || !target || session === null || rowsNow === null) return\n if (source.id === target.id) return\n\n // Suppress dnd-kit's own optimistic sort so it can be applied explicitly,\n // in the same order dnd-kit's OWN reference projection uses: reorder\n // FIRST, then project the dragged row's depth/parent against the\n // ALREADY-MOVED list. Projecting against the pre-move list (as an\n // earlier version of this code did) reads the wrong neighbours whenever\n // source and target aren't already adjacent — dnd-kit's `getProjection`\n // keys its `previous`/`next` lookup on the dragged row's POST-move index,\n // not the target's pre-move index, and the two disagree in both\n // directions once there's a row between them.\n event.preventDefault()\n\n const sorted = move(rowsNow as FlatMenuNode[], event)\n const { depth, parentId } = project(sorted, session, String(source.id), manager)\n setDragRowsNow(\n sorted.map((row) => (row.id === source.id ? { ...row, depth, parentId } : row)),\n )\n },\n [project, setDragRowsNow],\n )\n\n const handleDragMove = useCallback(\n (event: DragMoveEvent, manager: DragDropManager) => {\n const { source, target } = event.operation\n const session = sessionRef.current\n const rowsNow = dragRowsRef.current\n if (!source || !target || session === null || rowsNow === null) return\n\n // Same slot, different horizontal offset: re-project depth only.\n const activeId = String(source.id)\n const { depth, parentId } = project(rowsNow, session, activeId, manager)\n const current = rowsNow.find((row) => row.id === activeId)\n if (current === undefined) return\n if (current.depth === depth && current.parentId === parentId) return\n setDragRowsNow(\n rowsNow.map((row) => (row.id === activeId ? { ...row, depth, parentId } : row)),\n )\n },\n [project, setDragRowsNow],\n )\n\n const handleDragEnd = useCallback(\n (event: DragEndEvent) => {\n const session = sessionRef.current\n const rowsNow = dragRowsRef.current\n sessionRef.current = null\n setDragRowsNow(null)\n // A canceled drag (Escape / dropped nowhere) commits nothing — the tree\n // state was never touched during the gesture, so clearing the working\n // rows above is the whole rollback.\n if (event.canceled || session === null || rowsNow === null) return\n\n const settled = [...rowsNow, ...session.stash]\n // A click on the drag handle still ends as a completed drag. Rebuilding\n // unconditionally would mint a fresh tree object and light up \"Unsaved\n // changes\" for a gesture that moved nothing.\n if (rowSignature(settled) !== session.signature) setTree(buildMenuTree(settled))\n if (session.clamped) toast.warning(DEPTH_MESSAGE)\n },\n [setDragRowsNow],\n )\n\n // ---------------------------------------------------------------------------\n // Save / publish\n // ---------------------------------------------------------------------------\n\n const commit = useCallback(\n async (payload: Record<string, unknown>): Promise<boolean> => {\n // Mirrors the server-side `menuTreeValidation()` cross-field checks. They\n // should be unreachable (drag clamps, indent refuses), but a whole-tree\n // PATCH that 400s after the fact is a worse experience than a toast.\n if (!preflightTree(tree, menu.maxDepth)) return false\n if (droppedItemCount > 0 && !window.confirm(droppedConfirmMessage(droppedItemCount))) {\n return false\n }\n // `entityTypes` rides along ONLY when it actually changed: the column is\n // otherwise absent from the payload, and the write-boundary behavior\n // treats an absent key as \"this update doesn't touch the whitelist\" — so\n // a tree-only save can never rewrite (or clear) an authored one.\n const result = await editor.save({\n tree,\n ...(typesDirty && { entityTypes: toStoredTypes(acceptedTypes) }),\n ...payload,\n })\n if (result === null) return false\n setSavedTree(tree)\n setSavedAcceptedTypes(acceptedTypes)\n return true\n },\n [acceptedTypes, droppedItemCount, editor.save, menu.maxDepth, tree, typesDirty],\n )\n\n const handleSave = useCallback(async () => {\n if (await commit({})) toast.success('Menu saved')\n }, [commit])\n\n const handleSaveDraft = useCallback(async () => {\n // A draft write goes through `projectToEntityFields` and runs NO entity\n // behaviors, so `menuTreeValidation()` never sees it — a tree that breaks\n // the depth or node-count bound saves happily here and only blows up at\n // PUBLISH, far from the edit that caused it. Pre-flight both bounds now.\n if (!preflightTree(tree, menu.maxDepth)) return\n // A draft is a SNAPSHOT of the whole editor, not a partial update, so it\n // always carries both halves — unlike `commit`'s PATCH, which omits an\n // untouched `entityTypes` so it can't rewrite the column. Normalised the\n // same way the write boundary would, so the draft and the live row can\n // never disagree about how \"unrestricted\" is spelled.\n const result = await editor.saveDraft({ tree, entityTypes: toStoredTypes(acceptedTypes) })\n // `useContentEditor.saveDraft` already surfaces a failure via\n // `editor.submitError`, but every OTHER action here also raises a toast —\n // silently relying on a passive error string for this one action alone\n // would be the odd one out.\n if (result === null) {\n toast.error('Could not save draft')\n return\n }\n setDraftSavedTree(tree)\n setDraftSavedTypes(acceptedTypes)\n }, [acceptedTypes, editor.saveDraft, menu.maxDepth, tree])\n\n const handleTogglePublish = useCallback(async () => {\n const next = status === 'published' ? 'draft' : 'published'\n if (await commit({ status: next })) {\n setStatus(next)\n toast.success(next === 'published' ? 'Menu published' : 'Menu unpublished')\n }\n }, [commit, status])\n\n // Report unpersisted work to an embedding host (opt-in; the standalone page\n // omits it). A legitimate effect: it synchronises an EXTERNAL consumer with\n // derived state — it does not derive any state of our own, which is what\n // CLAUDE.md's \"no useEffect to sync state\" rule forbids. The host uses it to\n // guard destructive UI of its own, e.g. the block editor's menu picker, whose\n // switch remounts this component and would otherwise silently drop the tree.\n useEffect(() => {\n onDirtyChange?.(hasUnpersistedWork)\n }, [hasUnpersistedWork, onDirtyChange])\n\n // Unsaved-changes guard (phase 09 §3). A real browser-API subscription with a\n // cleanup — the legitimate kind of effect. Keyed on `hasUnpersistedWork`, not\n // `isDirty`: a draft-saved tree is safe to navigate away from even though\n // the live row is still stale.\n useEffect(() => {\n if (!hasUnpersistedWork) return\n const warn = (event: BeforeUnloadEvent) => {\n event.preventDefault()\n }\n window.addEventListener('beforeunload', warn)\n return () => window.removeEventListener('beforeunload', warn)\n }, [hasUnpersistedWork])\n\n // ---------------------------------------------------------------------------\n // Render\n // ---------------------------------------------------------------------------\n\n return (\n <div className=\"space-y-4\">\n {editor.isLockedByOther && editor.lockHolder && (\n <LockBanner\n lockHolder={editor.lockHolder}\n onForceUnlock={editor.forceUnlock}\n canForceUnlock={canPublish}\n />\n )}\n\n {draft && (\n <DraftBanner\n draft={draft}\n onDiscard={editor.discardDraft}\n onPublish={editor.publishDraft}\n canPublish={canPublish}\n isSubmitting={editor.isSubmitting}\n />\n )}\n\n {editor.savedAsDraft && <SavedAsDraftBanner />}\n\n {droppedItemCount > 0 && (\n <div className=\"rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3\">\n <p className=\"text-sm text-destructive\">\n {droppedItemCount} stored item{droppedItemCount === 1 ? '' : 's'} could not be read and{' '}\n {droppedItemCount === 1 ? 'is' : 'are'} not shown. Saving this menu would remove{' '}\n {droppedItemCount === 1 ? 'it' : 'them'} permanently.\n </p>\n </div>\n )}\n\n {editor.submitError && (\n <div className=\"rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3\">\n <p className=\"text-sm text-destructive\">{editor.submitError}</p>\n </div>\n )}\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button type=\"button\" size=\"sm\" onClick={handleAddLink} disabled={readOnly}>\n <Plus className=\"size-4\" aria-hidden /> Add link\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n onClick={handleAddHeader}\n disabled={readOnly}\n >\n <Heading className=\"size-4\" aria-hidden /> Add header\n </Button>\n\n <span className=\"ml-auto flex items-center gap-2\">\n {hasUnpersistedWork && (\n <span className=\"text-sm text-muted-foreground\">Unsaved changes</span>\n )}\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n onClick={handleSaveDraft}\n disabled={readOnly || editor.isSubmitting}\n >\n Save draft\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={handleSave}\n disabled={readOnly || editor.isSubmitting || !isDirty}\n loading={editor.isSubmitting}\n >\n Save\n </Button>\n {canPublish && (\n <Button\n type=\"button\"\n size=\"sm\"\n variant={status === 'published' ? 'outline' : 'default'}\n onClick={handleTogglePublish}\n disabled={readOnly || editor.isSubmitting}\n >\n {status === 'published' ? 'Unpublish' : 'Publish'}\n </Button>\n )}\n </span>\n </div>\n\n {/*\n Menu settings (phase 07 §2.8). The only setting with a control today is\n the `entityTypes` whitelist; `title` / `handle` are set at create time\n and `maxDepth` has no editor yet, so this stays a collapsed disclosure\n rather than a permanent panel competing with the tree for attention.\n Hidden entirely when the caller has no content types to choose from —\n an empty fieldset would just be a dead affordance.\n */}\n {entityTypes.length > 0 && (\n <details className=\"rounded-md border px-3 py-2\">\n <summary className=\"cursor-pointer text-sm font-medium\">Menu settings</summary>\n <fieldset className=\"mt-3\" disabled={readOnly}>\n <legend className=\"text-sm font-medium\">Content types</legend>\n <p className=\"mb-2 text-xs text-muted-foreground\">\n Which content types the “Add link” picker offers for this menu.\n {acceptedTypes.length === 0\n ? ' Nothing selected — this menu accepts every content type.'\n : ' Items already in the tree are never removed by narrowing this.'}\n </p>\n <div className=\"flex flex-wrap gap-x-4 gap-y-2\">\n {entityTypes.map((type) => {\n const id = `menu-entity-type-${type.name}`\n return (\n <div key={type.name} className=\"flex items-center gap-2\">\n <Checkbox\n id={id}\n checked={acceptedTypes.includes(type.name)}\n disabled={readOnly}\n onCheckedChange={() => handleToggleAcceptedType(type.name)}\n />\n <Label htmlFor={id} className=\"text-sm font-normal\">\n {type.label}\n </Label>\n </div>\n )\n })}\n </div>\n {hiddenAcceptedTypeCount > 0 && (\n <p className=\"mt-2 text-xs text-muted-foreground\">\n {hiddenAcceptedTypeCount} more content type\n {hiddenAcceptedTypeCount === 1 ? ' is' : 's are'} whitelisted but not available to\n you. {hiddenAcceptedTypeCount === 1 ? 'It is' : 'They are'} kept when you save.\n </p>\n )}\n </fieldset>\n </details>\n )}\n\n <div className=\"grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]\">\n <div>\n {/*\n Locale PREVIEW (phase 09 §5) — pure client state, no navigation and\n no revalidate. Deliberately NOT the admin's content-locale cookie\n switcher: this previews the SAME already-loaded tree through a\n different lens, and a menu is one structure for every locale (D009),\n so there is nothing to re-fetch. Hidden for a single-locale site,\n where it could only ever have one option.\n */}\n {locales.length > 1 && (\n <fieldset className=\"mb-2 flex flex-wrap items-center gap-1.5\">\n <legend className=\"float-left me-1.5 text-xs text-muted-foreground\">\n Preview as\n </legend>\n {locales.map((locale) => {\n const active = locale.code === previewLocale\n return (\n <button\n key={locale.code}\n type=\"button\"\n aria-pressed={active}\n aria-label={`Preview in ${locale.label}`}\n onClick={() => setPreviewLocale(locale.code)}\n className={cn(\n 'rounded-full border px-2.5 py-0.5 text-xs transition-colors',\n active\n ? 'border-primary bg-primary/10 text-foreground'\n : 'border-border text-muted-foreground hover:text-foreground',\n )}\n >\n {locale.code}\n </button>\n )\n })}\n </fieldset>\n )}\n\n {rows.length === 0 ? (\n <p className=\"rounded-md border border-dashed p-6 text-sm text-muted-foreground\">\n This menu has no items yet. Add a link or a header to start.\n </p>\n ) : (\n <DragDropProvider\n sensors={sensors}\n onDragStart={handleDragStart}\n onDragOver={handleDragOver}\n onDragMove={handleDragMove}\n onDragEnd={handleDragEnd}\n >\n <ul className=\"flex flex-col gap-1\">\n {rows.map((row, index) => {\n const item = findMenuItem(tree, row.id) ?? { ...row.data, children: [] }\n const liveTitle = liveTitleFor(row.data.link)\n return (\n <MenuTreeRow\n key={row.id}\n row={row}\n index={index}\n label={resolveItemLabel(item, {\n locale: previewLocale,\n defaultLocale,\n liveTitle,\n })}\n status={resolveItemStatus(item, summaries)}\n hiddenInPreview={isHiddenInLocale(item, previewLocale)}\n previewLocaleLabel={previewLocaleLabel}\n selected={selectedId === row.id}\n collapsed={collapsed.has(row.id)}\n disabled={readOnly}\n onSelect={setSelectedId}\n onToggleCollapse={handleToggleCollapse}\n onMove={handleMove}\n onIndent={handleIndent}\n onOutdent={handleOutdent}\n onRemove={handleRemove}\n />\n )\n })}\n </ul>\n </DragDropProvider>\n )}\n <p className=\"mt-3 text-xs text-muted-foreground\">\n Drag a row’s handle to reorder; drag right to nest. Keyboard: ⌥↑ / ⌥↓ to move, ⌥→ / ⌥←\n to indent and outdent. Max nesting for this menu: {menu.maxDepth}. Dimmed rows are\n hidden in the previewed language.\n </p>\n </div>\n\n {/*\n Keyed on the selection so the panel's own local state (the anchor\n picker's typed draft + loaded list) resets when a different item is\n selected — React's prescribed alternative to a copy-props-to-state\n effect (CLAUDE.md \"State Coordination\").\n */}\n <ItemInspector\n key={selectedItem?.id ?? 'none'}\n item={selectedItem}\n locales={locales}\n defaultLocale={defaultLocale}\n liveTitle={selectedItem === null ? undefined : liveTitleFor(selectedItem.link)}\n apiBasePath={apiBasePath}\n disabled={readOnly}\n onChange={handleItemChange}\n onRemove={handleRemove}\n />\n </div>\n\n {dialog}\n </div>\n )\n}\n\nconst DEPTH_MESSAGE = 'That would nest deeper than this menu allows.'\nconst SIZE_MESSAGE = `A menu can hold at most ${MAX_MENU_TREE_NODES} items.`\n\n/**\n * Client-side mirror of the two `menuTreeValidation()` cross-field bounds —\n * nesting depth against the menu's own `maxDepth`, and total node count\n * against the hard `MAX_MENU_TREE_NODES` cap. Raises the matching toast and\n * returns `false` when either is breached.\n *\n * Both the live Save path and the DRAFT path call this. The draft path is the\n * load-bearing one: draft writes run no entity behaviors, so the server-side\n * validator never inspects them and a breach would otherwise surface only at\n * publish time.\n */\nfunction preflightTree(tree: readonly MenuItem[], maxDepth: number): boolean {\n const { depth, nodeCount } = measureMenuTree(tree)\n if (nodeCount > MAX_MENU_TREE_NODES) {\n toast.error(SIZE_MESSAGE)\n return false\n }\n if (depth > maxDepth) {\n toast.error(DEPTH_MESSAGE)\n return false\n }\n return true\n}\n\n/**\n * Set equality for two `entityTypes` selections.\n *\n * Order-insensitive on purpose: the stored order is whatever the admin happened\n * to click, and an order-sensitive compare would report \"unsaved changes\" after\n * unchecking and re-checking the same box. Bounded by\n * `MAX_MENU_ENTITY_TYPE_WHITELIST` at the write boundary, so the nested scan is\n * over a few dozen strings at most.\n */\nfunction sameTypes(a: readonly string[], b: readonly string[]): boolean {\n return a.length === b.length && a.every((name) => b.includes(name))\n}\n\n/**\n * The selection as the COLUMN stores it: `null` for \"accepts every content\n * type\", never `[]`. One stored shape for unrestricted — see\n * `@murumets-ee/menus`' `menu-entity-types.ts`.\n */\nfunction toStoredTypes(selection: readonly string[]): string[] | null {\n return selection.length === 0 ? null : [...selection]\n}\n\nfunction droppedConfirmMessage(count: number): string {\n return `${count} stored item${count === 1 ? '' : 's'} could not be read and will be REMOVED permanently by this save. Continue?`\n}\n","'use client'\n\n/**\n * Tier 3 custom editor surface for `MenuBlock` (`@murumets-ee/blocks/library`)\n * — clicking a menu block anywhere in the page/site-layout editor (header,\n * footer, or in-page) opens this INSTEAD OF the generic property panel; see\n * `BlockDefinition.customEditorSurface` (`plan/blocks/08-adaptive-ui.md`\n * §\"Tier 3\") and `@murumets-ee/blocks/editor`'s `CustomEditorSurfaceModal`,\n * which provides the surrounding modal chrome (backdrop, focus trap,\n * Escape/outside-click dismiss) — this component is CONTENT only.\n *\n * ## Data flow\n *\n * Two fetches, both gated on a real interaction (this surface mounting, or\n * the picked handle changing) — never on every keystroke or block-selection\n * re-render (CLAUDE.md \"No Unbounded Fan-Out\"):\n *\n * 1. The menu picker's option list — `GET /api/admin/menu` (the generic\n * entity-list route every entity already has; no new route for this).\n * 2. The chosen menu's full editor payload — `GET\n * /api/admin/menus/editor-state?handle=` (`@murumets-ee/menus`'s new\n * route; see that package's `admin/routes.ts` header for what it returns).\n *\n * ## Persistence\n *\n * Entirely `<MenuTreeEditor>`'s own — Save / Save draft / Publish PATCH the\n * SAME `/api/admin/menu/:id` entity-CRUD route the standalone `/admin/menu/\n * [id]` page uses (`useContentEditor` inside that component). This surface\n * never writes menu tree data itself; the only write IT makes is the\n * block's own `handle` / `orientation` fields via `onChange`, which routes\n * through `block:field:set` — the normal block-editing op path.\n *\n * ## ⚠️ Second whole-tree writer — invariants\n *\n * `menu.tree` is ONE JSONB column and every write is whole-tree, so this\n * surface and the standalone `/admin/menu/[id]` page can clobber each other\n * unless they guard identically. A third writer (routing PR12's Meta-panel\n * \"Menus\" section) is landing in parallel. The four invariants any menu\n * writer must hold, and where each is enforced here:\n *\n * 1. **Never edit by a remembered id against a re-read tree.** This surface\n * does no target-based placement — the user manipulates the tree directly\n * and `<MenuTreeEditor>` edits its own local state. The related hazard\n * that DID apply is stale state across a menu SWITCH; the `key` on\n * `<MenuTreeEditor>` below is the fix.\n * 2. **Refuse to save a tree you could not fully read.** `droppedItemCount`\n * rides the response (counted against this very read) and\n * `<MenuTreeEditor>` confirms before saving past it.\n * 3. **Carry the existing draft payload** — `saveDraft` replaces rather than\n * merges. The route returns `draft`; `<MenuTreeEditor>` spreads its `data`.\n * 4. **Pre-flight depth AND node count client-side** — draft writes run no\n * entity behaviors, so the server validator never sees them.\n * `<MenuTreeEditor>`'s `preflightTree` covers both paths.\n *\n * Invariants 2–4 live in `<MenuTreeEditor>`, which BOTH menu-tree surfaces\n * render, so they are shared by construction. If a future writer needs the\n * placement half (invariant 1), reuse `placeMenuItem`\n * (`@murumets-ee/admin-ui`'s `meta-panel/menu-placement.ts`) rather than\n * reimplementing it — it composes the same `@murumets-ee/menus/schema`\n * primitives this surface's editor uses.\n *\n * ## Coordination with the Meta panel's Menus section (routing PR13)\n *\n * That section is the THIRD writer, and it shares this screen: admin-ui mounts\n * it as a host sidebar inside the same block editor. The two never run at\n * once — `customEditorSurfaceRule.isActive` yields while\n * `state.ui.showHostSidebar` is set, specifically so one menu is never live in\n * two editors (see that rule's comment). They also differ in KIND, which is\n * why mutual exclusion is the right guard rather than merging them:\n *\n * - the Meta panel writes SURGICALLY — read the tree fresh, apply one pure\n * placement op, `POST /draft` — so it is safe against a tree that changed\n * since the page rendered;\n * - this surface loads the tree into a full editor and saves it WHOLE, which\n * is what makes drag-reorder possible at all.\n *\n * Sequential use is safe in both directions because the editor-state route\n * reads draft-then-live: opening this surface after a Meta-panel edit shows\n * that edit rather than the pre-draft structure.\n */\n\nimport type { BlockEditorSurfaceProps } from '@murumets-ee/blocks'\nimport type { MenuBlock as CanonicalMenuBlock } from '@murumets-ee/blocks/library'\nimport type { MenuEditorStateResponse } from '@murumets-ee/menus/admin'\nimport { Button, Select } from '@murumets-ee/ui'\nimport { type ReactElement, useCallback, useEffect, useRef, useState } from 'react'\nimport { MenuTreeEditor } from './menu-tree-editor.js'\nimport type { MenuTreeEditorProps } from './types.js'\n\nconst SWITCH_DISCARD_MESSAGE =\n 'This menu has unsaved changes. Switching to a different menu will discard them. Continue?'\n\n/** Same default every other menus-ui component falls back to (see `MenuEditorPage`). */\nconst DEFAULT_ADMIN_API_BASE_PATH = '/api/admin'\n/** Where the \"Open full editor\" link points, absent a locale-aware admin base. */\nconst DEFAULT_ADMIN_BASE_PATH = '/admin'\n\ntype MenuBlockFields = (typeof CanonicalMenuBlock)['fields']\ntype MenuBlockSurfaceProps = BlockEditorSurfaceProps<MenuBlockFields>\n\ninterface MenuOption {\n readonly id: string\n readonly handle: string\n readonly title: string\n}\n\n/**\n * Narrow the untrusted `GET /api/admin/menu` list response to exactly the\n * fields the picker renders. Anything else about the row is ignored.\n * Exported for its own unit test — every other consumer reaches it only\n * through the component's fetch effect.\n */\nexport function parseMenuOptions(body: unknown): MenuOption[] {\n if (body === null || typeof body !== 'object' || !('items' in body)) return []\n const items = (body as { items?: unknown }).items\n if (!Array.isArray(items)) return []\n const out: MenuOption[] = []\n for (const raw of items as readonly unknown[]) {\n if (raw === null || typeof raw !== 'object') continue\n const { id, handle, title } = raw as Record<string, unknown>\n if (typeof id !== 'string' || typeof handle !== 'string' || handle.length === 0) continue\n out.push({ id, handle, title: typeof title === 'string' && title.length > 0 ? title : handle })\n }\n return out\n}\n\nexport interface CreateMenuBlockEditorSurfaceOptions {\n /** Admin API base (`/api/admin` unless overridden). */\n apiBasePath?: string\n /** Admin app base for the \"Open full editor\" link (`/admin` unless overridden). */\n adminBasePath?: string\n}\n\n/**\n * Build the Tier 3 surface component. Most hosts want the pre-built\n * `menuBlockEditorSurface` export below — this factory exists for hosts\n * that mount the admin API or admin app shell under a non-default base\n * path, mirroring `@murumets-ee/blocks/editor`'s `createFetchCommitter({\n * endpoint })` override.\n */\nexport function createMenuBlockEditorSurface(\n options: CreateMenuBlockEditorSurfaceOptions = {},\n): (props: MenuBlockSurfaceProps) => ReactElement {\n const apiBasePath = options.apiBasePath ?? DEFAULT_ADMIN_API_BASE_PATH\n const adminBasePath = options.adminBasePath ?? DEFAULT_ADMIN_BASE_PATH\n\n return function MenuBlockEditorSurface({\n values,\n onChange,\n onClose,\n }: MenuBlockSurfaceProps): ReactElement {\n // Possibly-absent at runtime despite the static type — a freshly\n // palette-inserted block arrives with `props: {}` because nothing applies\n // field defaults at insert time. Same coercion (and same reasoning) as the\n // block's render in `@murumets-ee/blocks`' `themes/default/renders/navigation.tsx`.\n const handle = typeof values.handle === 'string' ? values.handle : ''\n const orientation = values.orientation === 'vertical' ? 'vertical' : 'horizontal'\n\n const [options_, setOptions] = useState<MenuOption[] | null>(null)\n const [optionsError, setOptionsError] = useState(false)\n\n // DELIBERATE exception to CLAUDE.md \"no useEffect for data fetching\".\n //\n // That rule exists so INITIAL PAGE data is fetched server-side and streamed\n // as props, avoiding a client waterfall and a flash of empty state. This\n // component is not on that path: it is a modal that only ever mounts in\n // response to a user CLICKING a block in the editor canvas — i.e. it IS\n // the \"subsequent client-side interaction\" the rule explicitly permits.\n // There is no server component between the click and this surface (the\n // blocks editor is a client-side canvas), so an RSC prop-drill isn't\n // available even in principle.\n //\n // Bounded: at most one in-flight request per mount, cancelled on unmount;\n // the option list is capped server-side by `limit=200`.\n useEffect(() => {\n let cancelled = false\n fetch(`${apiBasePath}/menu?limit=200&sort=title`)\n .then((res) => (res.ok ? (res.json() as Promise<unknown>) : Promise.reject(res.status)))\n .then((body) => {\n if (!cancelled) setOptions(parseMenuOptions(body))\n })\n .catch(() => {\n if (!cancelled) setOptionsError(true)\n })\n return () => {\n cancelled = true\n }\n // `apiBasePath` is closed over from the factory call and never\n // changes for the component's lifetime — correctly omitted (biome's\n // exhaustive-deps rule agrees; see the identical omission below).\n }, [])\n\n const [editorData, setEditorData] = useState<MenuEditorStateResponse | null>(null)\n const [editorLoading, setEditorLoading] = useState(false)\n const [editorError, setEditorError] = useState<string | null>(null)\n\n useEffect(() => {\n if (handle.length === 0) {\n setEditorData(null)\n setEditorError(null)\n return\n }\n let cancelled = false\n setEditorLoading(true)\n setEditorError(null)\n fetch(`${apiBasePath}/menus/editor-state?handle=${encodeURIComponent(handle)}`)\n .then(async (res) => {\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as { error?: string }\n throw new Error(body.error ?? `Could not load this menu (${res.status})`)\n }\n return res.json() as Promise<MenuEditorStateResponse>\n })\n .then((data) => {\n if (!cancelled) setEditorData(data)\n })\n .catch((err: unknown) => {\n if (!cancelled) {\n setEditorError(err instanceof Error ? err.message : 'Could not load this menu')\n }\n })\n .finally(() => {\n if (!cancelled) setEditorLoading(false)\n })\n return () => {\n cancelled = true\n }\n // `apiBasePath` omitted — stable closure value, never changes.\n }, [handle])\n\n // Tracks the embedded editor's unpersisted work via its `onDirtyChange`\n // callback. A REF, not state: the value is only read inside an event\n // handler, so storing it in state would re-render the whole surface (and\n // the tree editor with it) on every first keystroke for no visual reason.\n const dirtyRef = useRef(false)\n const handleDirtyChange = useCallback((dirty: boolean) => {\n dirtyRef.current = dirty\n }, [])\n\n // Switching the picker changes `handle`, which changes the `key` below and\n // REMOUNTS `<MenuTreeEditor>` — discarding its local tree. That remount is\n // deliberate (it is what stops menu A's tree being PATCHed onto menu B),\n // but it must not silently destroy work: the editor's own `beforeunload`\n // guard only covers leaving the PAGE, not an in-modal remount.\n const handlePickMenu = useCallback(\n (nextHandle: string) => {\n if (nextHandle === handle) return\n if (dirtyRef.current && !window.confirm(SWITCH_DISCARD_MESSAGE)) return\n onChange('handle', nextHandle)\n },\n [handle, onChange],\n )\n\n const menuId = editorData?.menu.id\n const openFullEditorHref = menuId ? `${adminBasePath}/menu/${menuId}` : null\n\n return (\n <div className=\"flex h-full flex-col\">\n <div className=\"flex flex-wrap items-center gap-3 border-b border-border px-4 py-3\">\n <div className=\"flex flex-col gap-1\">\n <span className=\"text-xs font-medium text-muted-foreground\">Menu</span>\n {optionsError ? (\n <span className=\"text-sm text-destructive\">Could not load the menu list.</span>\n ) : (\n <Select\n aria-label=\"Menu\"\n value={handle}\n disabled={options_ === null}\n onChange={(event) => handlePickMenu(event.target.value)}\n >\n <option value=\"\">— choose a menu —</option>\n {(options_ ?? []).map((opt) => (\n <option key={opt.id} value={opt.handle}>\n {opt.title} ({opt.handle})\n </option>\n ))}\n </Select>\n )}\n </div>\n\n <div className=\"flex flex-col gap-1\">\n <span className=\"text-xs font-medium text-muted-foreground\">Orientation</span>\n <div className=\"flex overflow-hidden rounded-md border border-border\">\n {/* Compared against the COERCED `orientation` above, not raw\n `values.orientation` — an unset value must still show the\n effective (\"horizontal\") default as selected rather than\n leaving the segmented control with nothing pressed. */}\n {(['horizontal', 'vertical'] as const).map((option) => (\n <button\n key={option}\n type=\"button\"\n aria-pressed={orientation === option}\n onClick={() => onChange('orientation', option)}\n className={\n orientation === option\n ? 'bg-primary px-3 py-1.5 text-sm text-primary-foreground'\n : 'bg-transparent px-3 py-1.5 text-sm text-foreground hover:bg-accent'\n }\n >\n {option === 'horizontal' ? 'Horizontal' : 'Vertical'}\n </button>\n ))}\n </div>\n </div>\n\n <span className=\"ml-auto flex items-center gap-2\">\n {openFullEditorHref && (\n <a\n href={openFullEditorHref}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-muted-foreground underline hover:text-foreground\"\n >\n Open full editor ↗\n </a>\n )}\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={onClose}>\n Close\n </Button>\n </span>\n </div>\n\n <div className=\"min-h-0 flex-1 overflow-auto p-4\">\n {handle.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">\n Choose a menu above to edit its items here, or create one from the Menus admin screen\n first.\n </p>\n ) : editorLoading ? (\n <p className=\"text-sm text-muted-foreground\">Loading “{handle}”…</p>\n ) : editorError ? (\n <p className=\"text-sm text-destructive\">{editorError}</p>\n ) : editorData ? (\n /* `key` is LOAD-BEARING, not a list-key nicety. `<MenuTreeEditor>`\n seeds its whole-tree working state with `useState(menu.tree)`,\n and React ignores the initial value on subsequent renders — so\n re-pointing the picker from menu A to menu B would hand it\n B's `menu.id` while its internal `tree` state still held A's\n items, and the next Save would PATCH A's tree onto B. Keying on\n the id remounts the editor so state and target can never\n disagree (CLAUDE.md \"State Coordination\" Fix 3 — `key` over a\n copy-prop-to-state effect). */\n <MenuTreeEditor\n key={editorData.menu.id}\n {...buildMenuTreeEditorProps(editorData, apiBasePath)}\n onDirtyChange={handleDirtyChange}\n />\n ) : null}\n </div>\n </div>\n )\n }\n}\n\n/** Project the editor-state response into `<MenuTreeEditor>`'s prop contract. */\nfunction buildMenuTreeEditorProps(\n data: MenuEditorStateResponse,\n apiBasePath: string,\n): MenuTreeEditorProps {\n return {\n menu: data.menu,\n summaries: data.summaries,\n locales: data.locales,\n defaultLocale: data.defaultLocale,\n droppedItemCount: data.droppedItemCount,\n apiBasePath,\n entityTypes: data.entityTypes,\n canPublish: data.canPublish,\n // The pending draft, when the route found one. NOT optional polish:\n // `useContentEditor.saveDraft` REPLACES the draft payload rather than\n // merging, so `<MenuTreeEditor>` needs the existing `data` to spread it\n // back — without it, saving the tree from here would drop a `title` /\n // `handle` / `maxDepth` edit staged in the same draft from the entity form.\n ...(data.draft !== undefined && { draft: data.draft }),\n // Still no `lockResult` — the concurrent-edit LOCK is the one piece of\n // standalone-editor parity this surface does not claim (see the module\n // header); `<MenuTreeEditor>` treats it as optional and simply stays\n // editable. The \"Open full editor\" link is the route to the locked flow.\n }\n}\n\n/** The default-configured surface — what `@murumets-ee/menus-ui`'s `MenuBlock` export wires in. */\nexport const menuBlockEditorSurface = createMenuBlockEditorSurface()\n","/**\n * The batteries-included `menu` block: `@murumets-ee/blocks/library`'s\n * `MenuBlock` content schema + default render, PLUS the Tier 3 in-place tree\n * editor wired in as `customEditorSurface`.\n *\n * **Why the wiring can't live in `@murumets-ee/blocks` itself.** That\n * package must not depend on `@murumets-ee/menus` / `@murumets-ee/menus-ui`\n * — `@murumets-ee/menus` already depends on `@murumets-ee/blocks/links`, so\n * the reverse import would form a package cycle (`pnpm verify:cycles`). This\n * is the same reason `menus()` (the plugin factory) lives in `menus-ui` and\n * not `menus` — see that plugin's own header.\n *\n * Apps add navigation to their theme by importing THIS export (not\n * `@murumets-ee/blocks/library`'s bare `MenuBlock`) into their block\n * registry + theme `covers` map, the same way any other block is added —\n * see `apps/web-astro/src/blocks/{registry,theme}.ts` for the pattern an\n * app already follows for its own blocks.\n *\n * @example\n * ```ts\n * import { MenuBlock, MenuBlockRender } from '@murumets-ee/menus-ui'\n *\n * export const pageBlocks = [...otherBlocks, MenuBlock] as const\n * // and in the theme (unless already using `@murumets-ee/blocks/themes/default`,\n * // which covers `menu` automatically):\n * covers: { ...otherRenders, menu: MenuBlockRender }\n * ```\n */\n\nimport { MenuBlock as CanonicalMenuBlock } from '@murumets-ee/blocks/library'\nimport { navigationRenders } from '@murumets-ee/blocks/themes/default'\nimport { menuBlockEditorSurface } from './menu-block-editor-surface.js'\n\n// Explicit annotations below (rather than letting TS infer + print the\n// merged object-literal shape) — `defineBlock`'s return type nests types\n// like `IconRef` / `TraitDefinition` that this package doesn't re-export,\n// so declaration emit for an INFERRED type can't name them\n// (`TS4023: ... but cannot be named`). Building the annotation purely from\n// `typeof <import>` expressions sidesteps that; both are always nameable.\nexport const MenuBlock: typeof CanonicalMenuBlock & {\n customEditorSurface: typeof menuBlockEditorSurface\n} = {\n ...CanonicalMenuBlock,\n customEditorSurface: menuBlockEditorSurface,\n}\n\n/**\n * The default theme-agnostic browse-mode render — re-exported so an app\n * with its OWN custom theme (not `@murumets-ee/blocks/themes/default`'s\n * full `covers` map) can still map `menu: MenuBlockRender` without writing\n * one from scratch. Reads `ResolvedMenusContext` — see that context's\n * JSDoc (`@murumets-ee/blocks/core`) for what populates it.\n *\n * Reached via `navigationRenders.menu` (not a named `MenuDefault` export) —\n * `@murumets-ee/blocks/themes/default` only exports its per-domain render\n * maps (`structuralRenders`, `marketingRenders`, …), never individual block\n * renders; `navigationRenders` is that same convention's navigation-domain\n * entry.\n */\nexport const MenuBlockRender: typeof navigationRenders.menu = navigationRenders.menu\n"],"mappings":"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"}
|
package/dist/pages.d.mts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PageProps } from "@murumets-ee/core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/pages/menu-editor-page.d.ts
|
|
5
|
+
declare function MenuEditorPage({
|
|
6
|
+
params
|
|
7
|
+
}: PageProps<{
|
|
8
|
+
locale: string;
|
|
9
|
+
id: string;
|
|
10
|
+
}>): Promise<ReactNode>;
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/pages/menu-list-page.d.ts
|
|
13
|
+
declare function MenuListPage({
|
|
14
|
+
params
|
|
15
|
+
}: PageProps<{
|
|
16
|
+
locale: string;
|
|
17
|
+
}>): Promise<ReactNode>;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { MenuEditorPage, MenuListPage };
|
|
20
|
+
//# sourceMappingURL=pages.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pages.d.mts","names":[],"sources":["../src/pages/menu-editor-page.tsx","../src/pages/menu-list-page.tsx"],"mappings":";;;;iBAiEsB,cAAA,CAAA;EACpB;AAAA,GACC,SAAA;EAAY,MAAA;EAAgB,EAAA;AAAA,KAAgB,OAAA,CAAQ,SAAA;;;iBC7BjC,YAAA,CAAA;EAAe;AAAA,GAAU,SAAA;EAAY,MAAA;AAAA,KAAoB,OAAA,CAAQ,SAAA"}
|
package/dist/pages.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{getRegisteredAdminConfig as e}from"@murumets-ee/admin-ui/config-registry";import{hasEntityPermission as t,hasResourcePermission as n}from"@murumets-ee/admin-ui/pages";import{fetchEntityById as r,fetchEntityList as i,getEntityLabelSingular as a}from"@murumets-ee/admin-ui/server";import{ContentClient as o}from"@murumets-ee/content/client";import{LockService as s}from"@murumets-ee/content/lock";import{getApp as c}from"@murumets-ee/core";import{getSearchableConfig as l}from"@murumets-ee/entity";import{DEFAULT_MENU_DEPTH as u,Menu as d,measureMenuTree as f}from"@murumets-ee/menus";import{resolveMenuItemSummaries as p}from"@murumets-ee/menus/admin";import{collectMenuEntityRefs as m,normalizeMenuEntityTypes as h,normalizeMenuTree as g}from"@murumets-ee/menus/schema";import{MenuTreeEditor as _,NewMenuDialog as v}from"@murumets-ee/menus-ui";import{headers as y}from"next/headers";import{notFound as b}from"next/navigation";import{setRequestLocale as x}from"next-intl/server";import{jsx as S,jsxs as C}from"react/jsx-runtime";import{WorkflowStatusBadge as w}from"@murumets-ee/admin-ui/workflow";import{Table as T,TableBody as E,TableCell as D,TableHead as O,TableHeader as k,TableRow as A}from"@murumets-ee/ui";import j from"next/link";const M=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;async function N({params:t}){let i=e(),{locale:a,id:o}=await t;x(a),M.test(o)||b();let s=i.apiBasePath??`/api/admin`;return i.withAdminContext(a,async e=>{let t=c(),a=await r(d,o);a||b();let[l,f,v]=await Promise.all([P(i),n(`menu`,`publish`,i),F(i,o)]),y=v.draft?.data,x=y?.tree,{items:w,dropped:T}=g(x===void 0?a.tree:x);T>0&&t.logger.warn({menuId:a.id,handle:a.handle,dropped:T,fromDraft:x!==void 0},`menu editor: stored tree contained items that could not be read — the editor shows the rest`);let E=await p(t,m(w)),D=Object.fromEntries(E),O=e.availableLocales?e.availableLocales.filter(e=>e.enabled).map(e=>({code:e.code,label:e.label})):[{code:e.defaultLocale,label:e.defaultLocale}],k={menu:{id:a.id,handle:a.handle??``,title:a.title,maxDepth:a.maxDepth??u,status:a.status===`published`?`published`:`draft`,tree:w,entityTypes:h(y!==void 0&&`entityTypes`in y?y.entityTypes:a.entityTypes)},summaries:D,locales:O,defaultLocale:e.defaultLocale,droppedItemCount:T,apiBasePath:s,entityTypes:l,canPublish:f,...v.draft!==void 0&&{draft:v.draft},...v.lockResult!==void 0&&{lockResult:v.lockResult}};return C(`div`,{className:`space-y-4 p-6`,children:[C(`div`,{children:[S(`h1`,{className:`text-2xl font-bold`,children:a.title}),C(`p`,{className:`text-sm text-muted-foreground`,children:[S(`code`,{children:a.handle??`—`}),` · one tree, shared across every locale`]})]}),S(_,{...k})]})})}async function P(e){try{let n=[...c().entities.values()].filter(e=>l(e)!==void 0);return(await Promise.all(n.map(async n=>await t(n,`view`,e)?n:null))).filter(e=>e!==null).map(e=>({name:e.name,label:a(e)}))}catch(e){return c().logger.warn({err:e},`menu editor: could not resolve link picker content types`),[]}}async function F(e,t){let n=c(),r,i;try{let a=await new o({admin:n.getClient(d),entity:d}).getDraft(t,`_`);a&&(r={data:a.data,createdBy:a.createdBy,createdByName:a.createdByName,updatedAt:a.updatedAt});let c=await e.auth.getSession(await y());if(c?.user){let e=await new s({app:n}).acquireLock(d.name,t,`_`,{id:c.user.id,name:c.user.name??void 0});i=e.acquired?{acquired:!0}:{acquired:!1,lock:e.lock}}}catch(e){n.logger.warn({err:e,menuId:t},`menu editor: draft/lock state unavailable`)}return{...r!==void 0&&{draft:r},...i!==void 0&&{lockResult:i}}}async function I({params:t}){let r=e(),{locale:a}=await t;x(a);let o=r.adminBasePath.replace(`:locale`,a);return r.withAdminContext(a,async()=>{let[{items:e},t]=await Promise.all([i(d,{sortField:`title`,sortDirection:`asc`,limit:100}),n(`menu`,`create`,r)]);return C(`div`,{className:`space-y-4 p-6`,children:[C(`div`,{className:`flex items-start justify-between gap-4`,children:[C(`div`,{children:[S(`h1`,{className:`text-2xl font-bold`,children:`Menus`}),S(`p`,{className:`text-sm text-muted-foreground`,children:`Site navigation. Each menu is one ordered tree, shared across every locale.`})]}),t&&S(v,{apiBasePath:r.apiBasePath??`/api/admin`,adminBasePath:o})]}),e.length===0?S(`p`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:`No menus yet.`}):C(T,{children:[S(k,{children:C(A,{children:[S(O,{children:`Title`}),S(O,{children:`Handle`}),S(O,{className:`text-right`,children:`Items`}),S(O,{children:`Status`})]})}),S(E,{children:e.map(e=>C(A,{children:[S(D,{className:`font-medium`,children:S(j,{href:`${o}/menu/${e.id}`,className:`text-primary hover:underline`,children:e.title})}),S(D,{children:S(`code`,{className:`text-xs`,children:e.handle??`—`})}),S(D,{className:`text-right tabular-nums`,children:f(e.tree).nodeCount}),S(D,{children:S(w,{status:e.status===`published`?`published`:`draft`})})]},e.id))})]})]})})}export{N as MenuEditorPage,I as MenuListPage};
|
|
2
|
+
//# sourceMappingURL=pages.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pages.mjs","names":[],"sources":["../src/pages/menu-editor-page.tsx","../src/pages/menu-list-page.tsx"],"sourcesContent":["/**\n * `/admin/menu/[id]` — the menu editor shell (plan/routing phase 09 §2).\n *\n * Server component. It resolves EVERYTHING the tree editor needs — the menu\n * row, the live title / broken / unpublished status of each entity target, the\n * request's effective locale set, the link picker's content-type whitelist, and\n * the publishable edit state (pending draft + edit lock + `menu:publish`) — and\n * hands it down as props. There is no mount-time fetch in the client island\n * (CLAUDE.md \"Data Fetching\").\n *\n * Auth: mounted behind the admin shell's declarative gate (`menu:view` +\n * 404-on-deny, declared on the `defaultRoutes` spec in `plugin.ts`), which\n * refuses an unauthenticated caller too. Both reads below run under\n * `withAdminContext` through the REQUEST'S own admin client, so the entity\n * firewall applies a second time — `resolveMenuItemSummaries` deliberately does\n * not elevate, so a target type this admin can't view simply resolves to no\n * summary. Every write the editor makes is re-authorized by the admin API\n * (`menu:update` on the PATCH, `menu:publish` on the draft-publish route), so\n * the `canPublish` flag below is a UX affordance, never the gate.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasEntityPermission, hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { fetchEntityById, getEntityLabelSingular } from '@murumets-ee/admin-ui/server'\nimport { ContentClient } from '@murumets-ee/content/client'\nimport { LockService } from '@murumets-ee/content/lock'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport { getSearchableConfig } from '@murumets-ee/entity'\nimport { DEFAULT_MENU_DEPTH, Menu } from '@murumets-ee/menus'\nimport { type MenuItemSummary, resolveMenuItemSummaries } from '@murumets-ee/menus/admin'\nimport {\n collectMenuEntityRefs,\n normalizeMenuEntityTypes,\n normalizeMenuTree,\n} from '@murumets-ee/menus/schema'\nimport type {\n MenuEditorLocale,\n MenuLinkEntityType,\n MenuTreeEditorProps,\n} from '@murumets-ee/menus-ui'\nimport { MenuTreeEditor } from '@murumets-ee/menus-ui'\nimport { headers } from 'next/headers'\nimport { notFound } from 'next/navigation'\nimport { setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\n\n/**\n * Canonical UUID shape (mirrors the anchors route's own guard). Entity ids are\n * uuids, and `AdminClient.findById` passes its argument straight into a\n * `where id = $1` on a `uuid` column — a non-uuid segment would surface as a\n * Postgres `invalid input syntax` 500 instead of a 404. This also covers the\n * `/admin/menu/new` case: the `menu/[id]` route pattern is matched before the\n * generic entity family, so `new` arrives here as an `id`.\n */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/**\n * A menu's draft + lock are keyed by the DEFAULT snapshot key, never by the\n * admin's current content locale: one tree serves every locale (D009), so a\n * per-locale draft would let two locales hold divergent copies of the same\n * structure. `'_'` is the api-handler's own default-snapshot key\n * (`snapshotLocale`), which the client reaches by sending no `locale` param.\n */\nconst MENU_SNAPSHOT_LOCALE = '_'\n\nexport async function MenuEditorPage({\n params,\n}: PageProps<{ locale: string; id: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale, id } = await params\n setRequestLocale(locale)\n\n if (!UUID_RE.test(id)) notFound()\n\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n\n return config.withAdminContext(locale, async (ctx) => {\n const app = getApp()\n\n const row = await fetchEntityById(Menu, id)\n if (!row) notFound()\n\n const [entityTypes, canPublish, editState] = await Promise.all([\n resolveLinkEntityTypes(config),\n hasResourcePermission('menu', 'publish', config),\n loadMenuEditState(config, id),\n ])\n\n // Seed from the pending DRAFT when there is one, else the live row — the\n // same `{ ...initialData, ...draft.data }` precedence `EntityForm` applies.\n // Without it the editor would show the live tree under a banner that says\n // \"you are editing a pending draft\", and a Save would silently publish the\n // pre-draft structure.\n const draftData = editState.draft?.data\n const draftTree = draftData?.tree\n const source = draftTree !== undefined ? draftTree : row.tree\n // `field.json()` types the column as `JsonValue`, and a stored tree is only\n // *usually* well-formed (pre-validator rows, hand edits, old dumps). Narrow\n // it once, tolerantly and boundedly — see `normalizeMenuTree`.\n const { items: tree, dropped } = normalizeMenuTree(source)\n if (dropped > 0) {\n app.logger.warn(\n { menuId: row.id, handle: row.handle, dropped, fromDraft: draftTree !== undefined },\n 'menu editor: stored tree contained items that could not be read — the editor shows the rest',\n )\n }\n\n // ONE batched read for every distinct entity target (deduped), never one\n // per item — see `collectMenuEntityRefs`.\n const summaryMap = await resolveMenuItemSummaries(app, collectMenuEntityRefs(tree))\n // `Map` → plain object: the RSC payload crosses to the client island, and a\n // plain record needs no special serialisation on either side.\n const summaries: Record<string, MenuItemSummary> = Object.fromEntries(summaryMap)\n\n // The request's effective content locales (shipped `content()` defaults ⊕\n // DB `locale` override rows, resolved once per request at context\n // establishment). Disabled entries are excluded — a menu item can't be\n // authored for a locale the site no longer serves. Falls back to the\n // request's own locale pair when the host app doesn't wire the registry\n // (the contract marks `availableLocales` optional).\n const locales: MenuEditorLocale[] = ctx.availableLocales\n ? ctx.availableLocales.filter((l) => l.enabled).map((l) => ({ code: l.code, label: l.label }))\n : [{ code: ctx.defaultLocale, label: ctx.defaultLocale }]\n\n const editorProps: MenuTreeEditorProps = {\n menu: {\n id: row.id,\n handle: row.handle ?? '',\n title: row.title,\n // `maxDepth` / `status` carry column defaults but are typed\n // nullable (neither field is `required`), so re-apply the SAME\n // defaults here rather than pushing a \"maybe absent\" branch into\n // every editor consumer.\n maxDepth: row.maxDepth ?? DEFAULT_MENU_DEPTH,\n status: row.status === 'published' ? 'published' : 'draft',\n tree,\n // Same draft-then-live precedence the tree uses: a pending draft that\n // narrowed the whitelist must be what the editor shows, or a Save would\n // silently republish the pre-draft selection. `field.json()` types the\n // column as `JsonValue`, so narrow it the same tolerant way.\n entityTypes: normalizeMenuEntityTypes(\n draftData !== undefined && 'entityTypes' in draftData\n ? draftData.entityTypes\n : row.entityTypes,\n ),\n },\n summaries,\n locales,\n defaultLocale: ctx.defaultLocale,\n droppedItemCount: dropped,\n apiBasePath,\n entityTypes,\n canPublish,\n ...(editState.draft !== undefined && { draft: editState.draft }),\n ...(editState.lockResult !== undefined && { lockResult: editState.lockResult }),\n }\n\n return (\n <div className=\"space-y-4 p-6\">\n <div>\n <h1 className=\"text-2xl font-bold\">{row.title}</h1>\n <p className=\"text-sm text-muted-foreground\">\n {/* `handle` is a nullable column (`field.slug()` isn't `required`) — same convention as MenuListPage. */}\n <code>{row.handle ?? '—'}</code> · one tree, shared across every locale\n </p>\n </div>\n <MenuTreeEditor {...editorProps} />\n </div>\n )\n })\n}\n\n/**\n * Content types the \"Add link\" picker may search.\n *\n * Same derivation `EntityEditPage` uses for the block editor's picker: entities\n * that declare `searchable()` (the picker searches them through\n * `/api/admin/search/<entity>`, so a non-searchable type could never be picked\n * anyway) filtered by the caller's own `view` grant — hiding an option the\n * viewer can't use, on top of the search route's own 403/404.\n *\n * Not a fan-out risk: the list is the app's STATIC entity registry, and\n * `hasEntityPermission` is React-`cache()`d, so the session + roles load is\n * paid once per render no matter how many types there are — no per-entity I/O.\n *\n * This is only STAGE ONE of a two-stage narrowing. What this returns is the\n * option UNIVERSE — every type the signed-in admin could link to at all. The\n * per-menu `entityTypes` whitelist (phase 07 §2.8, shipped in PR12) is applied\n * on top, in the editor island: `MenuTreeEditor` derives the picker's offered\n * types during render as `entityTypes.filter(menuAcceptsEntityType(…))`, so the\n * narrowing follows the admin's live selection without a round trip. A menu\n * whose whitelist is `null` accepts everything and sees this universe unchanged.\n *\n * The two stages are deliberately independent (see `MenuEditorMenu.entityTypes`):\n * a stored whitelist may name a type THIS caller can't see, and neither stage is\n * a security boundary — `menu:update` on the write path is.\n */\nasync function resolveLinkEntityTypes(\n config: ReturnType<typeof getRegisteredAdminConfig>,\n): Promise<MenuLinkEntityType[]> {\n try {\n const searchable = [...getApp().entities.values()].filter(\n (entity) => getSearchableConfig(entity) !== undefined,\n )\n const visible = await Promise.all(\n searchable.map(async (entity) =>\n (await hasEntityPermission(entity, 'view', config)) ? entity : null,\n ),\n )\n return visible\n .filter((entity): entity is (typeof searchable)[number] => entity !== null)\n .map((entity) => ({ name: entity.name, label: getEntityLabelSingular(entity) }))\n } catch (err) {\n // Best-effort: the Content tab simply stays empty. URL + email links keep\n // working, so the editor is still usable.\n getApp().logger.warn({ err }, 'menu editor: could not resolve link picker content types')\n return []\n }\n}\n\n/**\n * Pending draft + edit lock for this menu — the same shape\n * `EntityEditPage.loadPublishableEditState` produces for any `publishable()`\n * entity, keyed by the menu's locale-invariant snapshot key.\n *\n * Failures degrade to \"proceed without\": the editing surface stays usable when\n * the draft/lock subsystem is partially down, exactly as the entity edit page\n * behaves. `menu:update` is still enforced on every write by the API.\n */\nasync function loadMenuEditState(\n config: ReturnType<typeof getRegisteredAdminConfig>,\n id: string,\n): Promise<Partial<Pick<MenuTreeEditorProps, 'draft' | 'lockResult'>>> {\n const app = getApp()\n let draft: MenuTreeEditorProps['draft']\n let lockResult: MenuTreeEditorProps['lockResult']\n\n try {\n const contentClient = new ContentClient({ admin: app.getClient(Menu), entity: Menu })\n const entry = await contentClient.getDraft(id, MENU_SNAPSHOT_LOCALE)\n if (entry) {\n draft = {\n data: entry.data,\n createdBy: entry.createdBy,\n createdByName: entry.createdByName,\n updatedAt: entry.updatedAt,\n }\n }\n\n const session = await config.auth.getSession(await headers())\n if (session?.user) {\n const result = await new LockService({ app }).acquireLock(\n Menu.name,\n id,\n MENU_SNAPSHOT_LOCALE,\n { id: session.user.id, name: session.user.name ?? undefined },\n )\n lockResult = result.acquired ? { acquired: true } : { acquired: false, lock: result.lock }\n }\n } catch (err) {\n app.logger.warn({ err, menuId: id }, 'menu editor: draft/lock state unavailable')\n }\n\n return {\n ...(draft !== undefined && { draft }),\n ...(lockResult !== undefined && { lockResult }),\n }\n}\n","/**\n * `/admin/menu` — the navigation-menu list (plan/routing phase 09 §2).\n *\n * A deliberately plain server-rendered table (handle / title / item count /\n * publish status) rather than an `<EntityList>`: menus are a handful of\n * hand-curated rows (`main`, `footer`, …), so there is nothing to paginate,\n * sort or search, and the interactive list would ship client JS for no gain.\n * One `fetchEntityList` under the request's own admin client — so the\n * `menu:view` firewall and the request locale both apply — and the rows link\n * straight into the editor.\n *\n * Auth: mounted behind the admin shell's declarative gate (`menu:view` +\n * 404-on-deny, declared on the `defaultRoutes` spec in `plugin.ts`), which\n * refuses an unauthenticated caller too (`hasResourcePermission` resolves no\n * session → no grant → `notFound()`). The read below is gated a second time by\n * the entity firewall inside `fetchEntityList`.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { fetchEntityList } from '@murumets-ee/admin-ui/server'\nimport { WorkflowStatusBadge } from '@murumets-ee/admin-ui/workflow'\nimport type { PageProps } from '@murumets-ee/core'\nimport { Menu, measureMenuTree } from '@murumets-ee/menus'\nimport { NewMenuDialog } from '@murumets-ee/menus-ui'\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@murumets-ee/ui'\nimport Link from 'next/link'\nimport { setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\n\n/**\n * Upper bound on rows fetched. Menus are site-wide navigation structures — a\n * big install has a dozen. The cap is a guard, not a page size: there is no\n * \"next page\" control, so a hypothetical 101st menu would be invisible.\n * Revisit (with real pagination) if that ever stops being absurd.\n */\nconst MAX_MENUS = 100\n\nexport async function MenuListPage({ params }: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n\n const basePath = config.adminBasePath.replace(':locale', locale)\n\n return config.withAdminContext(locale, async () => {\n const [{ items }, canCreate] = await Promise.all([\n fetchEntityList(Menu, {\n sortField: 'title',\n sortDirection: 'asc',\n limit: MAX_MENUS,\n }),\n // Deny-by-default: the \"New menu\" affordance only renders for a caller\n // who actually holds `menu:create`. The POST is gated again by the admin\n // API's own firewall, so this is least-surprise UI, not the boundary.\n hasResourcePermission('menu', 'create', config),\n ])\n\n return (\n <div className=\"space-y-4 p-6\">\n <div className=\"flex items-start justify-between gap-4\">\n <div>\n <h1 className=\"text-2xl font-bold\">Menus</h1>\n <p className=\"text-sm text-muted-foreground\">\n Site navigation. Each menu is one ordered tree, shared across every locale.\n </p>\n </div>\n {canCreate && (\n <NewMenuDialog\n apiBasePath={config.apiBasePath ?? '/api/admin'}\n adminBasePath={basePath}\n />\n )}\n </div>\n\n {items.length === 0 ? (\n <p className=\"rounded-md border border-dashed p-6 text-sm text-muted-foreground\">\n No menus yet.\n </p>\n ) : (\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Title</TableHead>\n <TableHead>Handle</TableHead>\n <TableHead className=\"text-right\">Items</TableHead>\n <TableHead>Status</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {items.map((menu) => (\n <TableRow key={menu.id}>\n <TableCell className=\"font-medium\">\n <Link\n href={`${basePath}/menu/${menu.id}`}\n className=\"text-primary hover:underline\"\n >\n {menu.title}\n </Link>\n </TableCell>\n <TableCell>\n {/* `handle` is a nullable column (`field.slug()` isn't `required`). */}\n <code className=\"text-xs\">{menu.handle ?? '—'}</code>\n </TableCell>\n {/*\n Total nodes across every level, not just top-level children —\n \"how big is this menu\" is the useful number in a list row.\n `measureMenuTree` walks ITERATIVELY and aborts at the hard\n bounds, so it stays cheap (and stack-safe) even on a\n hand-edited row.\n */}\n <TableCell className=\"text-right tabular-nums\">\n {measureMenuTree(menu.tree).nodeCount}\n </TableCell>\n <TableCell>\n <WorkflowStatusBadge\n status={menu.status === 'published' ? 'published' : 'draft'}\n />\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n )}\n </div>\n )\n })\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/plugin.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Menus plugin — assembles every contribution the admin shell needs from the
|
|
4
|
+
* server half (`@murumets-ee/menus`) and the UI half (`@murumets-ee/menus-ui`)
|
|
5
|
+
* so consumers get the full experience from a single plugin entry:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { defineLumiConfig } from '@murumets-ee/core'
|
|
9
|
+
* import { menus } from '@murumets-ee/menus-ui/plugin'
|
|
10
|
+
*
|
|
11
|
+
* export default defineLumiConfig({
|
|
12
|
+
* plugins: [content({ locales: [...] }), menus()],
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Purely declarative (CLAUDE.md "Plugin Contributions"): everything the
|
|
17
|
+
* framework needs is a field on the plugin object; there is no imperative
|
|
18
|
+
* registration step and no `init`.
|
|
19
|
+
*
|
|
20
|
+
* Location note: the factory lives in `@murumets-ee/menus-ui`, not in
|
|
21
|
+
* `@murumets-ee/menus`, for the same reason `ticketing()` lives in
|
|
22
|
+
* `ticketing-ui` — `menus-ui` already depends on `menus`, and hosting the
|
|
23
|
+
* factory here avoids a circular build dep. It also keeps `@murumets-ee/menus`
|
|
24
|
+
* react-free, which is what lets `apps/web-astro`'s server code and
|
|
25
|
+
* `@murumets-ee/content-api` depend on it.
|
|
26
|
+
*/
|
|
27
|
+
declare function menus(): Pick<import("@murumets-ee/core").Plugin, "server" | "adminUi" | "postInstall" | "requires" | "contributes"> & {
|
|
28
|
+
readonly name: "@murumets-ee/menus";
|
|
29
|
+
readonly shared?: undefined;
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { menus };
|
|
33
|
+
//# sourceMappingURL=plugin.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;AAqDA;;;;;;;;;;;;;;;;;;;;;;;;iBAAgB,KAAA,CAAA,GAAK,IAAA,6BAAA,MAAA;EAAA"}
|
package/dist/plugin.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{definePlugin as e}from"@murumets-ee/core";import{Menu as t}from"@murumets-ee/menus";import{menusRoutes as n}from"@murumets-ee/menus/admin";const r=async e=>(await import(`@murumets-ee/menus-ui/pages`)).MenuListPage(e),i=async e=>(await import(`@murumets-ee/menus-ui/pages`)).MenuEditorPage(e);function a(){return e({name:`@murumets-ee/menus`,requires:[`@murumets-ee/content`],server:{entities:[t],routes:[...n()]},adminUi:{pages:{MenuListPage:r,MenuEditorPage:i},sidebar:[{id:`menu`,group:`Content`,label:`Menus`,href:`/admin/menu`,iconName:`menu`,requires:`menu:view`}],defaultRoutes:[{path:`menu`,factory:`MenuListPage`,page:{requires:`menu:view`,defaultRoles:[`admin`],description:`Manage navigation menus.`}},{path:`menu/[id]`,factory:`MenuEditorPage`,page:{requires:`menu:view`,defaultRoles:[`admin`],description:`Edit a navigation menu.`}}]}})}export{a as menus};
|
|
2
|
+
//# sourceMappingURL=plugin.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * Menus plugin — assembles every contribution the admin shell needs from the\n * server half (`@murumets-ee/menus`) and the UI half (`@murumets-ee/menus-ui`)\n * so consumers get the full experience from a single plugin entry:\n *\n * ```ts\n * import { defineLumiConfig } from '@murumets-ee/core'\n * import { menus } from '@murumets-ee/menus-ui/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [content({ locales: [...] }), menus()],\n * })\n * ```\n *\n * Purely declarative (CLAUDE.md \"Plugin Contributions\"): everything the\n * framework needs is a field on the plugin object; there is no imperative\n * registration step and no `init`.\n *\n * Location note: the factory lives in `@murumets-ee/menus-ui`, not in\n * `@murumets-ee/menus`, for the same reason `ticketing()` lives in\n * `ticketing-ui` — `menus-ui` already depends on `menus`, and hosting the\n * factory here avoids a circular build dep. It also keeps `@murumets-ee/menus`\n * react-free, which is what lets `apps/web-astro`'s server code and\n * `@murumets-ee/content-api` depend on it.\n */\n\nimport { definePlugin, type PageFactory } from '@murumets-ee/core'\nimport { Menu } from '@murumets-ee/menus'\nimport { menusRoutes } from '@murumets-ee/menus/admin'\n\n// Lazy page factories — dynamic-import wrappers around the real RSC pages.\n//\n// Why: the plugin factory is evaluated at `lumi.config.ts` load time —\n// including when the CLI (`lumi migrate` / `seed` / `doctor`) and the worker\n// load the config through jiti / tsx. Neither applies Node's `react-server`\n// export condition, so any transitive `import 'server-only'` in the\n// page-module chain throws the moment it is loaded (`menus-ui/pages` pulls in\n// `@murumets-ee/admin-ui/server` → `@murumets-ee/core/clients`, which is\n// `server-only`-tagged to prevent client-component misuse).\n//\n// Wrapping the page captures as lazy thunks means the page modules only load\n// at render time — inside Next.js RSC, where `react-server` applies and\n// `server-only` resolves to the empty stub. The config-load chain stays clean\n// and the `server-only` protection on library mains stays in place.\nconst MenuListPage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/menus-ui/pages')\n return mod.MenuListPage(props)\n}\nconst MenuEditorPage: PageFactory<{ locale: string; id: string }> = async (props) => {\n const mod = await import('@murumets-ee/menus-ui/pages')\n return mod.MenuEditorPage(props)\n}\n\nexport function menus() {\n return definePlugin({\n name: '@murumets-ee/menus',\n // `menuTreeValidation()` validates each item's locale keys against the\n // content plugin's configured locales, so content() must be present. The\n // framework checks this BEFORE any init runs.\n requires: ['@murumets-ee/content'],\n server: {\n entities: [Menu],\n // `menusRoutes()` returns AdminRoute[] (one entry per menus prefix\n // produced by `combineAdminRoutes` of the per-endpoint declarations from\n // `defineAdminRoute`) — currently the block-anchor picker's\n // `GET /api/admin/menus/anchors`. Spread instead of nesting.\n routes: [...menusRoutes()],\n },\n // No `shared.pluginResources`: `menu:view/create/update/delete/publish` is\n // already auto-derived from the `Menu` entity (`entityActions()` in\n // `@murumets-ee/auth` appends `publish` for any `publishable()` entity), so\n // declaring the resource here would be a second, conflicting registration\n // of the same one.\n adminUi: {\n pages: {\n MenuListPage,\n MenuEditorPage,\n },\n sidebar: [\n {\n id: 'menu',\n group: 'Content',\n label: 'Menus',\n href: '/admin/menu',\n iconName: 'menu',\n // Deny-by-default nav: a role without `menu:view` never sees the\n // link (the page-level gate below is still the real defense).\n requires: 'menu:view',\n },\n ],\n // Each route's `page` spec is materialized into a `defineAdminPage`-\n // wrapped PageFactory inside `createAdminShell` (admin-ui's pages bundle,\n // RSC context). The wrapper registers the permission in the catalog with\n // `source: 'page'` and applies the 404-on-deny SSR gate before the lazy\n // inner thunk renders.\n //\n // Why declarative here vs. an inline `defineAdminPage(...)` call:\n // `defineAdminPage` lives at `@murumets-ee/admin-ui/pages`, which\n // transitively imports `server-only` — a static import at the top of this\n // file would throw under jiti when the CLI loads `lumi.config.ts`.\n //\n // `defaultRoles: ['admin']` matches `Menu.access` (create/update/delete\n // are `group.admin`-only): a menu is site-wide navigation, so a bad edit\n // shows on every page. Read access is aligned with write here rather than\n // opened wider — the entity's own posture, not a broader invention.\n //\n // `menu/[id]` uses Next.js bracket notation; `buildAdminRouteTable`\n // normalizes it to a `:id` capture and `AdminCatchAll` merges the\n // captured params with the outer `locale`, so `MenuEditorPage` receives\n // `params: Promise<{ locale: string; id: string }>`.\n defaultRoutes: [\n {\n path: 'menu',\n factory: 'MenuListPage',\n page: {\n requires: 'menu:view',\n defaultRoles: ['admin'],\n description: 'Manage navigation menus.',\n },\n },\n {\n path: 'menu/[id]',\n factory: 'MenuEditorPage',\n page: {\n requires: 'menu:view',\n defaultRoles: ['admin'],\n description: 'Edit a navigation menu.',\n },\n },\n ],\n },\n })\n}\n"],"mappings":"kJA4CA,MAAM,EAAgD,KAAO,KAEpD,MADW,OAAO,+BAAA,CACd,aAAa,CAAK,EAEzB,EAA8D,KAAO,KAElE,MADW,OAAO,+BAAA,CACd,eAAe,CAAK,EAGjC,SAAgB,GAAQ,CACtB,OAAO,EAAa,CAClB,KAAM,qBAIN,SAAU,CAAC,sBAAsB,EACjC,OAAQ,CACN,SAAU,CAAC,CAAI,EAKf,OAAQ,CAAC,GAAG,EAAY,CAAC,CAC3B,EAMA,QAAS,CACP,MAAO,CACL,eACA,gBACF,EACA,QAAS,CACP,CACE,GAAI,OACJ,MAAO,UACP,MAAO,QACP,KAAM,cACN,SAAU,OAGV,SAAU,WACZ,CACF,EAqBA,cAAe,CACb,CACE,KAAM,OACN,QAAS,eACT,KAAM,CACJ,SAAU,YACV,aAAc,CAAC,OAAO,EACtB,YAAa,0BACf,CACF,EACA,CACE,KAAM,YACN,QAAS,iBACT,KAAM,CACJ,SAAU,YACV,aAAc,CAAC,OAAO,EACtB,YAAa,yBACf,CACF,CACF,CACF,CACF,CAAC,CACH"}
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@murumets-ee/menus-ui",
|
|
3
|
+
"version": "0.37.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
9
|
+
"import": "./dist/index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"./pages": {
|
|
12
|
+
"types": "./dist/pages.d.mts",
|
|
13
|
+
"import": "./dist/pages.mjs"
|
|
14
|
+
},
|
|
15
|
+
"./plugin": {
|
|
16
|
+
"types": "./dist/plugin.d.mts",
|
|
17
|
+
"import": "./dist/plugin.mjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@dnd-kit/abstract": "^0.4.0",
|
|
25
|
+
"@dnd-kit/dom": "^0.4.0",
|
|
26
|
+
"@dnd-kit/helpers": "^0.4.0",
|
|
27
|
+
"@dnd-kit/react": "^0.4.0",
|
|
28
|
+
"@murumets-ee/admin-ui": "0.37.0",
|
|
29
|
+
"@murumets-ee/blocks": "0.37.0",
|
|
30
|
+
"@murumets-ee/content": "0.37.0",
|
|
31
|
+
"@murumets-ee/core": "0.37.0",
|
|
32
|
+
"@murumets-ee/entity": "0.37.0",
|
|
33
|
+
"@murumets-ee/menus": "0.37.0",
|
|
34
|
+
"@murumets-ee/ui": "0.37.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"lucide-react": ">=0.400.0",
|
|
38
|
+
"next": ">=15.0.0",
|
|
39
|
+
"next-intl": ">=4.0.0",
|
|
40
|
+
"react": ">=19.0.0",
|
|
41
|
+
"react-dom": ">=19.0.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"next": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"next-intl": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@testing-library/react": "^16.3.2",
|
|
53
|
+
"@types/react": "^19",
|
|
54
|
+
"@types/react-dom": "^19",
|
|
55
|
+
"lucide-react": "^0.577.0",
|
|
56
|
+
"next": "16.2.9",
|
|
57
|
+
"next-intl": "^4.13.0",
|
|
58
|
+
"react": "19.2.7",
|
|
59
|
+
"react-dom": "19.2.7",
|
|
60
|
+
"tsdown": "^0.22.2",
|
|
61
|
+
"typescript": "^5.7.3",
|
|
62
|
+
"vitest": "^2.1.8",
|
|
63
|
+
"@murumets-ee/admin-ui": "0.37.0",
|
|
64
|
+
"@murumets-ee/menus": "0.37.0",
|
|
65
|
+
"@murumets-ee/ui": "0.37.0"
|
|
66
|
+
},
|
|
67
|
+
"typeCoverage": {
|
|
68
|
+
"atLeast": 100
|
|
69
|
+
},
|
|
70
|
+
"bundleBudget": {
|
|
71
|
+
"dist/index.mjs": 32768,
|
|
72
|
+
"dist/pages.mjs": 8192,
|
|
73
|
+
"dist/plugin.mjs": 5120
|
|
74
|
+
},
|
|
75
|
+
"scripts": {
|
|
76
|
+
"build": "tsdown",
|
|
77
|
+
"dev": "tsdown --watch",
|
|
78
|
+
"test": "vitest run",
|
|
79
|
+
"test:watch": "vitest"
|
|
80
|
+
}
|
|
81
|
+
}
|