@jini-ai/admin 0.4.12 → 0.4.13
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 +4 -0
- 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 +74 -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 +17 -8
- 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
|
@@ -3,13 +3,104 @@ import { MEDIA_QUERY_KEYS, useMediaLibraryQuery } from './MediaQuery.hooks.js';
|
|
|
3
3
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
4
|
import { useController } from '../../../core/react/use-controller.js';
|
|
5
5
|
import { createLibraryController } from '../../controllers/library.controller.js';
|
|
6
|
-
import { useSharedMediaLibrary } from './MediaPage.hooks.js';
|
|
6
|
+
import { settleFromCache, useSharedMediaLibrary } from './MediaPage.hooks.js';
|
|
7
7
|
import { useMediaPorts } from './MediaPorts.hooks.js';
|
|
8
8
|
import { mediaMessagesEn } from '../../messages.en.js';
|
|
9
9
|
import { canRestoreMedia } from '../../rules.js';
|
|
10
|
+
const EMPTY_LABELS = {
|
|
11
|
+
images: 'No images yet. Upload an image to see it here.',
|
|
12
|
+
videos: 'No videos yet. Upload a video to see it here.',
|
|
13
|
+
all: 'No media found',
|
|
14
|
+
};
|
|
15
|
+
function contentFilter({ params }, _optional = {}) {
|
|
16
|
+
return params.filter === 'images' || params.filter === 'videos' ? params.filter : 'all';
|
|
17
|
+
}
|
|
18
|
+
function libraryPermissions({ permissions, api }, _optional = {}) {
|
|
19
|
+
return {
|
|
20
|
+
canUpload: permissions.includes('media.upload'),
|
|
21
|
+
canEdit: permissions.includes('media.update'),
|
|
22
|
+
canTrash: permissions.includes('media.trash'),
|
|
23
|
+
canRestore: permissions.includes('media.restore') && canRestoreMedia({ api }),
|
|
24
|
+
canPurge: permissions.includes('media.delete.force'),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Focuses the card with `id`, else whichever card now sits at `index`, else the grid itself. */
|
|
28
|
+
function focusCard({ grid, index }, { id } = {}) {
|
|
29
|
+
const cards = Array.from(grid?.querySelectorAll('[data-jini-part="media.card"]') ?? []);
|
|
30
|
+
const byId = id === undefined ? undefined : cards.find(card => card.dataset.mediaId === id);
|
|
31
|
+
const target = byId ?? cards[Math.min(index, cards.length - 1)] ?? grid;
|
|
32
|
+
target?.focus({ preventScroll: true });
|
|
33
|
+
}
|
|
34
|
+
function queryView({ snapshot }, _optional = {}) {
|
|
35
|
+
return {
|
|
36
|
+
statusFilter: snapshot?.query.status ?? 'all',
|
|
37
|
+
orderBy: snapshot?.query.orderBy ?? 'created',
|
|
38
|
+
search: snapshot?.query.search ?? '',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function progressView({ snapshot, filter }, _optional = {}) {
|
|
42
|
+
return {
|
|
43
|
+
loading: !snapshot || (snapshot.items === null && snapshot.loading && !snapshot.error),
|
|
44
|
+
busy: snapshot?.busy ?? false,
|
|
45
|
+
untyped: filter !== 'all' && snapshot?.hasUntyped === true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function purgeDialogView({ snapshot }, _optional = {}) {
|
|
49
|
+
const pending = snapshot?.pendingPurge;
|
|
50
|
+
return { open: !!pending, pending: snapshot?.busy ?? false, ...(pending ? { filename: pending.title } : {}) };
|
|
51
|
+
}
|
|
52
|
+
function queryHandlers({ controller }, _optional = {}) {
|
|
53
|
+
return {
|
|
54
|
+
onStatus: ({ value }) => {
|
|
55
|
+
const { status: _status, ...query } = controller?.getSnapshot().query ?? {};
|
|
56
|
+
void controller?.setQuery({ query: { ...query, ...(value === 'active' || value === 'trashed' ? { status: value } : {}) } });
|
|
57
|
+
},
|
|
58
|
+
onSearch: ({ value: search }) => {
|
|
59
|
+
void controller?.setQuery({ query: { ...controller.getSnapshot().query, search } });
|
|
60
|
+
},
|
|
61
|
+
onOrder: ({ value }) => {
|
|
62
|
+
void controller?.setQuery({ query: { ...controller.getSnapshot().query, orderBy: value === 'alphabetical' ? 'alphabetical' : 'created' } });
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function cardActions({ items, pendingIds, can, controller, announce, onEdit, onPreview }, _optional = {}) {
|
|
67
|
+
return items.map((item, index) => {
|
|
68
|
+
const write = (run, verb) => {
|
|
69
|
+
void run?.then(saved => { if (saved)
|
|
70
|
+
announce({ message: `${verb} ${item.title}`, focus: { id: item.id, index } }); });
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
item,
|
|
74
|
+
busy: pendingIds.includes(item.id),
|
|
75
|
+
...(can.canEdit ? { onEdit: () => onEdit(item) } : {}),
|
|
76
|
+
onPreview: () => onPreview(index),
|
|
77
|
+
...(can.canTrash ? { onTrash: () => write(controller?.trash({ id: item.id }), 'Trashed') } : {}),
|
|
78
|
+
...(can.canRestore ? { onRestore: () => write(controller?.restore({ id: item.id }), 'Restored') } : {}),
|
|
79
|
+
...(can.canPurge ? { onPurge: () => controller?.requestPurge({ item }) } : {}),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** Once a write has settled and the grid re-rendered, return focus to the affected card. */
|
|
84
|
+
function useFocusAfterWrites({ gridRef, snapshot }, _optional = {}) {
|
|
85
|
+
const [focusAfterAction, setFocusAfterAction] = useState(null);
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
if (!focusAfterAction || snapshot?.busy || snapshot?.loading)
|
|
88
|
+
return;
|
|
89
|
+
focusCard({ grid: gridRef.current, index: focusAfterAction.index }, { id: focusAfterAction.id });
|
|
90
|
+
setFocusAfterAction(null);
|
|
91
|
+
}, [focusAfterAction, snapshot?.busy, snapshot?.loading, snapshot?.items]);
|
|
92
|
+
const [focusAfterPurge, setFocusAfterPurge] = useState(null);
|
|
93
|
+
useLayoutEffect(() => {
|
|
94
|
+
if (focusAfterPurge === null || snapshot?.pendingPurge || snapshot?.busy)
|
|
95
|
+
return;
|
|
96
|
+
focusCard({ grid: gridRef.current, index: focusAfterPurge });
|
|
97
|
+
setFocusAfterPurge(null);
|
|
98
|
+
}, [focusAfterPurge, snapshot?.pendingPurge, snapshot?.busy, snapshot?.items]);
|
|
99
|
+
return { setFocusAfterAction, setFocusAfterPurge };
|
|
100
|
+
}
|
|
10
101
|
export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
11
102
|
const { mediaApi, mediaEvents } = useMediaPorts();
|
|
12
|
-
const filter =
|
|
103
|
+
const filter = contentFilter({ params });
|
|
13
104
|
const shared = useSharedMediaLibrary();
|
|
14
105
|
const cached = useMediaLibraryQuery({ api: mediaApi }, { enabled: !shared });
|
|
15
106
|
const invalidate = useInvalidate();
|
|
@@ -25,28 +116,10 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
25
116
|
const { controller, snapshot } = shared ?? local;
|
|
26
117
|
useEffect(() => !shared ? mediaEvents?.subscribe({ onRefresh: () => invalidate({ key: MEDIA_QUERY_KEYS.root }) }) : undefined, [mediaEvents, controller, shared, invalidate]);
|
|
27
118
|
useEffect(() => { if (!shared)
|
|
28
|
-
|
|
119
|
+
settleFromCache({ controller: local.controller, query: cached.query }); }, [shared, local.controller, cached.query.data, cached.query.error]);
|
|
29
120
|
const gridRef = useRef(null);
|
|
30
121
|
const [success, setSuccess] = useState(null);
|
|
31
|
-
const
|
|
32
|
-
useLayoutEffect(() => {
|
|
33
|
-
if (!focusAfterAction || snapshot?.busy || snapshot?.loading)
|
|
34
|
-
return;
|
|
35
|
-
const cards = Array.from(gridRef.current?.querySelectorAll('[data-jini-part="media.card"]') ?? []);
|
|
36
|
-
const target = cards.find(card => card.dataset.mediaId === focusAfterAction.id) ?? cards[Math.min(focusAfterAction.index, cards.length - 1)] ?? gridRef.current;
|
|
37
|
-
target?.focus({ preventScroll: true });
|
|
38
|
-
setFocusAfterAction(null);
|
|
39
|
-
}, [focusAfterAction, snapshot?.busy, snapshot?.loading, snapshot?.items]);
|
|
40
|
-
const [focusAfterPurge, setFocusAfterPurge] = useState(null);
|
|
41
|
-
useLayoutEffect(() => {
|
|
42
|
-
if (focusAfterPurge === null || snapshot?.pendingPurge || snapshot?.busy)
|
|
43
|
-
return;
|
|
44
|
-
const grid = gridRef.current;
|
|
45
|
-
const cards = grid?.querySelectorAll('[data-jini-part="media.card"]');
|
|
46
|
-
const target = cards?.[Math.min(focusAfterPurge, cards.length - 1)] ?? grid;
|
|
47
|
-
target?.focus({ preventScroll: true });
|
|
48
|
-
setFocusAfterPurge(null);
|
|
49
|
-
}, [focusAfterPurge, snapshot?.pendingPurge, snapshot?.busy, snapshot?.items]);
|
|
122
|
+
const { setFocusAfterAction, setFocusAfterPurge } = useFocusAfterWrites({ gridRef, snapshot });
|
|
50
123
|
useEffect(() => {
|
|
51
124
|
// A shared page controller survives tab unmounts. Re-entering after an error must
|
|
52
125
|
// be a recovery opportunity even when its cached filter already matches this tab.
|
|
@@ -58,32 +131,9 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
58
131
|
// buttons move the number without re-deriving "what's next" from an item reference.
|
|
59
132
|
const [lightboxIndex, setLightboxIndex] = useState(null);
|
|
60
133
|
const items = snapshot?.items ?? [], pendingIds = snapshot?.pendingIds ?? [];
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const canRestore = permissions.includes('media.restore') && canRestoreMedia({ api: mediaApi });
|
|
65
|
-
const canPurge = permissions.includes('media.delete.force');
|
|
66
|
-
const actions = items.map((item, index) => ({
|
|
67
|
-
item,
|
|
68
|
-
busy: pendingIds.includes(item.id),
|
|
69
|
-
...(canEdit ? { onEdit: () => setEditing(item) } : {}),
|
|
70
|
-
onPreview: () => setLightboxIndex(index),
|
|
71
|
-
...(canTrash
|
|
72
|
-
? {
|
|
73
|
-
onTrash: () => {
|
|
74
|
-
void controller?.trash({ id: item.id }).then(saved => { if (saved) {
|
|
75
|
-
setSuccess(`Trashed ${item.title}`);
|
|
76
|
-
setFocusAfterAction({ id: item.id, index });
|
|
77
|
-
} });
|
|
78
|
-
},
|
|
79
|
-
}
|
|
80
|
-
: {}),
|
|
81
|
-
...(canRestore ? { onRestore: () => { void controller?.restore({ id: item.id }).then(saved => { if (saved) {
|
|
82
|
-
setSuccess(`Restored ${item.title}`);
|
|
83
|
-
setFocusAfterAction({ id: item.id, index });
|
|
84
|
-
} }); } } : {}),
|
|
85
|
-
...(canPurge ? { onPurge: () => controller?.requestPurge({ item }) } : {}),
|
|
86
|
-
}));
|
|
134
|
+
const can = libraryPermissions({ permissions, api: mediaApi });
|
|
135
|
+
const announce = ({ message, focus }) => { setSuccess(message); setFocusAfterAction(focus); };
|
|
136
|
+
const actions = cardActions({ items, pendingIds, can, controller, announce, onEdit: setEditing, onPreview: setLightboxIndex });
|
|
87
137
|
function closeEdit() {
|
|
88
138
|
setEditing(null);
|
|
89
139
|
}
|
|
@@ -99,18 +149,12 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
99
149
|
setFocusAfterAction({ id: item.id, index: Math.max(0, items.findIndex(row => row.id === item.id)) });
|
|
100
150
|
}
|
|
101
151
|
return {
|
|
102
|
-
emptyLabel: filter
|
|
103
|
-
|
|
104
|
-
canUpload,
|
|
152
|
+
emptyLabel: EMPTY_LABELS[filter],
|
|
153
|
+
canUpload: can.canUpload,
|
|
105
154
|
success,
|
|
106
155
|
gridRef,
|
|
107
156
|
onRetry: () => { void controller?.load(); },
|
|
108
|
-
|
|
109
|
-
onStatus: ({ value }) => {
|
|
110
|
-
const { status: _status, ...query } = controller?.getSnapshot().query ?? {};
|
|
111
|
-
void controller?.setQuery({ query: { ...query, ...(value === 'active' || value === 'trashed' ? { status: value } : {}) } });
|
|
112
|
-
},
|
|
113
|
-
canPurge,
|
|
157
|
+
canPurge: can.canPurge,
|
|
114
158
|
controller,
|
|
115
159
|
snapshot,
|
|
116
160
|
actions,
|
|
@@ -122,19 +166,12 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
122
166
|
lightboxIndex,
|
|
123
167
|
onNavigate: setLightboxIndex,
|
|
124
168
|
closeLightbox: () => setLightboxIndex(null),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
onOrder: ({ value }) => {
|
|
130
|
-
void controller?.setQuery({ query: { ...controller.getSnapshot().query, orderBy: value === 'alphabetical' ? 'alphabetical' : 'created' } });
|
|
131
|
-
},
|
|
132
|
-
search: snapshot?.query.search ?? '',
|
|
133
|
-
loading: !snapshot || (snapshot.items === null && snapshot.loading && !snapshot.error),
|
|
134
|
-
busy: snapshot?.busy ?? false,
|
|
135
|
-
untyped: filter !== 'all' && snapshot?.hasUntyped === true,
|
|
169
|
+
...queryHandlers({ controller }),
|
|
170
|
+
...queryView({ snapshot }),
|
|
171
|
+
...progressView({ snapshot, filter }),
|
|
172
|
+
purgeDialog: purgeDialogView({ snapshot }),
|
|
136
173
|
async upload({ input, alt }) {
|
|
137
|
-
if (!canUpload)
|
|
174
|
+
if (!can.canUpload)
|
|
138
175
|
return false;
|
|
139
176
|
const result = await controller?.upload({ input, ...(alt === undefined ? {} : { alt }) }) ?? false;
|
|
140
177
|
if (result)
|
|
@@ -145,7 +182,7 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
145
182
|
confirmPurge: () => {
|
|
146
183
|
const item = controller?.getSnapshot().pendingPurge;
|
|
147
184
|
// The controller refuses to purge without a pending item, so there is nothing to announce.
|
|
148
|
-
if (!canPurge || !controller || !item)
|
|
185
|
+
if (!can.canPurge || !controller || !item)
|
|
149
186
|
return;
|
|
150
187
|
const index = items.findIndex(row => row.id === item.id);
|
|
151
188
|
return controller.confirmPurge({ confirmed: true }).then(saved => {
|
|
@@ -13,14 +13,26 @@ export interface MediaCardProps {
|
|
|
13
13
|
readonly onPurge?: () => void;
|
|
14
14
|
readonly onChoose?: () => void;
|
|
15
15
|
}
|
|
16
|
+
type PreviewStage = 'image' | 'video' | 'unsupported';
|
|
16
17
|
export declare function useMediaCard(props: MediaCardProps, _optional?: Record<string, never>): {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
statusClassName: string;
|
|
19
|
+
statusLabel: "active" | "trashed";
|
|
20
|
+
statusTone: "warning" | "success";
|
|
21
|
+
trashed: boolean;
|
|
22
|
+
menuItems: {
|
|
23
|
+
onPress: () => void;
|
|
24
|
+
disabled: boolean;
|
|
25
|
+
attrs?: {
|
|
26
|
+
'data-jini-variant': string;
|
|
27
|
+
};
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
}[];
|
|
31
|
+
src: string | undefined;
|
|
32
|
+
stage: PreviewStage;
|
|
33
|
+
onImageError: () => void;
|
|
34
|
+
onVideoError: () => void;
|
|
35
|
+
alt: string;
|
|
24
36
|
handles: {
|
|
25
37
|
expand: string;
|
|
26
38
|
edit: string;
|
|
@@ -43,32 +55,15 @@ export declare function useMediaCard(props: MediaCardProps, _optional?: Record<s
|
|
|
43
55
|
'data-agent-element': string;
|
|
44
56
|
'data-agent-label': string;
|
|
45
57
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
label: string;
|
|
49
|
-
onPress: () => void;
|
|
50
|
-
disabled: boolean;
|
|
51
|
-
attrs?: never;
|
|
52
|
-
} | {
|
|
53
|
-
id: string;
|
|
54
|
-
label: string;
|
|
55
|
-
attrs: {
|
|
56
|
-
'data-jini-variant': string;
|
|
57
|
-
};
|
|
58
|
-
onPress: () => void;
|
|
59
|
-
disabled: boolean;
|
|
60
|
-
})[];
|
|
61
|
-
src: string | undefined;
|
|
62
|
-
stage: "video" | "image" | "unsupported";
|
|
58
|
+
canPreview: boolean;
|
|
59
|
+
imagePreview: boolean;
|
|
63
60
|
image: boolean | "" | undefined;
|
|
64
61
|
video: boolean | "" | undefined;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
statusTone: "warning" | "success";
|
|
71
|
-
trashed: boolean;
|
|
62
|
+
preview: () => void | undefined;
|
|
63
|
+
byteSize: string | null;
|
|
64
|
+
uploadDate: string | null;
|
|
65
|
+
metadataSeparator: string;
|
|
66
|
+
busy: boolean;
|
|
72
67
|
item: MediaAsset;
|
|
73
68
|
handleBase?: string;
|
|
74
69
|
locale?: string;
|
|
@@ -80,4 +75,5 @@ export declare function useMediaCard(props: MediaCardProps, _optional?: Record<s
|
|
|
80
75
|
onPurge?: () => void;
|
|
81
76
|
onChoose?: () => void;
|
|
82
77
|
};
|
|
78
|
+
export {};
|
|
83
79
|
//# sourceMappingURL=MediaCard.hooks.d.ts.map
|
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { safeMediaUrl, formatByteSize, formatUploadDate, mediaRowHandles } from '../../rules.js';
|
|
3
|
+
function menuItems({ props }, _optional = {}) {
|
|
4
|
+
const disabled = props.busy === true, trashed = props.item.status === 'trashed';
|
|
5
|
+
const entries = [
|
|
6
|
+
{ id: 'edit', label: 'Edit metadata', onPress: props.onEdit, shown: true },
|
|
7
|
+
{ id: 'trash', label: 'Trash', onPress: props.onTrash, shown: !trashed },
|
|
8
|
+
// Restore belongs only in a trashed card's menu; active cards have nothing to restore.
|
|
9
|
+
{ id: 'restore', label: 'Restore', onPress: props.onRestore, shown: trashed },
|
|
10
|
+
{ id: 'purge', label: 'Delete permanently', onPress: props.onPurge, shown: trashed },
|
|
11
|
+
];
|
|
12
|
+
return entries.flatMap(({ id, label, onPress, shown }) => onPress && shown
|
|
13
|
+
? [{ id, label, ...(id === 'purge' ? { attrs: { 'data-jini-variant': 'danger' } } : {}), onPress, disabled }]
|
|
14
|
+
: []);
|
|
15
|
+
}
|
|
16
|
+
function metadata({ item }, { locale = 'en-US' } = {}) {
|
|
17
|
+
const byteSize = item.byteSize === undefined ? null : formatByteSize({ bytes: item.byteSize }, { locale });
|
|
18
|
+
const uploadDate = formatUploadDate({ createdAt: item.createdAt }, { locale });
|
|
19
|
+
return { byteSize, uploadDate, metadataSeparator: byteSize && uploadDate ? ' · ' : '' };
|
|
20
|
+
}
|
|
21
|
+
function previewFlags({ src, stage }, { onPreview } = {}) {
|
|
22
|
+
const previewable = !!onPreview && !!src;
|
|
23
|
+
return {
|
|
24
|
+
canPreview: previewable && stage !== 'unsupported',
|
|
25
|
+
imagePreview: previewable && stage === 'image',
|
|
26
|
+
image: src && stage === 'image',
|
|
27
|
+
video: src && stage === 'video',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function statusView({ item }, _optional = {}) {
|
|
31
|
+
const trashed = item.status === 'trashed';
|
|
32
|
+
return {
|
|
33
|
+
statusClassName: `jini-status jini-status-${item.status}`,
|
|
34
|
+
statusLabel: item.status,
|
|
35
|
+
statusTone: trashed ? 'warning' : 'success',
|
|
36
|
+
trashed,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function agentAttrs({ handleBase, title }, _optional = {}) {
|
|
40
|
+
return {
|
|
41
|
+
handles: { expand: `${handleBase}-expand`, edit: `${handleBase}-edit`, menu: `${handleBase}-menu` },
|
|
42
|
+
previewAttrs: { 'data-jini-part': 'media.preview', 'data-agent-element': `${handleBase}-expand`,
|
|
43
|
+
'data-agent-label': 'Open this asset larger in the lightbox', 'aria-label': `View "${title}" larger` },
|
|
44
|
+
editAttrs: { 'data-jini-part': 'media.edit', 'data-agent-element': `${handleBase}-edit`,
|
|
45
|
+
'data-agent-label': 'Open the edit form for this asset as a modal', 'aria-label': `Edit "${title}"` },
|
|
46
|
+
menuLabel: `Actions for "${title}"`,
|
|
47
|
+
menuAttrs: { 'data-agent-element': `${handleBase}-menu`, 'data-agent-label': `Actions for "${title}"` },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
3
50
|
export function useMediaCard(props, _optional = {}) {
|
|
4
51
|
/** Preview optimistically: image -> video -> placeholder. The list supplies no reliable media
|
|
5
52
|
* kind, and the byte route guarantees GET rather than HEAD, so probing headers would assume an
|
|
@@ -12,41 +59,20 @@ export function useMediaCard(props, _optional = {}) {
|
|
|
12
59
|
// immediately when the versioned URL changes, including in an already-open lightbox.
|
|
13
60
|
const stage = probe.src === src ? probe.stage : 'image';
|
|
14
61
|
const handleBase = props.handleBase ?? mediaRowHandles({ media: [props.item] })[0];
|
|
15
|
-
const byteSize = props.item.byteSize === undefined ? null : formatByteSize({ bytes: props.item.byteSize }, { locale: props.locale ?? 'en-US' });
|
|
16
|
-
const uploadDate = formatUploadDate({ createdAt: props.item.createdAt }, { locale: props.locale ?? 'en-US' });
|
|
17
62
|
return {
|
|
18
63
|
...props,
|
|
19
64
|
busy: props.busy === true,
|
|
20
|
-
|
|
21
|
-
metadataSeparator: byteSize && uploadDate ? ' · ' : '',
|
|
65
|
+
...metadata({ item: props.item }, { locale: props.locale }),
|
|
22
66
|
preview: () => props.onPreview?.(),
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
previewAttrs: { 'data-jini-part': 'media.preview', 'data-agent-element': `${handleBase}-expand`,
|
|
27
|
-
'data-agent-label': 'Open this asset larger in the lightbox', 'aria-label': `View "${props.item.title}" larger` },
|
|
28
|
-
editAttrs: { 'data-jini-part': 'media.edit', 'data-agent-element': `${handleBase}-edit`,
|
|
29
|
-
'data-agent-label': 'Open the edit form for this asset as a modal', 'aria-label': `Edit "${props.item.title}"` },
|
|
30
|
-
menuLabel: `Actions for "${props.item.title}"`,
|
|
31
|
-
menuAttrs: { 'data-agent-element': `${handleBase}-menu`, 'data-agent-label': `Actions for "${props.item.title}"` },
|
|
32
|
-
menuItems: [
|
|
33
|
-
...(props.onEdit ? [{ id: 'edit', label: 'Edit metadata', onPress: props.onEdit, disabled: props.busy === true }] : []),
|
|
34
|
-
...(props.onTrash && props.item.status !== 'trashed' ? [{ id: 'trash', label: 'Trash', onPress: props.onTrash, disabled: props.busy === true }] : []),
|
|
35
|
-
// Restore belongs only in a trashed card's menu; active cards have nothing to restore.
|
|
36
|
-
...(props.onRestore && props.item.status === 'trashed' ? [{ id: 'restore', label: 'Restore', onPress: props.onRestore, disabled: props.busy === true }] : []),
|
|
37
|
-
...(props.onPurge && props.item.status === 'trashed' ? [{ id: 'purge', label: 'Delete permanently', attrs: { 'data-jini-variant': 'danger' }, onPress: props.onPurge, disabled: props.busy === true }] : []),
|
|
38
|
-
],
|
|
67
|
+
...previewFlags({ src, stage }, { onPreview: props.onPreview }),
|
|
68
|
+
...agentAttrs({ handleBase, title: props.item.title }),
|
|
69
|
+
menuItems: menuItems({ props }),
|
|
39
70
|
src,
|
|
40
71
|
stage,
|
|
41
|
-
image: src && stage === 'image',
|
|
42
|
-
video: src && stage === 'video',
|
|
43
72
|
onImageError: () => setProbe({ src, stage: 'video' }),
|
|
44
73
|
onVideoError: () => setProbe({ src, stage: 'unsupported' }),
|
|
45
74
|
alt: props.item.alt || props.item.title || 'Untitled asset',
|
|
46
|
-
|
|
47
|
-
statusLabel: props.item.status,
|
|
48
|
-
statusTone: props.item.status === 'trashed' ? 'warning' : 'success',
|
|
49
|
-
trashed: props.item.status === 'trashed',
|
|
75
|
+
...statusView({ item: props.item }),
|
|
50
76
|
};
|
|
51
77
|
}
|
|
52
78
|
//# sourceMappingURL=MediaCard.hooks.js.map
|
|
@@ -14,8 +14,8 @@ export declare function useMediaGrid(props: MediaGridProps, _optional?: Record<s
|
|
|
14
14
|
locale?: string;
|
|
15
15
|
busy?: boolean;
|
|
16
16
|
onPurge?: () => void;
|
|
17
|
-
onEdit?: () => void;
|
|
18
17
|
onPreview?: () => void;
|
|
18
|
+
onEdit?: () => void;
|
|
19
19
|
onTrash?: () => void;
|
|
20
20
|
onRestore?: () => void;
|
|
21
21
|
onChoose?: () => void;
|
|
@@ -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;
|