@jini-ai/admin 0.4.12 → 0.6.10

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 (151) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
  4. package/dist/agent-plugins/package-file-tree.js +54 -51
  5. package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
  6. package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
  7. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
  8. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
  9. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
  10. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
  11. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
  12. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
  13. package/dist/browser/navigation.js +44 -27
  14. package/dist/comments/adapters/memory.js +30 -12
  15. package/dist/comments/conformance/comments-api.conformance.js +55 -33
  16. package/dist/comments/controllers/comment-queue.controller.js +13 -4
  17. package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
  18. package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
  19. package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
  20. package/dist/comments/react/index.d.ts +2 -1
  21. package/dist/comments/react/index.js +1 -1
  22. package/dist/consent/react/ConsentFields.d.ts +35 -0
  23. package/dist/consent/react/ConsentFields.js +76 -0
  24. package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
  25. package/dist/consent/react/ConsentPreviewFrame.js +14 -0
  26. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
  27. package/dist/consent/react/ConsentSettingsPanel.js +6 -17
  28. package/dist/consent/react/index.d.ts +6 -1
  29. package/dist/consent/react/index.js +2 -0
  30. package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
  31. package/dist/consent/react/settings-panel.hooks.js +26 -0
  32. package/dist/core/entities/rules.js +126 -81
  33. package/dist/core/manifest/rules.js +10 -10
  34. package/dist/core/module/create-admin.js +195 -117
  35. package/dist/core/ports/redirects.d.ts +8 -6
  36. package/dist/core/ports/redirects.js +8 -6
  37. package/dist/core/routing/rules.js +9 -4
  38. package/dist/core/transport/http.js +37 -20
  39. package/dist/forms/adapters/memory.js +22 -11
  40. package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
  41. package/dist/forms/conformance/forms-api.conformance.js +42 -46
  42. package/dist/forms/field-settings.js +150 -115
  43. package/dist/forms/html-rules.js +45 -28
  44. package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
  45. package/dist/forms/react/components/FieldSettingsRows.js +29 -0
  46. package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
  47. package/dist/forms/react/components/FieldSettingsSection.js +5 -6
  48. package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
  49. package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
  50. package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
  51. package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
  52. package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
  53. package/dist/forms/react/components/FormSubmissions.js +4 -20
  54. package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
  55. package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
  56. package/dist/media/adapters/memory.js +31 -26
  57. package/dist/media/controllers/edit-media.controller.js +2 -1
  58. package/dist/media/controllers/library.controller.js +48 -35
  59. package/dist/media/controllers/providers.controller.js +2 -1
  60. package/dist/media/html-attributes.js +29 -15
  61. package/dist/media/index.d.ts +1 -1
  62. package/dist/media/index.js +1 -1
  63. package/dist/media/react/components/MediaIcons.d.ts +0 -3
  64. package/dist/media/react/components/MediaIcons.js +0 -9
  65. package/dist/media/react/components/MediaLightbox.d.ts +2 -1
  66. package/dist/media/react/components/MediaLightbox.js +11 -5
  67. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
  68. package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
  69. package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
  70. package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
  71. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
  72. package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
  73. package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
  74. package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
  75. package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
  76. package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
  77. package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
  78. package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
  79. package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
  80. package/dist/media/react/index.js +4 -0
  81. package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
  82. package/dist/media/react/tabs/LibraryTab.js +2 -2
  83. package/dist/media/rules.d.ts +7 -0
  84. package/dist/media/rules.js +25 -2
  85. package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
  86. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
  87. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
  88. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
  89. package/dist/react/components/RowMenu/RowMenu.js +16 -10
  90. package/dist/react/components/Sidebar.js +32 -26
  91. package/dist/react/entities/EntityEdit.js +17 -14
  92. package/dist/react/entities/EntityField.d.ts +5 -2
  93. package/dist/react/entities/EntityField.js +38 -23
  94. package/dist/react/entities/EntityList.js +6 -5
  95. package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
  96. package/dist/react/entities/RelationTruncationNotice.js +9 -0
  97. package/dist/react/entities/use-entity-edit.d.ts +4 -2
  98. package/dist/react/entities/use-entity-edit.js +52 -33
  99. package/dist/react/shell/AdminShell.js +40 -23
  100. package/dist/react/shell/model.d.ts +4 -1
  101. package/dist/react/shell/model.js +3 -2
  102. package/dist/react/shell/use-admin-shell-session.js +25 -26
  103. package/dist/redirects/adapters/memory.js +9 -2
  104. package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
  105. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
  106. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
  107. package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
  108. package/dist/security/adapters/http.js +25 -10
  109. package/dist/security/adapters/memory.js +19 -5
  110. package/dist/security/controllers/access-tokens.controller.js +27 -26
  111. package/dist/security/controllers/other-credentials.controller.js +4 -2
  112. package/dist/security/controllers/root-key.controller.js +17 -9
  113. package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
  114. package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
  115. package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
  116. package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
  117. package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
  118. package/dist/security/react/tabs/RootKeyTab.js +12 -3
  119. package/dist/security/rules.js +45 -25
  120. package/dist/seo/controllers/seo.controller.js +4 -2
  121. package/dist/seo/react/components/MediaRefField.d.ts +1 -1
  122. package/dist/seo/react/components/MediaRefField.js +19 -2
  123. package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
  124. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
  125. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
  126. package/dist/seo/react/pages/SeoPage.d.ts +4 -4
  127. package/dist/seo/react/pages/SeoPage.js +26 -6
  128. package/dist/seo/rules.js +39 -10
  129. package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
  130. package/dist/source-control/controllers/source-control.controller.js +20 -8
  131. package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
  132. package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
  133. package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
  134. package/dist/widgets/controllers/instance-editor.controller.js +33 -24
  135. package/dist/widgets/controllers/library.controller.js +18 -12
  136. package/dist/widgets/controllers/region-editor.controller.js +6 -5
  137. package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
  138. package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
  139. package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
  140. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
  141. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
  142. package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
  143. package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
  144. package/dist/widgets/rules.d.ts +14 -2
  145. package/dist/widgets/rules.js +9 -6
  146. package/package.json +23 -14
  147. package/src/themes/default/admin.css +51 -0
  148. package/src/themes/default/chat.css +21 -0
  149. package/src/themes/default/manifest.json +29 -0
  150. package/dist/media/styles.d.ts +0 -4
  151. package/dist/media/styles.js +0 -5
@@ -15,5 +15,5 @@ export interface FieldSettingsSectionProps {
15
15
  * placeholder (slider, file…) focused the Help text ⓘ, whose tip opened half behind the dialog
16
16
  * (Chrome check, 2026-10-11). Focus must land on an input, never on an ⓘ.
17
17
  */
18
- export declare function FieldSettingsSection({ field, controller, t }: FieldSettingsSectionProps, _optional?: Record<string, never>): import("react").JSX.Element;
18
+ export declare function FieldSettingsSection({ field, controller, t }: FieldSettingsSectionProps): import("react").JSX.Element;
19
19
  //# sourceMappingURL=FieldSettingsSection.d.ts.map
@@ -1,8 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { agentHandle } from '@jini-ai/agentic';
3
- import { InfoTip } from '@jini-ai/ui/admin-widgets';
4
- import { MAX_UPLOAD_MB } from '../../field-settings.js';
5
3
  import { useFieldSettingsView } from '../hooks/forms-view.hooks.js';
4
+ import { FieldSettingsDefaultRows, FieldSettingsLimitRows, FieldSettingsOptionsRow, FieldSettingsTextRows, FieldSettingsUploadRows } from './FieldSettingsRows.js';
6
5
  /**
7
6
  * The settings half of the field dialog: one label-beside-control row per setting the field's
8
7
  * registry kind supports (`field-settings.ts` decides which). Markup only; every handler comes from
@@ -13,9 +12,9 @@ import { useFieldSettingsView } from '../hooks/forms-view.hooks.js';
13
12
  * placeholder (slider, file…) focused the Help text ⓘ, whose tip opened half behind the dialog
14
13
  * (Chrome check, 2026-10-11). Focus must land on an input, never on an ⓘ.
15
14
  */
16
- export function FieldSettingsSection({ field, controller, t }, _optional = {}) {
15
+ export function FieldSettingsSection({ field, controller, t }) {
17
16
  const view = useFieldSettingsView({ field, controller, t });
18
- const { settings, form } = view;
19
- return (_jsxs("div", { className: "field-settings", ...agentHandle({ handle: 'form-field-settings' }, { role: 'region', label: "This field's type-specific settings" }), children: [settings.placeholder ? (_jsxs("div", { className: "field-settings-row", children: [_jsx("label", { className: "field-label", htmlFor: "field-settings-placeholder", children: t('Placeholder') }), _jsx("input", { id: "field-settings-placeholder", "data-jini-autofocus": "", value: form.placeholder, onChange: view.onPlaceholder, ...agentHandle({ handle: 'form-field-settings-placeholder' }, { role: 'field', label: 'Hint text shown inside the empty control' }) })] })) : null, settings.helpText ? (_jsxs("div", { className: "field-settings-row", children: [_jsxs("span", { className: "field-settings-label", children: [_jsx("label", { className: "field-label", htmlFor: "field-settings-help", children: t('Help text') }), _jsx(InfoTip, { label: t('Shown under the field and read out by screen readers.') })] }), _jsx("input", { id: "field-settings-help", "data-jini-autofocus": "", value: form.helpText, onChange: view.onHelpText, ...agentHandle({ handle: 'form-field-settings-help' }, { role: 'field', label: 'Help text shown under this field' }) })] })) : null, settings.options ? (_jsxs("div", { className: "field-settings-row field-settings-row--top", children: [_jsxs("span", { className: "field-settings-label", children: [_jsx("span", { className: "field-label", id: "field-settings-options-label", children: t('Options') }), _jsx(InfoTip, { label: t('The label is what visitors see. The value is what the submission stores; it follows the label until you edit it.') })] }), _jsxs("div", { className: "field-options", role: "group", "aria-labelledby": "field-settings-options-label", children: [_jsx("ol", { className: "field-options-list", children: view.options.map((row) => (_jsxs("li", { className: "field-option-row", children: [_jsx("input", { className: "field-option-label", "aria-label": row.labelAria, placeholder: t('Label'), value: row.label, onChange: row.onLabel, ...agentHandle({ handle: `${row.handle}-label` }, { role: 'field', label: 'Option text visitors see' }) }), _jsx("input", { className: "field-option-value", "aria-label": row.valueAria, placeholder: t('Value'), value: row.value, onChange: row.onValue, ...agentHandle({ handle: `${row.handle}-value` }, { role: 'field', label: 'Option value stored with each submission' }) }), view.settings.multiple ? (_jsx("label", { className: "field-option-default", children: _jsx("input", { type: "checkbox", checked: row.isDefault, disabled: !row.value, onChange: row.onDefault, "aria-label": row.defaultAria }) })) : null, _jsxs("span", { className: "field-option-actions", children: [_jsx("button", { type: "button", className: "btn-ghost field-option-btn", disabled: row.isFirst, onClick: row.onUp, "aria-label": row.upAria, children: "\u2191" }), _jsx("button", { type: "button", className: "btn-ghost field-option-btn", disabled: row.isLast, onClick: row.onDown, "aria-label": row.downAria, children: "\u2193" }), _jsx("button", { type: "button", className: "btn-ghost field-option-btn", onClick: row.onRemove, "aria-label": row.removeAria, ...agentHandle({ handle: `${row.handle}-remove` }, { role: 'button', label: 'Remove this option' }), children: "\u00D7" })] })] }, row._rowId))) }), _jsx("button", { type: "button", className: "btn-secondary field-settings-add", onClick: view.addOption, ...agentHandle({ handle: 'form-field-options-add' }, { role: 'button', label: 'Add another option to this choice field' }), children: t('Add option') })] })] })) : null, settings.defaultKind === 'boolean' ? (_jsxs("div", { className: "field-settings-row", children: [_jsx("span", { className: "field-label", children: t('Default') }), _jsxs("label", { className: "form-checkbox-field", children: [_jsx("input", { type: "checkbox", checked: form.defaultChecked, onChange: view.onDefaultChecked, ...agentHandle({ handle: 'form-field-settings-default-checked' }, { role: 'checkbox', label: 'Whether this box starts checked' }) }), t('Checked by default')] })] })) : null, settings.defaultKind === 'choice' ? (_jsxs("div", { className: "field-settings-row", children: [_jsx("label", { className: "field-label", htmlFor: "field-settings-default", children: t('Default') }), _jsxs("select", { id: "field-settings-default", className: "field-settings-compact", value: form.defaultChoices[0] ?? '', onChange: view.onDefaultChoice, ...agentHandle({ handle: 'form-field-settings-default' }, { role: 'field', label: 'Which option starts selected — set with page.select_option' }), children: [_jsx("option", { value: "", children: t('None') }), view.defaultChoices.map((choice) => _jsx("option", { value: choice.value, children: choice.label }, choice.key))] })] })) : null, view.defaultInputType ? (_jsxs("div", { className: "field-settings-row", children: [_jsx("label", { className: "field-label", htmlFor: "field-settings-default", children: t('Default') }), _jsx("input", { id: "field-settings-default", "data-jini-autofocus": "", className: view.defaultClass, type: view.defaultInputType, value: form.defaultText, onChange: view.onDefaultText, ...agentHandle({ handle: 'form-field-settings-default' }, { role: 'field', label: 'The value this field starts with' }) })] })) : null, settings.length ? (_jsxs("div", { className: "field-settings-row", children: [_jsx("span", { className: "field-label", id: "field-settings-length-label", children: t('Length') }), _jsxs("span", { className: "field-settings-pair", role: "group", "aria-labelledby": "field-settings-length-label", children: [_jsx("input", { type: "number", min: 0, className: "field-settings-num", "aria-label": t('Min length'), placeholder: t('Min'), value: form.minLength, onChange: view.onMinLength, ...agentHandle({ handle: 'form-field-settings-min-length' }, { role: 'field', label: 'Fewest characters accepted' }) }), _jsx("span", { "aria-hidden": "true", children: "\u2013" }), _jsx("input", { type: "number", min: 1, className: "field-settings-num", "aria-label": t('Max length'), placeholder: t('Max'), value: form.maxLength, onChange: view.onMaxLength, ...agentHandle({ handle: 'form-field-settings-max-length' }, { role: 'field', label: 'Most characters accepted; blank means the default limit' }) })] })] })) : null, settings.pattern ? (_jsxs("div", { className: "field-settings-row", children: [_jsxs("span", { className: "field-settings-label", children: [_jsx("label", { className: "field-label", htmlFor: "field-settings-pattern", children: t('Pattern') }), _jsx(InfoTip, { label: t('Optional. The whole answer must match it, e.g. [0-9]+ for digits only. Groups, | and repeating more than once are not supported.') })] }), _jsx("input", { id: "field-settings-pattern", className: "field-settings-code", value: form.pattern, onChange: view.onPattern, spellCheck: false, ...agentHandle({ handle: 'form-field-settings-pattern' }, { role: 'field', label: 'Regular expression the whole answer must match' }) })] })) : null, settings.bounds ? (_jsxs("div", { className: "field-settings-row", children: [_jsx("span", { className: "field-label", id: "field-settings-bounds-label", children: t('Range') }), _jsxs("span", { className: "field-settings-pair", role: "group", "aria-labelledby": "field-settings-bounds-label", children: [_jsx("input", { type: view.boundsType, className: view.boundsClass, "aria-label": t('Min'), placeholder: t('Min'), value: form.min, onChange: view.onMin, ...agentHandle({ handle: 'form-field-settings-min' }, { role: 'field', label: 'Lowest value or earliest date/time accepted' }) }), _jsx("span", { "aria-hidden": "true", children: "\u2013" }), _jsx("input", { type: view.boundsType, className: view.boundsClass, "aria-label": t('Max'), placeholder: t('Max'), value: form.max, onChange: view.onMax, ...agentHandle({ handle: 'form-field-settings-max' }, { role: 'field', label: 'Highest value or latest date/time accepted' }) }), settings.step ? (_jsx("input", { type: "number", min: 0, step: "any", className: "field-settings-num", "aria-label": t('Step'), placeholder: t('Step'), value: form.step, onChange: view.onStep, ...agentHandle({ handle: 'form-field-settings-step' }, { role: 'field', label: 'Increment between accepted values' }) })) : null] })] })) : null, settings.upload ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "field-settings-row", children: [_jsxs("span", { className: "field-settings-label", children: [_jsx("span", { className: "field-label", id: "field-settings-files-label", children: t('Files') }), _jsx(InfoTip, { label: t('Leave all unticked to accept every safe type: images, video and PDF.') })] }), _jsx("span", { className: "field-settings-checks", role: "group", "aria-labelledby": "field-settings-files-label", children: view.uploads.map((choice) => (_jsxs("label", { className: "form-checkbox-field", children: [_jsx("input", { type: "checkbox", checked: choice.checked, onChange: choice.onToggle, ...agentHandle({ handle: choice.handle }, { role: 'checkbox', label: `Accept ${choice.label} uploads` }) }), t(choice.label)] }, choice.value))) })] }), _jsxs("div", { className: "field-settings-row", children: [_jsx("label", { className: "field-label", htmlFor: "field-settings-max-size", children: t('Max size (MB)') }), _jsx("input", { id: "field-settings-max-size", type: "number", min: 0, max: MAX_UPLOAD_MB, step: "any", className: "field-settings-num", placeholder: String(MAX_UPLOAD_MB), value: form.maxFileSizeMb, onChange: view.onMaxFileSize, ...agentHandle({ handle: 'form-field-settings-max-size' }, { role: 'field', label: 'Largest upload accepted, in megabytes' }) })] })] })) : null] }));
17
+ const { settings } = view;
18
+ return (_jsxs("div", { className: "field-settings", ...agentHandle({ handle: 'form-field-settings' }, { role: 'region', label: "This field's type-specific settings" }), children: [_jsx(FieldSettingsTextRows, { view: view, t: t }), settings.options ? _jsx(FieldSettingsOptionsRow, { view: view, t: t }) : null, _jsx(FieldSettingsDefaultRows, { view: view, t: t }), _jsx(FieldSettingsLimitRows, { view: view, t: t }), settings.upload ? _jsx(FieldSettingsUploadRows, { view: view, t: t }) : null] }));
20
19
  }
21
20
  //# sourceMappingURL=FieldSettingsSection.js.map
@@ -21,7 +21,7 @@ export interface FormEditorFieldsBodyProps {
21
21
  onCopyEmbed: () => void;
22
22
  copyFeedback: string | null;
23
23
  }
24
- export declare function FormEditorFieldsBody(props: FormEditorFieldsBodyProps, _optional?: {}): import("react").JSX.Element;
24
+ export declare function FormEditorFieldsBody(props: FormEditorFieldsBodyProps): import("react").JSX.Element;
25
25
  /** The page-header title/description text — split out because `FormEditor`'s isNew-dependent copy
26
26
  * (two ternaries, one with a `||` fallback) is otherwise indistinguishable, in the complexity
27
27
  * count, from the branches that actually decide what's on screen. */
@@ -1,24 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useFormFieldsBodyView } from "../hooks/forms-view.hooks.js";
3
3
  import { agentHandle } from '@jini-ai/agentic';
4
- import { FormFieldsEditor } from "./FormFieldsEditor.js";
5
- export function FormEditorFieldsBody(props, _optional = {}) {
4
+ import { FormEditorActions, FormEditorModeSwitch } from "./FormEditorFieldsBodyParts.js";
5
+ export function FormEditorFieldsBody(props) {
6
6
  const handlers = useFormFieldsBodyView(props);
7
- const { name, onNameChange, slug, onSlugChange, isNew, fields, existingFieldIds, onFieldsChange, form, saving, onStatusToggle, onSave, t, mode, onModeChange, html, onHtmlChange, onCopyEmbed, copyFeedback, } = props;
7
+ const { name, slug, isNew, t } = props;
8
8
  return (_jsxs(_Fragment, { children: [_jsx("div", { className: "field-group", children: _jsxs("div", { className: "field-row", children: [_jsxs("div", { className: "field", children: [_jsx("label", { className: "field-label", htmlFor: "form-name", children: t("Name") }), _jsx("input", { id: "form-name", value: name, onChange: handlers.onName, ...agentHandle({ handle: "form-editor-name" }, { role: "field", label: "This form's display name" }) })] }), _jsxs("div", { className: "field", children: [_jsx("label", { className: "field-label", htmlFor: "form-slug", children: t("Slug") }), _jsx("input", { id: "form-slug", value: slug, disabled: !isNew, onChange: handlers.onSlug, ...agentHandle({ handle: "form-editor-slug" }, {
9
9
  role: "field",
10
10
  label: "This form's URL slug — only editable while creating a new form",
11
- }) })] })] }) }), _jsxs("div", { className: "field-group", children: [_jsxs("div", { className: "segmented", role: "group", "aria-label": t("Form authoring mode"), children: [_jsx("button", { type: "button", className: mode === "builder" ? "is-active" : "", "aria-pressed": mode === "builder", onClick: handlers.onBuilder, children: t("Builder") }), _jsx("button", { type: "button", className: mode === "html" ? "is-active" : "", "aria-pressed": mode === "html", onClick: handlers.onHtmlMode, children: t("HTML") })] }), mode === "html" ? (_jsx("textarea", { className: "page-html-source", value: html, onChange: handlers.onHtml, spellCheck: false, "aria-label": t("Form HTML") })) : (_jsx("div", { className: "table-scroll", children: _jsx(FormFieldsEditor, { fields: fields, existingFieldIds: existingFieldIds, onChange: onFieldsChange, t: t }) }))] }), _jsxs("div", { className: "editor-actions form-actions", children: [form ? _jsx("button", { type: "button", className: "btn-secondary", onClick: onCopyEmbed, children: t("Copy HTML embed") }) : null, copyFeedback ? _jsx("span", { role: "status", children: copyFeedback }) : null, !isNew && form ? (
12
- // Reversible-but-access-affecting (turns off the live site's ability to accept
13
- // submissions through this form) — `.btn-warning`, not `.btn-danger`: nothing is
14
- // deleted, and the same control flips right back to "Enable". Re-enabling is the safe
15
- // direction, so it stays `.btn-secondary` rather than inheriting the warning look.
16
- _jsx("button", { type: "button", className: form.status === "active" ? "btn-warning" : "btn-secondary", disabled: saving, onClick: onStatusToggle, ...agentHandle({ handle: "form-editor-status-toggle" }, {
17
- role: "button",
18
- label: "Disable or re-enable this form — disabling stops it accepting new submissions without deleting it",
19
- }), children: form.status === "active" ? t("Disable") : t("Enable") })) : null, _jsx("button", { type: "button", disabled: saving, onClick: onSave, ...agentHandle({ handle: "form-editor-save" }, {
20
- role: "button",
21
- label: "Save this form's name, slug, fields and notification settings",
22
- }), children: isNew ? t("Create form") : t("Save") })] })] }));
11
+ }) })] })] }) }), _jsx("div", { className: "field-group", children: _jsx(FormEditorModeSwitch, { props: props, handlers: handlers }) }), _jsx(FormEditorActions, { props: props })] }));
23
12
  }
24
13
  //# sourceMappingURL=FormEditorFieldsBody.js.map
@@ -0,0 +1,18 @@
1
+ import type { useFormFieldsBodyView } from "../hooks/forms-view.hooks.js";
2
+ import type { FormEditorFieldsBodyProps } from "./FormEditorFieldsBody.js";
3
+ /**
4
+ * @file The two halves of `FormEditorFieldsBody` that branch on its props — the Builder/HTML switch
5
+ * and the action row — split out so each one's branches are counted on their own. Markup only.
6
+ */
7
+ type FieldsBodyHandlers = ReturnType<typeof useFormFieldsBodyView>;
8
+ /** Builder/HTML switch plus whichever editor the current mode shows. */
9
+ export declare function FormEditorModeSwitch({ props, handlers }: {
10
+ props: FormEditorFieldsBodyProps;
11
+ handlers: FieldsBodyHandlers;
12
+ }): import("react").JSX.Element;
13
+ /** Copy embed, copy feedback, Disable/Enable (saved forms only) and Save/Create. */
14
+ export declare function FormEditorActions({ props }: {
15
+ props: FormEditorFieldsBodyProps;
16
+ }): import("react").JSX.Element;
17
+ export {};
18
+ //# sourceMappingURL=FormEditorFieldsBodyParts.d.ts.map
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { agentHandle } from '@jini-ai/agentic';
3
+ import { FormFieldsEditor } from "./FormFieldsEditor.js";
4
+ /** Builder/HTML switch plus whichever editor the current mode shows. */
5
+ export function FormEditorModeSwitch({ props, handlers }) {
6
+ const { fields, existingFieldIds, onFieldsChange, t, mode, html } = props;
7
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "segmented", role: "group", "aria-label": t("Form authoring mode"), children: [_jsx("button", { type: "button", className: mode === "builder" ? "is-active" : "", "aria-pressed": mode === "builder", onClick: handlers.onBuilder, children: t("Builder") }), _jsx("button", { type: "button", className: mode === "html" ? "is-active" : "", "aria-pressed": mode === "html", onClick: handlers.onHtmlMode, children: t("HTML") })] }), mode === "html" ? (_jsx("textarea", { className: "page-html-source", value: html, onChange: handlers.onHtml, spellCheck: false, "aria-label": t("Form HTML") })) : (_jsx("div", { className: "table-scroll", children: _jsx(FormFieldsEditor, { fields: fields, existingFieldIds: existingFieldIds, onChange: onFieldsChange, t: t }) }))] }));
8
+ }
9
+ /** Copy embed, copy feedback, Disable/Enable (saved forms only) and Save/Create. */
10
+ export function FormEditorActions({ props }) {
11
+ const { isNew, form, saving, onStatusToggle, onSave, t, onCopyEmbed, copyFeedback } = props;
12
+ /* `form-actions` is a spacing-only hook layered on top of the shared `.editor-actions`
13
+ flex row. `.editor-actions` sets direction/gap/alignment but no top margin, and this row
14
+ is not a `.field-group`, so the `.field-group + .field-group` rhythm that separates every
15
+ other block on this screen skips it — leaving Disable/Save flush against the Recipients
16
+ input. Fixed here rather than by adding a margin to `.editor-actions` itself, because that
17
+ class is shared with the other editor screens and a global change would shift spacing on
18
+ screens nobody has looked at yet. */
19
+ return (_jsxs("div", { className: "editor-actions form-actions", children: [form ? _jsx("button", { type: "button", className: "btn-secondary", onClick: onCopyEmbed, children: t("Copy HTML embed") }) : null, copyFeedback ? _jsx("span", { role: "status", children: copyFeedback }) : null, !isNew && form ? (
20
+ // Reversible-but-access-affecting (turns off the live site's ability to accept
21
+ // submissions through this form) — `.btn-warning`, not `.btn-danger`: nothing is
22
+ // deleted, and the same control flips right back to "Enable". Re-enabling is the safe
23
+ // direction, so it stays `.btn-secondary` rather than inheriting the warning look.
24
+ _jsx("button", { type: "button", className: form.status === "active" ? "btn-warning" : "btn-secondary", disabled: saving, onClick: onStatusToggle, ...agentHandle({ handle: "form-editor-status-toggle" }, {
25
+ role: "button",
26
+ label: "Disable or re-enable this form — disabling stops it accepting new submissions without deleting it",
27
+ }), children: form.status === "active" ? t("Disable") : t("Enable") })) : null, _jsx("button", { type: "button", disabled: saving, onClick: onSave, ...agentHandle({ handle: "form-editor-save" }, {
28
+ role: "button",
29
+ label: "Save this form's name, slug, fields and notification settings",
30
+ }), children: isNew ? t("Create form") : t("Save") })] }));
31
+ }
32
+ //# sourceMappingURL=FormEditorFieldsBodyParts.js.map
@@ -8,5 +8,5 @@ export interface FormSubmissionsProps {
8
8
  t: (key: string) => string;
9
9
  answerColumns?: FormAnswerColumn[];
10
10
  }
11
- export declare function FormSubmissions({ formId, useFormSubmissionsHook, t, answerColumns }: FormSubmissionsProps, _optional?: Record<string, never>): import("react").JSX.Element;
11
+ export declare function FormSubmissions({ formId, useFormSubmissionsHook, t, answerColumns }: FormSubmissionsProps): import("react").JSX.Element;
12
12
  //# sourceMappingURL=FormSubmissions.d.ts.map
@@ -1,12 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useSubmissionsView } from "../hooks/forms-view.hooks.js";
3
- import { DataTable } from '../../../react/components/DataTable.js';
4
- import { agentHandle } from '@jini-ai/agentic';
5
3
  import { useWiredFormSubmissions } from '../hooks/use-form-submissions.hooks.js';
6
4
  import { useFormSubmissionDate } from '../hooks/use-form-submission-date.hooks.js';
7
5
  import { FormSubmissionDetail } from "./FormSubmissionDetail.js";
8
- import { FormSubmissionDate } from "./FormSubmissionDate.js";
9
- export function FormSubmissions({ formId, useFormSubmissionsHook = useWiredFormSubmissions, t, answerColumns = [] }, _optional = {}) {
6
+ import { FormSubmissionsTable } from "./FormSubmissionsTable.js";
7
+ export function FormSubmissions({ formId, useFormSubmissionsHook = useWiredFormSubmissions, t, answerColumns = [] }) {
10
8
  const formatDate = useFormSubmissionDate({});
11
9
  const controller = useFormSubmissionsHook({ formId });
12
10
  const { submissions, nextCursor, error, selectedId, loadingMore } = controller;
@@ -27,20 +25,6 @@ export function FormSubmissions({ formId, useFormSubmissionsHook = useWiredFormS
27
25
  // own post-early-return computation. `submissions` grows only on an explicit "Load more" click
28
26
  // (`use-form-submissions.hooks.ts`'s cursor-append), so this O(n) pass tracks real data changes,
29
27
  // not incidental re-renders.
30
- return (_jsxs("div", { children: [error ? _jsx("div", { className: "notice error", children: error }) : null, _jsx(DataTable, { rows: rows, rowKey: (s) => s.id, columns: [
31
- { key: "submitted-at", header: t("Submitted at"), cell: (s) => _jsx(FormSubmissionDate, { date: formatDate({ iso: s.submittedAt }) }) },
32
- { key: "source-ip", header: t("Source IP"), cell: (s) => s.sourceIp },
33
- ...answerColumns,
34
- {
35
- key: "view",
36
- cell: (s, index) => (_jsx("button", { type: "button", onClick: s.view, ...agentHandle({ handle: s.handle }, {
37
- role: "button",
38
- label: "Open this submission's full details",
39
- }), children: t("View") })),
40
- },
41
- ] }), nextCursor ? (_jsx("button", { type: "button", className: "btn-secondary", disabled: loadingMore, onClick: loadMore, ...agentHandle({ handle: "form-submissions-load-more" }, {
42
- role: "button",
43
- label: "Load the next page of submissions",
44
- }), children: loadingMore ? t("Loading…") : t("Load more") })) : null] }));
28
+ return (_jsx(FormSubmissionsTable, { rows: rows, loadMore: loadMore, error: error, nextCursor: nextCursor, loadingMore: loadingMore, formatDate: formatDate, answerColumns: answerColumns, t: t }));
45
29
  }
46
30
  //# sourceMappingURL=FormSubmissions.js.map
@@ -0,0 +1,19 @@
1
+ import type { FormAnswerColumn } from '../../html-rules.js';
2
+ import type { useSubmissionsView } from "../hooks/forms-view.hooks.js";
3
+ import type { useFormSubmissionDate } from '../hooks/use-form-submission-date.hooks.js';
4
+ /**
5
+ * @file The loaded-list half of `FormSubmissions`: an optional error above the table, the table
6
+ * itself, and "Load more" while a next page exists. Markup only.
7
+ */
8
+ export interface FormSubmissionsTableProps {
9
+ rows: ReturnType<typeof useSubmissionsView>["rows"];
10
+ loadMore: () => void;
11
+ error: string | null;
12
+ nextCursor: string | null;
13
+ loadingMore: boolean;
14
+ formatDate: ReturnType<typeof useFormSubmissionDate>;
15
+ answerColumns: FormAnswerColumn[];
16
+ t: (key: string) => string;
17
+ }
18
+ export declare function FormSubmissionsTable({ rows, loadMore, error, nextCursor, loadingMore, formatDate, answerColumns, t }: FormSubmissionsTableProps): import("react").JSX.Element;
19
+ //# sourceMappingURL=FormSubmissionsTable.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DataTable } from '../../../react/components/DataTable.js';
3
+ import { agentHandle } from '@jini-ai/agentic';
4
+ import { FormSubmissionDate } from "./FormSubmissionDate.js";
5
+ export function FormSubmissionsTable({ rows, loadMore, error, nextCursor, loadingMore, formatDate, answerColumns, t }) {
6
+ return (_jsxs("div", { children: [error ? _jsx("div", { className: "notice error", children: error }) : null, _jsx(DataTable, { rows: rows, rowKey: (s) => s.id, columns: [
7
+ { key: "submitted-at", header: t("Submitted at"), cell: (s) => _jsx(FormSubmissionDate, { date: formatDate({ iso: s.submittedAt }) }) },
8
+ { key: "source-ip", header: t("Source IP"), cell: (s) => s.sourceIp },
9
+ ...answerColumns,
10
+ {
11
+ key: "view",
12
+ cell: (s) => (_jsx("button", { type: "button", onClick: s.view, ...agentHandle({ handle: s.handle }, {
13
+ role: "button",
14
+ label: "Open this submission's full details",
15
+ }), children: t("View") })),
16
+ },
17
+ ] }), nextCursor ? (_jsx("button", { type: "button", className: "btn-secondary", disabled: loadingMore, onClick: loadMore, ...agentHandle({ handle: "form-submissions-load-more" }, {
18
+ role: "button",
19
+ label: "Load the next page of submissions",
20
+ }), children: loadingMore ? t("Loading…") : t("Load more") })) : null] }));
21
+ }
22
+ //# sourceMappingURL=FormSubmissionsTable.js.map
@@ -1,4 +1,14 @@
1
1
  import { queryMedia } from '../rules.js';
2
+ const METADATA_KEYS = ['title', 'slug', 'alt', 'caption', 'credit', 'width', 'height', 'cssClass', 'htmlAttributes'];
3
+ /** Only metadata keys with a defined value; null stays, so a patch can clear a nullable field. */
4
+ function pickMetadata({ patch }) {
5
+ return Object.fromEntries(Object.entries(patch).filter(([key, value]) => METADATA_KEYS.includes(key) && value !== undefined));
6
+ }
7
+ /** Decoded length of a base64 string: 3 bytes per 4 chars, minus trailing padding. */
8
+ function base64ByteSize({ dataBase64 }) {
9
+ const padding = dataBase64.match(/=+$/)?.[0].length ?? 0;
10
+ return Math.max(0, Math.floor(dataBase64.length * 3 / 4) - padding);
11
+ }
2
12
  export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().toISOString() } = {}) {
3
13
  const items = new Map(assets.map((item) => [item.id, Object.freeze({ ...item })]));
4
14
  let sequence = 0;
@@ -20,7 +30,7 @@ export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().t
20
30
  // The REAL server ignores the declared string and stores sniffContentType(bytes) instead.
21
31
  // This fake has no bytes to sniff — its input is an opaque base64 string a test made up —
22
32
  // so it echoes what the caller declared. Tests needing disagreement must seed assets directly.
23
- return { byteSize: Math.max(0, Math.floor(input.dataBase64.length * 3 / 4) - (input.dataBase64.match(/=+$/)?.[0].length ?? 0)), contentType: input.contentType, sha256: `memory-content-${++sequence}` };
33
+ return { byteSize: base64ByteSize(input), contentType: input.contentType, sha256: `memory-content-${++sequence}` };
24
34
  }
25
35
  return {
26
36
  replaceSupported: true,
@@ -38,15 +48,16 @@ export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().t
38
48
  const timestamp = now();
39
49
  return put({
40
50
  id,
41
- title: options.title ?? input.filename,
42
- alt: options.alt ?? '',
43
- caption: options.caption ?? '',
44
- credit: options.credit ?? '',
45
- slug: options.slug ?? id,
46
- width: options.width ?? null,
47
- height: options.height ?? null,
48
- cssClass: options.cssClass ?? null,
49
- htmlAttributes: options.htmlAttributes ?? null,
51
+ title: input.filename,
52
+ alt: '',
53
+ caption: '',
54
+ credit: '',
55
+ slug: id,
56
+ width: null,
57
+ height: null,
58
+ cssClass: null,
59
+ htmlAttributes: null,
60
+ ...pickMetadata({ patch: options }),
50
61
  status: 'active',
51
62
  publicUrl: null,
52
63
  createdAt: timestamp,
@@ -58,14 +69,13 @@ export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().t
58
69
  async update({ id, patch }, options = {}) {
59
70
  check(options);
60
71
  const previous = get(id);
61
- const metadata = Object.fromEntries(Object.entries(patch).filter(([key, value]) => ['title', 'slug', 'alt', 'caption', 'credit', 'width', 'height', 'cssClass', 'htmlAttributes'].includes(key) && value !== undefined));
62
- return put({ ...previous, ...metadata, version: previous.version + 1, updatedAt: now() });
72
+ return put({ ...previous, ...pickMetadata({ patch }), version: previous.version + 1, updatedAt: now() });
63
73
  },
64
74
  async replace({ id, upload }, options = {}) {
65
75
  check(options);
66
76
  const previous = get(id);
67
77
  if (previous.status !== 'active')
68
- throw new Error('Cannot replace trashed media');
78
+ throw new Error(`Cannot replace trashed media: ${id} (restore it first)`);
69
79
  return put({
70
80
  ...previous,
71
81
  ...content(upload),
@@ -92,7 +102,7 @@ export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().t
92
102
  async delete({ id }, options = {}) {
93
103
  check(options);
94
104
  if (get(id).status !== 'trashed')
95
- throw new Error('Trash media before permanent deletion');
105
+ throw new Error(`Trash media before permanent deletion: ${id}`);
96
106
  items.delete(id);
97
107
  return { purged: true };
98
108
  },
@@ -102,11 +112,11 @@ export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().t
102
112
  }
103
113
  export function createMemoryMediaProviders({ providers = [] }, _optional = {}) {
104
114
  const items = new Map(providers.map((item) => [item.id, Object.freeze({ ...item })]));
105
- function write(id, configured) {
115
+ function write(id, patch) {
106
116
  const previous = items.get(id);
107
117
  if (!previous)
108
- throw new Error('Unknown provider');
109
- const next = Object.freeze({ ...previous, configured });
118
+ throw new Error(`Unknown media provider: ${id}`);
119
+ const next = Object.freeze({ ...previous, ...patch });
110
120
  items.set(id, next);
111
121
  return next;
112
122
  }
@@ -117,22 +127,17 @@ export function createMemoryMediaProviders({ providers = [] }, _optional = {}) {
117
127
  },
118
128
  async saveSettings({ id, baseUrl, model }, options = {}) {
119
129
  options.signal?.throwIfAborted();
120
- const previous = items.get(id);
121
- if (!previous)
122
- throw new Error('Unknown provider');
123
- const next = Object.freeze({ ...previous, baseUrl, model });
124
- items.set(id, next);
125
- return next;
130
+ return write(id, { baseUrl, model });
126
131
  },
127
132
  async saveCredential({ id, credential }, options = {}) {
128
133
  options.signal?.throwIfAborted();
129
134
  if (!credential.trim())
130
- throw new Error('Credential is required');
131
- return write(id, true);
135
+ throw new Error(`Credential is required for media provider: ${id}`);
136
+ return write(id, { configured: true });
132
137
  },
133
138
  async removeCredential({ id }, options = {}) {
134
139
  options.signal?.throwIfAborted();
135
- return write(id, false);
140
+ return write(id, { configured: false });
136
141
  },
137
142
  };
138
143
  }
@@ -31,11 +31,12 @@ export function createEditMediaController({ api, item }, _optional = {}) {
31
31
  store.set({
32
32
  patch: { error: error instanceof Error ? error.message : 'Failed to save media metadata' },
33
33
  });
34
- return false;
35
34
  }
36
35
  finally {
37
36
  store.set({ patch: { saving: false } });
38
37
  }
38
+ // Reached only through the catch above: the failure is already recorded in `error`.
39
+ return false;
39
40
  }
40
41
  return {
41
42
  getSnapshot: store.getSnapshot,
@@ -23,6 +23,39 @@ export function createLibraryController({ api }, { query = {} } = {}) {
23
23
  retryTimer = null;
24
24
  }
25
25
  let readAbort = null, generation = 0;
26
+ /** Store patch for an accepted full library: filtered rows plus whole-library tab counts.
27
+ * @complexity O(n) in the library size. */
28
+ function libraryView({ media }, _optional = {}) {
29
+ return { items: queryMedia({ media, query: store.getSnapshot().query }), counts: countMediaTabs({ media }) };
30
+ }
31
+ /** Commits a read that is still the latest one; items are frozen so consumers cannot mutate rows. */
32
+ function acceptRead({ all }, _optional = {}) {
33
+ readFailed = false;
34
+ retryAttempt = 0;
35
+ library = all;
36
+ const { items, counts } = libraryView({ media: all });
37
+ store.set({
38
+ patch: {
39
+ items: Object.freeze(items.map((item) => Object.freeze({ ...item }))),
40
+ counts,
41
+ hasUntyped: all.some((item) => item.contentType === null),
42
+ error: null,
43
+ },
44
+ });
45
+ }
46
+ /** Publishes the latest read's failure and, when the failure is transient, schedules a retry. */
47
+ function failRead({ error }, _optional = {}) {
48
+ readFailed = true;
49
+ store.set({
50
+ patch: { loading: false, error: error instanceof Error ? error.message : 'Failed to load media' },
51
+ });
52
+ // Retry reads only, never destructive writes. Keep trying at a capped delay so
53
+ // recovery needs no manual repair, while an offline service isn't hammered.
54
+ if (isRetryableMediaError({ error })) {
55
+ const delay = Math.min(1000 * 2 ** Math.min(retryAttempt++, 5), 30000);
56
+ retryTimer = setTimeout(() => { retryTimer = null; void load(); }, delay);
57
+ }
58
+ }
26
59
  async function load(_required = {}, _optional = {}) {
27
60
  if (store.signal.aborted)
28
61
  return;
@@ -34,35 +67,13 @@ export function createLibraryController({ api }, { query = {} } = {}) {
34
67
  store.set({ patch: { loading: true, error: null } });
35
68
  try {
36
69
  const all = await api.list({}, { signal: abort.signal });
37
- const items = queryMedia({ media: all, query: store.getSnapshot().query });
38
- if (ticket === generation) {
39
- readFailed = false;
40
- retryAttempt = 0;
41
- library = all;
42
- store.set({
43
- patch: {
44
- items: Object.freeze(items.map((item) => Object.freeze({ ...item }))),
45
- counts: countMediaTabs({ media: all }),
46
- hasUntyped: all.some((item) => item.contentType === null),
47
- error: null,
48
- },
49
- });
50
- }
70
+ if (ticket === generation)
71
+ acceptRead({ all });
51
72
  }
52
73
  catch (error) {
53
74
  // A later background failure must not blank an already-rendered screen.
54
- if (!abort.signal.aborted && ticket === generation) {
55
- readFailed = true;
56
- store.set({
57
- patch: { loading: false, error: error instanceof Error ? error.message : 'Failed to load media' },
58
- });
59
- // Retry reads only, never destructive writes. Keep trying at a capped delay so
60
- // recovery needs no manual repair, while an offline service isn't hammered.
61
- if (isRetryableMediaError({ error })) {
62
- const delay = Math.min(1000 * 2 ** Math.min(retryAttempt++, 5), 30000);
63
- retryTimer = setTimeout(() => { retryTimer = null; void load(); }, delay);
64
- }
65
- }
75
+ if (!abort.signal.aborted && ticket === generation)
76
+ failRead({ error });
66
77
  }
67
78
  finally {
68
79
  if (ticket === generation)
@@ -76,7 +87,7 @@ export function createLibraryController({ api }, { query = {} } = {}) {
76
87
  return;
77
88
  if (all) {
78
89
  library = all;
79
- store.set({ patch: { items: queryMedia({ media: all, query: store.getSnapshot().query }), counts: countMediaTabs({ media: all }), hasUntyped: all.some(item => item.contentType === null), error: null } });
90
+ store.set({ patch: { ...libraryView({ media: all }), hasUntyped: all.some(item => item.contentType === null), error: null } });
80
91
  }
81
92
  if (error)
82
93
  store.set({ patch: { error: error.message } });
@@ -100,17 +111,18 @@ export function createLibraryController({ api }, { query = {} } = {}) {
100
111
  readAbort?.abort();
101
112
  cancelRetry();
102
113
  store.set({ patch: { loading: false, error: null } });
114
+ // A single exit: a write disposed mid-flight or a failed write reports false.
115
+ let saved = false;
103
116
  try {
104
117
  await run();
105
- if (store.signal.aborted)
106
- return false;
107
- await load();
108
- return true;
118
+ if (!store.signal.aborted) {
119
+ await load();
120
+ saved = true;
121
+ }
109
122
  }
110
123
  catch (error) {
111
- if (!store.signal.aborted)
112
- store.set({ patch: { loading: false, error: error instanceof Error ? error.message : 'Media write failed' } });
113
- return false;
124
+ // The store ignores patches once disposed, so a write failing after dispose publishes nothing.
125
+ store.set({ patch: { loading: false, error: error instanceof Error ? error.message : 'Media write failed' } });
114
126
  }
115
127
  finally {
116
128
  if (id === undefined)
@@ -118,6 +130,7 @@ export function createLibraryController({ api }, { query = {} } = {}) {
118
130
  const pendingIds = store.getSnapshot().pendingIds.filter(value => value !== id);
119
131
  store.set({ patch: { busy: pendingIds.length > 0, pendingIds } });
120
132
  }
133
+ return saved;
121
134
  });
122
135
  writes = operation.catch(() => undefined);
123
136
  return operation;
@@ -168,7 +181,7 @@ export function createLibraryController({ api }, { query = {} } = {}) {
168
181
  // A failed follow-up read must not leave a permanently deleted card focusable.
169
182
  if (library) {
170
183
  library = library.filter(row => row.id !== item.id);
171
- store.set({ patch: { items: queryMedia({ media: library, query: store.getSnapshot().query }), counts: countMediaTabs({ media: library }) } });
184
+ store.set({ patch: libraryView({ media: library }) });
172
185
  }
173
186
  });
174
187
  if (saved)
@@ -46,11 +46,12 @@ export function createProvidersController({ api }, _optional = {}) {
46
46
  }
47
47
  catch {
48
48
  store.set({ patch: { error: 'Failed to save provider credentials' } });
49
- return false;
50
49
  }
51
50
  finally {
52
51
  store.set({ patch: { saving: false } });
53
52
  }
53
+ // Reached only through the catch above: the failure is already recorded in `error`.
54
+ return false;
54
55
  }
55
56
  return {
56
57
  getSnapshot: store.getSnapshot,
@@ -80,7 +80,9 @@ const ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT = 0x20;
80
80
  function isUnsafeMediaUrlValue(value) {
81
81
  let stripped = "";
82
82
  for (const char of value) {
83
- if ((char.codePointAt(0) ?? 0) > ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT)
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)
84
86
  stripped += char;
85
87
  }
86
88
  stripped = stripped.toLowerCase();
@@ -165,15 +167,11 @@ export function parseMediaHtmlAttributes({ text }, _optional = {}) {
165
167
  // Non-whitespace text between the previous match and this one is a fragment the token pattern
166
168
  // could not parse as a name (e.g. a stray quote) — the tokenizer has lost sync, so everything
167
169
  // parsed so far is discarded rather than trusted.
168
- const skipped = trimmed.slice(consumed, match.index);
169
- if (skipped.trim() !== "") {
170
- const malformed = { reason: "malformed", attribute: skipped.trim() };
171
- errors.push(malformed);
172
- return { attributes: {}, errors, error: malformed };
173
- }
170
+ const skipped = trimmed.slice(consumed, match.index).trim();
171
+ if (skipped !== "")
172
+ return malformedMediaHtmlAttributes({ errors, fragment: skipped });
174
173
  consumed = match.index + match[0].length;
175
- const name = match[1].toLowerCase();
176
- const value = match[2] ?? match[3] ?? match[4] ?? "";
174
+ const { name, value } = readMediaHtmlAttributeToken({ match });
177
175
  const rejection = classifyMediaHtmlAttributeToken(name, value);
178
176
  if (rejection) {
179
177
  errors.push(rejection);
@@ -181,14 +179,30 @@ export function parseMediaHtmlAttributes({ text }, _optional = {}) {
181
179
  }
182
180
  attributes[name] = value;
183
181
  }
184
- const trailing = trimmed.slice(consumed);
185
- if (trailing.trim() !== "") {
186
- const malformed = { reason: "malformed", attribute: trailing.trim() };
187
- errors.push(malformed);
188
- return { attributes: {}, errors, error: malformed };
189
- }
182
+ const trailing = trimmed.slice(consumed).trim();
183
+ if (trailing !== "")
184
+ return malformedMediaHtmlAttributes({ errors, fragment: trailing });
190
185
  return { attributes, errors, error: errors[0] ?? null };
191
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 };
205
+ }
192
206
  /** The hint names the rejected token so operators can distinguish a typo from a hard no.
193
207
  * This is client feedback only; the server must independently enforce write/render policy. */
194
208
  export function describeMediaHtmlAttributeError({ error }, _optional = {}) {
@@ -3,7 +3,7 @@ export { mediaApiToken, mediaProvidersToken } from './ports.js';
3
3
  export type { MediaApiPort, MediaProvidersPort, MediaEventsPort } from './ports.js';
4
4
  export type { MediaAsset, MediaQuery, UploadInput, MediaMetadataPatch, MediaRequestOptions, MediaProvider, MediaProviderOption, MediaProviderConfiguration, MediaProviderConfigurationMap, MediaTabId, MediaContentTabId, MediaOrderBy, } from './models.js';
5
5
  export { mediaMessagesEn } from './messages.en.js';
6
- export { filterMediaByTab, resolveActiveTab, queryMedia, diffMediaMetadata, acceptsMedia, safeMediaUrl, canReplaceMedia, canRestoreMedia, isRetryableMediaError, parseOptionalPixelSize, formatByteSize, formatUploadDate, countMediaTabs, mediaEmbedSnippet, mediaRowHandles, } from './rules.js';
6
+ export { filterMediaByTab, resolveActiveTab, queryMedia, diffMediaMetadata, acceptsMedia, safeMediaUrl, canReplaceMedia, canRestoreMedia, isRetryableMediaError, parseOptionalPixelSize, formatByteSize, formatUploadDate, mediaFileName, countMediaTabs, mediaEmbedSnippet, mediaRowHandles, } from './rules.js';
7
7
  export { createLibraryController } from './controllers/library.controller.js';
8
8
  export type { LibraryState } from './controllers/library.controller.js';
9
9
  export { createEditMediaController } from './controllers/edit-media.controller.js';
@@ -1,7 +1,7 @@
1
1
  export { mediaModule } from './media.module.js';
2
2
  export { mediaApiToken, mediaProvidersToken } from './ports.js';
3
3
  export { mediaMessagesEn } from './messages.en.js';
4
- export { filterMediaByTab, resolveActiveTab, queryMedia, diffMediaMetadata, acceptsMedia, safeMediaUrl, canReplaceMedia, canRestoreMedia, isRetryableMediaError, parseOptionalPixelSize, formatByteSize, formatUploadDate, countMediaTabs, mediaEmbedSnippet, mediaRowHandles, } from './rules.js';
4
+ export { filterMediaByTab, resolveActiveTab, queryMedia, diffMediaMetadata, acceptsMedia, safeMediaUrl, canReplaceMedia, canRestoreMedia, isRetryableMediaError, parseOptionalPixelSize, formatByteSize, formatUploadDate, mediaFileName, countMediaTabs, mediaEmbedSnippet, mediaRowHandles, } from './rules.js';
5
5
  export { createLibraryController } from './controllers/library.controller.js';
6
6
  export { createEditMediaController } from './controllers/edit-media.controller.js';
7
7
  export { createProvidersController } from './controllers/providers.controller.js';