@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.
- package/CHANGELOG.md +70 -2
- package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
- package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
- package/dist/agent-plugins/package-file-tree.js +54 -51
- package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
- package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
- package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
- package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
- package/dist/browser/navigation.js +44 -27
- package/dist/comments/adapters/memory.js +30 -12
- package/dist/comments/conformance/comments-api.conformance.js +55 -33
- package/dist/comments/controllers/comment-queue.controller.js +13 -4
- package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
- package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
- package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
- package/dist/comments/react/index.d.ts +2 -1
- package/dist/comments/react/index.js +1 -1
- package/dist/consent/react/ConsentFields.d.ts +35 -0
- package/dist/consent/react/ConsentFields.js +76 -0
- package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
- package/dist/consent/react/ConsentPreviewFrame.js +14 -0
- package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
- package/dist/consent/react/ConsentSettingsPanel.js +6 -17
- package/dist/consent/react/index.d.ts +6 -1
- package/dist/consent/react/index.js +2 -0
- package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
- package/dist/consent/react/settings-panel.hooks.js +26 -0
- package/dist/core/entities/rules.js +126 -81
- package/dist/core/manifest/rules.js +10 -10
- package/dist/core/module/create-admin.js +195 -117
- package/dist/core/ports/redirects.d.ts +8 -6
- package/dist/core/ports/redirects.js +8 -6
- package/dist/core/routing/rules.js +9 -4
- package/dist/core/transport/http.js +37 -20
- package/dist/forms/adapters/memory.js +22 -11
- package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
- package/dist/forms/conformance/forms-api.conformance.js +42 -46
- package/dist/forms/field-settings.js +150 -115
- package/dist/forms/html-rules.js +45 -28
- package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
- package/dist/forms/react/components/FieldSettingsRows.js +29 -0
- package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
- package/dist/forms/react/components/FieldSettingsSection.js +5 -6
- package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
- package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
- package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
- package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
- package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
- package/dist/forms/react/components/FormSubmissions.js +4 -20
- package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
- package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
- package/dist/media/adapters/memory.js +31 -26
- package/dist/media/controllers/edit-media.controller.js +2 -1
- package/dist/media/controllers/library.controller.js +48 -35
- package/dist/media/controllers/providers.controller.js +2 -1
- package/dist/media/html-attributes.js +29 -15
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/react/components/MediaIcons.d.ts +0 -3
- package/dist/media/react/components/MediaIcons.js +0 -9
- package/dist/media/react/components/MediaLightbox.d.ts +2 -1
- package/dist/media/react/components/MediaLightbox.js +11 -5
- package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
- package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
- package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
- package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
- package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
- package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
- package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
- package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
- package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
- package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
- package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
- package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
- package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
- package/dist/media/react/index.js +4 -0
- package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
- package/dist/media/react/tabs/LibraryTab.js +2 -2
- package/dist/media/rules.d.ts +7 -0
- package/dist/media/rules.js +25 -2
- package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
- package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
- package/dist/react/components/RowMenu/RowMenu.js +16 -10
- package/dist/react/components/Sidebar.js +32 -26
- package/dist/react/entities/EntityEdit.js +17 -14
- package/dist/react/entities/EntityField.d.ts +5 -2
- package/dist/react/entities/EntityField.js +38 -23
- package/dist/react/entities/EntityList.js +6 -5
- package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
- package/dist/react/entities/RelationTruncationNotice.js +9 -0
- package/dist/react/entities/use-entity-edit.d.ts +4 -2
- package/dist/react/entities/use-entity-edit.js +52 -33
- package/dist/react/shell/AdminShell.js +40 -23
- package/dist/react/shell/model.d.ts +4 -1
- package/dist/react/shell/model.js +3 -2
- package/dist/react/shell/use-admin-shell-session.js +25 -26
- package/dist/redirects/adapters/memory.js +9 -2
- package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
- package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
- package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
- package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
- package/dist/security/adapters/http.js +25 -10
- package/dist/security/adapters/memory.js +19 -5
- package/dist/security/controllers/access-tokens.controller.js +27 -26
- package/dist/security/controllers/other-credentials.controller.js +4 -2
- package/dist/security/controllers/root-key.controller.js +17 -9
- package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
- package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
- package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
- package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
- package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
- package/dist/security/react/tabs/RootKeyTab.js +12 -3
- package/dist/security/rules.js +45 -25
- package/dist/seo/controllers/seo.controller.js +4 -2
- package/dist/seo/react/components/MediaRefField.d.ts +1 -1
- package/dist/seo/react/components/MediaRefField.js +19 -2
- package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
- package/dist/seo/react/pages/SeoPage.d.ts +4 -4
- package/dist/seo/react/pages/SeoPage.js +26 -6
- package/dist/seo/rules.js +39 -10
- package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
- package/dist/source-control/controllers/source-control.controller.js +20 -8
- package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
- package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
- package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
- package/dist/widgets/controllers/instance-editor.controller.js +33 -24
- package/dist/widgets/controllers/library.controller.js +18 -12
- package/dist/widgets/controllers/region-editor.controller.js +6 -5
- package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
- package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
- package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
- package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
- package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
- package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
- package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
- package/dist/widgets/rules.d.ts +14 -2
- package/dist/widgets/rules.js +9 -6
- package/package.json +23 -14
- package/src/themes/default/admin.css +51 -0
- package/src/themes/default/chat.css +21 -0
- package/src/themes/default/manifest.json +29 -0
- package/dist/media/styles.d.ts +0 -4
- 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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(() => {
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
46
|
+
const id = active && shortTabId({ id: active.id });
|
|
46
47
|
useEffect(() => {
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
lastRead
|
|
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:
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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?:
|
|
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 ?? [])
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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)
|
|
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';
|