@jini-ai/admin 0.6.15-rc.1 → 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 (104) 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/html-rules.js +10 -13
  30. package/dist/forms/react/hooks/forms-view.hooks.d.ts +2 -2
  31. package/dist/forms/react/hooks/forms-view.hooks.js +9 -1
  32. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +1 -3
  33. package/dist/forms/react/hooks/use-form-editor.hooks.js +8 -9
  34. package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -4
  35. package/dist/forms/react/hooks/use-form-submissions.hooks.js +1 -5
  36. package/dist/forms/react/hooks/use-forms-list.hooks.js +1 -2
  37. package/dist/forms/rules.js +7 -2
  38. package/dist/media/adapters/memory.js +5 -3
  39. package/dist/media/controllers/library.controller.js +4 -2
  40. package/dist/media/controllers/providers.controller.js +2 -1
  41. package/dist/media/html-attributes.d.ts +1 -8
  42. package/dist/media/html-attributes.js +6 -161
  43. package/dist/media/react/components/MediaCard.js +11 -1
  44. package/dist/media/react/hooks/EditMediaPanel.hooks.js +18 -7
  45. package/dist/media/react/hooks/LibraryTab.hooks.js +3 -2
  46. package/dist/media/react/hooks/ProvidersTab.hooks.js +19 -9
  47. package/dist/media/rules.js +6 -3
  48. package/dist/playground/adapters/memory.js +26 -7
  49. package/dist/playground/conformance/playground-api.conformance.js +6 -3
  50. package/dist/playground/react/hooks/PlaygroundPorts.hooks.js +6 -2
  51. package/dist/react/components/ConfirmButton/ConfirmButton.hooks.js +1 -1
  52. package/dist/react/components/ConfirmButton/ConfirmButton.js +2 -5
  53. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +2 -2
  54. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +3 -1
  55. package/dist/react/components/RowMenu/RowMenu.hooks.js +4 -8
  56. package/dist/react/components/Sidebar.js +1 -1
  57. package/dist/react/entities/EntityField.js +1 -0
  58. package/dist/react/entities/contribution.js +2 -1
  59. package/dist/react/entities/types.js +1 -1
  60. package/dist/react/overlays.js +2 -1
  61. package/dist/react/shell/AdminShell.hooks.js +1 -1
  62. package/dist/react/shell/use-admin-shell-session.js +2 -1
  63. package/dist/react/shell/use-admin-theme.js +6 -6
  64. package/dist/redirects/adapters/memory.js +16 -13
  65. package/dist/security/adapters/http.js +10 -11
  66. package/dist/security/adapters/memory.js +50 -15
  67. package/dist/security/conformance/security-api.conformance.js +6 -3
  68. package/dist/security/controllers/access-tokens.controller.js +7 -3
  69. package/dist/security/controllers/other-credentials.controller.js +4 -2
  70. package/dist/security/controllers/root-key.controller.js +12 -5
  71. package/dist/security/react/hooks/AccessTokensTab.hooks.js +8 -3
  72. package/dist/security/react/hooks/SecurityPorts.hooks.js +6 -2
  73. package/dist/security/rules.js +19 -5
  74. package/dist/seo/adapters/memory.js +5 -2
  75. package/dist/seo/controllers/seo-entry.controller.js +2 -1
  76. package/dist/seo/controllers/sitemap.controller.js +2 -1
  77. package/dist/seo/react/hooks/MediaPickerBridge.hooks.js +1 -1
  78. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -21
  79. package/dist/seo/react/hooks/SeoPage.hooks.js +0 -8
  80. package/dist/seo/react/hooks/use-seo.hooks.js +1 -1
  81. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -1
  82. package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +9 -1
  83. package/dist/seo/react/pages/SeoPage.js +17 -3
  84. package/dist/source-control/adapters/memory.js +30 -14
  85. package/dist/source-control/controllers/source-control.controller.js +5 -2
  86. package/dist/source-control/react/components/SourceControlProviderRow.js +6 -1
  87. package/dist/source-control/react/hooks/SourceControlPorts.hooks.js +6 -2
  88. package/dist/source-control/rules.js +9 -3
  89. package/dist/widgets/adapters/http.js +2 -1
  90. package/dist/widgets/adapters/memory.js +4 -2
  91. package/dist/widgets/conformance/widgets-api.conformance.js +5 -2
  92. package/dist/widgets/controllers/instance-editor.controller.js +3 -2
  93. package/dist/widgets/controllers/library.controller.js +2 -1
  94. package/dist/widgets/controllers/region-editor.controller.js +2 -1
  95. package/dist/widgets/controllers/regions.controller.js +2 -1
  96. package/dist/widgets/react/components/WidgetInstanceNotices.d.ts +1 -1
  97. package/dist/widgets/react/components/WidgetInstanceNotices.js +2 -0
  98. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +5 -2
  99. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +5 -2
  100. package/dist/widgets/react/pages/WidgetInstanceEditor.js +1 -1
  101. package/dist/widgets/react/pages/WidgetRegionEditor.js +1 -1
  102. package/dist/widgets/react/pages/WidgetsLibrary.js +1 -1
  103. package/dist/widgets/rules.js +5 -1
  104. package/package.json +13 -13
@@ -1,4 +1,4 @@
1
- import { FIELD_TYPE_REGISTRY } from "@jini-ai/cms/forms";
1
+ import { FIELD_TYPE_REGISTRY, escapeFormHtml } from "@jini-ai/cms/forms";
2
2
  /** Persisted descriptors, including those derived from HTML, supply column headers, choice labels and field types. */
3
3
  export function formAnswerColumns({ fields }, display = {}) {
4
4
  return fields.map((field) => {
@@ -85,9 +85,6 @@ export function formSubmissionRows({ data, answerColumns }, { unknownFieldLabel
85
85
  return { key, label: column?.header.trim() || unknownFieldLabel, value: formatted, file: formAnswerFile({ value }) };
86
86
  });
87
87
  }
88
- function escapeHtml(value) {
89
- return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
90
- }
91
88
  const kindOf = (type) => Object.hasOwn(FIELD_TYPE_REGISTRY, type) ? FIELD_TYPE_REGISTRY[type] : { control: "input", htmlType: "text" };
92
89
  const isDefault = (field, value) => Array.isArray(field.default) ? field.default.includes(value) : field.default === value;
93
90
  /** Name, required and the stored constraints, as authored HTML attributes the CMS reads back. */
@@ -97,13 +94,13 @@ function starterAttributes(field, kind) {
97
94
  ["pattern", field.pattern], ["min", field.min], ["max", field.max], ["step", field.step], ["accept", field.accept?.join(",")],
98
95
  ...Object.entries(kind.attributes ?? {}),
99
96
  ];
100
- return `name="${escapeHtml(field.id)}"${field.required ? " required" : ""}` +
101
- constraints.filter(([, value]) => value != null && value !== "").map(([name, value]) => ` ${name}="${escapeHtml(String(value))}"`).join("");
97
+ return `name="${escapeFormHtml({ value: field.id })}"${field.required ? " required" : ""}` +
98
+ constraints.filter(([, value]) => value != null && value !== "").map(([name, value]) => ` ${name}="${escapeFormHtml({ value: String(value) })}"`).join("");
102
99
  }
103
100
  /** One field's starter markup; every control comes from the registry kind, as the public render does. */
104
101
  function starterField(field) {
105
102
  const kind = kindOf(field.type);
106
- return starterRendererFor({ kind })({ field, kind, label: escapeHtml(field.label), attrs: starterAttributes(field, kind) });
103
+ return starterRendererFor({ kind })({ field, kind, label: escapeFormHtml({ value: field.label }), attrs: starterAttributes(field, kind) });
107
104
  }
108
105
  function starterRendererFor({ kind }) {
109
106
  if (kind.control === "group")
@@ -113,7 +110,7 @@ function starterRendererFor({ kind }) {
113
110
  if (kind.control === "textarea")
114
111
  return ({ label, attrs }) => `<label>${label}\n <textarea ${attrs}></textarea>\n</label>`;
115
112
  if (kind.htmlType === "hidden")
116
- return ({ field, label, attrs }) => `<input type="hidden" ${attrs} value="${escapeHtml(String(field.default ?? ""))}" aria-label="${label}">`;
113
+ return ({ field, label, attrs }) => `<input type="hidden" ${attrs} value="${escapeFormHtml({ value: String(field.default ?? "") })}" aria-label="${label}">`;
117
114
  if (kind.htmlType === "checkbox")
118
115
  return starterCheckbox;
119
116
  return ({ label, attrs }) => `<label>${label}\n <input type="${kind.htmlType}" ${attrs}>\n</label>`;
@@ -123,16 +120,16 @@ function starterGroup({ field, kind, label, attrs }) {
123
120
  // Native `required` on every checkbox would demand ALL of them, so a checkbox group leaves it
124
121
  // off (the server still enforces one-or-more); a radio group's `required` means "pick one".
125
122
  const boxAttrs = kind.multiple ? starterAttributes({ ...field, required: false }, kind) : attrs;
126
- const boxes = (field.options ?? []).map((option) => ` <label><input type="${kind.htmlType}" ${boxAttrs} value="${escapeHtml(option.value)}"${isDefault(field, option.value) ? " checked" : ""}> ${escapeHtml(option.label)}</label>`);
123
+ const boxes = (field.options ?? []).map((option) => ` <label><input type="${kind.htmlType}" ${boxAttrs} value="${escapeFormHtml({ value: option.value })}"${isDefault(field, option.value) ? " checked" : ""}> ${escapeFormHtml({ value: option.label })}</label>`);
127
124
  return `<fieldset>\n <legend>${label}</legend>\n${boxes.join("\n")}\n</fieldset>`;
128
125
  }
129
126
  function starterSelect({ field, kind, label, attrs }) {
130
- const empty = kind.multiple ? [] : [` <option value="">${escapeHtml(field.placeholder ?? "Choose an option")}</option>`];
131
- const choices = (field.options ?? []).map((option) => ` <option value="${escapeHtml(option.value)}"${isDefault(field, option.value) ? " selected" : ""}>${escapeHtml(option.label)}</option>`);
127
+ const empty = kind.multiple ? [] : [` <option value="">${escapeFormHtml({ value: field.placeholder ?? "Choose an option" })}</option>`];
128
+ const choices = (field.options ?? []).map((option) => ` <option value="${escapeFormHtml({ value: option.value })}"${isDefault(field, option.value) ? " selected" : ""}>${escapeFormHtml({ value: option.label })}</option>`);
132
129
  return `<label>${label}\n <select ${attrs}${kind.multiple ? " multiple" : ""}>\n${[...empty, ...choices].join("\n")}\n </select>\n</label>`;
133
130
  }
134
131
  function starterCheckbox({ field, label, attrs }) {
135
- const value = field.checkboxValue !== undefined ? ` value="${escapeHtml(field.checkboxValue)}"` : "";
132
+ const value = field.checkboxValue !== undefined ? ` value="${escapeFormHtml({ value: field.checkboxValue })}"` : "";
136
133
  const checked = field.default === true || (field.checkboxValue !== undefined && field.default === field.checkboxValue) ? " checked" : "";
137
134
  return `<label><input type="checkbox"${field.type === "toggle" ? ' role="switch"' : ""} ${attrs}${value}${checked}> ${label}</label>`;
138
135
  }
@@ -144,7 +141,7 @@ function starterCheckbox({ field, label, attrs }) {
144
141
  * @complexity O(f + o) for fields and their options; no I/O.
145
142
  */
146
143
  export function formHtmlStarter({ fields }, { submitLabel = "Send" } = {}) {
147
- return fields.map(starterField).join("\n\n") + `\n\n<button type="submit">${escapeHtml(submitLabel)}</button>`;
144
+ return fields.map(starterField).join("\n\n") + `\n\n<button type="submit">${escapeFormHtml({ value: submitLabel })}</button>`;
148
145
  }
149
146
  export function formHtmlEmbed({ slug }, _optional = {}) {
150
147
  const config = JSON.stringify({ type: "form", id: slug, mode: "html" }).replace(/'/g, "&#39;");
@@ -26,7 +26,7 @@ export declare function useFormsListView({ forms, t, formatFormDates, toggleStat
26
26
  createdAt: string;
27
27
  updatedAt: string;
28
28
  name: string;
29
- mode?: "builder" | "html" | undefined;
29
+ mode?: import("../../../core/ports/forms.js").AdminFormMode | undefined;
30
30
  workspaceId?: string;
31
31
  fields: AdminFormField[];
32
32
  notify: import("../../models.js").AdminFormNotify;
@@ -97,7 +97,7 @@ export declare function useFieldSettingsView({ field, controller, t }: {
97
97
  boundsType: import("../../field-settings.js").FieldBounds;
98
98
  boundsClass: string;
99
99
  /** `<input type>` for a typed default, or `null` when the default is a box or a choice. */
100
- defaultInputType: string | null;
100
+ defaultInputType: "text" | import("../../field-settings.js").FieldBounds | null;
101
101
  defaultClass: string | undefined;
102
102
  onPlaceholder: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
103
103
  onHelpText: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
@@ -56,6 +56,14 @@ export function useFieldAttributesView({ field, fieldIndex, controller, onCancel
56
56
  onRemove: () => controller.removeRow(row._rowId), })),
57
57
  };
58
58
  }
59
+ /** `<input type>` for a typed default: temporal kinds use their bounds type; boxes and choices have none. */
60
+ function defaultInputTypeFor(kind, boundsType) {
61
+ if (kind === 'temporal')
62
+ return boundsType;
63
+ if (kind === 'number')
64
+ return 'number';
65
+ return kind === 'text' || kind === 'textarea' ? 'text' : null;
66
+ }
59
67
  /** Bind the settings section's controls; which ones render comes from the field's registry kind. */
60
68
  export function useFieldSettingsView({ field, controller, t }, _optional = {}) {
61
69
  const settings = fieldSettingsFor({ type: field.type });
@@ -69,7 +77,7 @@ export function useFieldSettingsView({ field, controller, t }, _optional = {}) {
69
77
  settings, form, boundsType,
70
78
  boundsClass: settings.bounds === 'number' ? 'field-settings-num' : 'field-settings-compact',
71
79
  /** `<input type>` for a typed default, or `null` when the default is a box or a choice. */
72
- defaultInputType: kind === 'temporal' ? boundsType : kind === 'number' ? 'number' : kind === 'text' || kind === 'textarea' ? 'text' : null,
80
+ defaultInputType: defaultInputTypeFor(kind, boundsType),
73
81
  defaultClass: kind === 'number' || kind === 'temporal' ? 'field-settings-compact' : undefined,
74
82
  onPlaceholder: value('placeholder'), onHelpText: value('helpText'), onDefaultText: value('defaultText'),
75
83
  onMinLength: value('minLength'), onMaxLength: value('maxLength'), onPattern: value('pattern'),
@@ -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 });
@@ -1,13 +1,6 @@
1
- /** Browser-only live metadata hint, separate from server validation across the browser/Node
2
- * boundary. The server must enforce the same policy on writes and rendering independently.
3
- * Hints never gate Save: an invalid attribute once silently blocked saving unrelated title/alt/
4
- * caption/credit/class changes. Rejections must instead reach the authoritative atomic write.
5
- * One flat allowlist includes global and image/video extras: per-element name filtering is
6
- * unnecessary because an unused loading attribute on a wrapper is harmless. Extend the list,
7
- * not the tokenizer, when adding an allowed name. data-/aria- families remain open-ended. */
8
1
  export declare const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES: readonly ["class", "id", "style", "title", "role", "tabindex", "lang", "dir", "hidden", "translate", "draggable", "loading", "decoding", "playsinline", "muted", "loop", "autoplay", "poster"];
9
2
  /** Whether `name` is on the allowlist — a well-formed attribute name
10
- * ({@link MEDIA_HTML_ATTRIBUTE_NAME_PATTERN}) that is either an exact match against
3
+ * (`/^[a-z][a-z0-9-]*$/`) that is either an exact match against
11
4
  * {@link MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES} or carries a `data-`/`aria-` prefix (both open-ended
12
5
  * families with no fixed suffix list). Case-insensitive: HTML attribute names are themselves
13
6
  * case-insensitive, and an operator typing `DATA-FOO` should not slip past a lowercase-only check.
@@ -5,44 +5,10 @@
5
5
  * One flat allowlist includes global and image/video extras: per-element name filtering is
6
6
  * unnecessary because an unused loading attribute on a wrapper is harmless. Extend the list,
7
7
  * not the tokenizer, when adding an allowed name. data-/aria- families remain open-ended. */
8
- export const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES = [
9
- // Global attributes (2026-09-23).
10
- "class",
11
- "id",
12
- "style",
13
- "title",
14
- "role",
15
- "tabindex",
16
- "lang",
17
- "dir",
18
- "hidden",
19
- "translate",
20
- "draggable",
21
- // Pre-existing <img>/<video> extras, kept everywhere (one list, not one per element).
22
- "loading",
23
- "decoding",
24
- "playsinline",
25
- "muted",
26
- "loop",
27
- "autoplay",
28
- "poster",
29
- ];
30
- /**
31
- * The shape EVERY accepted attribute name must have, checked before allowlist membership
32
- * (2026-09-07 stored-XSS fix; keep identical in `@jini-ai/cms/media`'s `html-attributes.ts` — see
33
- * that file's copy of this comment for the full incident).
34
- *
35
- * The `data-`/`aria-` families below are open-ended by design — no fixed suffix list — so the
36
- * prefix check alone accepted whatever characters {@link HTML_ATTRIBUTE_TOKEN}'s name class
37
- * (`[^\s="']+`, which excludes only whitespace, `=` and quotes) let through. `<`, `>` and `/` are
38
- * all legal in that class, and the server renderer templating a name into ` name="value"` escapes
39
- * only the VALUE — so a stored `data-x><svg/onload=alert(1)` closed the `<img>` and opened a live
40
- * `<svg onload>` on the public page. Constraining the NAME to the characters a real HTML attribute
41
- * name can contain is what makes "the value is escaped" sufficient.
42
- */
43
- const MEDIA_HTML_ATTRIBUTE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
8
+ import { EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES, isAllowedEmbedHtmlAttributeName, parseEmbedHtmlAttributes, } from "@jini-ai/cms/widgets/markers";
9
+ export const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES = EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES;
44
10
  /** Whether `name` is on the allowlist — a well-formed attribute name
45
- * ({@link MEDIA_HTML_ATTRIBUTE_NAME_PATTERN}) that is either an exact match against
11
+ * (`/^[a-z][a-z0-9-]*$/`) that is either an exact match against
46
12
  * {@link MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES} or carries a `data-`/`aria-` prefix (both open-ended
47
13
  * families with no fixed suffix list). Case-insensitive: HTML attribute names are themselves
48
14
  * case-insensitive, and an operator typing `DATA-FOO` should not slip past a lowercase-only check.
@@ -51,82 +17,7 @@ const MEDIA_HTML_ATTRIBUTE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
51
17
  * one fixed-length array lookup.
52
18
  */
53
19
  export function isAllowedMediaHtmlAttributeName({ name }, _optional = {}) {
54
- const lower = name.toLowerCase();
55
- if (!MEDIA_HTML_ATTRIBUTE_NAME_PATTERN.test(lower))
56
- return false;
57
- if (lower.startsWith("data-") || lower.startsWith("aria-"))
58
- return true;
59
- return MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES.includes(lower);
60
- }
61
- /** Matches one `name`, or one `name="value"`/`name='value'`/`name=value` pair — the same loose
62
- * shape real HTML attribute syntax allows, since that is the syntax an operator typing this field
63
- * would naturally reach for. */
64
- const HTML_ATTRIBUTE_TOKEN = /([^\s="']+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/g;
65
- /** The highest ASCII code point {@link isUnsafeMediaUrlValue} strips: every C0 control character
66
- * plus the ordinary space, i.e. code points `0`-`32` inclusive. Named rather than written as an
67
- * inline regex control-character range so the linter never has to reason about a literal control
68
- * character inside a pattern. Ported from the shared server parser, `html-attributes.ts`. */
69
- const ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT = 0x20;
70
- /**
71
- * Whether `value`'s scheme, once every ASCII whitespace/control character (code points `0`-`32`) is
72
- * stripped and the result lowercased, starts with `javascript:`, `vbscript:` or `data:` — the three
73
- * schemes a browser will still execute or navigate to even when the URL carries interior whitespace
74
- * a naive end-trim (the old `.trim()`-only check) would miss, e.g. `java\tscript:x`: browsers strip
75
- * tabs and newlines from inside a URL before parsing its scheme, so this check must too. Ported
76
- * 2026-09-23 from the shared server parser's `isUnsafeUrlValue` (`html-attributes.ts`).
77
- *
78
- * @complexity O(n) in the value's length (one filter pass, one prefix test).
79
- */
80
- function isUnsafeMediaUrlValue(value) {
81
- let stripped = "";
82
- for (const char of value) {
83
- // `char` is never empty, so `charCodeAt(0)` is always a number; for an astral character it is
84
- // the high surrogate (>= 0xD800), which is kept exactly as the full code point would be.
85
- if (char.charCodeAt(0) > ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT)
86
- stripped += char;
87
- }
88
- stripped = stripped.toLowerCase();
89
- return stripped.startsWith("javascript:") || stripped.startsWith("vbscript:") || stripped.startsWith("data:");
90
- }
91
- /**
92
- * Whether a `style` value carries a known CSS injection or CSS-escape-bypass vector:
93
- * `expression(...)` (legacy IE script-in-CSS), a `javascript:`/`vbscript:` value inside a CSS
94
- * `url(...)`, `-moz-binding`/`behavior:` (XBL/HTC script bindings), `@import` (pulls in a second,
95
- * unvetted stylesheet), or ANY backslash — a CSS escape sequence (`\65` is `e`) can respell any of
96
- * the above past a plain substring check, so a backslash anywhere in the value is rejected outright.
97
- * Plain `url(...)` values (background images) are otherwise allowed. Ported 2026-09-23 from the
98
- * shared server parser's `isUnsafeStyleValue` (`html-attributes.ts`).
99
- *
100
- * @complexity O(n) in the value's length (one regex test).
101
- */
102
- const UNSAFE_MEDIA_STYLE_PATTERN = /expression\(|javascript:|vbscript:|-moz-binding|behavior:|@import|\\/i;
103
- function isUnsafeMediaStyleValue(value) {
104
- return UNSAFE_MEDIA_STYLE_PATTERN.test(value);
105
- }
106
- /** Classifies one already-tokenized `name`/`value` pair. Split out of {@link parseMediaHtmlAttributes}
107
- * so each rejection reason is its own directly testable branch, and so the loop below reads as
108
- * "tokenize, then classify" rather than one function doing both.
109
- *
110
- * Order matters (ported 2026-09-23 from the shared server parser's `classifyEmbedHtmlAttributeToken`):
111
- * `on*` is checked FIRST (so an `onerror` always reports as `event-handler`, the more actionable
112
- * reason, never the generic `disallowed-name`), then the URL-scheme check runs against EVERY value
113
- * regardless of name (matching the pre-existing `javascript:` check this is ported from), then
114
- * allowlist membership, then — only for an already-allowed `style` — the CSS-specific unsafe-value
115
- * check.
116
- *
117
- * @complexity O(1) — four fixed checks against one already-extracted token; each check's own O(n) in
118
- * the token's length is linear, not nested.
119
- */
120
- function classifyMediaHtmlAttributeToken(name, value) {
121
- if (name.startsWith("on"))
122
- return { reason: "event-handler", attribute: name };
123
- if (isUnsafeMediaUrlValue(value))
124
- return { reason: "unsafe-url", attribute: name };
125
- if (!isAllowedMediaHtmlAttributeName({ name }))
126
- return { reason: "disallowed-name", attribute: name };
127
- if (name === "style" && isUnsafeMediaStyleValue(value))
128
- return { reason: "unsafe-style", attribute: name };
129
- return null;
20
+ return isAllowedEmbedHtmlAttributeName({ name });
130
21
  }
131
22
  /**
132
23
  * Parses the `HTML attributes` field's free text (`name="value" name2="value2"`, or a bare
@@ -154,54 +45,8 @@ function classifyMediaHtmlAttributeToken(name, value) {
154
45
  * attributes plus O(e) for e rejected tokens.
155
46
  */
156
47
  export function parseMediaHtmlAttributes({ text }, _optional = {}) {
157
- const trimmed = text.trim();
158
- if (trimmed === "")
159
- return { attributes: {}, errors: [], error: null };
160
- const attributes = {};
161
- const errors = [];
162
- HTML_ATTRIBUTE_TOKEN.lastIndex = 0;
163
- let consumed = 0;
164
- // A `for` loop (rather than `while ((match = …exec(trimmed)) !== null)`) keeps every assignment
165
- // to `match` in statement position, never inside the loop's own test expression.
166
- for (let match = HTML_ATTRIBUTE_TOKEN.exec(trimmed); match !== null; match = HTML_ATTRIBUTE_TOKEN.exec(trimmed)) {
167
- // Non-whitespace text between the previous match and this one is a fragment the token pattern
168
- // could not parse as a name (e.g. a stray quote) — the tokenizer has lost sync, so everything
169
- // parsed so far is discarded rather than trusted.
170
- const skipped = trimmed.slice(consumed, match.index).trim();
171
- if (skipped !== "")
172
- return malformedMediaHtmlAttributes({ errors, fragment: skipped });
173
- consumed = match.index + match[0].length;
174
- const { name, value } = readMediaHtmlAttributeToken({ match });
175
- const rejection = classifyMediaHtmlAttributeToken(name, value);
176
- if (rejection) {
177
- errors.push(rejection);
178
- continue;
179
- }
180
- attributes[name] = value;
181
- }
182
- const trailing = trimmed.slice(consumed).trim();
183
- if (trailing !== "")
184
- return malformedMediaHtmlAttributes({ errors, fragment: trailing });
185
- return { attributes, errors, error: errors[0] ?? null };
186
- }
187
- /** The lowercased name and the value of one {@link HTML_ATTRIBUTE_TOKEN} match: whichever of the
188
- * double-quoted, single-quoted or unquoted value groups matched, or `""` for a bare boolean name.
189
- *
190
- * @complexity O(n) in the name's length (one lowercase pass).
191
- */
192
- function readMediaHtmlAttributeToken({ match }, _optional = {}) {
193
- return { name: match[1].toLowerCase(), value: match[2] ?? match[3] ?? match[4] ?? "" };
194
- }
195
- /** The fail-closed result once the tokenizer loses sync: `fragment` (already trimmed) is appended
196
- * to `errors` as the final `malformed` entry, and every accepted attribute is discarded — see
197
- * {@link parseMediaHtmlAttributes}'s header for why. Mutates `errors` (the caller's own list).
198
- *
199
- * @complexity O(1).
200
- */
201
- function malformedMediaHtmlAttributes({ errors, fragment }, _optional = {}) {
202
- const malformed = { reason: "malformed", attribute: fragment };
203
- errors.push(malformed);
204
- return { attributes: {}, errors, error: malformed };
48
+ // Media hints always use inline-attribute policy; stylesheet escape allowances do not apply.
49
+ return parseEmbedHtmlAttributes({ text });
205
50
  }
206
51
  /** The hint names the rejected token so operators can distinguish a typo from a hard no.
207
52
  * This is client feedback only; the server must independently enforce write/render policy. */
@@ -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 {