@orthacms/media-admin 0.11.0 → 0.13.0

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 (70) hide show
  1. package/dist/lib/components/AssetDetailPanel/MetaRow/index.d.ts +11 -0
  2. package/dist/lib/components/AssetDetailPanel/MetaRow/index.d.ts.map +1 -0
  3. package/dist/lib/components/AssetDetailPanel/MetaRow/index.js +9 -0
  4. package/dist/lib/components/AssetDetailPanel/index.d.ts +35 -0
  5. package/dist/lib/components/AssetDetailPanel/index.d.ts.map +1 -0
  6. package/dist/lib/components/AssetDetailPanel/index.js +158 -0
  7. package/dist/lib/components/FolderActionsMenu/index.d.ts +3 -1
  8. package/dist/lib/components/FolderActionsMenu/index.d.ts.map +1 -1
  9. package/dist/lib/components/FolderActionsMenu/index.js +3 -3
  10. package/dist/lib/components/MediaAltDrill/index.d.ts +9 -0
  11. package/dist/lib/components/MediaAltDrill/index.d.ts.map +1 -0
  12. package/dist/lib/components/MediaAltDrill/index.js +60 -0
  13. package/dist/lib/components/MediaAltSignal/index.d.ts +8 -0
  14. package/dist/lib/components/MediaAltSignal/index.d.ts.map +1 -0
  15. package/dist/lib/components/MediaAltSignal/index.js +35 -0
  16. package/dist/lib/components/MediaAssetTable/index.d.ts +25 -0
  17. package/dist/lib/components/MediaAssetTable/index.d.ts.map +1 -0
  18. package/dist/lib/components/MediaAssetTable/index.js +62 -0
  19. package/dist/lib/components/MediaFieldControl/index.d.ts.map +1 -1
  20. package/dist/lib/components/MediaFieldControl/index.js +19 -14
  21. package/dist/lib/components/MediaFieldInput/index.d.ts +19 -0
  22. package/dist/lib/components/MediaFieldInput/index.d.ts.map +1 -0
  23. package/dist/lib/components/MediaFieldInput/index.js +23 -0
  24. package/dist/lib/components/MediaFoldersNav/MediaFolderTreeItem/index.d.ts +17 -3
  25. package/dist/lib/components/MediaFoldersNav/MediaFolderTreeItem/index.d.ts.map +1 -1
  26. package/dist/lib/components/MediaFoldersNav/MediaFolderTreeItem/index.js +16 -6
  27. package/dist/lib/components/MediaFoldersNav/index.d.ts +10 -5
  28. package/dist/lib/components/MediaFoldersNav/index.d.ts.map +1 -1
  29. package/dist/lib/components/MediaFoldersNav/index.js +17 -9
  30. package/dist/lib/components/MediaGrid/MediaAssetCard/index.d.ts +9 -4
  31. package/dist/lib/components/MediaGrid/MediaAssetCard/index.d.ts.map +1 -1
  32. package/dist/lib/components/MediaGrid/MediaAssetCard/index.js +27 -11
  33. package/dist/lib/components/MediaGrid/index.d.ts +10 -10
  34. package/dist/lib/components/MediaGrid/index.d.ts.map +1 -1
  35. package/dist/lib/components/MediaGrid/index.js +10 -15
  36. package/dist/lib/components/MediaSelectionBar/index.d.ts +9 -5
  37. package/dist/lib/components/MediaSelectionBar/index.d.ts.map +1 -1
  38. package/dist/lib/components/MediaSelectionBar/index.js +16 -10
  39. package/dist/lib/components/MediaStorageWidget/index.js +2 -2
  40. package/dist/lib/components/MediaThumbnail/index.d.ts +9 -6
  41. package/dist/lib/components/MediaThumbnail/index.d.ts.map +1 -1
  42. package/dist/lib/components/MediaThumbnail/index.js +7 -6
  43. package/dist/lib/components/MediaToolbar/index.d.ts +10 -8
  44. package/dist/lib/components/MediaToolbar/index.d.ts.map +1 -1
  45. package/dist/lib/components/MediaToolbar/index.js +37 -13
  46. package/dist/lib/components/MediaTopBar/index.d.ts +7 -1
  47. package/dist/lib/components/MediaTopBar/index.d.ts.map +1 -1
  48. package/dist/lib/components/MediaTopBar/index.js +6 -3
  49. package/dist/lib/components/MediaUploadBanner/index.d.ts +7 -5
  50. package/dist/lib/components/MediaUploadBanner/index.d.ts.map +1 -1
  51. package/dist/lib/components/MediaUploadBanner/index.js +15 -8
  52. package/dist/lib/constants/index.d.ts +13 -0
  53. package/dist/lib/constants/index.d.ts.map +1 -1
  54. package/dist/lib/constants/index.js +11 -0
  55. package/dist/lib/hooks/useMediaInsights/index.d.ts +8 -0
  56. package/dist/lib/hooks/useMediaInsights/index.d.ts.map +1 -1
  57. package/dist/lib/hooks/useMediaInsights/index.js +20 -1
  58. package/dist/lib/infrastructure/httpMediaInsightsGateway/index.d.ts.map +1 -1
  59. package/dist/lib/infrastructure/httpMediaInsightsGateway/index.js +9 -0
  60. package/dist/lib/infrastructure/mediaInsightsGateway/index.d.ts +17 -0
  61. package/dist/lib/infrastructure/mediaInsightsGateway/index.d.ts.map +1 -1
  62. package/dist/lib/pages/MediaLibraryPage/index.d.ts +6 -4
  63. package/dist/lib/pages/MediaLibraryPage/index.d.ts.map +1 -1
  64. package/dist/lib/pages/MediaLibraryPage/index.js +82 -36
  65. package/dist/lib/utils/fileExtension/index.d.ts +9 -0
  66. package/dist/lib/utils/fileExtension/index.d.ts.map +1 -0
  67. package/dist/lib/utils/fileExtension/index.js +14 -0
  68. package/dist/lib/utils/mediaPlugin/index.d.ts.map +1 -1
  69. package/dist/lib/utils/mediaPlugin/index.js +35 -32
  70. package/package.json +10 -10
@@ -1,10 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useRef, useState } from 'react';
3
3
  import { defineMessages, useIntl } from 'react-intl';
4
4
  import { Button, ConfirmDialog, Drawer, DrawerContent, DrawerTitle, ResizeHandle, cn, toast, useResizablePanel } from '@orthacms/design-system';
5
- import { PanelLeft } from 'lucide-react';
5
+ import { FolderPlus, PanelLeft, UploadCloud } from 'lucide-react';
6
6
  import { useHasPermission } from '@orthacms/identity-admin';
7
- import { MEDIA_CREATE, MEDIA_DELETE, MEDIA_FOLDERS_PANEL, MEDIA_READ, MEDIA_UPDATE, ROOT_FOLDER_ID } from '../../constants';
7
+ import { MEDIA_CREATE, MEDIA_DELETE, MEDIA_FOLDERS_PANEL, MEDIA_READ, MEDIA_UPDATE, MEDIA_VIEW, MEDIA_VIEW_STORAGE_KEY, ROOT_FOLDER_ID } from '../../constants';
8
8
  import { useMediaLibrary } from '../../hooks/useMediaLibrary';
9
9
  import { MediaFoldersNav } from '../../components/MediaFoldersNav';
10
10
  import { MediaTopBar } from '../../components/MediaTopBar';
@@ -12,9 +12,10 @@ import { MediaToolbar } from '../../components/MediaToolbar';
12
12
  import { MediaSelectionBar } from '../../components/MediaSelectionBar';
13
13
  import { MediaUploadBanner } from '../../components/MediaUploadBanner';
14
14
  import { MediaGrid } from '../../components/MediaGrid';
15
+ import { MediaAssetTable } from '../../components/MediaAssetTable';
15
16
  import { MediaPagination } from '../../components/MediaPagination';
16
17
  import { MediaEmptyState } from '../../components/MediaEmptyState';
17
- import { AssetDetailDrawer } from '../../components/AssetDetailDrawer';
18
+ import { AssetDetailPanel } from '../../components/AssetDetailPanel';
18
19
  import { NewFolderDialog } from '../../components/NewFolderDialog';
19
20
  import { RenameDialog } from '../../components/RenameDialog';
20
21
  import { MoveAssetsDialog } from '../../components/MoveAssetsDialog';
@@ -24,10 +25,12 @@ import { useDocumentTitle } from '@orthacms/utils-admin';
24
25
  /** Intl descriptors for the Media Library page + its toasts, co-located. */
25
26
  const messages = defineMessages({
26
27
  title: { id: 'media.page.title', defaultMessage: 'Media Library' },
27
- subtitle: {
28
- id: 'media.page.subtitle',
29
- defaultMessage: '{count, plural, one {# item} other {# items}} in {location}'
28
+ fileCount: {
29
+ id: 'media.page.fileCount',
30
+ defaultMessage: '{count, plural, one {# file} other {# files}}'
30
31
  },
32
+ newFolder: { id: 'media.page.newFolder', defaultMessage: 'New folder' },
33
+ upload: { id: 'media.page.upload', defaultMessage: 'Upload' },
31
34
  allMedia: { id: 'media.page.allMedia', defaultMessage: 'All media' },
32
35
  noAccess: {
33
36
  id: 'media.page.noAccess',
@@ -133,19 +136,31 @@ const messages = defineMessages({
133
136
  * nothing.
134
137
  */
135
138
  const FOCUS_RECLAIM_MS = 1_000;
139
+ /** The remembered view, or the grid — storage can be absent or refuse. */
140
+ function readView() {
141
+ try {
142
+ const stored = window.localStorage.getItem(MEDIA_VIEW_STORAGE_KEY);
143
+ return stored === MEDIA_VIEW.List ? MEDIA_VIEW.List : MEDIA_VIEW.Grid;
144
+ }
145
+ catch {
146
+ return MEDIA_VIEW.Grid;
147
+ }
148
+ }
136
149
  /**
137
150
  * The Media Library, mounted inside the workspace shell at
138
151
  * `/workspaces/:id/media`. `useMediaLibrary` is the store — folders and the open
139
152
  * folder's assets come from `@orthacms/media-server`, and every action (browse
140
153
  * folders, upload, create folder, rename, duplicate, move, delete, filter, sort,
141
- * open the detail drawer) runs against the API, firing a toast per action —
154
+ * open the details panel) runs against the API, firing a toast per action —
142
155
  * except uploads, whose status lives in the `MediaUploadBanner`. Controls gate
143
156
  * on the real `media:*` matrix via `useHasPermission`.
144
157
  *
145
158
  * A sticky `MediaTopBar` heads the page (the shared icon-tile + breadcrumb
146
- * header every admin page carries), spanning both panes. Below it sits a
147
- * two-pane layout — a folders sidebar beside the asset browser — plus the detail
148
- * drawer and the create/rename/move/upload dialogs. Both panes sit
159
+ * header every admin page carries, with New folder / Upload at its right end),
160
+ * spanning every pane. Below it: the folder tree, the file browser (title, type
161
+ * chips + search + sort + Grid/List, the selection bar, then tiles or the
162
+ * table) and — while a file is open — the non-modal details panel as a third
163
+ * column; plus the create/rename/move/upload dialogs. The panes sit
149
164
  * **flush on the canvas** (no muted board, no bordered island), mirroring the
150
165
  * Content Library's work area: the sidebar is divided by a hairline `border-r`
151
166
  * and each pane scrolls independently. Sized with `flex-1` against the workspace
@@ -179,6 +194,16 @@ export function MediaLibraryPage() {
179
194
  const [deleteTarget, setDeleteTarget] = useState(null);
180
195
  /** A confirmed delete is in flight — the dialog shows a spinner meanwhile. */
181
196
  const [deleting, setDeleting] = useState(false);
197
+ const [view, setViewState] = useState(readView);
198
+ const setView = (next) => {
199
+ setViewState(next);
200
+ try {
201
+ window.localStorage.setItem(MEDIA_VIEW_STORAGE_KEY, next);
202
+ }
203
+ catch {
204
+ // A private window or blocked storage: the choice lasts the visit.
205
+ }
206
+ };
182
207
  /**
183
208
  * Puts focus on the grid and **keeps** it there for a few frames.
184
209
  *
@@ -213,14 +238,29 @@ export function MediaLibraryPage() {
213
238
  ? store.currentFolder.name
214
239
  : intl.formatMessage(messages.allMedia);
215
240
  const hasFilters = store.search.trim() !== '' || store.kindFilter !== 'all';
216
- // Sub-folders belong to the folder, not to a page of its assets, so they are
217
- // drawn once on the first page rather than repeating above every page of
218
- // files. On any later page the grid is assets alone.
219
- const showFolders = store.page === 1;
220
- const visibleFolders = showFolders ? store.childFolders : [];
221
- // Emptiness is now a fact about the whole folder, not about what happened to
222
- // load: `total` counts every asset matching the search and filter across it.
223
- const isEmpty = visibleFolders.length === 0 && store.total === 0;
241
+ // Emptiness is a fact about the whole folder, not about what happened to
242
+ // load: `total` counts every asset matching the search and filter across
243
+ // it. Sub-folders don't count — they are drawn in the tree, not the grid.
244
+ const isEmpty = store.total === 0;
245
+ // The details panel steps through the files on screen, in their order.
246
+ const detailIndex = store.detailAsset
247
+ ? store.visibleAssets.findIndex((a) => a.id === store.detailAsset?.id)
248
+ : -1;
249
+ const previousAsset = detailIndex > 0 ? store.visibleAssets[detailIndex - 1] : undefined;
250
+ const nextAsset = detailIndex >= 0 ? store.visibleAssets[detailIndex + 1] : undefined;
251
+ // The table's header checkbox: select the page, or clear it when it's all
252
+ // selected already.
253
+ const toggleAllOnPage = () => {
254
+ const page = store.visibleAssets;
255
+ if (page.length > 0 && page.every((a) => store.selectedIds.has(a.id))) {
256
+ store.clearSelection();
257
+ return;
258
+ }
259
+ for (const asset of page) {
260
+ if (!store.selectedIds.has(asset.id))
261
+ store.toggleSelect(asset.id);
262
+ }
263
+ };
224
264
  // Triggers a browser download for one asset (its bytes stream from the
225
265
  // `media:read`-gated raw route). Shared by the row action and bulk download.
226
266
  const downloadAsset = (asset) => {
@@ -231,7 +271,7 @@ export function MediaLibraryPage() {
231
271
  link.click();
232
272
  link.remove();
233
273
  };
234
- // A single dispatcher every card / row / drawer forwards asset actions to.
274
+ // A single dispatcher every tile / row / panel forwards asset actions to.
235
275
  const handleAssetAction = (kind, asset) => {
236
276
  switch (kind) {
237
277
  case 'open':
@@ -348,14 +388,13 @@ export function MediaLibraryPage() {
348
388
  if (store.isError) {
349
389
  return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-6 text-center", children: [_jsx("p", { className: "max-w-sm text-sm text-muted-foreground", role: "alert", children: intl.formatMessage(messages.loadError) }), _jsx(Button, { variant: "outline", onClick: store.reload, children: intl.formatMessage(messages.retry) })] }));
350
390
  }
351
- return (_jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", children: [_jsx(MediaTopBar, { breadcrumbs: store.breadcrumbs, onNavigate: store.navigateTo }), _jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col lg:flex-row", children: [_jsx("div", { className: "border-b p-3 lg:hidden", children: _jsxs(Button, { variant: "outline", size: "sm", className: "shadow-none", onClick: () => setNavOpen(true), children: [_jsx(PanelLeft, { "aria-hidden": true }), intl.formatMessage(messages.openNav)] }) }), _jsxs("div", { ref: foldersPanel.ref, style: foldersPanel.style, className: "relative hidden shrink-0 lg:flex", children: [_jsx(MediaFoldersNav, { className: "w-(--media-folders-width) border-r", folders: store.folders, currentFolderId: store.currentFolderId, counts: store.folderCounts, rootCount: store.folderCounts.get(ROOT_FOLDER_ID) ?? 0, onNavigate: store.navigateTo, onNewFolder: () => setNewFolderOpen(true), canCreate: canCreate }), _jsx(ResizeHandle, { ...foldersPanel.handleProps, edge: "right", label: intl.formatMessage(messages.resizeNav) })] }), _jsx("div", { className: "min-w-0 flex-1 overflow-auto", children: _jsxs("div", { className: "p-4 sm:p-6", children: [_jsxs("div", { className: "mb-4", children: [_jsx("h1", { className: "text-2xl font-semibold tracking-[-0.01em]", children: store.currentFolder
391
+ return (_jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", children: [_jsx(MediaTopBar, { breadcrumbs: store.breadcrumbs, onNavigate: store.navigateTo, actions: canCreate ? (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "outline", size: "sm", className: "shadow-none", onClick: () => setNewFolderOpen(true), children: [_jsx(FolderPlus, { "aria-hidden": true }), _jsx("span", { className: "hidden sm:inline", children: intl.formatMessage(messages.newFolder) })] }), _jsxs(Button, { size: "sm", onClick: () => setUploadOpen(true), children: [_jsx(UploadCloud, { "aria-hidden": true }), intl.formatMessage(messages.upload)] })] })) : null }), _jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col lg:flex-row", children: [_jsx("div", { className: "border-b p-3 lg:hidden", children: _jsxs(Button, { variant: "outline", size: "sm", className: "shadow-none", onClick: () => setNavOpen(true), children: [_jsx(PanelLeft, { "aria-hidden": true }), intl.formatMessage(messages.openNav)] }) }), _jsxs("div", { ref: foldersPanel.ref, style: foldersPanel.style, className: "relative hidden shrink-0 lg:flex", children: [_jsx(MediaFoldersNav, { className: "w-(--media-folders-width) border-r", folders: store.folders, currentFolderId: store.currentFolderId, counts: store.folderCounts, rootCount: store.folderCounts.get(ROOT_FOLDER_ID) ?? 0, onNavigate: store.navigateTo, onNewFolder: () => setNewFolderOpen(true), onRenameFolder: handleRenameFolder, onDeleteFolder: handleDeleteFolder, canCreate: canCreate, canUpdate: canUpdate, canDelete: canDelete }), _jsx(ResizeHandle, { ...foldersPanel.handleProps, edge: "right", label: intl.formatMessage(messages.resizeNav) })] }), _jsx("div", { className: "min-w-0 flex-1 overflow-auto", children: _jsxs("div", { className: cn('px-4 py-6 sm:px-8', selectedIds.length > 0 && 'pb-24'), children: [_jsxs("div", { className: "mb-4", children: [_jsx("h1", { className: "text-2xl font-semibold tracking-[-0.01em]", children: store.currentFolder
352
392
  ? store.currentFolder.name
353
- : intl.formatMessage(messages.title) }), _jsx("p", { className: "mt-0.5 text-sm text-muted-foreground", children: intl.formatMessage(messages.subtitle, {
354
- count: store.total,
355
- location: locationLabel
356
- }) })] }), _jsx(MediaToolbar, { search: store.search, onSearchChange: store.setSearch, kindFilter: store.kindFilter, onKindFilterChange: store.setKindFilter, sort: store.sort, onSortChange: store.setSort, onNewFolder: () => setNewFolderOpen(true), onUpload: () => setUploadOpen(true), canCreate: canCreate }), _jsx("p", { className: "sr-only", "aria-live": "polite", children: intl.formatMessage(messages.selection, {
393
+ : intl.formatMessage(messages.title) }), _jsx("p", { className: "mt-0.5 text-[13px] text-muted-foreground", children: intl.formatMessage(messages.fileCount, {
394
+ count: store.total
395
+ }) })] }), _jsx(MediaToolbar, { search: store.search, onSearchChange: store.setSearch, kindFilter: store.kindFilter, onKindFilterChange: store.setKindFilter, sort: store.sort, onSortChange: store.setSort, view: view, onViewChange: setView }), _jsx("p", { className: "sr-only", "aria-live": "polite", children: intl.formatMessage(messages.selection, {
357
396
  count: selectedIds.length
358
- }) }), _jsx(MediaUploadBanner, { items: store.uploadItems, summary: store.uploadSummary, onRetry: store.retryUpload, onCancel: store.cancelUpload, onDismiss: store.dismissUploads }), selectedIds.length > 0 ? (_jsx(MediaSelectionBar, { count: selectedIds.length, onDownload: () => {
397
+ }) }), _jsx(MediaUploadBanner, { items: store.uploadItems, summary: store.uploadSummary, onRetry: store.retryUpload, onCancel: store.cancelUpload, onDismiss: store.dismissUploads }), selectedIds.length > 0 ? (_jsx(MediaSelectionBar, { count: selectedIds.length, total: store.total, onDownload: () => {
359
398
  store.selectedAssets.forEach(downloadAsset);
360
399
  toast.success(intl.formatMessage(messages.tDownload, {
361
400
  name: `${selectedIds.length} files`
@@ -375,21 +414,28 @@ export function MediaLibraryPage() {
375
414
  }), onClear: store.clearSelection, canCreate: canCreate, canUpdate: canUpdate, canDelete: canDelete })) : null, _jsx("section", { ref: gridRef, tabIndex: -1, "aria-label": intl.formatMessage(messages.assetsRegion), "aria-busy": store.isRefreshing, className: cn('transition-opacity duration-(--ds-duration-base) motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', store.isRefreshing && 'opacity-60 delay-150'), children: isEmpty ? (_jsx(MediaEmptyState, { hasFilters: hasFilters, canCreate: canCreate, onUpload: () => setUploadOpen(true), onClearFilters: () => {
376
415
  store.setSearch('');
377
416
  store.setKindFilter('all');
378
- } })) : (_jsx(MediaGrid, { folders: visibleFolders, assets: store.visibleAssets, folderCounts: store.folderCounts, selectedIds: store.selectedIds, onOpenFolder: store.navigateTo, onRenameFolder: handleRenameFolder, onDeleteFolder: handleDeleteFolder, onToggleSelect: store.toggleSelect, onAssetAction: handleAssetAction, canCreate: canCreate, canUpdate: canUpdate, canDelete: canDelete })) }), store.total > 0 ? (_jsx(MediaPagination, { page: store.page, pageCount: store.pageCount, pageSize: store.pageSize, total: store.total, onPageChange: store.setPage, onPageSizeChange: store.setPageSize })) : null] }) })] }), _jsx(Drawer, { direction: "left", open: navOpen, onOpenChange: setNavOpen, shouldScaleBackground: false, children: _jsxs(DrawerContent, { className: "data-[vaul-drawer-direction=left]:w-72", children: [_jsx(DrawerTitle, { className: "sr-only", children: intl.formatMessage(messages.navTitle) }), _jsx(MediaFoldersNav, { className: "w-full", folders: store.folders, currentFolderId: store.currentFolderId, counts: store.folderCounts, rootCount: store.folderCounts.get(ROOT_FOLDER_ID) ?? 0, onNavigate: (folderId) => {
417
+ } })) : view === MEDIA_VIEW.List ? (_jsx(MediaAssetTable, { assets: store.visibleAssets, selectedIds: store.selectedIds, activeId: store.detailAsset?.id, onToggleSelect: store.toggleSelect, onToggleAll: toggleAllOnPage, onAction: handleAssetAction, canCreate: canCreate, canUpdate: canUpdate, canDelete: canDelete })) : (_jsx(MediaGrid, { assets: store.visibleAssets, selectedIds: store.selectedIds, activeId: store.detailAsset?.id, onToggleSelect: store.toggleSelect, onAssetAction: handleAssetAction, canCreate: canCreate, canUpdate: canUpdate, canDelete: canDelete })) }), store.total > 0 ? (_jsx(MediaPagination, { page: store.page, pageCount: store.pageCount, pageSize: store.pageSize, total: store.total, onPageChange: store.setPage, onPageSizeChange: store.setPageSize })) : null] }) }), store.detailAsset ? (_jsx(AssetDetailPanel, { className: "fixed inset-0 z-40 lg:static lg:z-auto lg:w-[340px] lg:shrink-0 lg:border-l", asset: store.detailAsset, locationLabel: locationLabel, onClose: store.closeDetail, onPrevious: previousAsset
418
+ ? () => store.openDetail(previousAsset.id)
419
+ : undefined, onNext: nextAsset
420
+ ? () => store.openDetail(nextAsset.id)
421
+ : undefined, onAction: handleAssetAction, onSaveAlt: (id, alt) => store.setAssetAlt(id, alt).then((ok) => {
422
+ if (ok) {
423
+ toast.success(intl.formatMessage(messages.tAltSaved));
424
+ }
425
+ return ok;
426
+ }), canCreate: canCreate, canUpdate: canUpdate, canDelete: canDelete })) : null] }), _jsx(Drawer, { direction: "left", open: navOpen, onOpenChange: setNavOpen, shouldScaleBackground: false, children: _jsxs(DrawerContent, { className: "data-[vaul-drawer-direction=left]:w-72", children: [_jsx(DrawerTitle, { className: "sr-only", children: intl.formatMessage(messages.navTitle) }), _jsx(MediaFoldersNav, { className: "w-full", folders: store.folders, currentFolderId: store.currentFolderId, counts: store.folderCounts, rootCount: store.folderCounts.get(ROOT_FOLDER_ID) ?? 0, onNavigate: (folderId) => {
379
427
  store.navigateTo(folderId);
380
428
  setNavOpen(false);
381
429
  }, onNewFolder: () => {
382
430
  setNavOpen(false);
383
431
  setNewFolderOpen(true);
384
- }, canCreate: canCreate })] }) }), _jsx(AssetDetailDrawer, { asset: store.detailAsset, locationLabel: locationLabel, onOpenChange: (open) => {
385
- if (!open)
386
- store.closeDetail();
387
- }, onAction: handleAssetAction, onSaveAlt: (id, alt) => store.setAssetAlt(id, alt).then((ok) => {
388
- if (ok) {
389
- toast.success(intl.formatMessage(messages.tAltSaved));
390
- }
391
- return ok;
392
- }), canCreate: canCreate, canUpdate: canUpdate, canDelete: canDelete }), _jsx(NewFolderDialog, { open: newFolderOpen, onOpenChange: setNewFolderOpen, locationLabel: locationLabel, onCreate: (name) => {
432
+ }, onRenameFolder: (folder) => {
433
+ setNavOpen(false);
434
+ handleRenameFolder(folder);
435
+ }, onDeleteFolder: (folder) => {
436
+ setNavOpen(false);
437
+ handleDeleteFolder(folder);
438
+ }, canCreate: canCreate, canUpdate: canUpdate, canDelete: canDelete })] }) }), _jsx(NewFolderDialog, { open: newFolderOpen, onOpenChange: setNewFolderOpen, locationLabel: locationLabel, onCreate: (name) => {
393
439
  void store.createFolder(name).then((ok) => {
394
440
  if (ok) {
395
441
  toast.success(intl.formatMessage(messages.tCreated, { name }));
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The short, upper-cased extension of a file name (`report-q3.pdf` → `PDF`),
3
+ * or `null` when there is none worth showing — no dot, a leading-dot name
4
+ * like `.env`, or a "extension" too long to be one. Labels the tiles and rows
5
+ * of files that have no picture to show, where a glyph alone can't tell a
6
+ * PDF from a DOCX.
7
+ */
8
+ export declare function fileExtension(name: string): string | null;
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/lib/utils/fileExtension/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKzD"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The short, upper-cased extension of a file name (`report-q3.pdf` → `PDF`),
3
+ * or `null` when there is none worth showing — no dot, a leading-dot name
4
+ * like `.env`, or a "extension" too long to be one. Labels the tiles and rows
5
+ * of files that have no picture to show, where a glyph alone can't tell a
6
+ * PDF from a DOCX.
7
+ */
8
+ export function fileExtension(name) {
9
+ const dot = name.lastIndexOf('.');
10
+ if (dot <= 0 || dot === name.length - 1)
11
+ return null;
12
+ const ext = name.slice(dot + 1);
13
+ return ext.length > 5 ? null : ext.toUpperCase();
14
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/lib/utils/mediaPlugin/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AA4C7D;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,WAAW,CAAC;AAE3C;;;;;;GAMG;AACH,wBAAgB,WAAW,IAAI,gBAAgB,CAqJ9C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/lib/utils/mediaPlugin/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AA2C7D;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,WAAW,CAAC;AAE3C;;;;;;GAMG;AACH,wBAAgB,WAAW,IAAI,gBAAgB,CAuJ9C"}
@@ -2,24 +2,25 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Suspense } from 'react';
3
3
  import { lazyPage } from '@orthacms/utils-admin';
4
4
  import { WORKSPACE_ROUTE_SLOT, WORKSPACE_NAV_SLOT } from '@orthacms/workspaces-admin';
5
- import { CONTENT_FIELD_TYPE, ENTRY_PRESAVE_SLOT, ENTRY_TAB, ENTRY_TAB_SLOT } from '@orthacms/content-admin';
5
+ import { CONTENT_FIELD_TYPE, ENTRY_FIELD_CONTROL_SLOT, ENTRY_PRESAVE_SLOT } from '@orthacms/content-admin';
6
6
  import { WYSIWYG_MEDIA_SLOT } from '@orthacms/wysiwyg-admin';
7
- import { INSIGHTS_SECTION_IDS, INSIGHTS_WIDGET_SLOT } from '@orthacms/insights-admin';
7
+ import { INSIGHTS_SECTION_IDS, INSIGHTS_SIGNAL_SLOT, INSIGHTS_WIDGET_SLOT } from '@orthacms/insights-admin';
8
8
  import { Image, Upload } from 'lucide-react';
9
9
  import { MediaStorageStat } from '../../components/MediaStorageStat';
10
10
  import { MediaStorageWidget } from '../../components/MediaStorageWidget';
11
11
  import { MediaUploadsWidget } from '../../components/MediaUploadsWidget';
12
- import { MediaAltTextWidget } from '../../components/MediaAltTextWidget';
13
- import { EntryMediaTab } from '../../components/EntryMediaTab';
12
+ import { MediaAltSignal } from '../../components/MediaAltSignal';
13
+ import { MediaAltDrill } from '../../components/MediaAltDrill';
14
+ import { MediaFieldInput } from '../../components/MediaFieldInput';
14
15
  import { MediaLibraryPageSkeleton } from '../../components/MediaLibrarySkeleton';
15
16
  import { WysiwygLibrarySource } from '../../components/WysiwygLibrarySource';
16
17
  import { WysiwygUploadSource } from '../../components/WysiwygUploadSource';
17
18
  import { MEDIA_PRESAVE_ID, usePendingMediaUploads } from '../../hooks/usePendingMediaUploads';
18
19
  import { MEDIA_READ } from '../../constants';
19
20
  const MediaLibraryPage = lazyPage(() => import('../../pages/MediaLibraryPage'), 'MediaLibraryPage');
20
- /** The Media tab applies to any type that declares a media field. */
21
- function hasMediaField(schema) {
22
- return schema.fields.some((field) => field.type === CONTENT_FIELD_TYPE.Media);
21
+ /** The media control owns every `media` field, wherever the form draws it. */
22
+ function isMediaField(field) {
23
+ return field.type === CONTENT_FIELD_TYPE.Media;
23
24
  }
24
25
  /**
25
26
  * Creates the admin-side Media Library plugin. It lives **strictly inside a
@@ -52,31 +53,37 @@ export function MediaPlugin() {
52
53
  id: 'insights.media.storageBreakdown',
53
54
  section: INSIGHTS_SECTION_IDS.Reach,
54
55
  order: 10,
55
- size: 'sm',
56
+ size: 'md',
56
57
  permission: MEDIA_READ,
57
58
  titleId: 'media.insights.storageBreakdown.title',
58
- defaultTitle: "What's using the storage",
59
+ defaultTitle: 'Storage',
59
60
  Component: MediaStorageWidget
60
61
  },
61
62
  {
62
63
  id: 'insights.media.uploads',
63
64
  section: INSIGHTS_SECTION_IDS.Reach,
64
65
  order: 20,
65
- size: 'sm',
66
+ size: 'md',
66
67
  permission: MEDIA_READ,
67
68
  titleId: 'media.insights.uploads.title',
68
69
  defaultTitle: 'Uploads',
69
70
  Component: MediaUploadsWidget
70
- },
71
+ }
72
+ ]
73
+ },
74
+ {
75
+ // The alt-text debt is a Needs-attention line now, opening the
76
+ // images behind it, rather than a card of its own.
77
+ slot: INSIGHTS_SIGNAL_SLOT,
78
+ items: [
71
79
  {
72
- id: 'insights.media.alt',
73
- section: INSIGHTS_SECTION_IDS.Reach,
74
- order: 30,
75
- size: 'sm',
80
+ id: 'media.alt',
81
+ order: 40,
76
82
  permission: MEDIA_READ,
77
- titleId: 'media.insights.alt.title',
83
+ titleId: 'media.insights.signal.alt.title',
78
84
  defaultTitle: 'Images missing alt text',
79
- Component: MediaAltTextWidget
85
+ Component: MediaAltSignal,
86
+ Drill: MediaAltDrill
80
87
  }
81
88
  ]
82
89
  },
@@ -102,23 +109,19 @@ export function MediaPlugin() {
102
109
  }
103
110
  ]
104
111
  },
105
- // The entry editor's Media tab — rendered by content-admin's
106
- // ENTRY_TAB_SLOT only when the open type has a media field, so the
107
- // tab appears exactly where media applies. Its `slug` is content's
108
- // own `media` tab route, already accepted by the tab router.
112
+ // A media field's control in the entry form. It used to be a Media
113
+ // tab of its own; the field now sits in the schema's order with the
114
+ // rest (ADR-0022), and content draws its label row and error.
115
+ // `composite`: the control is several buttons, so the field is
116
+ // named as a group rather than by a label on one of them.
109
117
  {
110
- slot: ENTRY_TAB_SLOT,
118
+ slot: ENTRY_FIELD_CONTROL_SLOT,
111
119
  items: [
112
120
  {
113
- id: 'media.entry.tab',
114
- slug: ENTRY_TAB.Media,
115
- label: {
116
- id: 'media.tab.label',
117
- defaultMessage: 'Media'
118
- },
119
- order: 10,
120
- appliesTo: hasMediaField,
121
- Component: EntryMediaTab
121
+ id: 'media.entry.field',
122
+ appliesTo: isMediaField,
123
+ composite: true,
124
+ Component: MediaFieldInput
122
125
  }
123
126
  ]
124
127
  },
@@ -161,7 +164,7 @@ export function MediaPlugin() {
161
164
  // runs inside the record's save and puts them in the library then,
162
165
  // so an abandoned edit leaves no orphan assets behind. It also owns
163
166
  // the staging state, mounted above the tab body (a route) so it
164
- // survives switching tabs.
167
+ // survives the form remounting.
165
168
  {
166
169
  slot: ENTRY_PRESAVE_SLOT,
167
170
  items: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orthacms/media-admin",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "@orthacms/media-admin — part of Ortha CMS.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://orthacms.com",
@@ -25,15 +25,15 @@
25
25
  "dist"
26
26
  ],
27
27
  "dependencies": {
28
- "@orthacms/bootstrap-admin": "^0.11.0",
29
- "@orthacms/content-admin": "^0.11.0",
30
- "@orthacms/content-domain": "^0.11.0",
31
- "@orthacms/design-system": "^0.11.0",
32
- "@orthacms/identity-admin": "^0.11.0",
33
- "@orthacms/insights-admin": "^0.11.0",
34
- "@orthacms/utils-admin": "^0.11.0",
35
- "@orthacms/workspaces-admin": "^0.11.0",
36
- "@orthacms/wysiwyg-admin": "^0.11.0",
28
+ "@orthacms/bootstrap-admin": "^0.13.0",
29
+ "@orthacms/content-admin": "^0.13.0",
30
+ "@orthacms/content-domain": "^0.13.0",
31
+ "@orthacms/design-system": "^0.13.0",
32
+ "@orthacms/identity-admin": "^0.13.0",
33
+ "@orthacms/insights-admin": "^0.13.0",
34
+ "@orthacms/utils-admin": "^0.13.0",
35
+ "@orthacms/workspaces-admin": "^0.13.0",
36
+ "@orthacms/wysiwyg-admin": "^0.13.0",
37
37
  "@tanstack/react-query": "^5.0.0",
38
38
  "lucide-react": "^1.17.0",
39
39
  "react-intl": "^7.0.0",