@jini-ai/admin 0.6.15-rc.2 → 0.6.15

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.
Files changed (99) hide show
  1. package/dist/agent-plugins/adapters/memory.js +9 -3
  2. package/dist/agent-plugins/conformance/agent-plugins-api.conformance.js +1 -1
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +14 -5
  4. package/dist/agent-plugins/package-file-tree.js +4 -6
  5. package/dist/agent-plugins/react/components/AgentPluginInspector.js +5 -2
  6. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +10 -4
  7. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +8 -5
  8. package/dist/agent-plugins/rules.js +3 -1
  9. package/dist/comments/adapters/http.js +2 -1
  10. package/dist/comments/adapters/memory.js +3 -1
  11. package/dist/comments/conformance/comments-api.conformance.js +3 -2
  12. package/dist/comments/controllers/comment-queue.controller.d.ts +1 -1
  13. package/dist/comments/controllers/comment-queue.controller.js +4 -2
  14. package/dist/core/entities/rules.js +13 -20
  15. package/dist/core/module/create-admin.js +2 -1
  16. package/dist/core/omit-keys.d.ts +7 -0
  17. package/dist/core/omit-keys.js +12 -0
  18. package/dist/core/ports/forms.d.ts +5 -3
  19. package/dist/core/transport/query-suffix.d.ts +3 -0
  20. package/dist/core/transport/query-suffix.js +5 -0
  21. package/dist/forms/adapters/http.js +3 -2
  22. package/dist/forms/adapters/memory.js +3 -2
  23. package/dist/forms/controllers/field-attributes-dialog.controller.js +17 -3
  24. package/dist/forms/controllers/form-fields-editor.controller.js +1 -2
  25. package/dist/forms/controllers/form-submission-detail.controller.js +1 -2
  26. package/dist/forms/controllers/form-submissions.controller.js +1 -2
  27. package/dist/forms/controllers/forms-list.controller.js +1 -2
  28. package/dist/forms/field-settings.js +15 -4
  29. package/dist/forms/react/hooks/forms-view.hooks.d.ts +2 -2
  30. package/dist/forms/react/hooks/forms-view.hooks.js +9 -1
  31. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +1 -3
  32. package/dist/forms/react/hooks/use-form-editor.hooks.js +8 -9
  33. package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -4
  34. package/dist/forms/react/hooks/use-form-submissions.hooks.js +1 -5
  35. package/dist/forms/react/hooks/use-forms-list.hooks.js +1 -2
  36. package/dist/forms/rules.js +7 -2
  37. package/dist/media/adapters/memory.js +5 -3
  38. package/dist/media/controllers/library.controller.js +4 -2
  39. package/dist/media/controllers/providers.controller.js +2 -1
  40. package/dist/media/react/components/MediaCard.js +11 -1
  41. package/dist/media/react/hooks/EditMediaPanel.hooks.js +18 -7
  42. package/dist/media/react/hooks/LibraryTab.hooks.js +3 -2
  43. package/dist/media/react/hooks/ProvidersTab.hooks.js +19 -9
  44. package/dist/media/rules.js +6 -3
  45. package/dist/playground/adapters/memory.js +26 -7
  46. package/dist/playground/conformance/playground-api.conformance.js +6 -3
  47. package/dist/playground/react/hooks/PlaygroundPorts.hooks.js +6 -2
  48. package/dist/react/components/ConfirmButton/ConfirmButton.hooks.js +1 -1
  49. package/dist/react/components/ConfirmButton/ConfirmButton.js +2 -5
  50. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +2 -2
  51. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +3 -1
  52. package/dist/react/components/RowMenu/RowMenu.hooks.js +4 -8
  53. package/dist/react/components/Sidebar.js +1 -1
  54. package/dist/react/entities/EntityField.js +1 -0
  55. package/dist/react/entities/contribution.js +2 -1
  56. package/dist/react/entities/types.js +1 -1
  57. package/dist/react/overlays.js +2 -1
  58. package/dist/react/shell/AdminShell.hooks.js +1 -1
  59. package/dist/react/shell/use-admin-shell-session.js +2 -1
  60. package/dist/react/shell/use-admin-theme.js +6 -6
  61. package/dist/redirects/adapters/memory.js +16 -13
  62. package/dist/security/adapters/http.js +10 -11
  63. package/dist/security/adapters/memory.js +50 -15
  64. package/dist/security/conformance/security-api.conformance.js +6 -3
  65. package/dist/security/controllers/access-tokens.controller.js +7 -3
  66. package/dist/security/controllers/other-credentials.controller.js +4 -2
  67. package/dist/security/controllers/root-key.controller.js +12 -5
  68. package/dist/security/react/hooks/AccessTokensTab.hooks.js +8 -3
  69. package/dist/security/react/hooks/SecurityPorts.hooks.js +6 -2
  70. package/dist/security/rules.js +19 -5
  71. package/dist/seo/adapters/memory.js +5 -2
  72. package/dist/seo/controllers/seo-entry.controller.js +2 -1
  73. package/dist/seo/controllers/sitemap.controller.js +2 -1
  74. package/dist/seo/react/hooks/MediaPickerBridge.hooks.js +1 -1
  75. package/dist/seo/react/hooks/use-seo.hooks.js +1 -1
  76. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -1
  77. package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +9 -1
  78. package/dist/seo/react/pages/SeoPage.js +7 -1
  79. package/dist/source-control/adapters/memory.js +30 -14
  80. package/dist/source-control/controllers/source-control.controller.js +5 -2
  81. package/dist/source-control/react/components/SourceControlProviderRow.js +6 -1
  82. package/dist/source-control/react/hooks/SourceControlPorts.hooks.js +6 -2
  83. package/dist/source-control/rules.js +9 -3
  84. package/dist/widgets/adapters/http.js +2 -1
  85. package/dist/widgets/adapters/memory.js +4 -2
  86. package/dist/widgets/conformance/widgets-api.conformance.js +5 -2
  87. package/dist/widgets/controllers/instance-editor.controller.js +3 -2
  88. package/dist/widgets/controllers/library.controller.js +2 -1
  89. package/dist/widgets/controllers/region-editor.controller.js +2 -1
  90. package/dist/widgets/controllers/regions.controller.js +2 -1
  91. package/dist/widgets/react/components/WidgetInstanceNotices.d.ts +1 -1
  92. package/dist/widgets/react/components/WidgetInstanceNotices.js +2 -0
  93. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +5 -2
  94. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +5 -2
  95. package/dist/widgets/react/pages/WidgetInstanceEditor.js +1 -1
  96. package/dist/widgets/react/pages/WidgetRegionEditor.js +1 -1
  97. package/dist/widgets/react/pages/WidgetsLibrary.js +1 -1
  98. package/dist/widgets/rules.js +5 -1
  99. package/package.json +13 -13
@@ -5,10 +5,8 @@ import { settingsFormFromField } from "../../field-settings.js";
5
5
  export function useFieldAttributesDialog(props, _optional = {}) {
6
6
  const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFieldAttributesDialogController({ field: props.field }), dependencies: [] });
7
7
  const state = stateSnapshot ?? { className: props.field.className ?? "", rows: [], settings: settingsFormFromField({ field: props.field }), error: null };
8
- const className = state.className;
8
+ const { className, rows, error } = state;
9
9
  const setClassName = (value) => stateController?.update({ key: "className", value });
10
- const rows = state.rows;
11
- const error = state.error;
12
10
  function updateRow(rowId, patch) { stateController?.updateRow({ rowId, patch }); }
13
11
  function removeRow(rowId) { stateController?.removeRow({ rowId }); }
14
12
  function addRow() { stateController?.addRow({}); }
@@ -8,24 +8,24 @@ import { useDirtyGuard } from "@jini-ai/ui/panel-kit";
8
8
  import { formEditorBaseline, formEditorDraft } from "../../form-editor-dirty.js";
9
9
  import { useFetchMutation, useFetchQuery } from "@jini-ai/ui/fetch-query";
10
10
  import { FORM_TABS, KEYS, existingFieldIdsOf, nextTabIndex, visibleFormEditorError } from "../../rules.js";
11
+ /** Ordinary Builder saves keep their existing endpoint/permissions; a mode change is explicit. */
12
+ function authoringPatch({ mode, html, savedMode }) {
13
+ if (mode === "html")
14
+ return { mode, html };
15
+ return savedMode === "html" ? { mode } : {};
16
+ }
11
17
  export function useFormEditor(props, { clipboard } = {}) {
12
18
  const { port, navigate, t } = props;
13
19
  const { tab } = props;
14
20
  const isNew = props.formId === "new";
15
21
  const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFormEditorController({}), dependencies: [] });
16
22
  const state = stateSnapshot ?? initialFormEditorState({});
17
- const form = state.form;
23
+ const { form, mode, html, copyFeedback, name, slug, fields, notify } = state;
18
24
  const setForm = (value) => stateController?.update({ key: "form", value });
19
- const mode = state.mode;
20
- const html = state.html;
21
25
  const setHtml = (value) => stateController?.update({ key: "html", value });
22
- const copyFeedback = state.copyFeedback;
23
26
  const setCopyFeedback = (value) => stateController?.update({ key: "copyFeedback", value });
24
- const name = state.name;
25
27
  const setName = (value) => stateController?.update({ key: "name", value });
26
- const slug = state.slug;
27
28
  const setSlug = (value) => stateController?.update({ key: "slug", value });
28
- const fields = state.fields;
29
29
  const setFields = (value) => stateController?.update({ key: "fields", value });
30
30
  // No UI edits this (owner ask 2026-09-22 removed the notify checkbox/recipients input from the
31
31
  // Fields tab — a future outside mail integration will design its own way to set it). Seeded from
@@ -33,7 +33,6 @@ export function useFormEditor(props, { clipboard } = {}) {
33
33
  // wipes out a `notify` setting that predates this UI removal. Lives in the section controller, not a
34
34
  // `const`, only so `handleSave`'s existing-form branch can resync it from the write's own response
35
35
  // exactly like `form`/`name`/`slug`/`fields` already do below.
36
- const notify = state.notify;
37
36
  // Roving-tabindex focus targets for the tab strip below, indexed the same as `FORM_TABS` — see
38
37
  // `nextTabIndex`'s doc comment for why the index math itself lives outside the component.
39
38
  const tabRefs = useRef([]);
@@ -106,7 +105,7 @@ export function useFormEditor(props, { clipboard } = {}) {
106
105
  }
107
106
  async function handleSave() {
108
107
  // Ordinary Builder saves keep their existing endpoint/permissions. A mode change is explicit.
109
- const authoring = mode === "html" ? { mode, html } : form?.mode === "html" ? { mode } : {};
108
+ const authoring = authoringPatch({ mode, html, savedMode: form?.mode });
110
109
  try {
111
110
  if (isNew) {
112
111
  // `notify` here is always this hook's own blank default — a new form has no earlier value
@@ -56,13 +56,10 @@ export function useFormSubmissionDetail(props, _optional = {}) {
56
56
  // The delete's own failure outranks a background load-refresh failure — flat `if`s rather than a
57
57
  // nested ternary, per `@jini-ai/ui/fetch-query`'s `resolveFetchQueryStatus` doc on why the two carry
58
58
  // a different complexity-gate weight for the same branch count.
59
- let error = null;
59
+ let error = list.error ? describeApiError(list.error, "failed to load submission") : null;
60
60
  if (deleteMutation.error) {
61
61
  error = describeTrashError({ error: deleteMutation.error, fallback: "delete failed", versionChangedMessage: t("This item changed since you loaded it. Reload and try again.") }).message;
62
62
  }
63
- else if (list.error) {
64
- error = describeApiError(list.error, "failed to load submission");
65
- }
66
63
  return {
67
64
  submission,
68
65
  error,
@@ -17,15 +17,11 @@ export function useFormSubmissions(props, _optional = {}) {
17
17
  });
18
18
  const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFormSubmissionsController({}), dependencies: [] });
19
19
  const state = stateSnapshot ?? initialFormSubmissionsState({});
20
- const morePages = state.morePages;
20
+ const { morePages, moreCursor, moreError, selectedId, loadingMore } = state;
21
21
  const setMorePages = (value) => stateController?.update({ key: "morePages", value });
22
- const moreCursor = state.moreCursor;
23
22
  const setMoreCursor = (value) => stateController?.update({ key: "moreCursor", value });
24
- const moreError = state.moreError;
25
23
  const setMoreError = (value) => stateController?.update({ key: "moreError", value });
26
- const selectedId = state.selectedId;
27
24
  const setSelectedId = (value) => stateController?.update({ key: "selectedId", value });
28
- const loadingMore = state.loadingMore;
29
25
  const setLoadingMore = (value) => stateController?.update({ key: "loadingMore", value });
30
26
  // The "latest call wins" guard for accumulated pages — see the file header. `loadingMoreRef` is
31
27
  // the synchronous half (blocks a same-tick double `loadMore`); `pageSettlement` is the async half
@@ -15,7 +15,7 @@ export function useFormsList(deps, { locale = "en" } = {}) {
15
15
  useEffect(() => events?.subscribe({ onRefresh: invalidateList }), [events, invalidateList]);
16
16
  const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFormsListController({}), dependencies: [] });
17
17
  const state = stateSnapshot ?? initialFormsListState({});
18
- const rowSavingId = state.rowSavingId;
18
+ const { rowSavingId, pendingDelete } = state;
19
19
  const setRowSavingId = (value) => stateController?.update({ key: "rowSavingId", value });
20
20
  const toggleMutation = useFetchMutation({
21
21
  run: ({ input: form }) => port.updateFormDefinition({ id: form.id }, { status: form.status === "active" ? "disabled" : "active" }),
@@ -25,7 +25,6 @@ export function useFormsList(deps, { locale = "en" } = {}) {
25
25
  // The form a `RowMenu` "Delete" selection is asking to confirm — `null` when the `ConfirmDialog`
26
26
  // is closed. `ConfirmDialog` stays mounted unconditionally in `FormsList.tsx`; this is what drives
27
27
  // its `open` prop — same shape `use-posts.hooks.ts`'s `pendingDelete` uses for its own row delete.
28
- const pendingDelete = state.pendingDelete;
29
28
  const setPendingDelete = (value) => stateController?.update({ key: "pendingDelete", value });
30
29
  const deleteMutation = useFetchMutation({
31
30
  run: ({ input: form }) => trash.trash({ type: "form", id: form.id }),
@@ -291,8 +291,13 @@ export const ATTRIBUTE_NAME_SUGGESTIONS = [
291
291
  * stable identity — same `_rowId`/module-counter pattern `collections/rules.ts`'s
292
292
  * `EditFieldsDialog` support uses for its own draft field rows. */
293
293
  let nextAttrRowId = 0;
294
+ function takeAttrRowId() {
295
+ const id = nextAttrRowId;
296
+ nextAttrRowId += 1;
297
+ return id;
298
+ }
294
299
  export function attrRowsFromField({ field }, _optional = {}) {
295
- return Object.entries(field.attributes ?? {}).map(([name, value]) => ({ _rowId: nextAttrRowId++, name, value }));
300
+ return Object.entries(field.attributes ?? {}).map(([name, value]) => ({ _rowId: takeAttrRowId(), name, value }));
296
301
  }
297
302
  /** @complexity O(n) in `rows.length`. */
298
303
  export function updateAttrRow({ rows, rowId, patch }, _optional = {}) {
@@ -304,7 +309,7 @@ export function removeAttrRow({ rows, rowId }, _optional = {}) {
304
309
  }
305
310
  /** @complexity O(n) in `rows.length`. */
306
311
  export function addAttrRow({ rows }, _optional = {}) {
307
- return [...rows, { _rowId: nextAttrRowId++, name: "", value: "" }];
312
+ return [...rows, { _rowId: takeAttrRowId(), name: "", value: "" }];
308
313
  }
309
314
  /**
310
315
  * Validates + shapes `FieldAttributesDialog`'s draft into the `Partial<AdminFormField>` patch
@@ -1,3 +1,4 @@
1
+ import { trimCharsEnd } from '@jini-ai/core/text';
1
2
  import { queryMedia } from '../rules.js';
2
3
  const METADATA_KEYS = ['title', 'slug', 'alt', 'caption', 'credit', 'width', 'height', 'cssClass', 'htmlAttributes'];
3
4
  /** Only metadata keys with a defined value; null stays, so a patch can clear a nullable field. */
@@ -6,12 +7,13 @@ function pickMetadata({ patch }) {
6
7
  }
7
8
  /** Decoded length of a base64 string: 3 bytes per 4 chars, minus trailing padding. */
8
9
  function base64ByteSize({ dataBase64 }) {
9
- const padding = dataBase64.match(/=+$/)?.[0].length ?? 0;
10
+ const padding = dataBase64.length - trimCharsEnd({ text: dataBase64, chars: '=' }).length;
10
11
  return Math.max(0, Math.floor(dataBase64.length * 3 / 4) - padding);
11
12
  }
12
13
  export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().toISOString() } = {}) {
13
14
  const items = new Map(assets.map((item) => [item.id, Object.freeze({ ...item })]));
14
15
  let sequence = 0;
16
+ const nextSequence = () => { sequence += 1; return sequence; };
15
17
  function check(options) {
16
18
  options.signal?.throwIfAborted();
17
19
  }
@@ -30,7 +32,7 @@ export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().t
30
32
  // The REAL server ignores the declared string and stores sniffContentType(bytes) instead.
31
33
  // This fake has no bytes to sniff — its input is an opaque base64 string a test made up —
32
34
  // so it echoes what the caller declared. Tests needing disagreement must seed assets directly.
33
- return { byteSize: base64ByteSize(input), contentType: input.contentType, sha256: `memory-content-${++sequence}` };
35
+ return { byteSize: base64ByteSize(input), contentType: input.contentType, sha256: `memory-content-${nextSequence()}` };
34
36
  }
35
37
  return {
36
38
  replaceSupported: true,
@@ -43,7 +45,7 @@ export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().t
43
45
  check(options);
44
46
  let id;
45
47
  do {
46
- id = `memory-${++sequence}`;
48
+ id = `memory-${nextSequence()}`;
47
49
  } while (items.has(id));
48
50
  const timestamp = now();
49
51
  return put({
@@ -52,7 +52,8 @@ export function createLibraryController({ api }, { query = {} } = {}) {
52
52
  // Retry reads only, never destructive writes. Keep trying at a capped delay so
53
53
  // recovery needs no manual repair, while an offline service isn't hammered.
54
54
  if (isRetryableMediaError({ error })) {
55
- const delay = Math.min(1000 * 2 ** Math.min(retryAttempt++, 5), 30000);
55
+ const delay = Math.min(1000 * 2 ** Math.min(retryAttempt, 5), 30000);
56
+ retryAttempt += 1;
56
57
  retryTimer = setTimeout(() => { retryTimer = null; void load(); }, delay);
57
58
  }
58
59
  }
@@ -63,7 +64,8 @@ export function createLibraryController({ api }, { query = {} } = {}) {
63
64
  readAbort?.abort();
64
65
  const abort = new AbortController();
65
66
  readAbort = abort;
66
- const ticket = ++generation;
67
+ generation += 1;
68
+ const ticket = generation;
67
69
  store.set({ patch: { loading: true, error: null } });
68
70
  try {
69
71
  const all = await api.list({}, { signal: abort.signal });
@@ -12,7 +12,8 @@ export function createProvidersController({ api }, _optional = {}) {
12
12
  async function load(_required = {}, _optional = {}) {
13
13
  if (store.signal.aborted)
14
14
  return;
15
- const ticket = ++generation;
15
+ generation += 1;
16
+ const ticket = generation;
16
17
  store.set({ patch: { loading: true } });
17
18
  try {
18
19
  const items = await api.list({}, { signal: store.signal });
@@ -2,8 +2,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Badge, Button, Menu } from '@jini-ai/ui-kit/react';
3
3
  import { useMediaCard } from '../hooks/MediaCard.hooks.js';
4
4
  import { EyeIcon, ExpandIcon, DocumentIcon } from './MediaIcons.js';
5
+ /** Image (expandable when a preview exists), then video, then a download placeholder. */
6
+ function previewMedia(vm) {
7
+ if (vm.image) {
8
+ const img = _jsx("img", { className: "jini-media-card-media", src: vm.src, alt: vm.alt, loading: "lazy", onError: vm.onImageError });
9
+ return vm.imagePreview ? _jsxs(Button, { className: "jini-media-card-expand jini-media-card-expand-fill", variant: "ghost", attrs: vm.previewAttrs, onPress: vm.preview, children: [img, _jsx(ExpandIcon, {})] }) : img;
10
+ }
11
+ if (vm.video)
12
+ return _jsx("video", { className: "jini-media-card-media", src: vm.src, controls: true, preload: "metadata", "aria-label": vm.alt, onError: vm.onVideoError });
13
+ return _jsxs("div", { className: "jini-media-card-placeholder", children: [_jsx(DocumentIcon, {}), _jsx("p", { className: "jini-media-card-placeholder-text", children: "Preview not available" }), vm.src && _jsx("a", { className: "jini-media-card-placeholder-link", href: vm.src, target: "_blank", rel: "noreferrer", children: "Download original" })] });
14
+ }
5
15
  function PreviewMarkup(vm, _optional = {}) {
6
- return _jsxs(_Fragment, { children: [vm.image ? (vm.imagePreview ? _jsxs(Button, { className: "jini-media-card-expand jini-media-card-expand-fill", variant: "ghost", attrs: vm.previewAttrs, onPress: vm.preview, children: [_jsx("img", { className: "jini-media-card-media", src: vm.src, alt: vm.alt, loading: "lazy", onError: vm.onImageError }), _jsx(ExpandIcon, {})] }) : _jsx("img", { className: "jini-media-card-media", src: vm.src, alt: vm.alt, loading: "lazy", onError: vm.onImageError })) : vm.video ? (_jsx("video", { className: "jini-media-card-media", src: vm.src, controls: true, preload: "metadata", "aria-label": vm.alt, onError: vm.onVideoError })) : (_jsxs("div", { className: "jini-media-card-placeholder", children: [_jsx(DocumentIcon, {}), _jsx("p", { className: "jini-media-card-placeholder-text", children: "Preview not available" }), vm.src && _jsx("a", { className: "jini-media-card-placeholder-link", href: vm.src, target: "_blank", rel: "noreferrer", children: "Download original" })] })), vm.onEdit && _jsxs(Button, { className: "jini-media-card-edit", variant: "ghost", attrs: vm.editAttrs, onPress: vm.onEdit, disabled: vm.busy, children: [_jsx(EyeIcon, {}), _jsx("span", { className: "jini-visually-hidden", children: "Edit metadata" })] }), vm.canPreview && !vm.imagePreview && _jsxs(Button, { className: "jini-media-card-expand", variant: "ghost", attrs: vm.previewAttrs, onPress: vm.preview, children: [_jsx(ExpandIcon, {}), _jsx("span", { className: "jini-visually-hidden", children: "View larger" })] })] });
16
+ return _jsxs(_Fragment, { children: [previewMedia(vm), vm.onEdit && _jsxs(Button, { className: "jini-media-card-edit", variant: "ghost", attrs: vm.editAttrs, onPress: vm.onEdit, disabled: vm.busy, children: [_jsx(EyeIcon, {}), _jsx("span", { className: "jini-visually-hidden", children: "Edit metadata" })] }), vm.canPreview && !vm.imagePreview && _jsxs(Button, { className: "jini-media-card-expand", variant: "ghost", attrs: vm.previewAttrs, onPress: vm.preview, children: [_jsx(ExpandIcon, {}), _jsx("span", { className: "jini-visually-hidden", children: "View larger" })] })] });
7
17
  }
8
18
  /** The lightbox uses the same fallback chain without the grid's metadata and actions. */
9
19
  export function MediaPreview(props, _optional = {}) {
@@ -4,6 +4,14 @@ import { useController } from '../../../core/react/use-controller.js';
4
4
  import { createEditMediaController } from '../../controllers/edit-media.controller.js';
5
5
  import { canReplaceMedia, mediaEmbedSnippet, parseOptionalPixelSize, safeMediaUrl } from '../../rules.js';
6
6
  import { parseMediaHtmlAttributes, describeMediaHtmlAttributeError } from '../../html-attributes.js';
7
+ /** Draft value for an edited field: pixel sizes parse, optional text clears to null when blank. */
8
+ function draftFieldValue(key, value) {
9
+ if (key === 'width' || key === 'height')
10
+ return parseOptionalPixelSize({ value });
11
+ if (key === 'cssClass' || key === 'htmlAttributes')
12
+ return value.trim() === '' ? null : value;
13
+ return value;
14
+ }
7
15
  export function useEditMediaPanel(props, _optional = {}) {
8
16
  const cached = useMediaLibraryQuery({ api: props.api }, { enabled: false });
9
17
  const { controller, snapshot } = useController({
@@ -15,8 +23,12 @@ export function useEditMediaPanel(props, _optional = {}) {
15
23
  const mounted = useRef(false);
16
24
  useEffect(() => {
17
25
  mounted.current = true;
18
- return () => { mounted.current = false; for (const timer of timers.current.values())
19
- clearTimeout(timer); timers.current.clear(); };
26
+ return () => {
27
+ mounted.current = false;
28
+ for (const timer of timers.current.values())
29
+ clearTimeout(timer);
30
+ timers.current.clear();
31
+ };
20
32
  }, []);
21
33
  const saving = !snapshot || snapshot.saving;
22
34
  const titleRef = useRef(null);
@@ -53,9 +65,7 @@ export function useEditMediaPanel(props, _optional = {}) {
53
65
  ...(key === 'title' ? { 'data-jini-autofocus': true } : {}),
54
66
  ...(key === 'htmlAttributes' ? { 'aria-invalid': !!hint } : {}) },
55
67
  onValueChange: ({ value }, _optional = {}) => {
56
- const next = key === 'width' || key === 'height' ? parseOptionalPixelSize({ value })
57
- : key === 'cssClass' || key === 'htmlAttributes' ? value.trim() === '' ? null : value : value;
58
- controller?.setDraft({ patch: { [key]: next } });
68
+ controller?.setDraft({ patch: { [key]: draftFieldValue(key, value) } });
59
69
  },
60
70
  }));
61
71
  // Server-computed public URL, never the authenticated original route. Trashed assets
@@ -65,6 +75,7 @@ export function useEditMediaPanel(props, _optional = {}) {
65
75
  const embed = mediaEmbedSnippet({ slug: props.item.slug });
66
76
  // Visible text is the legacy "Copy"/"Copied" on every row; the accessible name keeps
67
77
  // the value's noun so three identical buttons stay distinguishable, even mid-feedback.
78
+ const markCopied = (id, value) => setCopied(current => ({ ...current, [id]: value }));
68
79
  const copies = [
69
80
  { id: 'url', label: 'File URL', value: publicUrl, noun: 'URL' },
70
81
  { id: 'embed', label: 'Embed code', value: embed, noun: 'embed code' },
@@ -78,11 +89,11 @@ export function useEditMediaPanel(props, _optional = {}) {
78
89
  await navigator.clipboard.writeText(row.value);
79
90
  if (!mounted.current)
80
91
  return;
81
- setCopied(current => ({ ...current, [row.id]: true }));
92
+ markCopied(row.id, true);
82
93
  clearTimeout(timers.current.get(row.id));
83
94
  timers.current.set(row.id, setTimeout(() => {
84
95
  timers.current.delete(row.id);
85
- setCopied(current => ({ ...current, [row.id]: false }));
96
+ markCopied(row.id, false);
86
97
  }, 1500));
87
98
  }
88
99
  catch {
@@ -7,6 +7,7 @@ import { settleFromCache, useSharedMediaLibrary } from './MediaPage.hooks.js';
7
7
  import { useMediaPorts } from './MediaPorts.hooks.js';
8
8
  import { mediaMessagesEn } from '../../messages.en.js';
9
9
  import { canRestoreMedia } from '../../rules.js';
10
+ import { omitKeys } from '../../../core/omit-keys.js';
10
11
  const EMPTY_LABELS = {
11
12
  images: 'No images yet. Upload an image to see it here.',
12
13
  videos: 'No videos yet. Upload a video to see it here.',
@@ -52,7 +53,7 @@ function purgeDialogView({ snapshot }, _optional = {}) {
52
53
  function queryHandlers({ controller }, _optional = {}) {
53
54
  return {
54
55
  onStatus: ({ value }) => {
55
- const { status: _status, ...query } = controller?.getSnapshot().query ?? {};
56
+ const query = omitKeys(controller?.getSnapshot().query ?? {}, ['status']);
56
57
  void controller?.setQuery({ query: { ...query, ...(value === 'active' || value === 'trashed' ? { status: value } : {}) } });
57
58
  },
58
59
  onSearch: ({ value: search }) => {
@@ -183,7 +184,7 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
183
184
  const item = controller?.getSnapshot().pendingPurge;
184
185
  // The controller refuses to purge without a pending item, so there is nothing to announce.
185
186
  if (!can.canPurge || !controller || !item)
186
- return;
187
+ return undefined;
187
188
  const index = items.findIndex(row => row.id === item.id);
188
189
  return controller.confirmPurge({ confirmed: true }).then(saved => {
189
190
  if (saved) {
@@ -34,8 +34,10 @@ async function saveProviderSettings({ port, item, entry, signal }, _optional = {
34
34
  await port.saveSettings({ id: item.id, ...next }, { signal });
35
35
  }
36
36
  async function saveProvider({ port, item, signal }, { entry } = {}) {
37
- if (!entry)
38
- return removeIfStored({ port, item, signal });
37
+ if (!entry) {
38
+ await removeIfStored({ port, item, signal });
39
+ return;
40
+ }
39
41
  await saveProviderSettings({ port, item, entry, signal });
40
42
  if (entry.apiKey?.trim())
41
43
  await port.saveCredential({ id: item.id, credential: entry.apiKey }, { signal });
@@ -57,17 +59,21 @@ async function saveSequentially({ port, providers, lastRead, signal }, _optional
57
59
  function rowStatus({ entry, pending }, _optional = {}) {
58
60
  const saved = isMarkerOnlyEntry(entry);
59
61
  const mask = saved ? maskedKeyLabel(entry) : null;
62
+ const unsavedLabel = pending ? 'Unsaved' : null;
60
63
  return {
61
64
  mask,
62
- statusLabel: saved ? `Saved (${mask})` : pending ? 'Unsaved' : null,
65
+ statusLabel: saved ? `Saved (${mask})` : unsavedLabel,
63
66
  statusClassName: saved ? 'jini-field-status-badge jini-field-status-badge-success' : 'jini-field-status-badge',
64
67
  };
65
68
  }
69
+ /** Names the provider default an empty endpoint falls back to, when it has one. */
70
+ function defaultBaseUrlHint({ entry, item }) {
71
+ return !entry.baseUrl?.trim() && item.defaultBaseUrl ? `Uses ${item.defaultBaseUrl} by default.` : null;
72
+ }
66
73
  function baseUrlHint({ entry, item }, _optional = {}) {
67
74
  const invalid = isProviderBaseUrlInvalid(entry);
68
75
  return {
69
- invalid, hint: invalid ? 'Enter an absolute http:// or https:// URL that is not a private or internal address.'
70
- : !entry.baseUrl?.trim() && item.defaultBaseUrl ? `Uses ${item.defaultBaseUrl} by default.` : null,
76
+ invalid, hint: invalid ? 'Enter an absolute http:// or https:// URL that is not a private or internal address.' : defaultBaseUrlHint({ entry, item }),
71
77
  hintClassName: invalid ? 'jini-field-hint jini-hint-error' : 'jini-field-hint',
72
78
  hintRole: invalid ? 'alert' : undefined,
73
79
  ariaInvalid: invalid || undefined,
@@ -177,10 +183,14 @@ export function useProvidersTab(_required = {}, _optional = {}) {
177
183
  const catalog = mediaProviders?.catalog ?? discoveredCatalog;
178
184
  const pinnedIds = mediaProviders?.pinnedProviderIds ?? [];
179
185
  const ordered = sortProvidersByConfigured(catalog, editor.providers, pinnedIds);
180
- const toggleKey = (id) => setVisibleKeys(current => { const next = new Set(current); if (next.has(id))
181
- next.delete(id);
182
- else
183
- next.add(id); return next; });
186
+ const toggleKey = (id) => setVisibleKeys(current => {
187
+ const next = new Set(current);
188
+ if (next.has(id))
189
+ next.delete(id);
190
+ else
191
+ next.add(id);
192
+ return next;
193
+ });
184
194
  const rows = ordered.map((item, index) => providerRow({ item, entry: editor.providers[item.id] ?? {}, editor, visible: visibleKeys.has(item.id), toggle: () => toggleKey(item.id) }, { divider: index > 0 && !pinnedIds.includes(item.id) && pinnedIds.includes(ordered[index - 1].id), disabled: !mediaProviders }));
185
195
  return { rows, ...editorView({ editor, port: mediaProviders }) };
186
196
  }
@@ -1,3 +1,4 @@
1
+ import { trimChars } from '@jini-ai/core/text';
1
2
  /** Falls back to "all" for an absent or unrecognized ?tab= value — don't trust a raw
2
3
  * query value: a stale link or a typo must not blank the panel. */
3
4
  export function resolveActiveTab({ tabId }, _optional = {}) {
@@ -138,11 +139,13 @@ export function mediaEmbedSnippet({ slug }, _optional = {}) {
138
139
  export function mediaRowHandles({ media }, _optional = {}) {
139
140
  const used = new Set();
140
141
  return media.map((item, index) => {
141
- const slug = item.id.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
142
+ const slug = trimChars({ text: item.id.toLowerCase().replace(/[^a-z0-9]+/g, '-'), chars: '-' });
142
143
  const preferred = `media-item-${slug || index + 1}`;
143
144
  let handle = preferred, suffix = 2;
144
- while (used.has(handle))
145
- handle = `${preferred}-${suffix++}`;
145
+ while (used.has(handle)) {
146
+ handle = `${preferred}-${suffix}`;
147
+ suffix += 1;
148
+ }
146
149
  used.add(handle);
147
150
  return handle;
148
151
  });
@@ -18,13 +18,32 @@ export function createMemoryPlaygroundTargets(_required, { onListenerError } = {
18
18
  }
19
19
  return {
20
20
  getSnapshot: (_required = {}, _optional = {}) => snapshot,
21
- subscribe({ listener }, _optional = {}) { if (!disposed)
22
- listeners.add(listener); return () => listeners.delete(listener); },
23
- register({ target }, _optional = {}) { if (disposed)
24
- throw new Error('Playground target scope disposed'); const ticket = {}; owner = ticket; publish(target); return { release(_required = {}, _optional = {}) { if (owner !== ticket)
25
- return; owner = null; publish(null); } }; },
26
- dispose(_required = {}, _optional = {}) { if (disposed)
27
- return; disposed = true; owner = null; publish(null); listeners.clear(); },
21
+ subscribe({ listener }, _optional = {}) {
22
+ if (!disposed)
23
+ listeners.add(listener);
24
+ return () => listeners.delete(listener);
25
+ },
26
+ register({ target }, _optional = {}) {
27
+ if (disposed)
28
+ throw new Error('Playground target scope disposed');
29
+ const ticket = {};
30
+ owner = ticket;
31
+ publish(target);
32
+ return { release(_required = {}, _optional = {}) {
33
+ if (owner !== ticket)
34
+ return;
35
+ owner = null;
36
+ publish(null);
37
+ } };
38
+ },
39
+ dispose(_required = {}, _optional = {}) {
40
+ if (disposed)
41
+ return;
42
+ disposed = true;
43
+ owner = null;
44
+ publish(null);
45
+ listeners.clear();
46
+ },
28
47
  };
29
48
  }
30
49
  //# sourceMappingURL=memory.js.map
@@ -1,11 +1,14 @@
1
1
  /** Run with an isolated bus; this temporarily registers and releases test targets. */
2
2
  export function runPlaygroundApiConformance({ targets }, _optional = {}) {
3
3
  const checks = [];
4
- const assert = (ok, name) => { if (!ok)
5
- throw new Error(`Playground conformance: ${name}`); checks.push(name); };
4
+ const assert = (ok, name) => {
5
+ if (!ok)
6
+ throw new Error(`Playground conformance: ${name}`);
7
+ checks.push(name);
8
+ };
6
9
  const a = {}, b = {};
7
10
  let count = 0;
8
- const stop = targets.subscribe({ listener: () => count++ });
11
+ const stop = targets.subscribe({ listener: () => { count += 1; } });
9
12
  const first = targets.register({ target: a });
10
13
  let second;
11
14
  try {
@@ -1,6 +1,10 @@
1
1
  import { createContext, useContext } from 'react';
2
2
  import { AdminConfigError } from '../../../core/module/index.js';
3
3
  export const PlaygroundPortsContext = createContext(null);
4
- export function usePlaygroundPorts(_required = {}, _optional = {}) { const ports = useContext(PlaygroundPortsContext); if (!ports)
5
- throw new AdminConfigError({ issues: ['React scope unavailable: playground'] }); return ports; }
4
+ export function usePlaygroundPorts(_required = {}, _optional = {}) {
5
+ const ports = useContext(PlaygroundPortsContext);
6
+ if (!ports)
7
+ throw new AdminConfigError({ issues: ['React scope unavailable: playground'] });
8
+ return ports;
9
+ }
6
10
  //# sourceMappingURL=PlaygroundPorts.hooks.js.map
@@ -22,7 +22,7 @@ export function useConfirmButton({ onConfirm, document }, { pending, disabled }
22
22
  // standing per-row document listener.
23
23
  useEffect(() => {
24
24
  if (!confirming)
25
- return;
25
+ return undefined;
26
26
  function disarm() {
27
27
  setConfirming(false);
28
28
  }
@@ -29,11 +29,8 @@ const DEFAULT_PENDING_LABEL = '…';
29
29
  */
30
30
  export function ConfirmButton({ useConfirmButton: useConfirmButtonState = useConfirmButton, ...props }) {
31
31
  const { confirming, buttonRef, handleClick, handleBlur } = useConfirmButtonState({ onConfirm: props.onConfirm, document }, { pending: props.pending, disabled: props.disabled });
32
- const label = props.pending
33
- ? (props.pendingLabel ?? DEFAULT_PENDING_LABEL)
34
- : confirming
35
- ? props.confirmLabel
36
- : props.label;
32
+ const idleLabel = confirming ? props.confirmLabel : props.label;
33
+ const label = props.pending ? (props.pendingLabel ?? DEFAULT_PENDING_LABEL) : idleLabel;
37
34
  const className = [props.className, props.destructive ? 'btn-danger' : null].filter(Boolean).join(' ') || undefined;
38
35
  return (_jsxs(_Fragment, { children: [_jsx("button", { ref: buttonRef, type: "button", className: className, "data-armed": confirming ? 'true' : undefined, disabled: props.disabled || props.pending, onClick: handleClick, onBlur: handleBlur, "aria-label": props.ariaLabel, children: label }), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: confirming ? `Press "${props.confirmLabel}" to confirm, or press Escape to cancel.` : '' })] }));
39
36
  }
@@ -1,4 +1,5 @@
1
1
  import { agentHandle } from '@jini-ai/agentic';
2
+ import { omitKeys } from '../../../core/omit-keys.js';
2
3
  /**
3
4
  * The agent-attribute port `ConfirmDialog` hands UI-kit's confirm controller. UI-kit names selects
4
5
  * separately; agentic publishes them as fields and still owns handle validation. Absent role/label
@@ -18,7 +19,6 @@ export const confirmDialogAgentAttrs = (required, optional = {}) => agentHandle(
18
19
  * @complexity O(k) in the number of attributes.
19
20
  */
20
21
  export function withoutAccessibleName({ attrs }, _optional = {}) {
21
- const { 'aria-label': _accessibleName, ...rest } = attrs ?? {};
22
- return rest;
22
+ return omitKeys(attrs ?? {}, ['aria-label']);
23
23
  }
24
24
  //# sourceMappingURL=confirm-dialog-attrs.js.map
@@ -154,7 +154,9 @@ function parseEmbedConfig(raw) {
154
154
  }
155
155
  function galleryIdentityLabel(config) {
156
156
  const count = Array.isArray(config.images) ? config.images.length : 0;
157
- return count === 0 ? 'no images set' : `${count} ${count === 1 ? 'image' : 'images'}`;
157
+ if (count === 0)
158
+ return 'no images set';
159
+ return count === 1 ? '1 image' : `${count} images`;
158
160
  }
159
161
  export function InteractiveHtmlEditor(props) {
160
162
  return (_jsx(HtmlEditor, { ...props, isProtectedElement: (el) => isProtectedEmbedElement({ el }), describeEmbedPlaceholder: (el) => describeEmbedPlaceholder({ el }) }));
@@ -121,14 +121,10 @@ export function useRowMenu({ itemCount, window, document }) {
121
121
  openAt(0);
122
122
  }
123
123
  function onTriggerKeyDown(e) {
124
- if (e.key === 'ArrowDown') {
125
- e.preventDefault();
126
- openAt(0);
127
- }
128
- else if (e.key === 'ArrowUp') {
129
- e.preventDefault();
130
- openAt(itemCount - 1);
131
- }
124
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')
125
+ return;
126
+ e.preventDefault();
127
+ openAt(e.key === 'ArrowDown' ? 0 : itemCount - 1);
132
128
  }
133
129
  function onMenuKeyDown(e) {
134
130
  switch (e.key) {
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Fragment, createContext, createElement, useContext, useEffect, useMemo, useRef, useState, } from 'react';
2
+ import { createContext, createElement, useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { DEFAULT_ADMIN_BASE, adminHref } from '../../core/routing/rules.js';
5
5
  import { useNavSections } from '../hooks/use-nav-sections.js';
@@ -43,6 +43,7 @@ export function EntityField(props) {
43
43
  case 'integer':
44
44
  case 'real': return _jsx(NumberInput, { ...props });
45
45
  case 'text': return _jsx("input", { ...commonProps(props), value: typeof value === 'string' ? value : '', onChange: (e) => onChange({ value: blankToUndefined(e.target.value) }) });
46
+ default: return null;
46
47
  }
47
48
  }
48
49
  //# sourceMappingURL=EntityField.js.map
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { trimCharsEnd } from '@jini-ai/core/text';
2
3
  import { EntityIndex } from './EntityIndex.js';
3
4
  import { EntityList } from './EntityList.js';
4
5
  import { EntityDetail } from './EntityDetail.js';
@@ -18,7 +19,7 @@ export function createEntityRoutes({ adminBase, panelId, navigate }) {
18
19
  throw new Error('Entity panelId must be one URL segment');
19
20
  if (!adminBase.startsWith('/') || adminBase.startsWith('//') || /[?#]/.test(adminBase))
20
21
  throw new Error('Entity adminBase must be an absolute pathname');
21
- const base = adminBase.replace(/\/+$/, '');
22
+ const base = trimCharsEnd({ text: adminBase, chars: '/' });
22
23
  function routePath({ entity, id, view }) {
23
24
  const root = `/${panelId}`;
24
25
  if (entity === undefined)
@@ -1,6 +1,6 @@
1
1
  /** Stable source templates let host dictionaries translate copy before values are inserted. */
2
2
  export function entityText({ translate, key, values }) {
3
- return translate({ key: key }).replace(/\{([\w]+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
3
+ return translate({ key: key }).replace(/\{(\w+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
4
4
  }
5
5
  /**
6
6
  * Adapts the host's object-arg {@link EntityTranslate} to the positional `(key) => string` form the
@@ -4,6 +4,7 @@ import { createControllerStore } from '../core/module/controller-store.js';
4
4
  export function createOverlayController(_required = {}, _optional = {}) {
5
5
  const store = createControllerStore({ initial: { entries: [] } });
6
6
  let sequence = 0, cancelActive = null;
7
+ const nextSequence = () => { sequence += 1; return sequence; };
7
8
  return {
8
9
  getSnapshot: store.getSnapshot,
9
10
  subscribe: store.subscribe,
@@ -28,7 +29,7 @@ export function createOverlayController(_required = {}, _optional = {}) {
28
29
  signal?.addEventListener('abort', cancel, { once: true });
29
30
  store.set({
30
31
  patch: {
31
- entries: [{ id: ++sequence, render: () => render({ resolve: finish, cancel }) }],
32
+ entries: [{ id: nextSequence(), render: () => render({ resolve: finish, cancel }) }],
32
33
  },
33
34
  });
34
35
  });
@@ -14,7 +14,7 @@ export function useShellDrawer(args) {
14
14
  useEffect(() => setOpen(false), [args.routePath]);
15
15
  useEffect(() => {
16
16
  if (!open)
17
- return;
17
+ return undefined;
18
18
  const onKeyDown = (event) => {
19
19
  if (event.key === 'Escape')
20
20
  setOpen(false);