@maykonpaulo/maestro-admin 0.1.2 → 0.2.0-next.1

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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/components/MaestroAdmin.tsx
2
- import { useMemo as useMemo2, useState as useState4 } from "react";
2
+ import { useMemo as useMemo3, useState as useState9 } from "react";
3
3
 
4
4
  // src/client/MaestroClient.ts
5
5
  var MaestroApiError = class extends Error {
@@ -21,6 +21,9 @@ function buildListParams(query = {}) {
21
21
  if (query.searchFields?.length) params.set("searchFields", query.searchFields.join(","));
22
22
  }
23
23
  for (const s of query.sort ?? []) params.append("sort", `${s.field}:${s.direction}`);
24
+ for (const f of query.filters ?? []) {
25
+ params.append("filter", f.value === void 0 ? `${f.field}:${f.operator}` : `${f.field}:${f.operator}:${f.value}`);
26
+ }
24
27
  const qs = params.toString();
25
28
  return qs ? `?${qs}` : "";
26
29
  }
@@ -98,6 +101,15 @@ function useClient() {
98
101
  }
99
102
  return client;
100
103
  }
104
+ var EntitiesContext = createContext([]);
105
+ var AdminEntitiesProvider = EntitiesContext.Provider;
106
+ function useEntities() {
107
+ return useContext(EntitiesContext);
108
+ }
109
+ function useEntity(entityId) {
110
+ const entities = useEntities();
111
+ return entityId ? entities.find((e) => e.id === entityId) : void 0;
112
+ }
101
113
 
102
114
  // src/hooks.ts
103
115
  import { useCallback, useEffect, useState } from "react";
@@ -140,6 +152,45 @@ function useEntityRecord(entityId, id) {
140
152
  function listFields(entity) {
141
153
  return entity.fields.filter((f) => f.list.visible && !f.hidden).sort((a, b) => (a.list.order ?? 0) - (b.list.order ?? 0));
142
154
  }
155
+ var LIST_EXCLUDED_TYPES = /* @__PURE__ */ new Set(["json", "array", "text"]);
156
+ function listColumnRank(field) {
157
+ switch (field.type) {
158
+ case "string":
159
+ case "email":
160
+ case "document":
161
+ case "phone":
162
+ return 1;
163
+ case "enum":
164
+ return 2;
165
+ case "relation":
166
+ return 3;
167
+ case "boolean":
168
+ return 4;
169
+ case "date":
170
+ case "datetime":
171
+ case "time":
172
+ return 5;
173
+ case "uuid":
174
+ return 7;
175
+ case "url":
176
+ return 8;
177
+ default:
178
+ return 6;
179
+ }
180
+ }
181
+ function primaryListFields(entity, max = 8) {
182
+ const all = listFields(entity);
183
+ const curated = all.some((f) => f.list.order !== void 0 || f.list.width !== void 0);
184
+ if (curated || all.length <= max) return all;
185
+ const scored = all.filter((f) => !f.sensitive && f.name !== entity.primaryKey && !LIST_EXCLUDED_TYPES.has(f.type)).map((field, index) => {
186
+ let score = listColumnRank(field) * 10 + index * 0.01;
187
+ if (field.searchable) score -= 5;
188
+ if (field.required) score -= 2;
189
+ if (field.name === entity.displayField) score = -100;
190
+ return { field, score };
191
+ }).sort((a, b) => a.score - b.score);
192
+ return scored.slice(0, max).map((s) => s.field);
193
+ }
143
194
  function detailFields(entity) {
144
195
  return entity.fields.filter((f) => f.detail.visible).sort((a, b) => (a.detail.order ?? 0) - (b.detail.order ?? 0));
145
196
  }
@@ -148,33 +199,303 @@ function formFields(entity, mode) {
148
199
  }
149
200
 
150
201
  // src/components/Sidebar.tsx
151
- import { jsx, jsxs } from "react/jsx-runtime";
202
+ import { useEffect as useEffect2, useMemo, useRef, useState as useState2 } from "react";
203
+
204
+ // src/grouping.ts
205
+ var MIN_PREFIX = 4;
206
+ function commonPrefix(a, b) {
207
+ let i = 0;
208
+ while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
209
+ return a.slice(0, i);
210
+ }
211
+ function humanize(prefix) {
212
+ return prefix.charAt(0).toUpperCase() + prefix.slice(1);
213
+ }
214
+ function buildMenu(entities, labels) {
215
+ const byGroup = /* @__PURE__ */ new Map();
216
+ const rest = [];
217
+ for (const entity of entities) {
218
+ const override = labels?.entities?.[entity.id] ?? labels?.entities?.[entity.table];
219
+ if (override?.group) {
220
+ const existing = byGroup.get(override.group);
221
+ if (existing) existing.entities.push(entity);
222
+ else byGroup.set(override.group, { id: override.group, label: override.group, entities: [entity] });
223
+ } else {
224
+ rest.push(entity);
225
+ }
226
+ }
227
+ const sorted = [...rest].sort((a, b) => a.table < b.table ? -1 : a.table > b.table ? 1 : 0);
228
+ const ungrouped = [];
229
+ let i = 0;
230
+ while (i < sorted.length) {
231
+ let prefix = i + 1 < sorted.length ? commonPrefix(sorted[i].table, sorted[i + 1].table) : "";
232
+ if (prefix.length < MIN_PREFIX) {
233
+ ungrouped.push(sorted[i]);
234
+ i += 1;
235
+ continue;
236
+ }
237
+ let end = i + 1;
238
+ while (end + 1 < sorted.length) {
239
+ const next = commonPrefix(prefix, sorted[end + 1].table);
240
+ if (next.length < MIN_PREFIX) break;
241
+ prefix = next;
242
+ end += 1;
243
+ }
244
+ const members = sorted.slice(i, end + 1);
245
+ const label = labels?.groups?.[prefix] ?? humanize(prefix);
246
+ const existing = byGroup.get(prefix);
247
+ if (existing) existing.entities.push(...members);
248
+ else byGroup.set(prefix, { id: prefix, label, entities: members });
249
+ i = end + 1;
250
+ }
251
+ const collator = (a, b) => a.localeCompare(b);
252
+ const groups = [...byGroup.values()].sort((a, b) => collator(a.label, b.label));
253
+ for (const group of groups) {
254
+ group.entities.sort((a, b) => collator(a.label.plural, b.label.plural));
255
+ }
256
+ ungrouped.sort((a, b) => collator(a.label.plural, b.label.plural));
257
+ return { groups, ungrouped };
258
+ }
259
+
260
+ // src/i18n.ts
261
+ import { createContext as createContext2, useContext as useContext2 } from "react";
262
+ var EN = {
263
+ loading: "Loading\u2026",
264
+ loadingMetadata: "Loading metadata\u2026",
265
+ records: (total) => `${total} record${total === 1 ? "" : "s"}`,
266
+ searchPlaceholder: (plural) => `Search ${plural.toLowerCase()}\u2026`,
267
+ newRecord: "+ New",
268
+ view: "View",
269
+ edit: "Edit",
270
+ clone: "Clone",
271
+ remove: "Remove",
272
+ copyId: "Copy ID",
273
+ copyObject: "Copy object",
274
+ copyValue: "Copy value",
275
+ copied: "Copied!",
276
+ addToFilter: "Add to filter",
277
+ openRelated: (label) => `Open ${label}`,
278
+ filters: "Filters",
279
+ clearFilters: "Clear filters",
280
+ actions: "Actions",
281
+ noRecords: "No records",
282
+ tryDifferentSearch: "Try a different search.",
283
+ deleteConfirm: (singular) => `Delete this ${singular.toLowerCase()}? This cannot be undone.`,
284
+ page: (page, total) => `Page ${page} of ${total}`,
285
+ previous: "Previous",
286
+ next: "Next",
287
+ cancel: "Cancel",
288
+ save: "Save",
289
+ saving: "Saving\u2026",
290
+ create: "Create",
291
+ newTitle: (singular) => `New ${singular}`,
292
+ editTitle: (singular) => `Edit ${singular}`,
293
+ cloneTitle: (singular) => `Clone ${singular}`,
294
+ entitiesFooter: (count) => `${count} entities \xB7 Maestro`,
295
+ yes: "true",
296
+ no: "false",
297
+ searchMenu: "Search menu\u2026",
298
+ recents: "Recents",
299
+ menus: "Menus",
300
+ noMenuResults: "No matches"
301
+ };
302
+ var PT_BR = {
303
+ loading: "Carregando\u2026",
304
+ loadingMetadata: "Carregando metadados\u2026",
305
+ records: (total) => `${total} registro${total === 1 ? "" : "s"}`,
306
+ searchPlaceholder: (plural) => `Buscar ${plural.toLowerCase()}\u2026`,
307
+ newRecord: "+ Novo",
308
+ view: "Visualizar",
309
+ edit: "Editar",
310
+ clone: "Clonar",
311
+ remove: "Remover",
312
+ copyId: "Copiar ID",
313
+ copyObject: "Copiar objeto",
314
+ copyValue: "Copiar valor",
315
+ copied: "Copiado!",
316
+ addToFilter: "Adicionar ao filtro",
317
+ openRelated: (label) => `Abrir ${label}`,
318
+ filters: "Filtros",
319
+ clearFilters: "Limpar filtros",
320
+ actions: "A\xE7\xF5es",
321
+ noRecords: "Nenhum registro",
322
+ tryDifferentSearch: "Tente uma busca diferente.",
323
+ deleteConfirm: (singular) => `Remover este registro de ${singular}? Essa a\xE7\xE3o n\xE3o pode ser desfeita.`,
324
+ page: (page, total) => `P\xE1gina ${page} de ${total}`,
325
+ previous: "Anterior",
326
+ next: "Pr\xF3xima",
327
+ cancel: "Cancelar",
328
+ save: "Salvar",
329
+ saving: "Salvando\u2026",
330
+ create: "Criar",
331
+ newTitle: (singular) => `Novo(a) ${singular}`,
332
+ editTitle: (singular) => `Editar ${singular}`,
333
+ cloneTitle: (singular) => `Clonar ${singular}`,
334
+ entitiesFooter: (count) => `${count} entidades \xB7 Maestro`,
335
+ yes: "sim",
336
+ no: "n\xE3o",
337
+ searchMenu: "Buscar no menu\u2026",
338
+ recents: "Recentes",
339
+ menus: "Menus",
340
+ noMenuResults: "Nada encontrado"
341
+ };
342
+ var LOCALES = { en: EN, "pt-BR": PT_BR };
343
+ function stringsFor(locale) {
344
+ return LOCALES[locale] ?? EN;
345
+ }
346
+ var I18nContext = createContext2(EN);
347
+ var I18nProvider = I18nContext.Provider;
348
+ function useT() {
349
+ return useContext2(I18nContext);
350
+ }
351
+
352
+ // src/components/Sidebar.tsx
353
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
354
+ function normalize(text) {
355
+ return text.toLowerCase().normalize("NFD").replace(/\p{M}/gu, "");
356
+ }
357
+ function Highlight({ label, query }) {
358
+ const foldedQuery = normalize(query);
359
+ if (!foldedQuery) return label;
360
+ const folded = [];
361
+ const originalIndex = [];
362
+ for (let i = 0; i < label.length; i += 1) {
363
+ for (const ch of normalize(label[i])) {
364
+ folded.push(ch);
365
+ originalIndex.push(i);
366
+ }
367
+ }
368
+ const idx = folded.join("").indexOf(foldedQuery);
369
+ if (idx === -1) return label;
370
+ const start = originalIndex[idx];
371
+ const end = (originalIndex[idx + foldedQuery.length - 1] ?? label.length - 1) + 1;
372
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
373
+ label.slice(0, start),
374
+ /* @__PURE__ */ jsx("mark", { className: "rounded-sm bg-amber-200 text-inherit", children: label.slice(start, end) }),
375
+ label.slice(end)
376
+ ] });
377
+ }
378
+ function EntityButton({
379
+ entity,
380
+ active,
381
+ onSelect,
382
+ subtitle,
383
+ query
384
+ }) {
385
+ return /* @__PURE__ */ jsxs(
386
+ "button",
387
+ {
388
+ onClick: () => onSelect(entity.id),
389
+ className: `flex w-full flex-col rounded-md px-3 py-1.5 text-left text-sm transition-colors ${active ? "bg-indigo-100 font-medium text-indigo-700" : "text-slate-700 hover:bg-slate-100"}`,
390
+ children: [
391
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: query ? /* @__PURE__ */ jsx(Highlight, { label: entity.label.plural, query }) : entity.label.plural }),
392
+ subtitle && /* @__PURE__ */ jsx("span", { className: "truncate text-xs font-normal text-slate-400", children: subtitle })
393
+ ]
394
+ }
395
+ );
396
+ }
397
+ function SectionTitle({ children }) {
398
+ return /* @__PURE__ */ jsx("div", { className: "px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-slate-400", children });
399
+ }
152
400
  function Sidebar({
153
401
  entities,
154
402
  activeId,
155
403
  onSelect,
156
- title
404
+ title,
405
+ labels,
406
+ recents = []
157
407
  }) {
158
- return /* @__PURE__ */ jsxs("aside", { className: "flex w-60 shrink-0 flex-col border-r border-slate-200 bg-slate-50", children: [
408
+ const t = useT();
409
+ const [query, setQuery] = useState2("");
410
+ const [expanded, setExpanded] = useState2({});
411
+ const searchRef = useRef(null);
412
+ const menu = useMemo(() => buildMenu(entities, labels), [entities, labels]);
413
+ const groupOf = useMemo(() => {
414
+ const map = /* @__PURE__ */ new Map();
415
+ for (const group of menu.groups) for (const e of group.entities) map.set(e.id, group.label);
416
+ return map;
417
+ }, [menu]);
418
+ const recentEntities = recents.map((id) => entities.find((e) => e.id === id)).filter((e) => Boolean(e)).slice(0, 6);
419
+ const results = useMemo(() => {
420
+ if (!query) return [];
421
+ const q = normalize(query);
422
+ return entities.filter((e) => normalize(`${e.label.plural} ${e.label.singular} ${e.table}`).includes(q)).sort((a, b) => a.label.plural.localeCompare(b.label.plural));
423
+ }, [entities, query]);
424
+ useEffect2(() => {
425
+ const onKey = (e) => {
426
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
427
+ e.preventDefault();
428
+ searchRef.current?.focus();
429
+ }
430
+ };
431
+ document.addEventListener("keydown", onKey);
432
+ return () => document.removeEventListener("keydown", onKey);
433
+ }, []);
434
+ const select = (entityId) => {
435
+ setQuery("");
436
+ onSelect(entityId);
437
+ };
438
+ const isExpanded = (groupId, hasActive) => expanded[groupId] ?? hasActive;
439
+ return /* @__PURE__ */ jsxs("aside", { className: "flex w-64 shrink-0 flex-col border-r border-slate-200 bg-slate-50", children: [
159
440
  /* @__PURE__ */ jsx("div", { className: "border-b border-slate-200 px-4 py-4", children: /* @__PURE__ */ jsx("span", { className: "text-sm font-semibold tracking-tight text-slate-900", children: title }) }),
160
- /* @__PURE__ */ jsx("nav", { className: "flex-1 overflow-y-auto p-2", children: entities.map((e) => /* @__PURE__ */ jsx(
161
- "button",
441
+ /* @__PURE__ */ jsx("div", { className: "px-2 pt-2", children: /* @__PURE__ */ jsx(
442
+ "input",
162
443
  {
163
- onClick: () => onSelect(e.id),
164
- className: `flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${e.id === activeId ? "bg-indigo-100 font-medium text-indigo-700" : "text-slate-700 hover:bg-slate-100"}`,
165
- children: /* @__PURE__ */ jsx("span", { className: "truncate", children: e.label.plural })
444
+ ref: searchRef,
445
+ type: "search",
446
+ value: query,
447
+ onChange: (e) => setQuery(e.target.value),
448
+ onKeyDown: (e) => {
449
+ if (e.key === "Escape") setQuery("");
450
+ if (e.key === "Enter" && results[0]) select(results[0].id);
451
+ },
452
+ placeholder: `${t.searchMenu} (Ctrl+K)`,
453
+ className: "w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
454
+ }
455
+ ) }),
456
+ /* @__PURE__ */ jsx("nav", { className: "flex-1 overflow-y-auto p-2", children: query ? results.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-3 py-2 text-sm text-slate-400", children: t.noMenuResults }) : results.map((e) => /* @__PURE__ */ jsx(
457
+ EntityButton,
458
+ {
459
+ entity: e,
460
+ active: e.id === activeId,
461
+ onSelect: select,
462
+ subtitle: groupOf.get(e.id),
463
+ query
166
464
  },
167
465
  e.id
168
- )) }),
169
- /* @__PURE__ */ jsxs("div", { className: "border-t border-slate-200 px-4 py-3 text-xs text-slate-400", children: [
170
- entities.length,
171
- " entities \xB7 Maestro"
172
- ] })
466
+ )) : /* @__PURE__ */ jsxs(Fragment, { children: [
467
+ recentEntities.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
468
+ /* @__PURE__ */ jsx(SectionTitle, { children: t.recents }),
469
+ recentEntities.map((e) => /* @__PURE__ */ jsx(EntityButton, { entity: e, active: e.id === activeId, onSelect: select }, e.id))
470
+ ] }),
471
+ /* @__PURE__ */ jsx(SectionTitle, { children: t.menus }),
472
+ menu.groups.map((group) => {
473
+ const hasActive = group.entities.some((e) => e.id === activeId);
474
+ const open = isExpanded(group.id, hasActive);
475
+ return /* @__PURE__ */ jsxs("div", { children: [
476
+ /* @__PURE__ */ jsxs(
477
+ "button",
478
+ {
479
+ onClick: () => setExpanded((prev) => ({ ...prev, [group.id]: !open })),
480
+ className: "flex w-full items-center gap-1.5 rounded-md px-3 py-1.5 text-left text-sm font-medium text-slate-600 hover:bg-slate-100",
481
+ children: [
482
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-slate-400", children: open ? "\u25BE" : "\u25B8" }),
483
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: group.label }),
484
+ /* @__PURE__ */ jsx("span", { className: "ml-auto text-xs text-slate-400", children: group.entities.length })
485
+ ]
486
+ }
487
+ ),
488
+ open && /* @__PURE__ */ jsx("div", { className: "ml-3 border-l border-slate-200 pl-1", children: group.entities.map((e) => /* @__PURE__ */ jsx(EntityButton, { entity: e, active: e.id === activeId, onSelect: select }, e.id)) })
489
+ ] }, group.id);
490
+ }),
491
+ menu.ungrouped.map((e) => /* @__PURE__ */ jsx(EntityButton, { entity: e, active: e.id === activeId, onSelect: select }, e.id))
492
+ ] }) }),
493
+ /* @__PURE__ */ jsx("div", { className: "border-t border-slate-200 px-4 py-3 text-xs text-slate-400", children: t.entitiesFooter(entities.length) })
173
494
  ] });
174
495
  }
175
496
 
176
497
  // src/components/EntityList.tsx
177
- import { useMemo, useState as useState2 } from "react";
498
+ import { useMemo as useMemo2, useState as useState5 } from "react";
178
499
 
179
500
  // src/components/ui.tsx
180
501
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -223,57 +544,235 @@ function ErrorBanner({ error }) {
223
544
  return /* @__PURE__ */ jsx2("div", { className: "rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700", role: "alert", children: error.message });
224
545
  }
225
546
 
547
+ // src/components/RelationValue.tsx
548
+ import { useEffect as useEffect3, useState as useState3 } from "react";
549
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
550
+ var displayCache = /* @__PURE__ */ new Map();
551
+ function resolveDisplay(client, entityId, displayField, id) {
552
+ const key = `${entityId}/${id}`;
553
+ let cached = displayCache.get(key);
554
+ if (!cached) {
555
+ cached = client.findById(entityId, id).then((record) => {
556
+ const value = record[displayField];
557
+ return value === null || value === void 0 || value === "" ? void 0 : String(value);
558
+ }).catch(() => void 0);
559
+ displayCache.set(key, cached);
560
+ }
561
+ return cached;
562
+ }
563
+ function shortId(id) {
564
+ return id.length > 12 ? `${id.slice(0, 6)}\u2026${id.slice(-4)}` : id;
565
+ }
566
+ function RelationValue({ field, value }) {
567
+ const client = useClient();
568
+ const target = useEntity(field.relationEntity);
569
+ const id = String(value);
570
+ const [display, setDisplay] = useState3(void 0);
571
+ useEffect3(() => {
572
+ let cancelled = false;
573
+ setDisplay(void 0);
574
+ if (!target || target.displayField === target.primaryKey) return;
575
+ resolveDisplay(client, target.id, target.displayField, id).then((resolved) => {
576
+ if (!cancelled) setDisplay(resolved);
577
+ });
578
+ return () => {
579
+ cancelled = true;
580
+ };
581
+ }, [client, target, id]);
582
+ return /* @__PURE__ */ jsxs3(
583
+ "span",
584
+ {
585
+ title: id,
586
+ className: "inline-flex max-w-56 items-center gap-1 truncate rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-700",
587
+ children: [
588
+ target && /* @__PURE__ */ jsxs3("span", { className: "text-slate-400", children: [
589
+ target.label.singular,
590
+ ":"
591
+ ] }),
592
+ /* @__PURE__ */ jsx3("span", { className: "truncate", children: display ?? shortId(id) })
593
+ ]
594
+ }
595
+ );
596
+ }
597
+
226
598
  // src/components/FieldValue.tsx
227
- import { jsx as jsx3 } from "react/jsx-runtime";
599
+ import { jsx as jsx4 } from "react/jsx-runtime";
228
600
  function enumLabel(field, value) {
229
601
  const opt = field.enumOptions?.find((o) => o.value === value);
230
602
  return opt ? opt.label : String(value);
231
603
  }
232
- function FieldValue({ field, value }) {
604
+ function FieldValue({ field, value, compact = false }) {
605
+ const t = useT();
233
606
  if (value === null || value === void 0 || value === "") {
234
- return /* @__PURE__ */ jsx3("span", { className: "text-slate-400", children: "\u2014" });
607
+ return /* @__PURE__ */ jsx4("span", { className: "text-slate-400", children: "\u2014" });
235
608
  }
236
609
  if (field.sensitive) {
237
- return /* @__PURE__ */ jsx3("span", { className: "text-slate-400", children: "\u2022\u2022\u2022\u2022\u2022\u2022" });
610
+ return /* @__PURE__ */ jsx4("span", { className: "text-slate-400", children: "\u2022\u2022\u2022\u2022\u2022\u2022" });
238
611
  }
239
612
  switch (field.type) {
240
613
  case "boolean":
241
- return value ? /* @__PURE__ */ jsx3(Badge, { tone: "green", children: "true" }) : /* @__PURE__ */ jsx3(Badge, { tone: "slate", children: "false" });
614
+ return value ? /* @__PURE__ */ jsx4(Badge, { tone: "green", children: t.yes }) : /* @__PURE__ */ jsx4(Badge, { tone: "slate", children: t.no });
242
615
  case "enum":
243
- return /* @__PURE__ */ jsx3(Badge, { tone: "indigo", children: enumLabel(field, value) });
616
+ return /* @__PURE__ */ jsx4(Badge, { tone: "indigo", children: enumLabel(field, value) });
617
+ case "relation":
618
+ return field.relationEntity ? /* @__PURE__ */ jsx4(RelationValue, { field, value }) : /* @__PURE__ */ jsx4("span", { className: "text-slate-700", children: String(value) });
244
619
  case "date":
245
620
  case "datetime": {
246
621
  const d = new Date(String(value));
247
- return /* @__PURE__ */ jsx3("span", { children: Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString() });
622
+ return /* @__PURE__ */ jsx4("span", { className: "whitespace-nowrap", children: Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString() });
248
623
  }
249
624
  case "json":
250
- return /* @__PURE__ */ jsx3("code", { className: "block max-w-md overflow-x-auto rounded bg-slate-50 px-1.5 py-0.5 text-xs text-slate-700", children: JSON.stringify(value) });
625
+ return /* @__PURE__ */ jsx4("code", { className: `block overflow-x-auto rounded bg-slate-50 px-1.5 py-0.5 text-xs text-slate-700 ${compact ? "max-w-xs truncate" : "max-w-md"}`, children: JSON.stringify(value) });
251
626
  default:
252
- return /* @__PURE__ */ jsx3("span", { className: "text-slate-700", children: String(value) });
627
+ return /* @__PURE__ */ jsx4("span", { className: `text-slate-700 ${compact ? "block max-w-64 truncate" : ""}`, children: String(value) });
253
628
  }
254
629
  }
255
630
 
631
+ // src/components/menu.tsx
632
+ import { useEffect as useEffect4, useLayoutEffect, useRef as useRef2, useState as useState4 } from "react";
633
+ import { createPortal } from "react-dom";
634
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
635
+ function PopMenu({ anchor, items, onClose }) {
636
+ const ref = useRef2(null);
637
+ const [pos, setPos] = useState4(anchor);
638
+ useLayoutEffect(() => {
639
+ const el = ref.current;
640
+ if (!el) return;
641
+ const rect = el.getBoundingClientRect();
642
+ setPos({
643
+ x: Math.max(4, Math.min(anchor.x, window.innerWidth - rect.width - 8)),
644
+ y: Math.max(4, Math.min(anchor.y, window.innerHeight - rect.height - 8))
645
+ });
646
+ }, [anchor]);
647
+ useEffect4(() => {
648
+ const onDown = (e) => {
649
+ if (ref.current && !ref.current.contains(e.target)) onClose();
650
+ };
651
+ const onKey = (e) => {
652
+ if (e.key === "Escape") onClose();
653
+ };
654
+ document.addEventListener("mousedown", onDown);
655
+ document.addEventListener("keydown", onKey);
656
+ window.addEventListener("scroll", onClose, true);
657
+ window.addEventListener("resize", onClose);
658
+ return () => {
659
+ document.removeEventListener("mousedown", onDown);
660
+ document.removeEventListener("keydown", onKey);
661
+ window.removeEventListener("scroll", onClose, true);
662
+ window.removeEventListener("resize", onClose);
663
+ };
664
+ }, [onClose]);
665
+ return createPortal(
666
+ /* @__PURE__ */ jsx5(
667
+ "div",
668
+ {
669
+ ref,
670
+ role: "menu",
671
+ style: { position: "fixed", left: pos.x, top: pos.y, zIndex: 50 },
672
+ className: "min-w-44 rounded-md border border-slate-200 bg-white py-1 shadow-lg",
673
+ children: items.map((item) => /* @__PURE__ */ jsx5(
674
+ "button",
675
+ {
676
+ role: "menuitem",
677
+ onClick: () => {
678
+ onClose();
679
+ item.onSelect();
680
+ },
681
+ className: `block w-full px-3 py-1.5 text-left text-sm ${item.danger ? "text-red-600 hover:bg-red-50" : "text-slate-700 hover:bg-slate-100"}`,
682
+ children: item.label
683
+ },
684
+ item.id
685
+ ))
686
+ }
687
+ ),
688
+ document.body
689
+ );
690
+ }
691
+ function MenuButton({ items, label }) {
692
+ const [anchor, setAnchor] = useState4(void 0);
693
+ return /* @__PURE__ */ jsxs4(Fragment2, { children: [
694
+ /* @__PURE__ */ jsx5(
695
+ "button",
696
+ {
697
+ type: "button",
698
+ "aria-label": label,
699
+ title: label,
700
+ onClick: (e) => {
701
+ const rect = e.currentTarget.getBoundingClientRect();
702
+ setAnchor({ x: rect.right - 176, y: rect.bottom + 4 });
703
+ },
704
+ className: "rounded-md px-2 py-1 text-slate-500 hover:bg-slate-100 hover:text-slate-900",
705
+ children: "\u22EE"
706
+ }
707
+ ),
708
+ anchor && /* @__PURE__ */ jsx5(PopMenu, { anchor, items, onClose: () => setAnchor(void 0) })
709
+ ] });
710
+ }
711
+
712
+ // src/clipboard.ts
713
+ async function copyText(text) {
714
+ if (navigator.clipboard?.writeText) {
715
+ try {
716
+ await navigator.clipboard.writeText(text);
717
+ return;
718
+ } catch {
719
+ }
720
+ }
721
+ const textarea = document.createElement("textarea");
722
+ textarea.value = text;
723
+ textarea.style.position = "fixed";
724
+ textarea.style.opacity = "0";
725
+ document.body.appendChild(textarea);
726
+ textarea.select();
727
+ document.execCommand("copy");
728
+ textarea.remove();
729
+ }
730
+
256
731
  // src/components/EntityList.tsx
257
- import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
732
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
258
733
  var PAGE_SIZE = 20;
734
+ function filterFor(field, value) {
735
+ if (value === null || value === void 0) return { field: field.name, operator: "isNull" };
736
+ if (field.type === "boolean") return { field: field.name, operator: value ? "isTrue" : "isFalse" };
737
+ if (field.type === "json" || field.type === "array") return void 0;
738
+ return { field: field.name, operator: "equals", value: String(value) };
739
+ }
740
+ function filterChipText(entity, filter) {
741
+ const label = entity.fields.find((f) => f.name === filter.field)?.label ?? filter.field;
742
+ if (filter.operator === "isNull") return `${label} = \u2205`;
743
+ if (filter.operator === "isTrue") return `${label} = \u2713`;
744
+ if (filter.operator === "isFalse") return `${label} = \u2717`;
745
+ const value = filter.value ?? "";
746
+ return `${label} = ${value.length > 24 ? `${value.slice(0, 24)}\u2026` : value}`;
747
+ }
259
748
  function EntityList({
260
749
  entity,
261
750
  onOpen,
262
751
  onCreate,
263
- onEdit
752
+ onEdit,
753
+ onClone,
754
+ onNavigate,
755
+ initialFilters
264
756
  }) {
265
757
  const client = useClient();
266
- const columns = useMemo(() => listFields(entity), [entity]);
267
- const searchFields = useMemo(() => entity.fields.filter((f) => f.searchable).map((f) => f.name), [entity]);
268
- const [page, setPage] = useState2(1);
269
- const [search, setSearch] = useState2("");
270
- const [sort, setSort] = useState2(void 0);
758
+ const t = useT();
759
+ const entities = useEntities();
760
+ const relatedLabel = (entityId) => entities.find((e) => e.id === entityId)?.label.singular ?? entityId;
761
+ const columns = useMemo2(() => primaryListFields(entity), [entity]);
762
+ const searchFields = useMemo2(() => entity.fields.filter((f) => f.searchable).map((f) => f.name), [entity]);
763
+ const [page, setPage] = useState5(1);
764
+ const [search, setSearch] = useState5("");
765
+ const [sort, setSort] = useState5(void 0);
766
+ const [filters, setFilters] = useState5(initialFilters ?? []);
767
+ const [cellMenu, setCellMenu] = useState5(void 0);
768
+ const [copied, setCopied] = useState5(false);
271
769
  const query = {
272
770
  page,
273
771
  pageSize: PAGE_SIZE,
274
772
  search: search || void 0,
275
773
  searchFields,
276
- sort: sort ? [sort] : void 0
774
+ sort: sort ? [sort] : void 0,
775
+ filters: filters.length ? filters : void 0
277
776
  };
278
777
  const { data, error, loading, reload } = useEntityList(entity.id, query);
279
778
  const toggleSort = (fieldName) => {
@@ -281,27 +780,81 @@ function EntityList({
281
780
  (prev) => prev?.field === fieldName ? { field: fieldName, direction: prev.direction === "asc" ? "desc" : "asc" } : { field: fieldName, direction: "asc" }
282
781
  );
283
782
  };
783
+ const flashCopied = () => {
784
+ setCopied(true);
785
+ setTimeout(() => setCopied(false), 1500);
786
+ };
787
+ const copy = async (text) => {
788
+ await copyText(text);
789
+ flashCopied();
790
+ };
791
+ const addFilter = (filter) => {
792
+ setFilters((prev) => {
793
+ const others = prev.filter((f) => f.field !== filter.field);
794
+ return [...others, filter];
795
+ });
796
+ setPage(1);
797
+ };
284
798
  const onDelete = async (id) => {
285
- if (!globalThis.confirm(`Delete this ${entity.label.singular.toLowerCase()}? This cannot be undone.`)) return;
799
+ if (!globalThis.confirm(t.deleteConfirm(entity.label.singular))) return;
286
800
  await client.remove(entity.id, id);
287
801
  reload();
288
802
  };
803
+ const rowMenuItems = (record) => {
804
+ const id = String(record[entity.primaryKey]);
805
+ const items = [{ id: "view", label: `\u{1F441} ${t.view}`, onSelect: () => onOpen(id) }];
806
+ if (entity.capabilities.update) items.push({ id: "edit", label: `\u270F\uFE0F ${t.edit}`, onSelect: () => onEdit(id) });
807
+ if (entity.capabilities.clone && entity.capabilities.create) {
808
+ items.push({ id: "clone", label: `\u29C9 ${t.clone}`, onSelect: () => onClone(id) });
809
+ }
810
+ items.push(
811
+ { id: "copy-id", label: `\u2398 ${t.copyId}`, onSelect: () => void copy(id) },
812
+ { id: "copy-object", label: `{} ${t.copyObject}`, onSelect: () => void copy(JSON.stringify(record, null, 2)) }
813
+ );
814
+ if (entity.capabilities.delete) {
815
+ items.push({ id: "remove", label: `\u{1F5D1} ${t.remove}`, danger: true, onSelect: () => void onDelete(id) });
816
+ }
817
+ return items;
818
+ };
819
+ const cellMenuItems = (state) => {
820
+ const { field, record } = state;
821
+ const id = String(record[entity.primaryKey]);
822
+ const value = record[field.name];
823
+ const items = [];
824
+ const filter = filterFor(field, value);
825
+ if (filter) items.push({ id: "filter", label: `\u25BC ${t.addToFilter}`, onSelect: () => addFilter(filter) });
826
+ items.push({ id: "view", label: `\u{1F441} ${t.view}`, onSelect: () => onOpen(id) });
827
+ if (entity.capabilities.update) items.push({ id: "edit", label: `\u270F\uFE0F ${t.edit}`, onSelect: () => onEdit(id) });
828
+ if (field.type === "relation" && field.relationEntity && value !== null && value !== void 0) {
829
+ const targetId = field.relationEntity;
830
+ items.push({
831
+ id: "open-related",
832
+ label: `\u2197 ${t.openRelated(relatedLabel(targetId))}`,
833
+ onSelect: () => onNavigate(targetId, String(value))
834
+ });
835
+ }
836
+ if (value !== null && value !== void 0) {
837
+ items.push({
838
+ id: "copy-value",
839
+ label: `\u2398 ${t.copyValue}`,
840
+ onSelect: () => void copy(typeof value === "object" ? JSON.stringify(value) : String(value))
841
+ });
842
+ }
843
+ return items;
844
+ };
289
845
  const totalPages = data?.totalPages ?? (data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1);
290
- return /* @__PURE__ */ jsxs3("section", { className: "space-y-4", children: [
291
- /* @__PURE__ */ jsxs3("header", { className: "flex flex-wrap items-center justify-between gap-3", children: [
292
- /* @__PURE__ */ jsxs3("div", { children: [
293
- /* @__PURE__ */ jsx4("h1", { className: "text-lg font-semibold text-slate-900", children: entity.label.plural }),
294
- data && /* @__PURE__ */ jsxs3("p", { className: "text-sm text-slate-500", children: [
295
- data.total,
296
- " records"
297
- ] })
846
+ return /* @__PURE__ */ jsxs5("section", { className: "space-y-4", children: [
847
+ /* @__PURE__ */ jsxs5("header", { className: "flex flex-wrap items-center justify-between gap-3", children: [
848
+ /* @__PURE__ */ jsxs5("div", { children: [
849
+ /* @__PURE__ */ jsx6("h1", { className: "text-lg font-semibold text-slate-900", children: entity.label.plural }),
850
+ data && /* @__PURE__ */ jsx6("p", { className: "text-sm text-slate-500", children: t.records(data.total) })
298
851
  ] }),
299
- /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-2", children: [
300
- searchFields.length > 0 && /* @__PURE__ */ jsx4(
852
+ /* @__PURE__ */ jsxs5("div", { className: "flex items-center gap-2", children: [
853
+ searchFields.length > 0 && /* @__PURE__ */ jsx6(
301
854
  "input",
302
855
  {
303
856
  type: "search",
304
- placeholder: `Search ${entity.label.plural.toLowerCase()}\u2026`,
857
+ placeholder: t.searchPlaceholder(entity.label.plural),
305
858
  value: search,
306
859
  onChange: (e) => {
307
860
  setSearch(e.target.value);
@@ -310,13 +863,46 @@ function EntityList({
310
863
  className: "rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
311
864
  }
312
865
  ),
313
- entity.capabilities.create && /* @__PURE__ */ jsx4(Button, { variant: "primary", onClick: onCreate, children: "+ New" })
866
+ entity.capabilities.create && /* @__PURE__ */ jsx6(Button, { variant: "primary", onClick: onCreate, children: t.newRecord })
314
867
  ] })
315
868
  ] }),
316
- error && /* @__PURE__ */ jsx4(ErrorBanner, { error }),
317
- loading && !data ? /* @__PURE__ */ jsx4(Spinner, { label: `Loading ${entity.label.plural.toLowerCase()}\u2026` }) : data && data.records.length === 0 ? /* @__PURE__ */ jsx4(EmptyState, { title: "No records", hint: search ? "Try a different search." : void 0 }) : /* @__PURE__ */ jsx4("div", { className: "overflow-x-auto rounded-lg border border-slate-200", children: /* @__PURE__ */ jsxs3("table", { className: "min-w-full divide-y divide-slate-200 text-sm", children: [
318
- /* @__PURE__ */ jsx4("thead", { className: "bg-slate-50", children: /* @__PURE__ */ jsxs3("tr", { children: [
319
- columns.map((f) => /* @__PURE__ */ jsxs3(
869
+ filters.length > 0 && /* @__PURE__ */ jsxs5("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [
870
+ /* @__PURE__ */ jsxs5("span", { className: "text-slate-500", children: [
871
+ t.filters,
872
+ ":"
873
+ ] }),
874
+ filters.map((f) => /* @__PURE__ */ jsxs5(
875
+ "button",
876
+ {
877
+ onClick: () => {
878
+ setFilters((prev) => prev.filter((p) => p.field !== f.field));
879
+ setPage(1);
880
+ },
881
+ title: f.value,
882
+ className: "inline-flex items-center gap-1 rounded-full bg-indigo-50 px-2.5 py-0.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100",
883
+ children: [
884
+ filterChipText(entity, f),
885
+ /* @__PURE__ */ jsx6("span", { "aria-hidden": true, children: "\xD7" })
886
+ ]
887
+ },
888
+ f.field
889
+ )),
890
+ /* @__PURE__ */ jsx6(
891
+ "button",
892
+ {
893
+ onClick: () => {
894
+ setFilters([]);
895
+ setPage(1);
896
+ },
897
+ className: "text-xs text-slate-500 underline hover:text-slate-700",
898
+ children: t.clearFilters
899
+ }
900
+ )
901
+ ] }),
902
+ error && /* @__PURE__ */ jsx6(ErrorBanner, { error }),
903
+ loading && !data ? /* @__PURE__ */ jsx6(Spinner, { label: t.loading }) : data && data.records.length === 0 ? /* @__PURE__ */ jsx6(EmptyState, { title: t.noRecords, hint: search || filters.length ? t.tryDifferentSearch : void 0 }) : /* @__PURE__ */ jsx6("div", { className: "overflow-x-auto rounded-lg border border-slate-200", children: /* @__PURE__ */ jsxs5("table", { className: "min-w-full divide-y divide-slate-200 text-sm", children: [
904
+ /* @__PURE__ */ jsx6("thead", { className: "bg-slate-50", children: /* @__PURE__ */ jsxs5("tr", { children: [
905
+ columns.map((f) => /* @__PURE__ */ jsxs5(
320
906
  "th",
321
907
  {
322
908
  onClick: () => f.sortable && toggleSort(f.name),
@@ -328,78 +914,128 @@ function EntityList({
328
914
  },
329
915
  f.name
330
916
  )),
331
- /* @__PURE__ */ jsx4("th", { className: "px-3 py-2" })
917
+ /* @__PURE__ */ jsx6("th", { className: "px-3 py-2 text-right font-medium text-slate-600", children: t.actions })
332
918
  ] }) }),
333
- /* @__PURE__ */ jsx4("tbody", { className: "divide-y divide-slate-100 bg-white", children: data?.records.map((record, i) => {
919
+ /* @__PURE__ */ jsx6("tbody", { className: "divide-y divide-slate-100 bg-white", children: data?.records.map((record, i) => {
334
920
  const id = String(record[entity.primaryKey]);
335
- return /* @__PURE__ */ jsxs3("tr", { className: "hover:bg-slate-50", children: [
336
- columns.map((f) => /* @__PURE__ */ jsx4(
921
+ return /* @__PURE__ */ jsxs5("tr", { className: "hover:bg-slate-50", children: [
922
+ columns.map((f) => /* @__PURE__ */ jsx6(
337
923
  "td",
338
924
  {
339
925
  className: "cursor-pointer px-3 py-2 align-top",
340
- onClick: () => onOpen(id),
341
- children: /* @__PURE__ */ jsx4(FieldValue, { field: f, value: record[f.name] })
926
+ onClick: (e) => setCellMenu({ anchor: { x: e.clientX, y: e.clientY }, field: f, record }),
927
+ onDoubleClick: () => onOpen(id),
928
+ children: /* @__PURE__ */ jsx6(FieldValue, { field: f, value: record[f.name], compact: true })
342
929
  },
343
930
  f.name
344
931
  )),
345
- /* @__PURE__ */ jsxs3("td", { className: "whitespace-nowrap px-3 py-2 text-right", children: [
346
- entity.capabilities.update && /* @__PURE__ */ jsx4(Button, { variant: "ghost", onClick: () => onEdit(id), children: "Edit" }),
347
- entity.capabilities.delete && /* @__PURE__ */ jsx4(Button, { variant: "ghost", className: "text-red-600", onClick: () => onDelete(id), children: "Delete" })
348
- ] })
932
+ /* @__PURE__ */ jsx6("td", { className: "whitespace-nowrap px-3 py-2 text-right", children: /* @__PURE__ */ jsx6(MenuButton, { label: t.actions, items: rowMenuItems(record) }) })
349
933
  ] }, id || i);
350
934
  }) })
351
935
  ] }) }),
352
- data && totalPages > 1 && /* @__PURE__ */ jsxs3("div", { className: "flex items-center justify-end gap-2 text-sm", children: [
353
- /* @__PURE__ */ jsx4(Button, { variant: "secondary", disabled: page <= 1, onClick: () => setPage((p) => p - 1), children: "Previous" }),
354
- /* @__PURE__ */ jsxs3("span", { className: "text-slate-500", children: [
355
- "Page ",
356
- page,
357
- " of ",
358
- totalPages
359
- ] }),
360
- /* @__PURE__ */ jsx4(Button, { variant: "secondary", disabled: page >= totalPages, onClick: () => setPage((p) => p + 1), children: "Next" })
936
+ cellMenu && /* @__PURE__ */ jsx6(PopMenu, { anchor: cellMenu.anchor, items: cellMenuItems(cellMenu), onClose: () => setCellMenu(void 0) }),
937
+ copied && /* @__PURE__ */ jsx6("div", { className: "fixed bottom-4 right-4 z-50 rounded-md bg-slate-900 px-3 py-1.5 text-sm text-white shadow-lg", children: t.copied }),
938
+ data && totalPages > 1 && /* @__PURE__ */ jsxs5("div", { className: "flex items-center justify-end gap-2 text-sm", children: [
939
+ /* @__PURE__ */ jsx6(Button, { variant: "secondary", disabled: page <= 1, onClick: () => setPage((p) => p - 1), children: t.previous }),
940
+ /* @__PURE__ */ jsx6("span", { className: "text-slate-500", children: t.page(page, totalPages) }),
941
+ /* @__PURE__ */ jsx6(Button, { variant: "secondary", disabled: page >= totalPages, onClick: () => setPage((p) => p + 1), children: t.next })
361
942
  ] })
362
943
  ] });
363
944
  }
364
945
 
365
946
  // src/components/EntityDetail.tsx
366
- import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
947
+ import { useState as useState6 } from "react";
948
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
367
949
  function EntityDetail({
368
950
  entity,
369
951
  id,
370
952
  onBack,
371
- onEdit
953
+ onEdit,
954
+ onClone,
955
+ onDeleted,
956
+ onNavigate
372
957
  }) {
958
+ const client = useClient();
959
+ const t = useT();
960
+ const entities = useEntities();
373
961
  const { data, error, loading } = useEntityRecord(entity.id, id);
374
962
  const fields = detailFields(entity);
375
- return /* @__PURE__ */ jsxs4("section", { className: "space-y-4", children: [
376
- /* @__PURE__ */ jsxs4("header", { className: "flex items-center justify-between gap-3", children: [
377
- /* @__PURE__ */ jsxs4("div", { children: [
378
- /* @__PURE__ */ jsxs4("button", { onClick: onBack, className: "text-sm text-indigo-600 hover:underline", children: [
963
+ const [copied, setCopied] = useState6(false);
964
+ const copy = async (text) => {
965
+ await copyText(text);
966
+ setCopied(true);
967
+ setTimeout(() => setCopied(false), 1500);
968
+ };
969
+ const onDelete = async () => {
970
+ if (!globalThis.confirm(t.deleteConfirm(entity.label.singular))) return;
971
+ await client.remove(entity.id, id);
972
+ onDeleted();
973
+ };
974
+ const menuItems = [];
975
+ if (entity.capabilities.clone && entity.capabilities.create) {
976
+ menuItems.push({ id: "clone", label: `\u29C9 ${t.clone}`, onSelect: onClone });
977
+ }
978
+ menuItems.push({ id: "copy-id", label: `\u2398 ${t.copyId}`, onSelect: () => void copy(id) });
979
+ if (data) {
980
+ menuItems.push({
981
+ id: "copy-object",
982
+ label: `{} ${t.copyObject}`,
983
+ onSelect: () => void copy(JSON.stringify(data, null, 2))
984
+ });
985
+ }
986
+ if (entity.capabilities.delete) {
987
+ menuItems.push({ id: "remove", label: `\u{1F5D1} ${t.remove}`, danger: true, onSelect: () => void onDelete() });
988
+ }
989
+ return /* @__PURE__ */ jsxs6("section", { className: "space-y-4", children: [
990
+ /* @__PURE__ */ jsxs6("header", { className: "flex items-center justify-between gap-3", children: [
991
+ /* @__PURE__ */ jsxs6("div", { children: [
992
+ /* @__PURE__ */ jsxs6("button", { onClick: onBack, className: "text-sm text-indigo-600 hover:underline", children: [
379
993
  "\u2190 ",
380
994
  entity.label.plural
381
995
  ] }),
382
- /* @__PURE__ */ jsxs4("h1", { className: "text-lg font-semibold text-slate-900", children: [
996
+ /* @__PURE__ */ jsxs6("h1", { className: "text-lg font-semibold text-slate-900", children: [
383
997
  entity.label.singular,
384
998
  " ",
385
999
  data ? String(data[entity.displayField] ?? id) : ""
386
1000
  ] })
387
1001
  ] }),
388
- entity.capabilities.update && /* @__PURE__ */ jsx5(Button, { variant: "primary", onClick: onEdit, children: "Edit" })
1002
+ /* @__PURE__ */ jsxs6("div", { className: "flex items-center gap-1", children: [
1003
+ entity.capabilities.update && /* @__PURE__ */ jsx7(Button, { variant: "primary", onClick: onEdit, children: t.edit }),
1004
+ /* @__PURE__ */ jsx7(MenuButton, { label: t.actions, items: menuItems })
1005
+ ] })
389
1006
  ] }),
390
- error && /* @__PURE__ */ jsx5(ErrorBanner, { error }),
391
- loading && !data ? /* @__PURE__ */ jsx5(Spinner, {}) : data && /* @__PURE__ */ jsx5("dl", { className: "divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white", children: fields.map((f) => /* @__PURE__ */ jsxs4("div", { className: "grid grid-cols-3 gap-4 px-4 py-3", children: [
392
- /* @__PURE__ */ jsx5("dt", { className: "text-sm font-medium text-slate-500", children: f.label }),
393
- /* @__PURE__ */ jsx5("dd", { className: "col-span-2 text-sm", children: /* @__PURE__ */ jsx5(FieldValue, { field: f, value: data[f.name] }) })
394
- ] }, f.name)) })
1007
+ error && /* @__PURE__ */ jsx7(ErrorBanner, { error }),
1008
+ loading && !data ? /* @__PURE__ */ jsx7(Spinner, {}) : data && /* @__PURE__ */ jsx7("dl", { className: "divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white", children: fields.map((f) => {
1009
+ const value = data[f.name];
1010
+ const isRelation = f.type === "relation" && f.relationEntity && value !== null && value !== void 0;
1011
+ const target = isRelation ? entities.find((e) => e.id === f.relationEntity) : void 0;
1012
+ return /* @__PURE__ */ jsxs6("div", { className: "grid grid-cols-3 gap-4 px-4 py-3", children: [
1013
+ /* @__PURE__ */ jsx7("dt", { className: "text-sm font-medium text-slate-500", children: f.label }),
1014
+ /* @__PURE__ */ jsxs6("dd", { className: "col-span-2 flex items-center gap-2 text-sm", children: [
1015
+ /* @__PURE__ */ jsx7(FieldValue, { field: f, value }),
1016
+ isRelation && /* @__PURE__ */ jsxs6(
1017
+ "button",
1018
+ {
1019
+ onClick: () => onNavigate(f.relationEntity, String(value)),
1020
+ className: "text-xs text-indigo-600 hover:underline",
1021
+ children: [
1022
+ t.openRelated(target?.label.singular ?? f.relationEntity),
1023
+ " \u2197"
1024
+ ]
1025
+ }
1026
+ )
1027
+ ] })
1028
+ ] }, f.name);
1029
+ }) }),
1030
+ copied && /* @__PURE__ */ jsx7("div", { className: "fixed bottom-4 right-4 z-50 rounded-md bg-slate-900 px-3 py-1.5 text-sm text-white shadow-lg", children: t.copied })
395
1031
  ] });
396
1032
  }
397
1033
 
398
1034
  // src/components/EntityForm.tsx
399
- import { useEffect as useEffect2, useState as useState3 } from "react";
1035
+ import { useEffect as useEffect5, useState as useState7 } from "react";
400
1036
 
401
1037
  // src/components/FieldInput.tsx
402
- import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1038
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
403
1039
  var inputClass = "w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-800 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:bg-slate-100";
404
1040
  function FieldInput({
405
1041
  field,
@@ -410,7 +1046,7 @@ function FieldInput({
410
1046
  const id = `field-${field.name}`;
411
1047
  switch (field.type) {
412
1048
  case "boolean":
413
- return /* @__PURE__ */ jsx6(
1049
+ return /* @__PURE__ */ jsx8(
414
1050
  "input",
415
1051
  {
416
1052
  id,
@@ -426,7 +1062,7 @@ function FieldInput({
426
1062
  case "float":
427
1063
  case "decimal":
428
1064
  case "currency":
429
- return /* @__PURE__ */ jsx6(
1065
+ return /* @__PURE__ */ jsx8(
430
1066
  "input",
431
1067
  {
432
1068
  id,
@@ -440,7 +1076,7 @@ function FieldInput({
440
1076
  }
441
1077
  );
442
1078
  case "enum":
443
- return /* @__PURE__ */ jsxs5(
1079
+ return /* @__PURE__ */ jsxs7(
444
1080
  "select",
445
1081
  {
446
1082
  id,
@@ -449,13 +1085,13 @@ function FieldInput({
449
1085
  onChange: (e) => onChange(e.target.value),
450
1086
  className: inputClass,
451
1087
  children: [
452
- /* @__PURE__ */ jsx6("option", { value: "", children: "\u2014" }),
453
- field.enumOptions?.map((o) => /* @__PURE__ */ jsx6("option", { value: String(o.value), children: o.label }, String(o.value)))
1088
+ /* @__PURE__ */ jsx8("option", { value: "", children: "\u2014" }),
1089
+ field.enumOptions?.map((o) => /* @__PURE__ */ jsx8("option", { value: String(o.value), children: o.label }, String(o.value)))
454
1090
  ]
455
1091
  }
456
1092
  );
457
1093
  case "date":
458
- return /* @__PURE__ */ jsx6(
1094
+ return /* @__PURE__ */ jsx8(
459
1095
  "input",
460
1096
  {
461
1097
  id,
@@ -468,7 +1104,7 @@ function FieldInput({
468
1104
  );
469
1105
  case "text":
470
1106
  case "json":
471
- return /* @__PURE__ */ jsx6(
1107
+ return /* @__PURE__ */ jsx8(
472
1108
  "textarea",
473
1109
  {
474
1110
  id,
@@ -491,7 +1127,7 @@ function FieldInput({
491
1127
  }
492
1128
  );
493
1129
  default:
494
- return /* @__PURE__ */ jsx6(
1130
+ return /* @__PURE__ */ jsx8(
495
1131
  "input",
496
1132
  {
497
1133
  id,
@@ -507,23 +1143,37 @@ function FieldInput({
507
1143
  }
508
1144
 
509
1145
  // src/components/EntityForm.tsx
510
- import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1146
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
511
1147
  function EntityForm({
512
1148
  entity,
513
1149
  mode,
514
1150
  id,
1151
+ cloneFromId,
515
1152
  onCancel,
516
1153
  onSaved
517
1154
  }) {
518
1155
  const client = useClient();
1156
+ const t = useT();
519
1157
  const fields = formFields(entity, mode);
520
- const existing = useEntityRecord(entity.id, mode === "edit" ? id : void 0);
521
- const [values, setValues] = useState3({});
522
- const [saving, setSaving] = useState3(false);
523
- const [error, setError] = useState3(void 0);
524
- useEffect2(() => {
525
- if (mode === "edit" && existing.data) setValues(existing.data);
526
- }, [mode, existing.data]);
1158
+ const sourceId = mode === "edit" ? id : cloneFromId;
1159
+ const existing = useEntityRecord(entity.id, sourceId);
1160
+ const [values, setValues] = useState7({});
1161
+ const [saving, setSaving] = useState7(false);
1162
+ const [error, setError] = useState7(void 0);
1163
+ useEffect5(() => {
1164
+ if (!existing.data || !sourceId) return;
1165
+ if (mode === "edit") {
1166
+ setValues(existing.data);
1167
+ return;
1168
+ }
1169
+ const seed = {};
1170
+ for (const f of entity.fields) {
1171
+ if (f.name === entity.primaryKey) continue;
1172
+ if (!f.form.cloneable || f.readonly) continue;
1173
+ if (existing.data[f.name] !== void 0) seed[f.name] = existing.data[f.name];
1174
+ }
1175
+ setValues(seed);
1176
+ }, [mode, sourceId, existing.data, entity]);
527
1177
  const submit = async (e) => {
528
1178
  e.preventDefault();
529
1179
  setSaving(true);
@@ -541,25 +1191,26 @@ function EntityForm({
541
1191
  setSaving(false);
542
1192
  }
543
1193
  };
544
- if (mode === "edit" && existing.loading && !existing.data) {
545
- return /* @__PURE__ */ jsx7(Spinner, {});
1194
+ if (sourceId && existing.loading && !existing.data) {
1195
+ return /* @__PURE__ */ jsx9(Spinner, {});
546
1196
  }
547
- return /* @__PURE__ */ jsxs6("section", { className: "max-w-2xl space-y-4", children: [
548
- /* @__PURE__ */ jsxs6("header", { children: [
549
- /* @__PURE__ */ jsxs6("button", { onClick: onCancel, className: "text-sm text-indigo-600 hover:underline", children: [
1197
+ const title = mode === "edit" ? t.editTitle(entity.label.singular) : cloneFromId ? t.cloneTitle(entity.label.singular) : t.newTitle(entity.label.singular);
1198
+ return /* @__PURE__ */ jsxs8("section", { className: "max-w-2xl space-y-4", children: [
1199
+ /* @__PURE__ */ jsxs8("header", { children: [
1200
+ /* @__PURE__ */ jsxs8("button", { onClick: onCancel, className: "text-sm text-indigo-600 hover:underline", children: [
550
1201
  "\u2190 ",
551
1202
  entity.label.plural
552
1203
  ] }),
553
- /* @__PURE__ */ jsx7("h1", { className: "text-lg font-semibold text-slate-900", children: mode === "create" ? `New ${entity.label.singular}` : `Edit ${entity.label.singular}` })
1204
+ /* @__PURE__ */ jsx9("h1", { className: "text-lg font-semibold text-slate-900", children: title })
554
1205
  ] }),
555
- error && /* @__PURE__ */ jsx7(ErrorBanner, { error }),
556
- /* @__PURE__ */ jsxs6("form", { onSubmit: submit, className: "space-y-4 rounded-lg border border-slate-200 bg-white p-5", children: [
557
- fields.map((f) => /* @__PURE__ */ jsxs6("div", { className: "space-y-1", children: [
558
- /* @__PURE__ */ jsxs6("label", { htmlFor: `field-${f.name}`, className: "block text-sm font-medium text-slate-700", children: [
1206
+ error && /* @__PURE__ */ jsx9(ErrorBanner, { error }),
1207
+ /* @__PURE__ */ jsxs8("form", { onSubmit: submit, className: "space-y-4 rounded-lg border border-slate-200 bg-white p-5", children: [
1208
+ fields.map((f) => /* @__PURE__ */ jsxs8("div", { className: "space-y-1", children: [
1209
+ /* @__PURE__ */ jsxs8("label", { htmlFor: `field-${f.name}`, className: "block text-sm font-medium text-slate-700", children: [
559
1210
  f.label,
560
- f.required && /* @__PURE__ */ jsx7("span", { className: "ml-0.5 text-red-500", children: "*" })
1211
+ f.required && /* @__PURE__ */ jsx9("span", { className: "ml-0.5 text-red-500", children: "*" })
561
1212
  ] }),
562
- /* @__PURE__ */ jsx7(
1213
+ /* @__PURE__ */ jsx9(
563
1214
  FieldInput,
564
1215
  {
565
1216
  field: f,
@@ -568,77 +1219,154 @@ function EntityForm({
568
1219
  onChange: (next) => setValues((v) => ({ ...v, [f.name]: next }))
569
1220
  }
570
1221
  ),
571
- f.form.helpText && /* @__PURE__ */ jsx7("p", { className: "text-xs text-slate-500", children: f.form.helpText })
1222
+ f.form.helpText && /* @__PURE__ */ jsx9("p", { className: "text-xs text-slate-500", children: f.form.helpText })
572
1223
  ] }, f.name)),
573
- /* @__PURE__ */ jsxs6("div", { className: "flex justify-end gap-2 border-t border-slate-100 pt-4", children: [
574
- /* @__PURE__ */ jsx7(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: saving, children: "Cancel" }),
575
- /* @__PURE__ */ jsx7(Button, { type: "submit", variant: "primary", disabled: saving, children: saving ? "Saving\u2026" : mode === "create" ? "Create" : "Save" })
1224
+ /* @__PURE__ */ jsxs8("div", { className: "flex justify-end gap-2 border-t border-slate-100 pt-4", children: [
1225
+ /* @__PURE__ */ jsx9(Button, { type: "button", variant: "secondary", onClick: onCancel, disabled: saving, children: t.cancel }),
1226
+ /* @__PURE__ */ jsx9(Button, { type: "submit", variant: "primary", disabled: saving, children: saving ? t.saving : mode === "create" ? t.create : t.save })
576
1227
  ] })
577
1228
  ] })
578
1229
  ] });
579
1230
  }
580
1231
 
1232
+ // src/labels.ts
1233
+ function relabelField(field, entityOverride, globalFields) {
1234
+ const label = entityOverride?.fields?.[field.name] ?? globalFields[field.name];
1235
+ return label ? { ...field, label } : field;
1236
+ }
1237
+ function relabelEntity(entity, labels) {
1238
+ const override = labels.entities?.[entity.id] ?? labels.entities?.[entity.table];
1239
+ const globalFields = labels.fields ?? {};
1240
+ return {
1241
+ ...entity,
1242
+ label: {
1243
+ singular: override?.singular ?? entity.label.singular,
1244
+ plural: override?.plural ?? override?.singular ?? entity.label.plural
1245
+ },
1246
+ fields: entity.fields.map((f) => relabelField(f, override, globalFields))
1247
+ };
1248
+ }
1249
+ function applyLabels(metadata, labels) {
1250
+ if (!labels || !labels.entities && !labels.fields) return metadata;
1251
+ return { ...metadata, entities: metadata.entities.map((e) => relabelEntity(e, labels)) };
1252
+ }
1253
+
1254
+ // src/recents.ts
1255
+ import { useCallback as useCallback2, useState as useState8 } from "react";
1256
+ var STORAGE_KEY = "maestro-admin:recents";
1257
+ var MAX_RECENTS = 8;
1258
+ function read(key) {
1259
+ try {
1260
+ const raw = globalThis.localStorage?.getItem(key);
1261
+ const parsed = raw ? JSON.parse(raw) : [];
1262
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
1263
+ } catch {
1264
+ return [];
1265
+ }
1266
+ }
1267
+ function write(key, ids) {
1268
+ try {
1269
+ globalThis.localStorage?.setItem(key, JSON.stringify(ids));
1270
+ } catch {
1271
+ }
1272
+ }
1273
+ function useRecents(scope) {
1274
+ const key = scope ? `${STORAGE_KEY}:${scope}` : STORAGE_KEY;
1275
+ const [recents, setRecents] = useState8(() => read(key));
1276
+ const push = useCallback2(
1277
+ (entityId) => {
1278
+ setRecents((prev) => {
1279
+ const next = [entityId, ...prev.filter((id) => id !== entityId)].slice(0, MAX_RECENTS);
1280
+ write(key, next);
1281
+ return next;
1282
+ });
1283
+ },
1284
+ [key]
1285
+ );
1286
+ return { recents, push };
1287
+ }
1288
+
581
1289
  // src/components/MaestroAdmin.tsx
582
- import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
583
- function AdminShell({ title }) {
1290
+ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
1291
+ function AdminShell({ title, labels }) {
584
1292
  const { data, error, loading } = useMetadata();
585
- const [view, setView] = useState4(void 0);
586
- const entities = data?.entities ?? [];
1293
+ const t = useT();
1294
+ const [view, setView] = useState9(void 0);
1295
+ const { recents, push: pushRecent } = useRecents();
1296
+ const metadata = useMemo3(() => data ? applyLabels(data, labels) : void 0, [data, labels]);
1297
+ const entities = metadata?.entities ?? [];
587
1298
  const activeId = view?.entityId ?? entities[0]?.id;
588
1299
  const activeEntity = entities.find((e) => e.id === activeId);
589
1300
  const effectiveView = view ?? (activeId ? { kind: "list", entityId: activeId } : void 0);
1301
+ const openEntity = (entityId) => {
1302
+ pushRecent(entityId);
1303
+ setView({ kind: "list", entityId });
1304
+ };
1305
+ const navigateToRecord = (entityId, id) => {
1306
+ pushRecent(entityId);
1307
+ setView({ kind: "detail", entityId, id });
1308
+ };
590
1309
  if (loading && !data) {
591
- return /* @__PURE__ */ jsx8("div", { className: "flex h-full items-center justify-center", children: /* @__PURE__ */ jsx8(Spinner, { label: "Loading metadata\u2026" }) });
1310
+ return /* @__PURE__ */ jsx10("div", { className: "flex h-full items-center justify-center", children: /* @__PURE__ */ jsx10(Spinner, { label: t.loadingMetadata }) });
592
1311
  }
593
- return /* @__PURE__ */ jsxs7("div", { className: "flex h-full min-h-screen bg-white text-slate-800", children: [
594
- /* @__PURE__ */ jsx8(
1312
+ return /* @__PURE__ */ jsx10(AdminEntitiesProvider, { value: entities, children: /* @__PURE__ */ jsxs9("div", { className: "flex h-screen overflow-hidden bg-white text-slate-800", children: [
1313
+ /* @__PURE__ */ jsx10(
595
1314
  Sidebar,
596
1315
  {
597
1316
  entities,
598
1317
  activeId,
599
1318
  title,
600
- onSelect: (entityId) => setView({ kind: "list", entityId })
1319
+ labels,
1320
+ recents,
1321
+ onSelect: openEntity
601
1322
  }
602
1323
  ),
603
- /* @__PURE__ */ jsxs7("main", { className: "flex-1 overflow-y-auto p-6", children: [
604
- error && /* @__PURE__ */ jsx8(ErrorBanner, { error }),
605
- activeEntity && effectiveView && effectiveView.kind === "list" && /* @__PURE__ */ jsx8(
1324
+ /* @__PURE__ */ jsxs9("main", { className: "flex-1 overflow-y-auto p-6", children: [
1325
+ error && /* @__PURE__ */ jsx10(ErrorBanner, { error }),
1326
+ activeEntity && effectiveView && effectiveView.kind === "list" && /* @__PURE__ */ jsx10(
606
1327
  EntityList,
607
1328
  {
608
1329
  entity: activeEntity,
609
1330
  onOpen: (id) => setView({ kind: "detail", entityId: activeEntity.id, id }),
610
1331
  onCreate: () => setView({ kind: "create", entityId: activeEntity.id }),
611
- onEdit: (id) => setView({ kind: "edit", entityId: activeEntity.id, id })
1332
+ onEdit: (id) => setView({ kind: "edit", entityId: activeEntity.id, id }),
1333
+ onClone: (id) => setView({ kind: "create", entityId: activeEntity.id, cloneFromId: id }),
1334
+ onNavigate: navigateToRecord
612
1335
  }
613
1336
  ),
614
- activeEntity && effectiveView?.kind === "detail" && /* @__PURE__ */ jsx8(
1337
+ activeEntity && effectiveView?.kind === "detail" && /* @__PURE__ */ jsx10(
615
1338
  EntityDetail,
616
1339
  {
617
1340
  entity: activeEntity,
618
1341
  id: effectiveView.id,
619
1342
  onBack: () => setView({ kind: "list", entityId: activeEntity.id }),
620
- onEdit: () => setView({ kind: "edit", entityId: activeEntity.id, id: effectiveView.id })
1343
+ onEdit: () => setView({ kind: "edit", entityId: activeEntity.id, id: effectiveView.id }),
1344
+ onClone: () => setView({ kind: "create", entityId: activeEntity.id, cloneFromId: effectiveView.id }),
1345
+ onDeleted: () => setView({ kind: "list", entityId: activeEntity.id }),
1346
+ onNavigate: navigateToRecord
621
1347
  }
622
1348
  ),
623
- activeEntity && (effectiveView?.kind === "create" || effectiveView?.kind === "edit") && /* @__PURE__ */ jsx8(
1349
+ activeEntity && (effectiveView?.kind === "create" || effectiveView?.kind === "edit") && /* @__PURE__ */ jsx10(
624
1350
  EntityForm,
625
1351
  {
626
1352
  entity: activeEntity,
627
1353
  mode: effectiveView.kind,
628
1354
  id: effectiveView.kind === "edit" ? effectiveView.id : void 0,
1355
+ cloneFromId: effectiveView.kind === "create" ? effectiveView.cloneFromId : void 0,
629
1356
  onCancel: () => setView({ kind: "list", entityId: activeEntity.id }),
630
1357
  onSaved: () => setView({ kind: "list", entityId: activeEntity.id })
631
1358
  }
632
1359
  )
633
1360
  ] })
634
- ] });
1361
+ ] }) });
635
1362
  }
636
1363
  function MaestroAdmin(props) {
637
- const client = useMemo2(
1364
+ const client = useMemo3(
638
1365
  () => props.client ?? new MaestroClient({ apiUrl: props.apiUrl ?? "", headers: props.headers }),
639
1366
  [props.client, props.apiUrl, props.headers]
640
1367
  );
641
- return /* @__PURE__ */ jsx8(AdminClientProvider, { value: client, children: /* @__PURE__ */ jsx8(AdminShell, { title: props.title ?? "Maestro Admin" }) });
1368
+ const strings = useMemo3(() => stringsFor(props.locale ?? "en"), [props.locale]);
1369
+ return /* @__PURE__ */ jsx10(AdminClientProvider, { value: client, children: /* @__PURE__ */ jsx10(I18nProvider, { value: strings, children: /* @__PURE__ */ jsx10(AdminShell, { title: props.title ?? "Maestro Admin", labels: props.labels }) }) });
642
1370
  }
643
1371
  export {
644
1372
  AdminClientProvider,
@@ -647,17 +1375,24 @@ export {
647
1375
  EntityList,
648
1376
  FieldInput,
649
1377
  FieldValue,
1378
+ I18nProvider,
650
1379
  MaestroAdmin,
651
1380
  MaestroApiError,
652
1381
  MaestroClient,
653
1382
  Sidebar,
1383
+ applyLabels,
1384
+ buildMenu,
654
1385
  detailFields,
655
1386
  formFields,
656
1387
  listFields,
1388
+ primaryListFields,
1389
+ stringsFor,
657
1390
  useAsync,
658
1391
  useClient,
659
1392
  useEntityList,
660
1393
  useEntityRecord,
661
- useMetadata
1394
+ useMetadata,
1395
+ useRecents,
1396
+ useT
662
1397
  };
663
1398
  //# sourceMappingURL=index.js.map