@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.
Files changed (151) hide show
  1. package/CHANGELOG.md +4 -0
  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 +74 -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 +17 -8
  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
@@ -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 = params.filter === 'images' || params.filter === 'videos' ? params.filter : 'all';
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
- local.controller?.settleLibrary({ ...(cached.query.data ? { all: cached.query.data } : {}), error: cached.query.error }); }, [shared, local.controller, cached.query.data, cached.query.error]);
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 [focusAfterAction, setFocusAfterAction] = useState(null);
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 canUpload = permissions.includes('media.upload');
62
- const canEdit = permissions.includes('media.update');
63
- const canTrash = permissions.includes('media.trash');
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 === 'images' ? 'No images yet. Upload an image to see it here.'
103
- : filter === 'videos' ? 'No videos yet. Upload a video to see it here.' : 'No media found',
104
- canUpload,
152
+ emptyLabel: EMPTY_LABELS[filter],
153
+ canUpload: can.canUpload,
105
154
  success,
106
155
  gridRef,
107
156
  onRetry: () => { void controller?.load(); },
108
- statusFilter: snapshot?.query.status ?? 'all',
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
- onSearch: ({ value: search }) => {
126
- void controller?.setQuery({ query: { ...controller.getSnapshot().query, search } });
127
- },
128
- orderBy: snapshot?.query.orderBy ?? 'created',
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
- busy: boolean;
18
- byteSize: string | null;
19
- uploadDate: string | null;
20
- metadataSeparator: string;
21
- preview: () => void | undefined;
22
- canPreview: boolean;
23
- imagePreview: boolean;
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
- menuItems: ({
47
- id: string;
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
- onImageError: () => void;
66
- onVideoError: () => void;
67
- alt: string;
68
- statusClassName: string;
69
- statusLabel: "active" | "trashed";
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
- byteSize, uploadDate,
21
- metadataSeparator: byteSize && uploadDate ? ' · ' : '',
65
+ ...metadata({ item: props.item }, { locale: props.locale }),
22
66
  preview: () => props.onPreview?.(),
23
- canPreview: !!props.onPreview && !!src && stage !== 'unsupported',
24
- imagePreview: !!props.onPreview && !!src && stage === 'image',
25
- handles: { expand: `${handleBase}-expand`, edit: `${handleBase}-edit`, menu: `${handleBase}-menu` },
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
- statusClassName: `jini-status jini-status-${props.item.status}`,
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
- 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;