@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
@@ -7,21 +7,17 @@ export interface MediaLightboxProps {
7
7
  readonly onNavigate: (index: number) => void;
8
8
  readonly onClose: () => void;
9
9
  }
10
+ /** Resolves the open asset and the shared lightbox's set props. Clamping, keyboard, focus and
11
+ * the open/close lifecycle live in `@jini-ai/ui`'s `useLightbox`. */
10
12
  export declare function useMediaLightbox(props: MediaLightboxProps, _optional?: Record<string, never>): {
11
13
  item: MediaAsset | null;
12
- open: boolean;
13
- hasPrev: boolean;
14
- hasNext: boolean;
15
- previous: () => void;
16
- next: () => void;
17
- items: readonly MediaAsset[];
18
- api: MediaApiPort;
19
- activeIndex: number | null;
20
- onNavigate: (index: number) => void;
21
- onClose: () => void;
22
- dialogRef: import("react").RefObject<HTMLDialogElement | null>;
23
- closeRef: import("react").RefObject<HTMLButtonElement | null>;
24
- title: string;
25
- position: string;
14
+ lightbox: {
15
+ open: boolean;
16
+ title: string;
17
+ index: number;
18
+ count: number;
19
+ onNavigate: (index: number) => void;
20
+ onClose: () => void;
21
+ };
26
22
  };
27
23
  //# sourceMappingURL=MediaLightbox.hooks.d.ts.map
@@ -1,63 +1,16 @@
1
- import { useEffect, useRef } from 'react';
1
+ /** Resolves the open asset and the shared lightbox's set props. Clamping, keyboard, focus and
2
+ * the open/close lifecycle live in `@jini-ai/ui`'s `useLightbox`. */
2
3
  export function useMediaLightbox(props, _optional = {}) {
3
4
  const item = props.activeIndex === null ? null : props.items[props.activeIndex] ?? null;
4
- function go(index) {
5
- // Clamps rather than wrapping: staying on the last asset is the less surprising
6
- // default for an operator navigating a specific set of uploads, not a slideshow.
7
- if (index >= 0 && index < props.items.length) {
8
- const focusedPart = dialogRef.current?.ownerDocument.activeElement?.getAttribute('data-jini-part');
9
- // Keep focus in the dialog when the focused arrow disappears at a boundary.
10
- // Interior navigation leaves focus where the operator put it.
11
- if ((index === 0 && focusedPart === 'media.lightbox.previous') ||
12
- (index === props.items.length - 1 && focusedPart === 'media.lightbox.next'))
13
- closeRef.current?.focus({ preventScroll: true });
14
- props.onNavigate(index);
15
- }
16
- }
17
- const closeRef = useRef(null);
18
- const dialogRef = useRef(null);
19
- const open = !!item;
20
- useEffect(() => {
21
- // Close exists at every position, so it is a stable initial focus target. Only
22
- // opening focuses it; navigation must preserve the operator's current focus.
23
- if (open)
24
- closeRef.current?.focus();
25
- }, [open]);
26
- // Dialog owns open/close/focus, keyed on open rather than index: navigation must not
27
- // reopen an already-open dialog or recapture its trigger from inside the lightbox.
28
- useEffect(() => {
29
- const dialog = dialogRef.current;
30
- if (!dialog || !item)
31
- return;
32
- const onKey = (event) => {
33
- if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')
34
- return;
35
- event.preventDefault();
36
- if (props.activeIndex !== null)
37
- go(props.activeIndex + (event.key === 'ArrowRight' ? 1 : -1));
38
- // Native video controls may also seek while focused, as in the legacy lightbox.
39
- // Preview stage remains private, so do not silently infer it from contentType.
40
- };
41
- dialog.ownerDocument.addEventListener('keydown', onKey);
42
- return () => dialog.ownerDocument.removeEventListener('keydown', onKey);
43
- }, [props.activeIndex, props.items, props.onNavigate, !!item]);
44
5
  return {
45
- dialogRef,
46
- closeRef,
47
- title: item ? `Media preview: ${item.title}` : 'Media preview',
48
- position: item && props.activeIndex !== null ? `${props.activeIndex + 1} / ${props.items.length}` : '',
49
- ...props,
50
6
  item,
51
- open: !!item,
52
- hasPrev: props.activeIndex !== null && props.activeIndex > 0,
53
- hasNext: props.activeIndex !== null && props.activeIndex + 1 < props.items.length,
54
- previous: () => {
55
- if (props.activeIndex !== null)
56
- go(props.activeIndex - 1);
57
- },
58
- next: () => {
59
- if (props.activeIndex !== null)
60
- go(props.activeIndex + 1);
7
+ lightbox: {
8
+ open: !!item,
9
+ title: item?.title ?? '',
10
+ index: props.activeIndex ?? 0,
11
+ count: props.items.length,
12
+ onNavigate: props.onNavigate,
13
+ onClose: props.onClose,
61
14
  },
62
15
  };
63
16
  }
@@ -1,3 +1,4 @@
1
+ import { useMediaLibraryQuery } from './MediaQuery.hooks.js';
1
2
  import type { ReactNode } from 'react';
2
3
  import type { ModulePageProps } from '../../../core/react/bind-react.js';
3
4
  import { createLibraryController } from '../../controllers/library.controller.js';
@@ -10,6 +11,11 @@ export declare function useSharedMediaLibrary(_required?: Record<string, never>,
10
11
  controller: ReturnType<typeof createLibraryController> | null;
11
12
  snapshot: LibraryState | null;
12
13
  } | null;
14
+ /** Feeds the cached library read into a controller; an absent read leaves `all` untouched. */
15
+ export declare function settleFromCache({ controller, query }: {
16
+ controller: ReturnType<typeof createLibraryController> | null;
17
+ query: ReturnType<typeof useMediaLibraryQuery>['query'];
18
+ }, _optional?: Record<string, never>): void;
13
19
  export interface MediaPageProps extends ModulePageProps {
14
20
  readonly headerActions?: ReactNode;
15
21
  }
@@ -10,30 +10,31 @@ export const MediaLibraryContext = createContext(null);
10
10
  export function useSharedMediaLibrary(_required = {}, _optional = {}) {
11
11
  return useContext(MediaLibraryContext);
12
12
  }
13
+ /** Feeds the cached library read into a controller; an absent read leaves `all` untouched. */
14
+ export function settleFromCache({ controller, query }, _optional = {}) {
15
+ controller?.settleLibrary({ ...(query.data ? { all: query.data } : {}), error: query.error });
16
+ }
13
17
  export function useMediaPage(props, _optional = {}) {
14
18
  const [localTab, setLocalTab] = useState('all');
15
19
  const { mediaApi, mediaEvents, mediaProviders } = useMediaPorts();
16
20
  const cached = useMediaLibraryQuery({ api: mediaApi }, { enabled: props.description.visible });
17
21
  const providers = useMediaProvidersQuery({ port: mediaProviders }, { enabled: props.description.visible && props.description.grants.includes('media.providers') });
18
22
  const invalidate = useInvalidate();
19
- const library = useController({ create: () => createLibraryController({ api: cached.api }, { query: { filter: requestedFilter({ tabId: props.requestedTab ?? localTab }) } }), dependencies: [cached.api] }, { start: ({ controller }) => { if (props.description.visible)
23
+ const tabId = props.requestedTab ?? localTab;
24
+ const library = useController({ create: () => createLibraryController({ api: cached.api }, { query: { filter: requestedFilter({ tabId }) } }), dependencies: [cached.api] }, { start: ({ controller }) => { if (props.description.visible)
20
25
  void controller.load(); } });
21
26
  useEffect(() => props.description.visible ? mediaEvents?.subscribe({ onRefresh: () => { invalidate({ key: MEDIA_QUERY_KEYS.root }); } }) : undefined, [props.description.visible, mediaEvents, library.controller, invalidate]);
22
- useEffect(() => { library.controller?.settleLibrary({ ...(cached.query.data ? { all: cached.query.data } : {}), error: cached.query.error }); }, [library.controller, cached.query.data, cached.query.error]);
27
+ useEffect(() => { settleFromCache({ controller: library.controller, query: cached.query }); }, [library.controller, cached.query.data, cached.query.error]);
23
28
  /* An unavailable read is not evidence of a zero catalogue. */
24
29
  const providerCount = providers.query.data?.length ?? null;
25
- const visible = props.description.visible
26
- ? props.description.tabs.filter((tab) => tab.visible)
27
- : [];
28
- const requested = resolveActiveTab({ tabId: props.requestedTab ?? localTab });
29
- const active = visible.find((tab) => tab.id.endsWith(`.${requested}`)) ?? visible[0];
30
+ const { visible, active } = visibleTabs({ description: props.description, tabId });
30
31
  const tabs = visible.map((tab) => {
31
- const id = tab.id.slice(tab.id.lastIndexOf('.') + 1);
32
+ const id = shortTabId({ id: tab.id });
32
33
  return {
33
34
  ...tab,
34
35
  id,
35
36
  handle: `media-tab-${id}`,
36
- icon: createElement(id === 'images' ? ImageIcon : id === 'videos' ? VideoIcon : id === 'external-providers' ? CloudIcon : AllIcon, {}),
37
+ icon: createElement(TAB_ICONS.get(id) ?? AllIcon, {}),
37
38
  count: id === 'external-providers' ? providerCount : library.snapshot?.counts?.[id],
38
39
  selected: active?.id === tab.id,
39
40
  onPress: () => {
@@ -42,10 +43,11 @@ export function useMediaPage(props, _optional = {}) {
42
43
  },
43
44
  };
44
45
  });
45
- const id = active?.id.slice(active.id.lastIndexOf('.') + 1);
46
+ const id = active && shortTabId({ id: active.id });
46
47
  useEffect(() => {
47
- if (props.description.visible && (id === 'all' || id === 'images' || id === 'videos') && library.controller?.getSnapshot().query.filter !== id)
48
- void library.controller?.setQuery({ query: { ...library.controller.getSnapshot().query, filter: id } });
48
+ const filter = libraryFilter({ id });
49
+ if (props.description.visible && filter && library.controller?.getSnapshot().query.filter !== filter)
50
+ void library.controller?.setQuery({ query: { ...library.controller.getSnapshot().query, filter } });
49
51
  }, [id, library.controller, props.description.visible]);
50
52
  return {
51
53
  library,
@@ -58,6 +60,21 @@ export function useMediaPage(props, _optional = {}) {
58
60
  denied: !props.description.visible,
59
61
  };
60
62
  }
63
+ const TAB_ICONS = new Map([['images', ImageIcon], ['videos', VideoIcon], ['external-providers', CloudIcon]]);
64
+ /** Module tab ids are namespaced (`media.images`); the page and its tabs use the last segment. */
65
+ function shortTabId({ id }, _optional = {}) {
66
+ return id.slice(id.lastIndexOf('.') + 1);
67
+ }
68
+ function libraryFilter({ id }, _optional = {}) {
69
+ return id === 'all' || id === 'images' || id === 'videos' ? id : undefined;
70
+ }
71
+ function visibleTabs({ description, tabId }, _optional = {}) {
72
+ const visible = description.visible
73
+ ? description.tabs.filter((tab) => tab.visible)
74
+ : [];
75
+ const requested = resolveActiveTab({ tabId });
76
+ return { visible, active: visible.find((tab) => tab.id.endsWith(`.${requested}`)) ?? visible[0] };
77
+ }
61
78
  function requestedFilter({ tabId }) {
62
79
  const tab = resolveActiveTab({ tabId });
63
80
  return tab === 'external-providers' ? 'all' : tab;
@@ -1,9 +1,36 @@
1
1
  import type { ChangeEvent } from 'react';
2
2
  export declare function useProvidersTab(_required?: Record<string, never>, _optional?: Record<string, never>): {
3
+ reload: () => void;
4
+ reloadDisabled: boolean;
5
+ reloadLabel: string;
6
+ unreachable: boolean;
7
+ empty: boolean;
8
+ save: () => void;
9
+ saveLabel: string;
10
+ saveDisabled: boolean;
11
+ saved: boolean;
12
+ saveError: boolean;
13
+ disabled: boolean;
14
+ settingsDisabled: boolean;
3
15
  rows: {
4
- divider: boolean;
5
- statusLabel: string | null;
6
- statusClassName: string;
16
+ invalid: boolean;
17
+ hint: string | null;
18
+ hintClassName: string;
19
+ hintRole: "alert" | undefined;
20
+ ariaInvalid: true | undefined;
21
+ clearLabel: string;
22
+ clearDisabled: boolean;
23
+ clear: () => void;
24
+ baseUrl: string;
25
+ baseUrlPlaceholder: string;
26
+ baseUrlLabel: string;
27
+ onBaseUrl: (event: ChangeEvent<HTMLInputElement>) => void;
28
+ model: string;
29
+ modelLabel: string;
30
+ modelsId: string;
31
+ hasModels: boolean;
32
+ onModel: (event: ChangeEvent<HTMLInputElement>) => void;
33
+ toggle: () => void;
7
34
  credential: string;
8
35
  credentialType: "text" | "password";
9
36
  credentialPlaceholder: string;
@@ -15,41 +42,14 @@ export declare function useProvidersTab(_required?: Record<string, never>, _opti
15
42
  };
16
43
  toggleLabel: string;
17
44
  visible: boolean;
18
- toggle(): void;
19
45
  onCredential: (event: ChangeEvent<HTMLInputElement>) => void;
20
- baseUrl: string;
21
- baseUrlPlaceholder: string;
22
- baseUrlLabel: string;
23
- onBaseUrl: (event: ChangeEvent<HTMLInputElement>) => void;
24
- model: string;
25
- modelLabel: string;
26
- modelsId: string;
27
- hasModels: boolean;
28
- onModel: (event: ChangeEvent<HTMLInputElement>) => void;
29
- clearLabel: string;
30
- clearDisabled: boolean;
31
- clear: () => void;
32
- invalid: boolean;
33
- hint: string | null;
34
- hintClassName: string;
35
- hintRole: "alert" | undefined;
36
- ariaInvalid: true | undefined;
46
+ statusLabel: string | null;
47
+ statusClassName: string;
48
+ divider: boolean;
37
49
  id: string;
38
50
  label: string;
39
51
  defaultBaseUrl?: string;
40
52
  models?: readonly string[];
41
53
  }[];
42
- reload: () => void;
43
- reloadDisabled: boolean;
44
- reloadLabel: string;
45
- unreachable: boolean;
46
- empty: boolean;
47
- save: () => void;
48
- saveLabel: string;
49
- saveDisabled: boolean;
50
- saved: boolean;
51
- saveError: boolean;
52
- disabled: boolean;
53
- settingsDisabled: boolean;
54
54
  };
55
55
  //# sourceMappingURL=ProvidersTab.hooks.d.ts.map
@@ -3,6 +3,12 @@ import { MEDIA_QUERY_KEYS, useMediaProvidersQuery } from './MediaQuery.hooks.js'
3
3
  import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useMediaProvidersTab, sortProvidersByConfigured, isEntryPresent, isMarkerOnlyEntry, maskedKeyLabel, isProviderBaseUrlInvalid, invalidBaseUrlProviderIds } from '@jini-ai/ui';
5
5
  import { useMediaPorts } from './MediaPorts.hooks.js';
6
+ /** Host adapters supply canonical IDs (media/PORT.md); keep them literal in the editor.
7
+ * Pure operations preserve values and copy maps in O(n) time/space; ID lookup is O(1). */
8
+ const catalogPolicy = {
9
+ canonicalMediaProviderId: ({ id }, _options = {}) => id,
10
+ normalizeMediaProviderMap: ({ providers }, _options = {}) => ({ ...providers }),
11
+ };
6
12
  function configuration({ items }, _optional = {}) {
7
13
  return Object.fromEntries(items.filter(item => item.configured || item.apiKeyTail || item.baseUrl?.trim() || item.model?.trim())
8
14
  .map(item => [item.id, { apiKeyConfigured: item.configured,
@@ -10,6 +16,108 @@ function configuration({ items }, _optional = {}) {
10
16
  ...(item.baseUrl === undefined ? {} : { baseUrl: item.baseUrl }),
11
17
  ...(item.model === undefined ? {} : { model: item.model }) }]));
12
18
  }
19
+ /** Drops a cleared provider's stored state; one that never held any needs no call. */
20
+ async function removeIfStored({ port, item, signal }, _optional = {}) {
21
+ if (item.configured || item.baseUrl || item.model)
22
+ await port.removeCredential({ id: item.id }, { signal });
23
+ }
24
+ /** Absent endpoint and model compare and send as blank, the stored "use the default". */
25
+ function settingsOf({ source }, _optional = {}) {
26
+ return { baseUrl: source.baseUrl ?? '', model: source.model ?? '' };
27
+ }
28
+ async function saveProviderSettings({ port, item, entry, signal }, _optional = {}) {
29
+ const next = settingsOf({ source: entry }), stored = settingsOf({ source: item });
30
+ if (next.baseUrl === stored.baseUrl && next.model === stored.model)
31
+ return;
32
+ if (!port.saveSettings)
33
+ throw new Error('Provider settings unavailable');
34
+ await port.saveSettings({ id: item.id, ...next }, { signal });
35
+ }
36
+ async function saveProvider({ port, item, signal }, { entry } = {}) {
37
+ if (!entry)
38
+ return removeIfStored({ port, item, signal });
39
+ await saveProviderSettings({ port, item, entry, signal });
40
+ if (entry.apiKey?.trim())
41
+ await port.saveCredential({ id: item.id, credential: entry.apiKey }, { signal });
42
+ }
43
+ async function saveSequentially({ port, providers, lastRead, signal }, _optional = {}) {
44
+ // Older hosts retain the one-button editor; writes remain sequential and failures
45
+ // retain local edits. Atomic whole-set hosts should implement saveChanges instead.
46
+ // Submitted ids the last read never returned (catalog-only providers getting their
47
+ // first key or settings) start from an empty stored state, or their writes are lost.
48
+ const known = new Set(lastRead.map(item => item.id));
49
+ const unread = Object.keys(providers).filter(id => !known.has(id)).map(id => ({ id, configured: false }));
50
+ for (const item of [...lastRead, ...unread])
51
+ await saveProvider({ port, item, signal }, { entry: providers[item.id] });
52
+ const items = await port.list({}, { signal });
53
+ if (!items)
54
+ throw new Error('Provider service unavailable');
55
+ return items;
56
+ }
57
+ function rowStatus({ entry, pending }, _optional = {}) {
58
+ const saved = isMarkerOnlyEntry(entry);
59
+ const mask = saved ? maskedKeyLabel(entry) : null;
60
+ return {
61
+ mask,
62
+ statusLabel: saved ? `Saved (${mask})` : pending ? 'Unsaved' : null,
63
+ statusClassName: saved ? 'jini-field-status-badge jini-field-status-badge-success' : 'jini-field-status-badge',
64
+ };
65
+ }
66
+ function baseUrlHint({ entry, item }, _optional = {}) {
67
+ const invalid = isProviderBaseUrlInvalid(entry);
68
+ return {
69
+ invalid, hint: invalid ? 'Enter an absolute http:// or https:// URL that is not a private or internal address.'
70
+ : !entry.baseUrl?.trim() && item.defaultBaseUrl ? `Uses ${item.defaultBaseUrl} by default.` : null,
71
+ hintClassName: invalid ? 'jini-field-hint jini-hint-error' : 'jini-field-hint',
72
+ hintRole: invalid ? 'alert' : undefined,
73
+ ariaInvalid: invalid || undefined,
74
+ };
75
+ }
76
+ function credentialFields({ item, entry, editor, visible }, { mask = null } = {}) {
77
+ return {
78
+ credential: entry.apiKey ?? '', credentialType: visible ? 'text' : 'password',
79
+ credentialPlaceholder: mask ?? 'Paste your API key',
80
+ credentialAttrs: { 'data-jini-part': 'media.provider.credential', 'data-agent-private': true,
81
+ 'data-agent-element': `media-provider-${item.id}-api-key`, 'aria-label': `${item.label} credential` },
82
+ toggleLabel: `${item.label} ${visible ? 'Hide' : 'Show'}`, visible,
83
+ onCredential: (event) => editor.updateProvider(item.id, { apiKey: event.currentTarget.value }),
84
+ };
85
+ }
86
+ function settingsFields({ item, entry, editor }, _optional = {}) {
87
+ return {
88
+ baseUrl: entry.baseUrl ?? '', baseUrlPlaceholder: item.defaultBaseUrl || 'https://api.example.com',
89
+ baseUrlLabel: `${item.label} base URL`,
90
+ onBaseUrl: (event) => editor.updateProvider(item.id, { baseUrl: event.currentTarget.value }),
91
+ model: entry.model ?? '', modelLabel: `${item.label} model`, modelsId: `jini-media-provider-models-${item.id}`, hasModels: !!item.models?.length,
92
+ onModel: (event) => editor.updateProvider(item.id, { model: event.currentTarget.value }),
93
+ };
94
+ }
95
+ function providerRow({ item, entry, editor, visible, toggle }, { divider = false, disabled = false } = {}) {
96
+ const { mask, ...status } = rowStatus({ entry, pending: editor.pendingProviderIds.has(item.id) });
97
+ return {
98
+ ...item,
99
+ divider,
100
+ ...status,
101
+ ...credentialFields({ item, entry, editor, visible }, { mask }),
102
+ toggle,
103
+ ...settingsFields({ item, entry, editor }),
104
+ clearLabel: `${item.label} Clear`, clearDisabled: !isEntryPresent(entry) || disabled,
105
+ clear: () => editor.clearProvider(item.id),
106
+ ...baseUrlHint({ entry, item }),
107
+ };
108
+ }
109
+ function editorView({ editor, port }, _optional = {}) {
110
+ const loading = editor.load.status === 'loading', saving = editor.save.status === 'saving';
111
+ return { reload: editor.reload, reloadDisabled: loading,
112
+ reloadLabel: loading ? 'Reloading…' : 'Reload',
113
+ unreachable: editor.load.status === 'unreachable', empty: !editor.hasAnyConfigured,
114
+ save: editor.saveChanges, saveLabel: saving ? 'Saving…' : 'Save changes',
115
+ saveDisabled: !port || saving || !editor.pendingProviderIds.size || !!invalidBaseUrlProviderIds(editor.providers).length,
116
+ saved: editor.save.status === 'saved', saveError: editor.save.status === 'save-error', disabled: !port,
117
+ // Fail closed: a host that can persist neither the whole set nor per-provider settings
118
+ // must not accept endpoint/model edits that would only surface as a failed save.
119
+ settingsDisabled: !port || (!port.saveChanges && !port.saveSettings) };
120
+ }
13
121
  export function useProvidersTab(_required = {}, _optional = {}) {
14
122
  const { mediaProviders } = useMediaPorts();
15
123
  const cached = useMediaProvidersQuery({ port: mediaProviders });
@@ -46,44 +154,15 @@ export function useProvidersTab(_required = {}, _optional = {}) {
46
154
  if (!mediaProviders)
47
155
  throw new Error('Provider service unavailable');
48
156
  const signal = lifetime.current.signal;
49
- if (mediaProviders.saveChanges) {
50
- const items = await mediaProviders.saveChanges({ providers }, { signal });
51
- lastRead = items;
52
- return configuration({ items });
53
- }
54
- // Older hosts retain the one-button editor; writes remain sequential and failures
55
- // retain local edits. Atomic whole-set hosts should implement saveChanges instead.
56
- // Submitted ids the last read never returned (catalog-only providers getting their
57
- // first key or settings) start from an empty stored state, or their writes are lost.
58
- const known = new Set(lastRead.map(item => item.id));
59
- const unread = Object.keys(providers).filter(id => !known.has(id)).map(id => ({ id, configured: false }));
60
- for (const item of [...lastRead, ...unread]) {
61
- const entry = providers[item.id];
62
- if (!entry) {
63
- if (item.configured || item.baseUrl || item.model)
64
- await mediaProviders.removeCredential({ id: item.id }, { signal });
65
- continue;
66
- }
67
- if ((entry.baseUrl ?? '') !== (item.baseUrl ?? '') || (entry.model ?? '') !== (item.model ?? '')) {
68
- if (!mediaProviders.saveSettings)
69
- throw new Error('Provider settings unavailable');
70
- await mediaProviders.saveSettings({ id: item.id, baseUrl: entry.baseUrl ?? '', model: entry.model ?? '' }, { signal });
71
- }
72
- if (entry.apiKey?.trim())
73
- await mediaProviders.saveCredential({ id: item.id, credential: entry.apiKey }, { signal });
74
- }
75
- const items = await mediaProviders.list({}, { signal });
76
- if (!items)
77
- throw new Error('Provider service unavailable');
157
+ const items = mediaProviders.saveChanges
158
+ ? await mediaProviders.saveChanges({ providers }, { signal })
159
+ : await saveSequentially({ port: mediaProviders, providers, lastRead, signal });
78
160
  lastRead = items;
79
161
  return configuration({ items });
80
162
  },
81
163
  };
82
164
  }, [mediaProviders]);
83
- const save = useFetchMutation({ run: async ({ input }) => {
84
- const result = await rawEditorPort.saveMediaProviders(input);
85
- return result;
86
- } }, { invalidates: [MEDIA_QUERY_KEYS.providers] });
165
+ const save = useFetchMutation({ run: ({ input }) => rawEditorPort.saveMediaProviders(input) }, { invalidates: [MEDIA_QUERY_KEYS.providers] });
87
166
  const saves = useRef(save);
88
167
  saves.current = save;
89
168
  const editorPort = useMemo(() => ({
@@ -94,54 +173,15 @@ export function useProvidersTab(_required = {}, _optional = {}) {
94
173
  },
95
174
  saveMediaProviders: (providers) => saves.current.mutate({ input: providers }),
96
175
  }), [rawEditorPort]);
97
- const editor = useMediaProvidersTab({ port: editorPort });
176
+ const editor = useMediaProvidersTab({ port: editorPort, catalogPolicy });
98
177
  const catalog = mediaProviders?.catalog ?? discoveredCatalog;
99
178
  const pinnedIds = mediaProviders?.pinnedProviderIds ?? [];
100
179
  const ordered = sortProvidersByConfigured(catalog, editor.providers, pinnedIds);
101
- const rows = ordered.map((item, index) => {
102
- const entry = editor.providers[item.id] ?? {};
103
- const saved = isMarkerOnlyEntry(entry);
104
- const mask = saved ? maskedKeyLabel(entry) : null;
105
- const visible = visibleKeys.has(item.id);
106
- const invalid = isProviderBaseUrlInvalid(entry);
107
- const modelsId = `jini-media-provider-models-${item.id}`;
108
- return {
109
- ...item,
110
- divider: index > 0 && !pinnedIds.includes(item.id) && pinnedIds.includes(ordered[index - 1].id),
111
- statusLabel: saved ? `Saved (${mask})` : editor.pendingProviderIds.has(item.id) ? 'Unsaved' : null,
112
- statusClassName: saved ? 'jini-field-status-badge jini-field-status-badge-success' : 'jini-field-status-badge',
113
- credential: entry.apiKey ?? '', credentialType: visible ? 'text' : 'password',
114
- credentialPlaceholder: mask ?? 'Paste your API key',
115
- credentialAttrs: { 'data-jini-part': 'media.provider.credential', 'data-agent-private': true,
116
- 'data-agent-element': `media-provider-${item.id}-api-key`, 'aria-label': `${item.label} credential` },
117
- toggleLabel: `${item.label} ${visible ? 'Hide' : 'Show'}`, visible,
118
- toggle() { setVisibleKeys(current => { const next = new Set(current); if (next.has(item.id))
119
- next.delete(item.id);
120
- else
121
- next.add(item.id); return next; }); },
122
- onCredential: (event) => editor.updateProvider(item.id, { apiKey: event.currentTarget.value }),
123
- baseUrl: entry.baseUrl ?? '', baseUrlPlaceholder: item.defaultBaseUrl || 'https://api.example.com',
124
- baseUrlLabel: `${item.label} base URL`,
125
- onBaseUrl: (event) => editor.updateProvider(item.id, { baseUrl: event.currentTarget.value }),
126
- model: entry.model ?? '', modelLabel: `${item.label} model`, modelsId, hasModels: !!item.models?.length,
127
- onModel: (event) => editor.updateProvider(item.id, { model: event.currentTarget.value }),
128
- clearLabel: `${item.label} Clear`, clearDisabled: !isEntryPresent(entry) || !mediaProviders,
129
- clear: () => editor.clearProvider(item.id),
130
- invalid, hint: invalid ? 'Enter an absolute http:// or https:// URL that is not a private or internal address.'
131
- : !entry.baseUrl?.trim() && item.defaultBaseUrl ? `Uses ${item.defaultBaseUrl} by default.` : null,
132
- hintClassName: invalid ? 'jini-field-hint jini-hint-error' : 'jini-field-hint',
133
- hintRole: invalid ? 'alert' : undefined,
134
- ariaInvalid: invalid || undefined,
135
- };
136
- });
137
- return { rows, reload: editor.reload, reloadDisabled: editor.load.status === 'loading',
138
- reloadLabel: editor.load.status === 'loading' ? 'Reloading…' : 'Reload',
139
- unreachable: editor.load.status === 'unreachable', empty: !editor.hasAnyConfigured,
140
- save: editor.saveChanges, saveLabel: editor.save.status === 'saving' ? 'Saving…' : 'Save changes',
141
- saveDisabled: !mediaProviders || editor.save.status === 'saving' || !editor.pendingProviderIds.size || !!invalidBaseUrlProviderIds(editor.providers).length,
142
- saved: editor.save.status === 'saved', saveError: editor.save.status === 'save-error', disabled: !mediaProviders,
143
- // Fail closed: a host that can persist neither the whole set nor per-provider settings
144
- // must not accept endpoint/model edits that would only surface as a failed save.
145
- settingsDisabled: !mediaProviders || (!mediaProviders.saveChanges && !mediaProviders.saveSettings) };
180
+ const toggleKey = (id) => setVisibleKeys(current => { const next = new Set(current); if (next.has(id))
181
+ next.delete(id);
182
+ else
183
+ next.add(id); return next; });
184
+ const rows = ordered.map((item, index) => providerRow({ item, entry: editor.providers[item.id] ?? {}, editor, visible: visibleKeys.has(item.id), toggle: () => toggleKey(item.id) }, { divider: index > 0 && !pinnedIds.includes(item.id) && pinnedIds.includes(ordered[index - 1].id), disabled: !mediaProviders }));
185
+ return { rows, ...editorView({ editor, port: mediaProviders }) };
146
186
  }
147
187
  //# sourceMappingURL=ProvidersTab.hooks.js.map
@@ -18,12 +18,13 @@ export declare function readMediaFile({ file }: {
18
18
  signal?: AbortSignal;
19
19
  createReader?: () => FileReader;
20
20
  }): Promise<UploadInput>;
21
+ type ReadFile = (required: {
22
+ file: File;
23
+ }, optional?: {
24
+ signal?: AbortSignal;
25
+ }) => Promise<UploadInput>;
21
26
  export declare function useUploadButton(props: UploadButtonProps, { readFile }?: {
22
- readFile?: (required: {
23
- file: File;
24
- }, optional?: {
25
- signal?: AbortSignal;
26
- }) => Promise<UploadInput>;
27
+ readFile?: ReadFile;
27
28
  }): {
28
29
  alt: string;
29
30
  withAlt: boolean;
@@ -52,4 +53,5 @@ export declare function useUploadButton(props: UploadButtonProps, { readFile }?:
52
53
  onPress: () => void | undefined;
53
54
  label: string;
54
55
  };
56
+ export {};
55
57
  //# sourceMappingURL=UploadButton.hooks.d.ts.map
@@ -26,6 +26,22 @@ export function readMediaFile({ file }, { signal, createReader = () => new FileR
26
26
  reader.readAsDataURL(file);
27
27
  });
28
28
  }
29
+ /** Uploads files in order, recording each landed name in `uploaded` so a mid-batch failure
30
+ * still reports what succeeded. Resolves false when the toolbar unmounted mid-batch. */
31
+ async function sendFiles({ files, signal, uploaded, alt, upload, readFile }, _optional = {}) {
32
+ const trimmed = alt.trim();
33
+ for (const file of files) {
34
+ const input = await readFile({ file }, { signal });
35
+ if (signal.aborted)
36
+ return false;
37
+ if (!await upload({ input, ...(trimmed ? { alt: trimmed } : {}) }))
38
+ throw new Error(`Upload failed for ${file.name}`);
39
+ if (signal.aborted)
40
+ return false;
41
+ uploaded.push(file.name);
42
+ }
43
+ return true;
44
+ }
29
45
  export function useUploadButton(props, { readFile = readMediaFile } = {}) {
30
46
  // Advisory picker formats mirror the host's standard ceiling, including video. The
31
47
  // injected server still validates/sniffs bytes; a file picker is never a security boundary.
@@ -40,13 +56,27 @@ export function useUploadButton(props, { readFile = readMediaFile } = {}) {
40
56
  lifetime.current = abort;
41
57
  return () => abort.abort();
42
58
  }, []);
59
+ // Multi-select hosts take the whole pick; single-file toolbars keep only the first.
60
+ const limit = (files) => files.slice(0, props.multiple === false ? 1 : undefined);
43
61
  function onFile(event) {
44
62
  if (!inFlight.current && !props.disabled) {
45
- setSelectedFiles(Array.from(event.currentTarget.files ?? []).slice(0, props.multiple === false ? 1 : undefined));
63
+ setSelectedFiles(limit(Array.from(event.currentTarget.files ?? [])));
46
64
  setError(null);
47
65
  setSuccess(null);
48
66
  }
49
67
  }
68
+ function settle({ files, uploaded }, _optional = {}) {
69
+ // Keep only failed/unattempted files: retry must not duplicate successes in a batch.
70
+ setSelectedFiles(files.slice(uploaded.length));
71
+ setBusy(false);
72
+ if (uploaded.length)
73
+ setSuccess(`Uploaded ${uploaded.join(', ')}`);
74
+ }
75
+ function clearForm() {
76
+ setAlt('');
77
+ if (inputRef.current)
78
+ inputRef.current.value = '';
79
+ }
50
80
  async function uploadFiles(files) {
51
81
  if (!files.length || inFlight.current || props.disabled)
52
82
  return;
@@ -56,22 +86,9 @@ export function useUploadButton(props, { readFile = readMediaFile } = {}) {
56
86
  setError(null);
57
87
  setSuccess(null);
58
88
  const uploaded = [];
59
- let remaining = [...files];
60
89
  try {
61
- for (const file of files) {
62
- const input = await readFile({ file }, { signal });
63
- if (signal.aborted)
64
- return;
65
- if (!await props.upload({ input, ...(alt.trim() ? { alt: alt.trim() } : {}) }))
66
- throw new Error(`Upload failed for ${file.name}`);
67
- if (signal.aborted)
68
- return;
69
- uploaded.push(file.name);
70
- remaining = remaining.slice(1);
71
- }
72
- setAlt('');
73
- if (inputRef.current)
74
- inputRef.current.value = '';
90
+ if (await sendFiles({ files, signal, uploaded, alt, upload: props.upload, readFile }))
91
+ clearForm();
75
92
  }
76
93
  catch (error) {
77
94
  if (!signal.aborted)
@@ -79,13 +96,8 @@ export function useUploadButton(props, { readFile = readMediaFile } = {}) {
79
96
  }
80
97
  finally {
81
98
  inFlight.current = false;
82
- if (!signal.aborted) {
83
- // Keep only failed/unattempted files: retry must not duplicate successes in a batch.
84
- setSelectedFiles(remaining);
85
- setBusy(false);
86
- if (uploaded.length)
87
- setSuccess(`Uploaded ${uploaded.join(', ')}`);
88
- }
99
+ if (!signal.aborted)
100
+ settle({ files, uploaded });
89
101
  }
90
102
  }
91
103
  return {
@@ -104,7 +116,7 @@ export function useUploadButton(props, { readFile = readMediaFile } = {}) {
104
116
  event.preventDefault();
105
117
  if (inFlight.current || props.disabled)
106
118
  return;
107
- const files = Array.from(event.dataTransfer.files).slice(0, props.multiple === false ? 1 : undefined);
119
+ const files = limit(Array.from(event.dataTransfer.files));
108
120
  setSelectedFiles(files);
109
121
  void uploadFiles(files);
110
122
  },
@@ -1,3 +1,7 @@
1
+ // Styling: scoped feature presentation used to travel with compiled JS (a `mediaStyles` string in
2
+ // `media/styles.ts`), which also overrode host dialog CSS that could accidentally display a closed
3
+ // dialog or shrink a large media preview. Host CSS now supplies presentation and the closed-dialog
4
+ // safeguards on jini- classes, so no style string ships from this module.
1
5
  export { useGallerySelection } from './hooks/GallerySelection.hooks.js';
2
6
  import { createElement } from 'react';
3
7
  import { bindReact } from '../../core/react/bind-react.js';