@murumets-ee/media 0.71.0 → 0.73.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 (137) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/backfill-B1StTnxg.mjs +2 -0
  4. package/dist/backfill-B1StTnxg.mjs.map +1 -0
  5. package/dist/backfill-trigger-BFL1L7_j.mjs +2 -0
  6. package/dist/backfill-trigger-BFL1L7_j.mjs.map +1 -0
  7. package/dist/backlog-D-H5VUQA.mjs +2 -0
  8. package/dist/backlog-D-H5VUQA.mjs.map +1 -0
  9. package/dist/client.d.mts +65 -7
  10. package/dist/client.d.mts.map +1 -1
  11. package/dist/client.mjs +1 -1
  12. package/dist/client.mjs.map +1 -1
  13. package/dist/crop-editor.d.mts +61 -0
  14. package/dist/crop-editor.d.mts.map +1 -0
  15. package/dist/crop-editor.mjs +2 -0
  16. package/dist/crop-editor.mjs.map +1 -0
  17. package/dist/crop-rect-B-AJ2pWm.mjs +2 -0
  18. package/dist/crop-rect-B-AJ2pWm.mjs.map +1 -0
  19. package/dist/crop-rect-C-SbjoxK.d.mts +59 -0
  20. package/dist/crop-rect-C-SbjoxK.d.mts.map +1 -0
  21. package/dist/crop.d.mts +2 -1
  22. package/dist/crop.mjs +1 -1
  23. package/dist/definitions-LJpgrdxd.mjs +2 -0
  24. package/dist/definitions-LJpgrdxd.mjs.map +1 -0
  25. package/dist/deps-DaGjHO16.mjs +2 -0
  26. package/dist/deps-DaGjHO16.mjs.map +1 -0
  27. package/dist/derive-media-type-C38ZA_Cl.mjs +2 -0
  28. package/dist/derive-media-type-C38ZA_Cl.mjs.map +1 -0
  29. package/dist/en-Duj_Pp-O.mjs +2 -0
  30. package/dist/en-Duj_Pp-O.mjs.map +1 -0
  31. package/dist/entity-CsDdjKz6.mjs +2 -0
  32. package/dist/entity-CsDdjKz6.mjs.map +1 -0
  33. package/dist/et-BFdSZnb5.mjs +2 -0
  34. package/dist/et-BFdSZnb5.mjs.map +1 -0
  35. package/dist/generate-variants-Dpy4aT2o.mjs +2 -0
  36. package/dist/generate-variants-Dpy4aT2o.mjs.map +1 -0
  37. package/dist/i18n.mjs +1 -1
  38. package/dist/i18n.mjs.map +1 -1
  39. package/dist/image-styles-settings.d.mts +8 -1
  40. package/dist/image-styles-settings.d.mts.map +1 -1
  41. package/dist/image-styles-settings.mjs +1 -1
  42. package/dist/image-styles-settings.mjs.map +1 -1
  43. package/dist/image-styles.d.mts +35 -21
  44. package/dist/image-styles.d.mts.map +1 -1
  45. package/dist/image-styles.mjs +1 -2
  46. package/dist/image-styles.mjs.map +1 -1
  47. package/dist/index.d.mts +50 -42
  48. package/dist/index.d.mts.map +1 -1
  49. package/dist/index.mjs +1 -1
  50. package/dist/index.mjs.map +1 -1
  51. package/dist/media-config-DkbnXIjC.mjs +2 -0
  52. package/dist/media-config-DkbnXIjC.mjs.map +1 -0
  53. package/dist/owned-delete-Cn_KFFjS.mjs +2 -0
  54. package/dist/owned-delete-Cn_KFFjS.mjs.map +1 -0
  55. package/dist/picker.d.mts +1 -94
  56. package/dist/picker.d.mts.map +1 -1
  57. package/dist/picker.mjs +1 -2
  58. package/dist/picker.mjs.map +1 -1
  59. package/dist/plugin.d.mts +2 -1
  60. package/dist/plugin.d.mts.map +1 -1
  61. package/dist/plugin.mjs +1 -1
  62. package/dist/plugin.mjs.map +1 -1
  63. package/dist/process-image-wQwiRhyk.mjs +2 -0
  64. package/dist/process-image-wQwiRhyk.mjs.map +1 -0
  65. package/dist/processing.d.mts +201 -7
  66. package/dist/processing.d.mts.map +1 -1
  67. package/dist/processing.mjs +1 -1
  68. package/dist/public-resolver.d.mts +80 -16
  69. package/dist/public-resolver.d.mts.map +1 -1
  70. package/dist/public-resolver.mjs +1 -1
  71. package/dist/public-resolver.mjs.map +1 -1
  72. package/dist/query-client.d.mts +1 -1
  73. package/dist/query-client.mjs +1 -1
  74. package/dist/regenerate-variants-RwV_AQSo.mjs +2 -0
  75. package/dist/regenerate-variants-RwV_AQSo.mjs.map +1 -0
  76. package/dist/register-CJKjNl2w.mjs +2 -0
  77. package/dist/register-CJKjNl2w.mjs.map +1 -0
  78. package/dist/resolve-image-styles-YExI329D.mjs +2 -0
  79. package/dist/resolve-image-styles-YExI329D.mjs.map +1 -0
  80. package/dist/routes-DaE0M8h8.mjs +2 -0
  81. package/dist/routes-DaE0M8h8.mjs.map +1 -0
  82. package/dist/ru-ojvshRJy.mjs +2 -0
  83. package/dist/ru-ojvshRJy.mjs.map +1 -0
  84. package/dist/schedule-JlvfUEVA.mjs +2 -0
  85. package/dist/schedule-JlvfUEVA.mjs.map +1 -0
  86. package/dist/shapes-BpOEJeLx.mjs +2 -0
  87. package/dist/shapes-BpOEJeLx.mjs.map +1 -0
  88. package/dist/shapes-C51rXSDJ.d.mts +90 -0
  89. package/dist/shapes-C51rXSDJ.d.mts.map +1 -0
  90. package/dist/shapes-CqppzFzb.mjs +2 -0
  91. package/dist/shapes-CqppzFzb.mjs.map +1 -0
  92. package/dist/slot-CY4isqmN.mjs +2 -0
  93. package/dist/slot-CY4isqmN.mjs.map +1 -0
  94. package/dist/{types-CnORVkLZ.d.mts → types-6PZQ4Zq6.d.mts} +82 -5
  95. package/dist/types-6PZQ4Zq6.d.mts.map +1 -0
  96. package/dist/types-Lj3SkZQ_.d.mts +107 -0
  97. package/dist/types-Lj3SkZQ_.d.mts.map +1 -0
  98. package/dist/variant-plan-86tJKtVi.mjs +2 -0
  99. package/dist/variant-plan-86tJKtVi.mjs.map +1 -0
  100. package/dist/variant-state-CBtqb9Cb.mjs +2 -0
  101. package/dist/variant-state-CBtqb9Cb.mjs.map +1 -0
  102. package/package.json +21 -8
  103. package/dist/client-CSOH7Zun.mjs +0 -2
  104. package/dist/client-CSOH7Zun.mjs.map +0 -1
  105. package/dist/en-0YnQ_Qz-.mjs +0 -2
  106. package/dist/en-0YnQ_Qz-.mjs.map +0 -1
  107. package/dist/entity-CNxAERYG.mjs +0 -2
  108. package/dist/entity-CNxAERYG.mjs.map +0 -1
  109. package/dist/entity-DKFCdQJT.mjs +0 -2
  110. package/dist/entity-DKFCdQJT.mjs.map +0 -1
  111. package/dist/et-BIxGYWVK.mjs +0 -2
  112. package/dist/et-BIxGYWVK.mjs.map +0 -1
  113. package/dist/image-styles-settings-DEnu-V2E.mjs +0 -2
  114. package/dist/image-styles-settings-DEnu-V2E.mjs.map +0 -1
  115. package/dist/plugin-B_ZtfiAc.mjs +0 -2
  116. package/dist/plugin-B_ZtfiAc.mjs.map +0 -1
  117. package/dist/process-image-WByEHTXQ.mjs +0 -2
  118. package/dist/process-image-WByEHTXQ.mjs.map +0 -1
  119. package/dist/regenerate-variants-2Lmo5kf8.mjs +0 -2
  120. package/dist/regenerate-variants-2Lmo5kf8.mjs.map +0 -1
  121. package/dist/regenerate-variants-B9AXXN6N.mjs +0 -2
  122. package/dist/regenerate-variants-B9AXXN6N.mjs.map +0 -1
  123. package/dist/resolve-image-styles-Cakg1Qpp.mjs +0 -2
  124. package/dist/resolve-image-styles-Cakg1Qpp.mjs.map +0 -1
  125. package/dist/resolve-image-styles-iN9JbZYf.mjs +0 -2
  126. package/dist/resolve-image-styles-iN9JbZYf.mjs.map +0 -1
  127. package/dist/routes-C7Fx-055.mjs +0 -2
  128. package/dist/routes-C7Fx-055.mjs.map +0 -1
  129. package/dist/ru-UUTBfsMF.mjs +0 -2
  130. package/dist/ru-UUTBfsMF.mjs.map +0 -1
  131. package/dist/types-CnORVkLZ.d.mts.map +0 -1
  132. package/dist/usage-CVAqkS6h.mjs +0 -2
  133. package/dist/usage-CVAqkS6h.mjs.map +0 -1
  134. package/dist/variant-key-JBTJXPL1.mjs +0 -2
  135. package/dist/variant-key-JBTJXPL1.mjs.map +0 -1
  136. package/dist/variant-key-RKke2RfS.mjs +0 -2
  137. package/dist/variant-key-RKke2RfS.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"picker.mjs","names":["ImageIcon"],"sources":["../src/picker/admin-callbacks.ts","../src/lib/cn.ts","../src/derive-media-type.ts","../src/picker/media-type-icon.ts","../src/grid/media-card.tsx","../src/grid/media-grid.tsx","../src/picker/provider.tsx","../src/picker/search-bar.tsx","../src/picker/upload-zone.tsx","../src/picker/media-picker.tsx"],"sourcesContent":["/**\n * Pre-built media callbacks that talk to the admin API.\n *\n * Eliminates boilerplate in every project — just call:\n * const media = createAdminMediaCallbacks('/api/admin')\n *\n * Returns callbacks compatible with both MediaPickerProvider and\n * BlockEditor's `media` prop (with getMediaUrl for thumbnail previews).\n */\n\nimport type { MediaPickerCallbacks, MediaPickerItem, MediaPickerListResult } from './types'\n\n/** Extended callbacks with getMediaUrl (for editor thumbnail previews) */\nexport type AdminMediaCallbacks = MediaPickerCallbacks & {\n getMediaUrl: (id: string) => Promise<string | null>\n}\n\n/**\n * Create media callbacks that talk to the admin API.\n *\n * @param apiBasePath - Base path for admin API (default: '/api/admin')\n * @returns Callbacks for MediaPickerProvider + BlockEditor media prop\n *\n * @example\n * ```tsx\n * import { createAdminMediaCallbacks } from '@murumets-ee/media/picker'\n *\n * const media = createAdminMediaCallbacks()\n * // Use with BlockEditor:\n * <BlockEditor media={media} ... />\n * // Use with MediaPickerProvider:\n * <MediaPickerProvider fetchMedia={media.fetchMedia} uploadMedia={media.uploadMedia}>\n * ```\n */\nexport function createAdminMediaCallbacks(apiBasePath = '/api/admin'): AdminMediaCallbacks {\n const baseUrl = `${apiBasePath}/media`\n\n // Admin API error responses are `{ error: string }`. Parse the body so\n // the real cause (e.g. \"Missing required environment variable:\n // STORAGE_ENDPOINT\") reaches the UI instead of just the status code.\n const readErrorMessage = async (res: Response): Promise<string> => {\n try {\n const body = (await res.clone().json()) as { error?: unknown }\n if (typeof body.error === 'string' && body.error.length > 0) return body.error\n } catch {\n // fall through — response wasn't JSON\n }\n try {\n const text = await res.text()\n if (text) return text\n } catch {\n // ignore\n }\n return `HTTP ${String(res.status)}`\n }\n\n return {\n fetchMedia: async (options: {\n search?: string | undefined\n mediaType?: string | undefined\n limit: number\n offset: number\n }): Promise<MediaPickerListResult> => {\n const params = new URLSearchParams()\n if (options.search) params.set('search', options.search)\n if (options.mediaType) params.set('mediaType', options.mediaType)\n params.set('limit', String(options.limit))\n params.set('offset', String(options.offset))\n\n const url = `${baseUrl}?${params.toString()}`\n const res = await fetch(url)\n if (!res.ok) throw new Error(await readErrorMessage(res))\n return res.json() as Promise<MediaPickerListResult>\n },\n\n uploadMedia: async (file: File): Promise<MediaPickerItem> => {\n const formData = new FormData()\n formData.append('file', file)\n // The media LIBRARY is publishable web assets, so it opts in explicitly.\n // Without this the upload route's deny-by-default leaves the asset\n // private, `getPublicUrls` returns nothing for it, and every image on the\n // public site resolves to `null` behind a 200 and one warn log (F030).\n // Sent per-upload rather than by flipping a global default, because the\n // same route also receives rent's inspection photos, which must not be\n // world-readable.\n formData.append('visibility', 'public')\n\n const res = await fetch(baseUrl, { method: 'POST', body: formData })\n if (!res.ok) throw new Error(await readErrorMessage(res))\n return res.json() as Promise<MediaPickerItem>\n },\n\n getMediaUrl: async (id: string): Promise<string | null> => {\n try {\n const res = await fetch(`${baseUrl}/${id}`)\n if (!res.ok) return null\n const item = (await res.json()) as MediaPickerItem\n return item.url ?? null\n } catch {\n return null\n }\n },\n }\n}\n","import { type ClassValue, clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n","import type { MediaType } from './types.js'\n\n/**\n * Classify a MIME type into the coarse `MediaType` an upload is stored under.\n *\n * Pure and dependency-free, and in its own module on purpose: `client.ts`\n * (`import 'server-only'`) needs it to set `mediaType` at upload, and the admin\n * list's MIME-type cell (`picker/media-type-icon.ts`, a CLIENT entry) needs the\n * same answer to pick an icon. One rule in one file means the icon a row shows\n * cannot disagree with the type it was stored as — which two copies would, the\n * first time someone taught one of them about a new document type.\n */\nexport function deriveMediaType(mimeType: string): MediaType {\n if (mimeType.startsWith('image/')) return 'image'\n if (mimeType.startsWith('video/')) return 'video'\n if (mimeType.startsWith('audio/')) return 'audio'\n if (\n mimeType === 'application/pdf' ||\n mimeType.startsWith('application/msword') ||\n mimeType.startsWith('application/vnd.')\n ) {\n return 'document'\n }\n return 'other'\n}\n","import { File, FileText, Film, Image as ImageIcon, type LucideIcon, Music } from 'lucide-react'\nimport { deriveMediaType } from '../derive-media-type.js'\n\n/**\n * The non-image members of `@murumets-ee/media`'s `MediaType`, copied rather\n * than imported.\n *\n * This module ships from the `./picker` tsdown entry — a build SEPARATE from\n * the root `index.ts` entry that already exports `MediaType` by name. Naming\n * it here too would duplicate its declaration across two `.d.mts` files\n * (`verify:dts`, #767): each array entry in `tsdown.config.ts` builds\n * independently and cannot share a declaration chunk, so a type named in two\n * entries' public APIs is emitted twice, and TypeScript can no longer treat\n * the two copies as identical if either ever gains an invariant member.\n *\n * Kept honest by `media-type-icon.test.ts`, which imports the real\n * `MediaType` (a type-only import, erased before it ever reaches a `.d.mts`)\n * and asserts this literal still matches it exactly.\n */\ntype NonImageMediaType = 'video' | 'audio' | 'document' | 'other'\n\n/**\n * The typed-icon fallback for every media type that is not `'image'`.\n *\n * The ONE rule three admin surfaces branch on (F115): the media list, the\n * picker's `MediaCard` and the media edit form all show a real `<img>` for\n * `mediaType === 'image'` and one of these icons otherwise. Extracted from\n * `MediaCard`, which held the only copy, so a fourth surface reaches for this\n * instead of writing a fourth `Record<MediaType, LucideIcon>`.\n */\nexport const NON_IMAGE_MEDIA_TYPE_ICONS: Record<NonImageMediaType, LucideIcon> = {\n video: Film,\n audio: Music,\n document: FileText,\n other: File,\n}\n\n/** The icon for a non-image media type, or `null` for `'image'` (render the asset itself). */\nexport function mediaTypeIcon(mediaType: 'image' | NonImageMediaType): LucideIcon | null {\n return mediaType === 'image' ? null : NON_IMAGE_MEDIA_TYPE_ICONS[mediaType]\n}\n\n/**\n * The icon for a MIME type — the admin list's MIME column, where the string\n * `video/mp4` is replaced by a film (entity-table D008's `mimeType` display).\n *\n * Unlike {@link mediaTypeIcon}, an image gets one too: that function returns\n * `null` for `'image'` because its callers draw the ASSET instead, and a column\n * that shows a type has no asset to draw. The classification is\n * `deriveMediaType` — the rule the upload stored the row's `mediaType` by — so\n * the icon cannot disagree with the type column beside it.\n *\n * Takes a plain string and returns a `LucideIcon`, so `MediaType` is not named\n * in this entry's public API (see the note above `NonImageMediaType`).\n */\nexport function mimeTypeIcon(mimeType: string): LucideIcon {\n const mediaType = deriveMediaType(mimeType)\n return mediaType === 'image' ? ImageIcon : NON_IMAGE_MEDIA_TYPE_ICONS[mediaType]\n}\n","import { Check, ImageIcon } from 'lucide-react'\nimport { cn } from '../lib/cn'\nimport { mediaTypeIcon } from '../picker/media-type-icon'\nimport type { MediaPickerClassNames } from '../picker/types'\n\n/**\n * A structural copy of `@murumets-ee/media`'s own `MediaType`, not an\n * import — same reason `media-type-icon.ts`'s `NonImageMediaType` isn't one.\n * This module ships from the `./picker` tsdown entry (re-exported by\n * `picker/index.ts`), a build SEPARATE from the root `index.ts` entry that\n * already exports `MediaType` by name; importing (and thereby re-exporting,\n * via `MediaCardProps`) it here too duplicates its declaration across two\n * `.d.mts` files (`verify:dts`, #767) the moment this type reaches the\n * entry's own public surface.\n */\ntype MediaCardMediaType = 'image' | 'video' | 'audio' | 'document' | 'other'\n\n/**\n * One tile: a thumbnail (or a typed icon for anything that is not an image),\n * a hover title overlay, and a selection checkmark.\n *\n * Promoted out of `picker/` (entity-table PR06 §2) — this is the whole point\n * of the promotion. `<MediaPicker>`'s `MediaGrid` and the admin-ui entity\n * list's `grid` layout renderer both draw tiles through this ONE component,\n * so a feature added to one reaches both rather than diverging the way\n * WordPress's Backbone grid and `WP_List_Table` did (D007's cautionary tale).\n *\n * Decoupled from `MediaPickerItem` on purpose: the entity list's grid layout\n * has no `MediaPickerItem` to hand it — it has a TanStack row and a\n * `ViewGridOptions`-resolved field name — so the props here are the minimal\n * shape both callers can build without synthesising fake data for fields\n * neither has (a picker item's `size`/`width`/`height`, an entity row's MIME\n * type).\n */\nexport interface MediaCardProps {\n /** Resolved thumbnail/asset URL. `null` (or a non-`'image'` `mediaType`) draws the typed icon instead. */\n url: string | null\n /** The tile's accessible name and its hover-overlay label. */\n title: string\n /**\n * The image's OWN alt text, when the caller has one — an editor-authored\n * field distinct from `title` (a display name most often equal to the\n * filename). Falls back to `title` only when omitted (`undefined`) or\n * `null` — a deliberately EMPTY string (`''`, the WCAG decorative-image\n * marker) is preserved, not overridden. Review finding, entity-table PR06:\n * the promotion had collapsed this into `title` and silently dropped a\n * screen-reader-meaningful alt text down to the display name; a follow-up\n * critique pass then caught the first fix using `||` (which clobbers `''`\n * the same way) instead of `??`.\n */\n alt?: string | null | undefined\n /**\n * The item's classification. `'image'` draws `url` as an `<img>`;\n * anything else — including `undefined`, an entity with no classification\n * field at all — draws a typed (or generic) icon, never a broken `<img>`.\n */\n mediaType: MediaCardMediaType | undefined\n isSelected: boolean\n onToggle: () => void\n /**\n * Renders the tile as a real `<a href>` instead of a select button — the\n * admin-ui entity list's grid layout (entity-table D004 ported to a card:\n * the tile is the row's link, same as the table's title cell) sets this so\n * cmd/ctrl-click, middle-click and \"open in new tab\" all work, exactly the\n * capabilities S4 requires of the table. `onToggle` and `isSelected` still\n * apply — the caller's OWN checkbox overlay (drawn outside this component,\n * at a higher z-index) is what fires `onToggle`, not a click on the tile.\n *\n * Omitted (the picker's own use, `<MediaGrid>`) keeps the whole tile a\n * select button, unchanged from before the promotion.\n */\n href?: string | undefined\n /**\n * Suppresses this tile's own checkmark badge — driven by whether the\n * CALLER is drawing a DIFFERENT overlay in the same top-right corner, not\n * by `href`. Critique finding, entity-table PR06: the first cut of the\n * overlay-collision fix keyed this off `!!href`, which only happens to\n * equal \"does the caller draw an actions overlay here\" for today's one\n * caller (Media, where `editHref` and `allowDelete` are both always on).\n * The moment an entity reaches the grid through the generic list page with\n * a permission-gated `editHref` (so `href` can be `undefined`) while still\n * allowing delete (so an actions overlay IS drawn), `!!href` goes false\n * and the badge reappears under the menu it was meant to stay clear of.\n * `EntityGridLayout` passes the caller's own truth — whether it rendered\n * an `_actions` cell for this row — instead of re-deriving it from `href`.\n */\n hideSelectionBadge?: boolean | undefined\n classNames?: MediaPickerClassNames | undefined\n}\n\nexport function MediaCard({\n url,\n title,\n alt,\n mediaType,\n isSelected,\n onToggle,\n href,\n hideSelectionBadge,\n classNames,\n}: MediaCardProps) {\n const isImage = mediaType === 'image' && !!url\n // `??`, not `||`: an editor-set EMPTY alt (`''`) is the WCAG-legitimate way\n // to mark an image decorative — a screen reader should skip it, not get\n // `title` instead. `||` would treat that deliberate `''` the same as\n // \"never set\" and silently override it. Matches what the pre-promotion\n // `<img alt={item.alt ?? item.filename}>` already did.\n const imgAlt = alt ?? title\n // `mediaTypeIcon('image')` is `null` on purpose — its OTHER caller\n // (`ImageCell`) draws the asset for that branch, so it never asks for an\n // icon. Here that branch is exactly the row this tile has no asset FOR\n // (an image row with no processed thumbnail — a real, reachable state,\n // not a hypothetical: found via browser verification, entity-table PR06)\n // — matching `ImageCell`'s own \"no url yet\" fallback rather than the\n // BLANK tile `mediaTypeIcon`'s `null` would otherwise draw.\n const Icon =\n mediaType === undefined ? null : mediaType === 'image' ? ImageIcon : mediaTypeIcon(mediaType)\n\n const sharedClassName = cn(\n // `block`: a `<button>` defaults to `inline-block` (width/height already\n // apply), but a plain `<a>` defaults to `inline` — CSS gives width/height\n // NO EFFECT on a non-replaced inline element, so the `w-full`\n // (`classNames.card`, set by callers) and `aspect-square` below did\n // nothing for the href-mode tile: it collapsed to its content's\n // intrinsic size instead of filling the grid track (CodeRabbit CLI\n // finding, entity-table PR06). Harmless on `<button>`, which already\n // behaves this way.\n 'group relative block aspect-square overflow-hidden rounded-lg border-2 transition-all',\n 'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-950',\n isSelected\n ? 'border-blue-500 ring-2 ring-blue-500/20'\n : 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-800 dark:hover:border-zinc-700',\n classNames?.card,\n isSelected && classNames?.cardSelected,\n )\n\n const content = (\n <>\n {/* Thumbnail */}\n {isImage ? (\n // biome-ignore lint/performance/noImgElement: same reasoning as admin-ui's ImageCell — this ships in a published toolkit package consumed by Next AND non-Next harnesses.\n <img\n src={url}\n alt={imgAlt}\n className={cn('h-full w-full object-cover', classNames?.cardImage)}\n loading=\"lazy\"\n />\n ) : (\n <div className=\"flex h-full w-full items-center justify-center bg-zinc-100 dark:bg-zinc-800\">\n {Icon && <Icon className=\"h-8 w-8 text-zinc-400 dark:text-zinc-500\" />}\n </div>\n )}\n\n {/*\n Selection checkmark, suppressed only when the CALLER says it is\n drawing something else in this corner (`hideSelectionBadge`) — see\n that prop's doc for why this is no longer inferred from `href`. The\n entity list's grid layout draws its own checkbox overlay at the\n top-LEFT and, when the row has one, a row-actions menu at the\n top-RIGHT — the same corner this badge used to claim unconditionally,\n so a selected AND hovered tile stacked the \"⋯\" button directly on top\n of it. The border/ring highlight above is still selection's primary\n signal whenever the badge is hidden; the picker (button mode, never\n passes this prop) keeps the badge as its only one.\n */}\n {isSelected && !hideSelectionBadge && (\n <div className=\"absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-white\">\n <Check className=\"h-3 w-3\" />\n </div>\n )}\n\n {/* Title on hover */}\n <div\n className={cn(\n 'absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5',\n 'opacity-0 transition-opacity group-hover:opacity-100',\n classNames?.cardLabel,\n )}\n >\n <p className=\"truncate text-xs text-white\">{title}</p>\n </div>\n </>\n )\n\n // Two literal branches rather than a polymorphic `Root = href ? 'a' : 'button'`\n // tag: an anchor and a button take different, non-overlapping prop sets\n // (`href` vs `type`/`onClick`), and a runtime-chosen tag name can't be typed\n // against both without `any`.\n if (href) {\n return (\n <a href={href} aria-label={title} className={sharedClassName}>\n {content}\n </a>\n )\n }\n return (\n <button\n type=\"button\"\n onClick={onToggle}\n aria-pressed={isSelected}\n aria-label={title}\n className={sharedClassName}\n >\n {content}\n </button>\n )\n}\n","import { cn } from '../lib/cn'\nimport type { MediaPickerClassNames, MediaPickerItem } from '../picker/types'\nimport { MediaCard } from './media-card'\n\ninterface MediaGridProps {\n items: MediaPickerItem[]\n selected: Set<string>\n onToggle: (item: MediaPickerItem) => void\n isLoading: boolean\n total: number\n offset: number\n limit: number\n onPageChange: (offset: number) => void\n classNames?: MediaPickerClassNames | undefined\n}\n\n/**\n * The `<MediaPicker>` dialog's own grid — its pagination (offset/limit, a\n * Prev/Next footer) is the picker's, not the entity list's. The TILE it\n * draws is the promoted, shared `MediaCard` (entity-table PR06 §2); this\n * wrapper's job is adapting `MediaPickerItem[]` to that tile's generic props.\n */\nexport function MediaGrid({\n items,\n selected,\n onToggle,\n isLoading,\n total,\n offset,\n limit,\n onPageChange,\n classNames,\n}: MediaGridProps) {\n if (isLoading) {\n return (\n <div className={cn('grid grid-cols-4 gap-3 sm:grid-cols-6', classNames?.grid)}>\n {Array.from({ length: 12 }).map((_, i) => (\n <div\n key={`skeleton-${i.toString()}`}\n className={cn(\n 'aspect-square animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800',\n classNames?.loading,\n )}\n />\n ))}\n </div>\n )\n }\n\n if (items.length === 0) {\n return (\n <div\n className={cn(\n 'py-12 text-center text-sm text-zinc-500 dark:text-zinc-400',\n classNames?.empty,\n )}\n >\n No media found. Upload a file to get started.\n </div>\n )\n }\n\n const totalPages = Math.ceil(total / limit)\n const currentPage = Math.floor(offset / limit) + 1\n\n return (\n <>\n <div className={cn('grid grid-cols-4 gap-3 sm:grid-cols-6', classNames?.grid)}>\n {items.map((item) => (\n <MediaCard\n key={item.id}\n url={item.mediaType === 'image' ? item.url : null}\n // `||`, not `??`: unlike `alt` (where an editor-set `''` is the\n // WCAG-legitimate \"decorative, skip me\" marker worth preserving\n // — see `media-card.tsx`), an empty TITLE has no such meaning.\n // `??` alone left it through for an item with `title: ''`\n // (distinct from `title: null`), and `title` is what MediaCard's\n // `aria-label` uses directly — an empty accessible name, not just\n // a missing caption (CodeRabbit CLI finding, entity-table PR06).\n title={item.title || item.filename}\n alt={item.alt}\n mediaType={item.mediaType}\n isSelected={selected.has(item.id)}\n onToggle={() => onToggle(item)}\n classNames={classNames}\n />\n ))}\n </div>\n {totalPages > 1 && (\n <div className=\"mt-4 flex items-center justify-center gap-2\">\n <button\n type=\"button\"\n disabled={currentPage <= 1}\n onClick={() => onPageChange(offset - limit)}\n className=\"rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700\"\n >\n Prev\n </button>\n <span className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n {currentPage} / {totalPages}\n </span>\n <button\n type=\"button\"\n disabled={currentPage >= totalPages}\n onClick={() => onPageChange(offset + limit)}\n className=\"rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700\"\n >\n Next\n </button>\n </div>\n )}\n </>\n )\n}\n","import { createContext, type ReactNode, useContext, useMemo } from 'react'\nimport type { MediaPickerCallbacks } from './types'\n\nconst MediaPickerContext = createContext<MediaPickerCallbacks | null>(null)\n\nexport interface MediaPickerProviderProps extends MediaPickerCallbacks {\n children: ReactNode\n}\n\n/**\n * Provides media picker callbacks to all picker instances below.\n * Wrap your admin layout with this provider.\n *\n * @example\n * ```tsx\n * <MediaPickerProvider\n * fetchMedia={fetchMediaAction}\n * uploadMedia={uploadMediaAction}\n * >\n * <AdminShell>...</AdminShell>\n * </MediaPickerProvider>\n * ```\n */\nexport function MediaPickerProvider({\n children,\n fetchMedia,\n uploadMedia,\n}: MediaPickerProviderProps) {\n const value = useMemo<MediaPickerCallbacks>(\n () => ({ fetchMedia, uploadMedia }),\n [fetchMedia, uploadMedia],\n )\n\n return <MediaPickerContext value={value}>{children}</MediaPickerContext>\n}\n\nexport function useMediaPicker(): MediaPickerCallbacks {\n const ctx = useContext(MediaPickerContext)\n if (!ctx) {\n throw new Error(\n 'useMediaPicker must be used within <MediaPickerProvider>. ' +\n 'Wrap your admin layout with <MediaPickerProvider fetchMedia={...} uploadMedia={...}>.',\n )\n }\n return ctx\n}\n","import { Search } from 'lucide-react'\nimport { cn } from '../lib/cn'\nimport type { MediaPickerClassNames } from './types'\n\ninterface SearchBarProps {\n value: string\n onChange: (value: string) => void\n mediaType: string | undefined\n onMediaTypeChange: (type: string | undefined) => void\n /** When true, the media type filter is locked (no tabs shown) */\n locked?: boolean | undefined\n classNames?: MediaPickerClassNames | undefined\n}\n\nconst FILTER_OPTIONS = [\n { value: undefined, label: 'All' },\n { value: 'image', label: 'Images' },\n { value: 'video', label: 'Videos' },\n { value: 'audio', label: 'Audio' },\n { value: 'document', label: 'Docs' },\n] as const\n\nexport function SearchBar({\n value,\n onChange,\n mediaType,\n onMediaTypeChange,\n locked,\n classNames,\n}: SearchBarProps) {\n // If locked (caller pre-set the mediaType), hide filter tabs\n const showFilters = !locked\n\n return (\n <div className=\"flex items-center gap-3\">\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400\" />\n <input\n type=\"text\"\n value={value}\n onChange={(e) => onChange(e.target.value)}\n placeholder=\"Search media...\"\n className={cn(\n 'w-full rounded-md border border-zinc-300 bg-transparent py-2 pl-10 pr-3 text-sm',\n 'placeholder:text-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',\n 'dark:border-zinc-700 dark:placeholder:text-zinc-500',\n classNames?.searchInput,\n )}\n />\n </div>\n {showFilters && (\n <div className={cn('flex gap-1', classNames?.filterTabs)}>\n {FILTER_OPTIONS.map((opt) => (\n <button\n key={opt.label}\n type=\"button\"\n onClick={() => onMediaTypeChange(opt.value)}\n className={cn(\n 'rounded-md px-3 py-1.5 text-xs font-medium transition-colors',\n mediaType === opt.value\n ? 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900'\n : 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800',\n )}\n >\n {opt.label}\n </button>\n ))}\n </div>\n )}\n </div>\n )\n}\n","import { Upload } from 'lucide-react'\nimport { type DragEvent, useCallback, useRef, useState } from 'react'\nimport { cn } from '../lib/cn'\nimport type { MediaPickerClassNames } from './types'\n\ninterface UploadZoneProps {\n onUpload: (file: File) => Promise<void>\n isUploading: boolean\n accept?: string[] | undefined\n classNames?: MediaPickerClassNames | undefined\n}\n\nexport function UploadZone({ onUpload, isUploading, accept, classNames }: UploadZoneProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const [isDragging, setIsDragging] = useState(false)\n\n const handleDrop = useCallback(\n async (e: DragEvent) => {\n e.preventDefault()\n setIsDragging(false)\n const file = e.dataTransfer.files[0]\n if (file) {\n await onUpload(file)\n }\n },\n [onUpload],\n )\n\n const handleFileSelect = useCallback(\n async (e: React.ChangeEvent<HTMLInputElement>) => {\n const file = e.target.files?.[0]\n if (file) {\n await onUpload(file)\n e.target.value = ''\n }\n },\n [onUpload],\n )\n\n return (\n <button\n type=\"button\"\n onDragOver={(e) => {\n e.preventDefault()\n setIsDragging(true)\n }}\n onDragLeave={() => setIsDragging(false)}\n onDrop={handleDrop}\n onClick={() => inputRef.current?.click()}\n className={cn(\n 'mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-6 transition-colors',\n isDragging\n ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'\n : 'border-zinc-300 hover:border-zinc-400 dark:border-zinc-700 dark:hover:border-zinc-600',\n classNames?.uploadZone,\n isDragging && classNames?.uploadZoneActive,\n )}\n >\n <Upload className=\"mb-2 h-6 w-6 text-zinc-400 dark:text-zinc-500\" />\n <p className=\"text-sm text-zinc-600 dark:text-zinc-400\">\n {isUploading ? 'Uploading...' : 'Drop a file here or click to upload'}\n </p>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept?.join(',')}\n onChange={handleFileSelect}\n className=\"hidden\"\n />\n </button>\n )\n}\n","import * as DialogPrimitive from '@radix-ui/react-dialog'\nimport * as VisuallyHidden from '@radix-ui/react-visually-hidden'\nimport { X } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { cn } from '../lib/cn'\nimport { MediaGrid } from '../grid/media-grid'\nimport { useMediaPicker } from './provider'\nimport { SearchBar } from './search-bar'\nimport type { MediaPickerItem, MediaPickerProps } from './types'\nimport { UploadZone } from './upload-zone'\n\nconst ITEMS_PER_PAGE = 24\n\n/** Media types whose files share one MIME top-level type (`image/*`, …). */\nconst MIME_FAMILIES = new Set(['image', 'video', 'audio'])\n\n/** The file-input patterns a locked media type implies, if it implies any. */\nfunction acceptFor(mediaType: string | undefined): string[] | undefined {\n return mediaType !== undefined && MIME_FAMILIES.has(mediaType) ? [`${mediaType}/*`] : undefined\n}\n\n/**\n * `'file'` is the residual kind — NOT an image and NOT a video — matching the\n * server's `mediaTypeMatchesKind`. It is not a list classification the media\n * API knows, so it never goes out as a browse filter.\n */\nconst RESIDUAL_KIND = 'file'\n\n/**\n * Whether a file the author chose may go into a field locked to `mediaType`,\n * judged before upload from the browser's own guess. An EMPTY `file.type` (the\n * browser could not tell — a HEIC or MKV on some platforms) passes: the server\n * sniffs the real type from the content, and the post-upload check still\n * guards the pick.\n */\nfunction fileMatches(mediaType: string | undefined, file: File): boolean {\n if (mediaType === undefined || file.type === '') return true\n if (mediaType === RESIDUAL_KIND) {\n return !file.type.startsWith('image/') && !file.type.startsWith('video/')\n }\n if (!MIME_FAMILIES.has(mediaType)) return true\n return file.type.startsWith(`${mediaType}/`)\n}\n\n/**\n * Why `item` cannot go into a field locked to `mediaType`, or `null` when it\n * can. The browse list is already filtered server-side; this is the guard for\n * what the list does not cover — an upload the server classified differently.\n */\nfunction typeMismatch(mediaType: string | undefined, item: MediaPickerItem): string | null {\n if (mediaType === undefined || item.mediaType === mediaType) return null\n const name = item.title ?? item.filename\n if (mediaType === RESIDUAL_KIND) {\n if (item.mediaType !== 'image' && item.mediaType !== 'video') return null\n return `This field takes a document or other file — “${name}” is ${withArticle(item.mediaType)}.`\n }\n return `This field takes ${mediaType} files — “${name}” is ${item.mediaType === 'other' ? 'not one' : withArticle(item.mediaType)}.`\n}\n\n/** \"an image\", \"an audio file\"-style reading: `an` before a vowel sound, `a` otherwise. */\nfunction withArticle(mediaType: MediaPickerItem['mediaType']): string {\n return /^[aeiou]/.test(mediaType) ? `an ${mediaType}` : `a ${mediaType}`\n}\n\nexport function MediaPicker({\n open,\n onOpenChange,\n onSelect,\n mode = 'single',\n accept,\n mediaType,\n maxSelect,\n selectedIds = [],\n title = 'Select Media',\n description,\n classNames,\n className,\n children,\n}: MediaPickerProps) {\n const { fetchMedia, uploadMedia } = useMediaPicker()\n\n // State\n const [items, setItems] = useState<MediaPickerItem[]>([])\n const [total, setTotal] = useState(0)\n const [selected, setSelected] = useState<Set<string>>(() => new Set(selectedIds))\n const [search, setSearch] = useState('')\n // The browse filter the author picks from the tabs. A caller's `mediaType`\n // LOCKS the filter and wins over it, derived per render rather than copied\n // into state: the dialog stays mounted between opens, so a copy taken at\n // first mount (when a host's picker had no type yet) kept listing every\n // file forever — a field asking for images was offered videos, and the\n // video landed in the image field (editor F116, reopened in PR59).\n const [mediaTypeFilter, setMediaTypeFilter] = useState<string | undefined>(undefined)\n const effectiveMediaType = mediaType ?? mediaTypeFilter\n // What the list request filters by. The residual `'file'` kind has no list\n // classification, so it browses everything and refuses images/videos on pick.\n const listMediaType = mediaType === RESIDUAL_KIND ? undefined : effectiveMediaType\n // A locked type also restricts what the file input offers, unless the caller\n // passed its own patterns.\n const effectiveAccept = accept ?? acceptFor(mediaType)\n const [isLoading, setIsLoading] = useState(false)\n const [isUploading, setIsUploading] = useState(false)\n const [offset, setOffset] = useState(0)\n const [error, setError] = useState<string | null>(null)\n // When the server reports storage isn't configured, we render a\n // banner and hide the upload zone + grid instead of failing silently\n // or showing an empty state the admin can't act on.\n const [storageDisabled, setStorageDisabled] = useState<string | null>(null)\n\n // Fetch media on open / filter change\n const loadMedia = useCallback(async () => {\n setIsLoading(true)\n setError(null)\n try {\n const result = await fetchMedia({\n search: search || undefined,\n mediaType: listMediaType,\n limit: ITEMS_PER_PAGE,\n offset,\n })\n if (result.configured === false) {\n setStorageDisabled(result.reason ?? 'Media storage is not configured on this server.')\n setItems([])\n setTotal(0)\n } else {\n setStorageDisabled(null)\n setItems(result.items)\n setTotal(result.total)\n }\n } catch (err) {\n setError(\n err instanceof Error && err.message\n ? `Failed to load media: ${err.message}`\n : 'Failed to load media.',\n )\n } finally {\n setIsLoading(false)\n }\n }, [fetchMedia, search, listMediaType, offset])\n\n useEffect(() => {\n if (open) {\n loadMedia()\n }\n }, [open, loadMedia])\n\n // Reset state when dialog closes (open transitions true → false)\n const prevOpen = useRef(open)\n const selectedIdsRef = useRef(selectedIds)\n selectedIdsRef.current = selectedIds\n useEffect(() => {\n if (prevOpen.current && !open) {\n setSearch('')\n setOffset(0)\n setSelected(new Set(selectedIdsRef.current))\n }\n prevOpen.current = open\n }, [open])\n\n // Selection\n const handleToggle = useCallback(\n (item: MediaPickerItem) => {\n const mismatch = typeMismatch(mediaType, item)\n if (mismatch !== null) {\n setError(mismatch)\n return\n }\n // Single mode: auto-confirm on click — no separate \"Select\" step needed\n if (mode === 'single') {\n onSelect([item])\n onOpenChange(false)\n return\n }\n\n // Multi mode: toggle selection in the set\n setSelected((prev) => {\n const next = new Set(prev)\n if (next.has(item.id)) {\n next.delete(item.id)\n } else {\n if (maxSelect && next.size >= maxSelect) return prev\n next.add(item.id)\n }\n return next\n })\n },\n [mode, maxSelect, mediaType, onSelect, onOpenChange],\n )\n\n const handleConfirm = useCallback(() => {\n const selectedItems = items.filter((item) => selected.has(item.id))\n onSelect(selectedItems)\n onOpenChange(false)\n }, [items, selected, onSelect, onOpenChange])\n\n // Upload\n const handleUpload = useCallback(\n async (file: File) => {\n setError(null)\n // Refused before the upload: a file the field cannot hold would otherwise\n // land in the library AND be auto-selected into the field.\n if (!fileMatches(mediaType, file)) {\n setError(\n mediaType === RESIDUAL_KIND\n ? `This field takes a document or other file — “${file.name}” is an image or a video.`\n : `This field takes ${mediaType} files — “${file.name}” is not one.`,\n )\n return\n }\n setIsUploading(true)\n try {\n const uploaded = await uploadMedia(file)\n // The server classifies the stored file; trust that over the browser's\n // guess from the extension.\n const mismatch = typeMismatch(mediaType, uploaded)\n if (mismatch !== null) {\n setError(mismatch)\n return\n }\n\n // Single mode: auto-confirm the just-uploaded file\n if (mode === 'single') {\n onSelect([uploaded])\n onOpenChange(false)\n return\n }\n\n // Multi mode: add to grid and select\n setItems((prev) => [uploaded, ...prev])\n setTotal((prev) => prev + 1)\n setSelected((prev) => new Set([...prev, uploaded.id]))\n } catch (err) {\n setError(\n err instanceof Error && err.message\n ? `Upload failed: ${err.message}`\n : 'Upload failed. Please try again.',\n )\n } finally {\n setIsUploading(false)\n }\n },\n [uploadMedia, mode, mediaType, onSelect, onOpenChange],\n )\n\n return (\n <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>\n <DialogPrimitive.Portal>\n <DialogPrimitive.Overlay\n className={cn(\n 'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n classNames?.overlay,\n )}\n />\n <DialogPrimitive.Content\n {...(!description && { 'aria-describedby': undefined })}\n className={cn(\n 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',\n 'flex max-h-[85vh] w-[90vw] max-w-4xl flex-col',\n 'rounded-xl border border-zinc-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950',\n classNames?.content,\n className,\n )}\n >\n {/* Header */}\n <div\n className={cn(\n 'flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-800',\n classNames?.header,\n )}\n >\n <DialogPrimitive.Title\n className={cn(\n 'text-lg font-semibold text-zinc-900 dark:text-zinc-50',\n classNames?.title,\n )}\n >\n {title}\n </DialogPrimitive.Title>\n {description && (\n <VisuallyHidden.Root asChild>\n <DialogPrimitive.Description>{description}</DialogPrimitive.Description>\n </VisuallyHidden.Root>\n )}\n <DialogPrimitive.Close className=\"rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-600\">\n <X className=\"h-4 w-4\" />\n <VisuallyHidden.Root>Close</VisuallyHidden.Root>\n </DialogPrimitive.Close>\n </div>\n\n {/* Toolbar */}\n <div\n className={cn(\n 'border-b border-zinc-200 px-6 py-3 dark:border-zinc-800',\n classNames?.toolbar,\n )}\n >\n <SearchBar\n value={search}\n onChange={setSearch}\n mediaType={effectiveMediaType}\n onMediaTypeChange={setMediaTypeFilter}\n locked={!!mediaType}\n classNames={classNames}\n />\n </div>\n\n {/* Content */}\n <div className=\"flex-1 overflow-y-auto px-6 py-4\">\n {error && (\n <div\n role=\"alert\"\n className=\"mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400\"\n >\n {error}\n </div>\n )}\n {storageDisabled ? (\n <div\n role=\"alert\"\n className=\"rounded-lg border border-amber-200 bg-amber-50 px-4 py-6 text-center dark:border-amber-900 dark:bg-amber-950/30\"\n >\n <p className=\"text-sm font-medium text-amber-900 dark:text-amber-200\">\n Media storage is not configured\n </p>\n <p className=\"mt-1 text-xs text-amber-800 dark:text-amber-300\">{storageDisabled}</p>\n <p className=\"mt-3 text-xs text-amber-700 dark:text-amber-400\">\n Set the required environment variables and restart the app to enable uploads.\n </p>\n </div>\n ) : (\n <>\n <UploadZone\n onUpload={handleUpload}\n isUploading={isUploading}\n accept={effectiveAccept}\n classNames={classNames}\n />\n <MediaGrid\n items={items}\n selected={selected}\n onToggle={handleToggle}\n isLoading={isLoading}\n total={total}\n offset={offset}\n limit={ITEMS_PER_PAGE}\n onPageChange={setOffset}\n classNames={classNames}\n />\n </>\n )}\n </div>\n\n {/* Footer — multi mode shows confirm/cancel, single mode shows item count only */}\n <div\n className={cn(\n 'flex items-center justify-between border-t border-zinc-200 px-6 py-4 dark:border-zinc-800',\n classNames?.footer,\n )}\n >\n <span className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n {mode === 'single'\n ? `${total.toString()} item${total !== 1 ? 's' : ''} — click to select`\n : selected.size > 0\n ? `${selected.size.toString()} selected`\n : `${total.toString()} item${total !== 1 ? 's' : ''}`}\n </span>\n <div className=\"flex gap-2\">\n {children}\n <DialogPrimitive.Close asChild>\n <button\n type=\"button\"\n className={cn(\n 'rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50',\n 'dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-900',\n classNames?.cancelButton,\n )}\n >\n Cancel\n </button>\n </DialogPrimitive.Close>\n {mode !== 'single' && (\n <button\n type=\"button\"\n onClick={handleConfirm}\n disabled={selected.size === 0}\n className={cn(\n 'rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n classNames?.confirmButton,\n )}\n >\n {`Select (${selected.size.toString()})`}\n </button>\n )}\n </div>\n </div>\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n </DialogPrimitive.Root>\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"picker.mjs","names":["ImageIcon"],"sources":["../src/picker/admin-callbacks.ts","../src/lib/cn.ts","../src/picker/media-type-icon.ts","../src/grid/media-card.tsx","../src/grid/media-grid.tsx","../src/picker/provider.tsx","../src/picker/search-bar.tsx","../src/picker/upload-zone.tsx","../src/picker/media-picker.tsx"],"sourcesContent":["/**\n * Pre-built media callbacks that talk to the admin API.\n *\n * Eliminates boilerplate in every project — just call:\n * const media = createAdminMediaCallbacks('/api/admin')\n *\n * Returns callbacks compatible with both MediaPickerProvider and\n * BlockEditor's `media` prop (with getMediaUrl for thumbnail previews).\n */\n\nimport type { MediaPickerCallbacks, MediaPickerItem, MediaPickerListResult } from './types'\n\n/** Extended callbacks with getMediaUrl (for editor thumbnail previews) */\nexport type AdminMediaCallbacks = MediaPickerCallbacks & {\n getMediaUrl: (id: string) => Promise<string | null>\n}\n\n/**\n * Create media callbacks that talk to the admin API.\n *\n * @param apiBasePath - Base path for admin API (default: '/api/admin')\n * @returns Callbacks for MediaPickerProvider + BlockEditor media prop\n *\n * @example\n * ```tsx\n * import { createAdminMediaCallbacks } from '@murumets-ee/media/picker'\n *\n * const media = createAdminMediaCallbacks()\n * // Use with BlockEditor:\n * <BlockEditor media={media} ... />\n * // Use with MediaPickerProvider:\n * <MediaPickerProvider fetchMedia={media.fetchMedia} uploadMedia={media.uploadMedia}>\n * ```\n */\nexport function createAdminMediaCallbacks(apiBasePath = '/api/admin'): AdminMediaCallbacks {\n const baseUrl = `${apiBasePath}/media`\n\n // Admin API error responses are `{ error: string }`. Parse the body so\n // the real cause (e.g. \"Missing required environment variable:\n // STORAGE_ENDPOINT\") reaches the UI instead of just the status code.\n const readErrorMessage = async (res: Response): Promise<string> => {\n try {\n const body = (await res.clone().json()) as { error?: unknown }\n if (typeof body.error === 'string' && body.error.length > 0) return body.error\n } catch {\n // fall through — response wasn't JSON\n }\n try {\n const text = await res.text()\n if (text) return text\n } catch {\n // ignore\n }\n return `HTTP ${String(res.status)}`\n }\n\n return {\n fetchMedia: async (options: {\n search?: string | undefined\n mediaType?: string | undefined\n limit: number\n offset: number\n }): Promise<MediaPickerListResult> => {\n const params = new URLSearchParams()\n if (options.search) params.set('search', options.search)\n if (options.mediaType) params.set('mediaType', options.mediaType)\n params.set('limit', String(options.limit))\n params.set('offset', String(options.offset))\n\n const url = `${baseUrl}?${params.toString()}`\n const res = await fetch(url)\n if (!res.ok) throw new Error(await readErrorMessage(res))\n return res.json() as Promise<MediaPickerListResult>\n },\n\n uploadMedia: async (file: File): Promise<MediaPickerItem> => {\n const formData = new FormData()\n formData.append('file', file)\n // The media LIBRARY is publishable web assets, so it opts in explicitly.\n // Without this the upload route's deny-by-default leaves the asset\n // private, `getPublicUrls` returns nothing for it, and every image on the\n // public site resolves to `null` behind a 200 and one warn log (F030).\n // Sent per-upload rather than by flipping a global default, because the\n // same route also receives rent's inspection photos, which must not be\n // world-readable.\n formData.append('visibility', 'public')\n\n const res = await fetch(baseUrl, { method: 'POST', body: formData })\n if (!res.ok) throw new Error(await readErrorMessage(res))\n return res.json() as Promise<MediaPickerItem>\n },\n\n getMediaUrl: async (id: string): Promise<string | null> => {\n try {\n const res = await fetch(`${baseUrl}/${id}`)\n if (!res.ok) return null\n const item = (await res.json()) as MediaPickerItem\n return item.url ?? null\n } catch {\n return null\n }\n },\n }\n}\n","import { type ClassValue, clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n","import { File, FileText, Film, Image as ImageIcon, type LucideIcon, Music } from 'lucide-react'\nimport { deriveMediaType } from '../derive-media-type.js'\n\n/**\n * The non-image members of `@murumets-ee/media`'s `MediaType`, copied rather\n * than imported.\n *\n * This module ships from the `./picker` tsdown entry — a build SEPARATE from\n * the root `index.ts` entry that already exports `MediaType` by name. Naming\n * it here too would duplicate its declaration across two `.d.mts` files\n * (`verify:dts`, #767): each array entry in `tsdown.config.ts` builds\n * independently and cannot share a declaration chunk, so a type named in two\n * entries' public APIs is emitted twice, and TypeScript can no longer treat\n * the two copies as identical if either ever gains an invariant member.\n *\n * Kept honest by `media-type-icon.test.ts`, which imports the real\n * `MediaType` (a type-only import, erased before it ever reaches a `.d.mts`)\n * and asserts this literal still matches it exactly.\n */\ntype NonImageMediaType = 'video' | 'audio' | 'document' | 'other'\n\n/**\n * The typed-icon fallback for every media type that is not `'image'`.\n *\n * The ONE rule three admin surfaces branch on (F115): the media list, the\n * picker's `MediaCard` and the media edit form all show a real `<img>` for\n * `mediaType === 'image'` and one of these icons otherwise. Extracted from\n * `MediaCard`, which held the only copy, so a fourth surface reaches for this\n * instead of writing a fourth `Record<MediaType, LucideIcon>`.\n */\nexport const NON_IMAGE_MEDIA_TYPE_ICONS: Record<NonImageMediaType, LucideIcon> = {\n video: Film,\n audio: Music,\n document: FileText,\n other: File,\n}\n\n/** The icon for a non-image media type, or `null` for `'image'` (render the asset itself). */\nexport function mediaTypeIcon(mediaType: 'image' | NonImageMediaType): LucideIcon | null {\n return mediaType === 'image' ? null : NON_IMAGE_MEDIA_TYPE_ICONS[mediaType]\n}\n\n/**\n * The icon for a MIME type — the admin list's MIME column, where the string\n * `video/mp4` is replaced by a film (entity-table D008's `mimeType` display).\n *\n * Unlike {@link mediaTypeIcon}, an image gets one too: that function returns\n * `null` for `'image'` because its callers draw the ASSET instead, and a column\n * that shows a type has no asset to draw. The classification is\n * `deriveMediaType` — the rule the upload stored the row's `mediaType` by — so\n * the icon cannot disagree with the type column beside it.\n *\n * Takes a plain string and returns a `LucideIcon`, so `MediaType` is not named\n * in this entry's public API (see the note above `NonImageMediaType`).\n */\nexport function mimeTypeIcon(mimeType: string): LucideIcon {\n const mediaType = deriveMediaType(mimeType)\n return mediaType === 'image' ? ImageIcon : NON_IMAGE_MEDIA_TYPE_ICONS[mediaType]\n}\n","import { Check, ImageIcon } from 'lucide-react'\nimport { cn } from '../lib/cn'\nimport { mediaTypeIcon } from '../picker/media-type-icon'\nimport type { MediaPickerClassNames } from '../picker/types'\n\n/**\n * A structural copy of `@murumets-ee/media`'s own `MediaType`, not an\n * import — same reason `media-type-icon.ts`'s `NonImageMediaType` isn't one.\n * This module ships from the `./picker` tsdown entry (re-exported by\n * `picker/index.ts`), a build SEPARATE from the root `index.ts` entry that\n * already exports `MediaType` by name; importing (and thereby re-exporting,\n * via `MediaCardProps`) it here too duplicates its declaration across two\n * `.d.mts` files (`verify:dts`, #767) the moment this type reaches the\n * entry's own public surface.\n */\ntype MediaCardMediaType = 'image' | 'video' | 'audio' | 'document' | 'other'\n\n/**\n * One tile: a thumbnail (or a typed icon for anything that is not an image),\n * a hover title overlay, and a selection checkmark.\n *\n * Promoted out of `picker/` (entity-table PR06 §2) — this is the whole point\n * of the promotion. `<MediaPicker>`'s `MediaGrid` and the admin-ui entity\n * list's `grid` layout renderer both draw tiles through this ONE component,\n * so a feature added to one reaches both rather than diverging the way\n * WordPress's Backbone grid and `WP_List_Table` did (D007's cautionary tale).\n *\n * Decoupled from `MediaPickerItem` on purpose: the entity list's grid layout\n * has no `MediaPickerItem` to hand it — it has a TanStack row and a\n * `ViewGridOptions`-resolved field name — so the props here are the minimal\n * shape both callers can build without synthesising fake data for fields\n * neither has (a picker item's `size`/`width`/`height`, an entity row's MIME\n * type).\n */\nexport interface MediaCardProps {\n /** Resolved thumbnail/asset URL. `null` (or a non-`'image'` `mediaType`) draws the typed icon instead. */\n url: string | null\n /** The tile's accessible name and its hover-overlay label. */\n title: string\n /**\n * The image's OWN alt text, when the caller has one — an editor-authored\n * field distinct from `title` (a display name most often equal to the\n * filename). Falls back to `title` only when omitted (`undefined`) or\n * `null` — a deliberately EMPTY string (`''`, the WCAG decorative-image\n * marker) is preserved, not overridden. Review finding, entity-table PR06:\n * the promotion had collapsed this into `title` and silently dropped a\n * screen-reader-meaningful alt text down to the display name; a follow-up\n * critique pass then caught the first fix using `||` (which clobbers `''`\n * the same way) instead of `??`.\n */\n alt?: string | null | undefined\n /**\n * The item's classification. `'image'` draws `url` as an `<img>`;\n * anything else — including `undefined`, an entity with no classification\n * field at all — draws a typed (or generic) icon, never a broken `<img>`.\n */\n mediaType: MediaCardMediaType | undefined\n isSelected: boolean\n onToggle: () => void\n /**\n * Renders the tile as a real `<a href>` instead of a select button — the\n * admin-ui entity list's grid layout (entity-table D004 ported to a card:\n * the tile is the row's link, same as the table's title cell) sets this so\n * cmd/ctrl-click, middle-click and \"open in new tab\" all work, exactly the\n * capabilities S4 requires of the table. `onToggle` and `isSelected` still\n * apply — the caller's OWN checkbox overlay (drawn outside this component,\n * at a higher z-index) is what fires `onToggle`, not a click on the tile.\n *\n * Omitted (the picker's own use, `<MediaGrid>`) keeps the whole tile a\n * select button, unchanged from before the promotion.\n */\n href?: string | undefined\n /**\n * Suppresses this tile's own checkmark badge — driven by whether the\n * CALLER is drawing a DIFFERENT overlay in the same top-right corner, not\n * by `href`. Critique finding, entity-table PR06: the first cut of the\n * overlay-collision fix keyed this off `!!href`, which only happens to\n * equal \"does the caller draw an actions overlay here\" for today's one\n * caller (Media, where `editHref` and `allowDelete` are both always on).\n * The moment an entity reaches the grid through the generic list page with\n * a permission-gated `editHref` (so `href` can be `undefined`) while still\n * allowing delete (so an actions overlay IS drawn), `!!href` goes false\n * and the badge reappears under the menu it was meant to stay clear of.\n * `EntityGridLayout` passes the caller's own truth — whether it rendered\n * an `_actions` cell for this row — instead of re-deriving it from `href`.\n */\n hideSelectionBadge?: boolean | undefined\n classNames?: MediaPickerClassNames | undefined\n}\n\nexport function MediaCard({\n url,\n title,\n alt,\n mediaType,\n isSelected,\n onToggle,\n href,\n hideSelectionBadge,\n classNames,\n}: MediaCardProps) {\n const isImage = mediaType === 'image' && !!url\n // `??`, not `||`: an editor-set EMPTY alt (`''`) is the WCAG-legitimate way\n // to mark an image decorative — a screen reader should skip it, not get\n // `title` instead. `||` would treat that deliberate `''` the same as\n // \"never set\" and silently override it. Matches what the pre-promotion\n // `<img alt={item.alt ?? item.filename}>` already did.\n const imgAlt = alt ?? title\n // `mediaTypeIcon('image')` is `null` on purpose — its OTHER caller\n // (`ImageCell`) draws the asset for that branch, so it never asks for an\n // icon. Here that branch is exactly the row this tile has no asset FOR\n // (an image row with no processed thumbnail — a real, reachable state,\n // not a hypothetical: found via browser verification, entity-table PR06)\n // — matching `ImageCell`'s own \"no url yet\" fallback rather than the\n // BLANK tile `mediaTypeIcon`'s `null` would otherwise draw.\n const Icon =\n mediaType === undefined ? null : mediaType === 'image' ? ImageIcon : mediaTypeIcon(mediaType)\n\n const sharedClassName = cn(\n // `block`: a `<button>` defaults to `inline-block` (width/height already\n // apply), but a plain `<a>` defaults to `inline` — CSS gives width/height\n // NO EFFECT on a non-replaced inline element, so the `w-full`\n // (`classNames.card`, set by callers) and `aspect-square` below did\n // nothing for the href-mode tile: it collapsed to its content's\n // intrinsic size instead of filling the grid track (CodeRabbit CLI\n // finding, entity-table PR06). Harmless on `<button>`, which already\n // behaves this way.\n 'group relative block aspect-square overflow-hidden rounded-lg border-2 transition-all',\n 'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-950',\n isSelected\n ? 'border-blue-500 ring-2 ring-blue-500/20'\n : 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-800 dark:hover:border-zinc-700',\n classNames?.card,\n isSelected && classNames?.cardSelected,\n )\n\n const content = (\n <>\n {/* Thumbnail */}\n {isImage ? (\n // biome-ignore lint/performance/noImgElement: same reasoning as admin-ui's ImageCell — this ships in a published toolkit package consumed by Next AND non-Next harnesses.\n <img\n src={url}\n alt={imgAlt}\n className={cn('h-full w-full object-cover', classNames?.cardImage)}\n loading=\"lazy\"\n />\n ) : (\n <div className=\"flex h-full w-full items-center justify-center bg-zinc-100 dark:bg-zinc-800\">\n {Icon && <Icon className=\"h-8 w-8 text-zinc-400 dark:text-zinc-500\" />}\n </div>\n )}\n\n {/*\n Selection checkmark, suppressed only when the CALLER says it is\n drawing something else in this corner (`hideSelectionBadge`) — see\n that prop's doc for why this is no longer inferred from `href`. The\n entity list's grid layout draws its own checkbox overlay at the\n top-LEFT and, when the row has one, a row-actions menu at the\n top-RIGHT — the same corner this badge used to claim unconditionally,\n so a selected AND hovered tile stacked the \"⋯\" button directly on top\n of it. The border/ring highlight above is still selection's primary\n signal whenever the badge is hidden; the picker (button mode, never\n passes this prop) keeps the badge as its only one.\n */}\n {isSelected && !hideSelectionBadge && (\n <div className=\"absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-white\">\n <Check className=\"h-3 w-3\" />\n </div>\n )}\n\n {/* Title on hover */}\n <div\n className={cn(\n 'absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5',\n 'opacity-0 transition-opacity group-hover:opacity-100',\n classNames?.cardLabel,\n )}\n >\n <p className=\"truncate text-xs text-white\">{title}</p>\n </div>\n </>\n )\n\n // Two literal branches rather than a polymorphic `Root = href ? 'a' : 'button'`\n // tag: an anchor and a button take different, non-overlapping prop sets\n // (`href` vs `type`/`onClick`), and a runtime-chosen tag name can't be typed\n // against both without `any`.\n if (href) {\n return (\n <a href={href} aria-label={title} className={sharedClassName}>\n {content}\n </a>\n )\n }\n return (\n <button\n type=\"button\"\n onClick={onToggle}\n aria-pressed={isSelected}\n aria-label={title}\n className={sharedClassName}\n >\n {content}\n </button>\n )\n}\n","import { cn } from '../lib/cn'\nimport type { MediaPickerClassNames, MediaPickerItem } from '../picker/types'\nimport { MediaCard } from './media-card'\n\ninterface MediaGridProps {\n items: MediaPickerItem[]\n selected: Set<string>\n onToggle: (item: MediaPickerItem) => void\n isLoading: boolean\n total: number\n offset: number\n limit: number\n onPageChange: (offset: number) => void\n classNames?: MediaPickerClassNames | undefined\n}\n\n/**\n * The `<MediaPicker>` dialog's own grid — its pagination (offset/limit, a\n * Prev/Next footer) is the picker's, not the entity list's. The TILE it\n * draws is the promoted, shared `MediaCard` (entity-table PR06 §2); this\n * wrapper's job is adapting `MediaPickerItem[]` to that tile's generic props.\n */\nexport function MediaGrid({\n items,\n selected,\n onToggle,\n isLoading,\n total,\n offset,\n limit,\n onPageChange,\n classNames,\n}: MediaGridProps) {\n if (isLoading) {\n return (\n <div className={cn('grid grid-cols-4 gap-3 sm:grid-cols-6', classNames?.grid)}>\n {Array.from({ length: 12 }).map((_, i) => (\n <div\n key={`skeleton-${i.toString()}`}\n className={cn(\n 'aspect-square animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800',\n classNames?.loading,\n )}\n />\n ))}\n </div>\n )\n }\n\n if (items.length === 0) {\n return (\n <div\n className={cn(\n 'py-12 text-center text-sm text-zinc-500 dark:text-zinc-400',\n classNames?.empty,\n )}\n >\n No media found. Upload a file to get started.\n </div>\n )\n }\n\n const totalPages = Math.ceil(total / limit)\n const currentPage = Math.floor(offset / limit) + 1\n\n return (\n <>\n <div className={cn('grid grid-cols-4 gap-3 sm:grid-cols-6', classNames?.grid)}>\n {items.map((item) => (\n <MediaCard\n key={item.id}\n url={item.mediaType === 'image' ? item.url : null}\n // `||`, not `??`: unlike `alt` (where an editor-set `''` is the\n // WCAG-legitimate \"decorative, skip me\" marker worth preserving\n // — see `media-card.tsx`), an empty TITLE has no such meaning.\n // `??` alone left it through for an item with `title: ''`\n // (distinct from `title: null`), and `title` is what MediaCard's\n // `aria-label` uses directly — an empty accessible name, not just\n // a missing caption (CodeRabbit CLI finding, entity-table PR06).\n title={item.title || item.filename}\n alt={item.alt}\n mediaType={item.mediaType}\n isSelected={selected.has(item.id)}\n onToggle={() => onToggle(item)}\n classNames={classNames}\n />\n ))}\n </div>\n {totalPages > 1 && (\n <div className=\"mt-4 flex items-center justify-center gap-2\">\n <button\n type=\"button\"\n disabled={currentPage <= 1}\n onClick={() => onPageChange(offset - limit)}\n className=\"rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700\"\n >\n Prev\n </button>\n <span className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n {currentPage} / {totalPages}\n </span>\n <button\n type=\"button\"\n disabled={currentPage >= totalPages}\n onClick={() => onPageChange(offset + limit)}\n className=\"rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700\"\n >\n Next\n </button>\n </div>\n )}\n </>\n )\n}\n","import { createContext, type ReactNode, useContext, useMemo } from 'react'\nimport type { MediaPickerCallbacks } from './types'\n\nconst MediaPickerContext = createContext<MediaPickerCallbacks | null>(null)\n\nexport interface MediaPickerProviderProps extends MediaPickerCallbacks {\n children: ReactNode\n}\n\n/**\n * Provides media picker callbacks to all picker instances below.\n * Wrap your admin layout with this provider.\n *\n * @example\n * ```tsx\n * <MediaPickerProvider\n * fetchMedia={fetchMediaAction}\n * uploadMedia={uploadMediaAction}\n * >\n * <AdminShell>...</AdminShell>\n * </MediaPickerProvider>\n * ```\n */\nexport function MediaPickerProvider({\n children,\n fetchMedia,\n uploadMedia,\n}: MediaPickerProviderProps) {\n const value = useMemo<MediaPickerCallbacks>(\n () => ({ fetchMedia, uploadMedia }),\n [fetchMedia, uploadMedia],\n )\n\n return <MediaPickerContext value={value}>{children}</MediaPickerContext>\n}\n\nexport function useMediaPicker(): MediaPickerCallbacks {\n const ctx = useContext(MediaPickerContext)\n if (!ctx) {\n throw new Error(\n 'useMediaPicker must be used within <MediaPickerProvider>. ' +\n 'Wrap your admin layout with <MediaPickerProvider fetchMedia={...} uploadMedia={...}>.',\n )\n }\n return ctx\n}\n","import { Search } from 'lucide-react'\nimport { cn } from '../lib/cn'\nimport type { MediaPickerClassNames } from './types'\n\ninterface SearchBarProps {\n value: string\n onChange: (value: string) => void\n mediaType: string | undefined\n onMediaTypeChange: (type: string | undefined) => void\n /** When true, the media type filter is locked (no tabs shown) */\n locked?: boolean | undefined\n classNames?: MediaPickerClassNames | undefined\n}\n\nconst FILTER_OPTIONS = [\n { value: undefined, label: 'All' },\n { value: 'image', label: 'Images' },\n { value: 'video', label: 'Videos' },\n { value: 'audio', label: 'Audio' },\n { value: 'document', label: 'Docs' },\n] as const\n\nexport function SearchBar({\n value,\n onChange,\n mediaType,\n onMediaTypeChange,\n locked,\n classNames,\n}: SearchBarProps) {\n // If locked (caller pre-set the mediaType), hide filter tabs\n const showFilters = !locked\n\n return (\n <div className=\"flex items-center gap-3\">\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400\" />\n <input\n type=\"text\"\n value={value}\n onChange={(e) => onChange(e.target.value)}\n placeholder=\"Search media...\"\n className={cn(\n 'w-full rounded-md border border-zinc-300 bg-transparent py-2 pl-10 pr-3 text-sm',\n 'placeholder:text-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',\n 'dark:border-zinc-700 dark:placeholder:text-zinc-500',\n classNames?.searchInput,\n )}\n />\n </div>\n {showFilters && (\n <div className={cn('flex gap-1', classNames?.filterTabs)}>\n {FILTER_OPTIONS.map((opt) => (\n <button\n key={opt.label}\n type=\"button\"\n onClick={() => onMediaTypeChange(opt.value)}\n className={cn(\n 'rounded-md px-3 py-1.5 text-xs font-medium transition-colors',\n mediaType === opt.value\n ? 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900'\n : 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800',\n )}\n >\n {opt.label}\n </button>\n ))}\n </div>\n )}\n </div>\n )\n}\n","import { Upload } from 'lucide-react'\nimport { type DragEvent, useCallback, useRef, useState } from 'react'\nimport { cn } from '../lib/cn'\nimport type { MediaPickerClassNames } from './types'\n\ninterface UploadZoneProps {\n onUpload: (file: File) => Promise<void>\n isUploading: boolean\n accept?: string[] | undefined\n classNames?: MediaPickerClassNames | undefined\n}\n\nexport function UploadZone({ onUpload, isUploading, accept, classNames }: UploadZoneProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const [isDragging, setIsDragging] = useState(false)\n\n const handleDrop = useCallback(\n async (e: DragEvent) => {\n e.preventDefault()\n setIsDragging(false)\n const file = e.dataTransfer.files[0]\n if (file) {\n await onUpload(file)\n }\n },\n [onUpload],\n )\n\n const handleFileSelect = useCallback(\n async (e: React.ChangeEvent<HTMLInputElement>) => {\n const file = e.target.files?.[0]\n if (file) {\n await onUpload(file)\n e.target.value = ''\n }\n },\n [onUpload],\n )\n\n return (\n <button\n type=\"button\"\n onDragOver={(e) => {\n e.preventDefault()\n setIsDragging(true)\n }}\n onDragLeave={() => setIsDragging(false)}\n onDrop={handleDrop}\n onClick={() => inputRef.current?.click()}\n className={cn(\n 'mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-6 transition-colors',\n isDragging\n ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'\n : 'border-zinc-300 hover:border-zinc-400 dark:border-zinc-700 dark:hover:border-zinc-600',\n classNames?.uploadZone,\n isDragging && classNames?.uploadZoneActive,\n )}\n >\n <Upload className=\"mb-2 h-6 w-6 text-zinc-400 dark:text-zinc-500\" />\n <p className=\"text-sm text-zinc-600 dark:text-zinc-400\">\n {isUploading ? 'Uploading...' : 'Drop a file here or click to upload'}\n </p>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept?.join(',')}\n onChange={handleFileSelect}\n className=\"hidden\"\n />\n </button>\n )\n}\n","import * as DialogPrimitive from '@radix-ui/react-dialog'\nimport * as VisuallyHidden from '@radix-ui/react-visually-hidden'\nimport { X } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { cn } from '../lib/cn'\nimport { MediaGrid } from '../grid/media-grid'\nimport { useMediaPicker } from './provider'\nimport { SearchBar } from './search-bar'\nimport type { MediaPickerItem, MediaPickerProps } from './types'\nimport { UploadZone } from './upload-zone'\n\nconst ITEMS_PER_PAGE = 24\n\n/** Media types whose files share one MIME top-level type (`image/*`, …). */\nconst MIME_FAMILIES = new Set(['image', 'video', 'audio'])\n\n/** The file-input patterns a locked media type implies, if it implies any. */\nfunction acceptFor(mediaType: string | undefined): string[] | undefined {\n return mediaType !== undefined && MIME_FAMILIES.has(mediaType) ? [`${mediaType}/*`] : undefined\n}\n\n/**\n * `'file'` is the residual kind — NOT an image and NOT a video — matching the\n * server's `mediaTypeMatchesKind`. It is not a list classification the media\n * API knows, so it never goes out as a browse filter.\n */\nconst RESIDUAL_KIND = 'file'\n\n/**\n * Whether a file the author chose may go into a field locked to `mediaType`,\n * judged before upload from the browser's own guess. An EMPTY `file.type` (the\n * browser could not tell — a HEIC or MKV on some platforms) passes: the server\n * sniffs the real type from the content, and the post-upload check still\n * guards the pick.\n */\nfunction fileMatches(mediaType: string | undefined, file: File): boolean {\n if (mediaType === undefined || file.type === '') return true\n if (mediaType === RESIDUAL_KIND) {\n return !file.type.startsWith('image/') && !file.type.startsWith('video/')\n }\n if (!MIME_FAMILIES.has(mediaType)) return true\n return file.type.startsWith(`${mediaType}/`)\n}\n\n/**\n * Why `item` cannot go into a field locked to `mediaType`, or `null` when it\n * can. The browse list is already filtered server-side; this is the guard for\n * what the list does not cover — an upload the server classified differently.\n */\nfunction typeMismatch(mediaType: string | undefined, item: MediaPickerItem): string | null {\n if (mediaType === undefined || item.mediaType === mediaType) return null\n const name = item.title ?? item.filename\n if (mediaType === RESIDUAL_KIND) {\n if (item.mediaType !== 'image' && item.mediaType !== 'video') return null\n return `This field takes a document or other file — “${name}” is ${withArticle(item.mediaType)}.`\n }\n return `This field takes ${mediaType} files — “${name}” is ${item.mediaType === 'other' ? 'not one' : withArticle(item.mediaType)}.`\n}\n\n/** \"an image\", \"an audio file\"-style reading: `an` before a vowel sound, `a` otherwise. */\nfunction withArticle(mediaType: MediaPickerItem['mediaType']): string {\n return /^[aeiou]/.test(mediaType) ? `an ${mediaType}` : `a ${mediaType}`\n}\n\nexport function MediaPicker({\n open,\n onOpenChange,\n onSelect,\n mode = 'single',\n accept,\n mediaType,\n maxSelect,\n selectedIds = [],\n title = 'Select Media',\n description,\n classNames,\n className,\n children,\n}: MediaPickerProps) {\n const { fetchMedia, uploadMedia } = useMediaPicker()\n\n // State\n const [items, setItems] = useState<MediaPickerItem[]>([])\n const [total, setTotal] = useState(0)\n const [selected, setSelected] = useState<Set<string>>(() => new Set(selectedIds))\n const [search, setSearch] = useState('')\n // The browse filter the author picks from the tabs. A caller's `mediaType`\n // LOCKS the filter and wins over it, derived per render rather than copied\n // into state: the dialog stays mounted between opens, so a copy taken at\n // first mount (when a host's picker had no type yet) kept listing every\n // file forever — a field asking for images was offered videos, and the\n // video landed in the image field (editor F116, reopened in PR59).\n const [mediaTypeFilter, setMediaTypeFilter] = useState<string | undefined>(undefined)\n const effectiveMediaType = mediaType ?? mediaTypeFilter\n // What the list request filters by. The residual `'file'` kind has no list\n // classification, so it browses everything and refuses images/videos on pick.\n const listMediaType = mediaType === RESIDUAL_KIND ? undefined : effectiveMediaType\n // A locked type also restricts what the file input offers, unless the caller\n // passed its own patterns.\n const effectiveAccept = accept ?? acceptFor(mediaType)\n const [isLoading, setIsLoading] = useState(false)\n const [isUploading, setIsUploading] = useState(false)\n const [offset, setOffset] = useState(0)\n const [error, setError] = useState<string | null>(null)\n // When the server reports storage isn't configured, we render a\n // banner and hide the upload zone + grid instead of failing silently\n // or showing an empty state the admin can't act on.\n const [storageDisabled, setStorageDisabled] = useState<string | null>(null)\n\n // Fetch media on open / filter change\n const loadMedia = useCallback(async () => {\n setIsLoading(true)\n setError(null)\n try {\n const result = await fetchMedia({\n search: search || undefined,\n mediaType: listMediaType,\n limit: ITEMS_PER_PAGE,\n offset,\n })\n if (result.configured === false) {\n setStorageDisabled(result.reason ?? 'Media storage is not configured on this server.')\n setItems([])\n setTotal(0)\n } else {\n setStorageDisabled(null)\n setItems(result.items)\n setTotal(result.total)\n }\n } catch (err) {\n setError(\n err instanceof Error && err.message\n ? `Failed to load media: ${err.message}`\n : 'Failed to load media.',\n )\n } finally {\n setIsLoading(false)\n }\n }, [fetchMedia, search, listMediaType, offset])\n\n useEffect(() => {\n if (open) {\n loadMedia()\n }\n }, [open, loadMedia])\n\n // Reset state when dialog closes (open transitions true → false)\n const prevOpen = useRef(open)\n const selectedIdsRef = useRef(selectedIds)\n selectedIdsRef.current = selectedIds\n useEffect(() => {\n if (prevOpen.current && !open) {\n setSearch('')\n setOffset(0)\n setSelected(new Set(selectedIdsRef.current))\n }\n prevOpen.current = open\n }, [open])\n\n // Selection\n const handleToggle = useCallback(\n (item: MediaPickerItem) => {\n const mismatch = typeMismatch(mediaType, item)\n if (mismatch !== null) {\n setError(mismatch)\n return\n }\n // Single mode: auto-confirm on click — no separate \"Select\" step needed\n if (mode === 'single') {\n onSelect([item])\n onOpenChange(false)\n return\n }\n\n // Multi mode: toggle selection in the set\n setSelected((prev) => {\n const next = new Set(prev)\n if (next.has(item.id)) {\n next.delete(item.id)\n } else {\n if (maxSelect && next.size >= maxSelect) return prev\n next.add(item.id)\n }\n return next\n })\n },\n [mode, maxSelect, mediaType, onSelect, onOpenChange],\n )\n\n const handleConfirm = useCallback(() => {\n const selectedItems = items.filter((item) => selected.has(item.id))\n onSelect(selectedItems)\n onOpenChange(false)\n }, [items, selected, onSelect, onOpenChange])\n\n // Upload\n const handleUpload = useCallback(\n async (file: File) => {\n setError(null)\n // Refused before the upload: a file the field cannot hold would otherwise\n // land in the library AND be auto-selected into the field.\n if (!fileMatches(mediaType, file)) {\n setError(\n mediaType === RESIDUAL_KIND\n ? `This field takes a document or other file — “${file.name}” is an image or a video.`\n : `This field takes ${mediaType} files — “${file.name}” is not one.`,\n )\n return\n }\n setIsUploading(true)\n try {\n const uploaded = await uploadMedia(file)\n // The server classifies the stored file; trust that over the browser's\n // guess from the extension.\n const mismatch = typeMismatch(mediaType, uploaded)\n if (mismatch !== null) {\n setError(mismatch)\n return\n }\n\n // Single mode: auto-confirm the just-uploaded file\n if (mode === 'single') {\n onSelect([uploaded])\n onOpenChange(false)\n return\n }\n\n // Multi mode: add to grid and select\n setItems((prev) => [uploaded, ...prev])\n setTotal((prev) => prev + 1)\n setSelected((prev) => new Set([...prev, uploaded.id]))\n } catch (err) {\n setError(\n err instanceof Error && err.message\n ? `Upload failed: ${err.message}`\n : 'Upload failed. Please try again.',\n )\n } finally {\n setIsUploading(false)\n }\n },\n [uploadMedia, mode, mediaType, onSelect, onOpenChange],\n )\n\n return (\n <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>\n <DialogPrimitive.Portal>\n <DialogPrimitive.Overlay\n className={cn(\n 'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n classNames?.overlay,\n )}\n />\n <DialogPrimitive.Content\n {...(!description && { 'aria-describedby': undefined })}\n className={cn(\n 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',\n 'flex max-h-[85vh] w-[90vw] max-w-4xl flex-col',\n 'rounded-xl border border-zinc-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950',\n classNames?.content,\n className,\n )}\n >\n {/* Header */}\n <div\n className={cn(\n 'flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-800',\n classNames?.header,\n )}\n >\n <DialogPrimitive.Title\n className={cn(\n 'text-lg font-semibold text-zinc-900 dark:text-zinc-50',\n classNames?.title,\n )}\n >\n {title}\n </DialogPrimitive.Title>\n {description && (\n <VisuallyHidden.Root asChild>\n <DialogPrimitive.Description>{description}</DialogPrimitive.Description>\n </VisuallyHidden.Root>\n )}\n <DialogPrimitive.Close className=\"rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-600\">\n <X className=\"h-4 w-4\" />\n <VisuallyHidden.Root>Close</VisuallyHidden.Root>\n </DialogPrimitive.Close>\n </div>\n\n {/* Toolbar */}\n <div\n className={cn(\n 'border-b border-zinc-200 px-6 py-3 dark:border-zinc-800',\n classNames?.toolbar,\n )}\n >\n <SearchBar\n value={search}\n onChange={setSearch}\n mediaType={effectiveMediaType}\n onMediaTypeChange={setMediaTypeFilter}\n locked={!!mediaType}\n classNames={classNames}\n />\n </div>\n\n {/* Content */}\n <div className=\"flex-1 overflow-y-auto px-6 py-4\">\n {error && (\n <div\n role=\"alert\"\n className=\"mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400\"\n >\n {error}\n </div>\n )}\n {storageDisabled ? (\n <div\n role=\"alert\"\n className=\"rounded-lg border border-amber-200 bg-amber-50 px-4 py-6 text-center dark:border-amber-900 dark:bg-amber-950/30\"\n >\n <p className=\"text-sm font-medium text-amber-900 dark:text-amber-200\">\n Media storage is not configured\n </p>\n <p className=\"mt-1 text-xs text-amber-800 dark:text-amber-300\">{storageDisabled}</p>\n <p className=\"mt-3 text-xs text-amber-700 dark:text-amber-400\">\n Set the required environment variables and restart the app to enable uploads.\n </p>\n </div>\n ) : (\n <>\n <UploadZone\n onUpload={handleUpload}\n isUploading={isUploading}\n accept={effectiveAccept}\n classNames={classNames}\n />\n <MediaGrid\n items={items}\n selected={selected}\n onToggle={handleToggle}\n isLoading={isLoading}\n total={total}\n offset={offset}\n limit={ITEMS_PER_PAGE}\n onPageChange={setOffset}\n classNames={classNames}\n />\n </>\n )}\n </div>\n\n {/* Footer — multi mode shows confirm/cancel, single mode shows item count only */}\n <div\n className={cn(\n 'flex items-center justify-between border-t border-zinc-200 px-6 py-4 dark:border-zinc-800',\n classNames?.footer,\n )}\n >\n <span className=\"text-sm text-zinc-500 dark:text-zinc-400\">\n {mode === 'single'\n ? `${total.toString()} item${total !== 1 ? 's' : ''} — click to select`\n : selected.size > 0\n ? `${selected.size.toString()} selected`\n : `${total.toString()} item${total !== 1 ? 's' : ''}`}\n </span>\n <div className=\"flex gap-2\">\n {children}\n <DialogPrimitive.Close asChild>\n <button\n type=\"button\"\n className={cn(\n 'rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50',\n 'dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-900',\n classNames?.cancelButton,\n )}\n >\n Cancel\n </button>\n </DialogPrimitive.Close>\n {mode !== 'single' && (\n <button\n type=\"button\"\n onClick={handleConfirm}\n disabled={selected.size === 0}\n className={cn(\n 'rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n classNames?.confirmButton,\n )}\n >\n {`Select (${selected.size.toString()})`}\n </button>\n )}\n </div>\n </div>\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n </DialogPrimitive.Root>\n )\n}\n"],"mappings":"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"}
package/dist/plugin.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as MediaPluginConfig, t as ImageStyle } from "./types-CnORVkLZ.mjs";
1
+ import { a as ImageVocabulary, c as MediaPluginConfig, i as ImageStyle } from "./types-6PZQ4Zq6.mjs";
2
2
  import { getMediaMessages } from "./i18n.mjs";
3
3
 
4
4
  //#region src/plugin.d.ts
@@ -26,6 +26,7 @@ declare function media(config?: MediaPluginConfig): Pick<import("@murumets-ee/co
26
26
  readonly shared?: {
27
27
  readonly messages: typeof getMediaMessages;
28
28
  readonly settings: readonly [import("@murumets-ee/settings").SettingsDefinition<{
29
+ readonly vocabulary: import("@murumets-ee/settings").JsonSettingConfig<ImageVocabulary>;
29
30
  readonly imageStyles: import("@murumets-ee/settings").JsonSettingConfig<Record<string, ImageStyle>>;
30
31
  }, "media.imageStyles">];
31
32
  };
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;;;;;;;;iBAyCgB,cAAA,CAAA,GAAkB,QAAQ,CAAC,iBAAA;;;;;;;;;;;;iBAkB3B,KAAA,CAAM,MAAA,GAAS,iBAAA,GAAiB,IAAA,6BAAA,MAAA;EAAA"}
1
+ {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;;;;;;;;iBA6CgB,cAAA,CAAA,GAAkB,QAAQ,CAAC,iBAAA;;;;;;;;;;;;iBAe3B,KAAA,CAAM,MAAA,GAAS,iBAAA,GAAiB,IAAA,6BAAA,MAAA;EAAA"}
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{t as e}from"./entity-DKFCdQJT.mjs";import{imageStylesSettings as t}from"./image-styles-settings.mjs";import{getMediaMessages as n}from"./i18n.mjs";import{combineAdminRoutes as r,defineAdminRoute as i,definePlugin as a,getPluginConfig as o,hasPluginConfig as s,runBounded as c,safeAudit as l,safeAuditMany as u}from"@murumets-ee/core";import{ImageStylesManager as d,RegenerateVariantsAction as f}from"@murumets-ee/media/image-styles";const p=`@murumets-ee/media`,m=[`image/*`,`video/*`,`audio/*`,`application/pdf`,`application/vnd.openxmlformats-officedocument.*`,`application/vnd.oasis.opendocument.*`,`text/plain`,`text/csv`];function h(e){return{acceptedTypes:e?.acceptedTypes??[...m],maxUploadSize:e?.maxUploadSize!==void 0&&e.maxUploadSize>0?e.maxUploadSize:50*1024*1024,defaultVisibility:e?.defaultVisibility??`public`,imageStyles:e?.imageStyles??{thumbnail:{width:200,height:200,fit:`cover`,format:`webp`,quality:80}}}}function g(){return s(`@murumets-ee/media`)?o(p):h()}const _=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function v(e){return e!==void 0&&_.test(e)}const y=[`image`,`video`,`audio`,`document`,`other`];function b(e){return y.includes(e)}function x(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function S(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function C(e,t){return S({error:e},t)}const w={clientPromise:null};async function T(){if(!w.clientPromise){let e=(async()=>{let{getApp:e}=await import(`@murumets-ee/core`),{createStorageClient:t}=await import(`@murumets-ee/storage`),{getStorageConfig:n}=await import(`@murumets-ee/storage/plugin`),r=e();return t(n(),{app:r})})();e.catch(()=>{w.clientPromise===e&&(w.clientPromise=null)}),w.clientPromise=e}return w.clientPromise}async function E(){let{createAdminClient:e}=await import(`@murumets-ee/core/clients`),{MediaClient:t}=await import(`./client.mjs`),{Media:n}=await import(`./entity-DKFCdQJT.mjs`).then(e=>e.n),r=await T();return new t({admin:e(n),storage:r})}const D=async(e,t)=>{let{isStorageConfigured:n,getStorageConfigReason:r}=await import(`@murumets-ee/storage`),i=t.segments;if(i.length===2&&i[1]===`usage`){let e=i[0];if(!v(e))return C(`Invalid media ID format`,400);let{findMediaUsages:t}=await import(`./usage.mjs`),{getApp:n}=await import(`@murumets-ee/core`);return S({usages:await t(e,n())})}if(i.length>=2)return C(`Not found`,404);let a=i.length>0?i[0]:void 0;if(a!==void 0&&!v(a))return C(`Invalid media ID format`,400);if(!n()){let e=r()??`Storage not configured`;return i.length>0?S({error:e,configured:!1,reason:e},503):S({items:[],total:0,configured:!1,reason:e})}let o=await E();if(a!==void 0){let e=await o.findById(a);if(!e)return C(`Media not found`,404);let t=await o.getUrl(a);return S({...e,url:t})}let s=new URL(e.url),c=s.searchParams.get(`search`)??void 0,l=s.searchParams.get(`mediaType`),u=l!==null&&b(l)?l:void 0,d=Math.min(Math.max(x(s.searchParams.get(`limit`),24),1),100),f=Math.min(x(s.searchParams.get(`offset`),0),1e5),p=await o.findMany({...c!==void 0&&{search:c},...u!==void 0&&{mediaType:u},limit:d,offset:f}),m=p.items.map(e=>e.id),h=p.items.filter(e=>e.mediaType===`image`).map(e=>e.id),[g,_]=await Promise.all([o.getUrls(m),o.getVariantUrls(h,`thumbnail`)]);return S({items:p.items.map(e=>{let t=e.mediaType===`image`?_.get(e.id):void 0;return{id:e.id,title:e.title??null,alt:e.alt??null,filename:e.filename,mimeType:e.mimeType,size:e.size,mediaType:e.mediaType,url:g.get(e.id)??``,...t!==void 0&&{thumbnailUrl:t},width:e.width??null,height:e.height??null}}),total:p.total})},O=async(e,t)=>{let{isStorageConfigured:n,getStorageConfigReason:r,detectMimeType:i,isMimeTypeAllowed:a,readBoundedMultipart:o,tooLarge:s}=await import(`@murumets-ee/storage`);if(t.segments.length>0)return C(`Not found`,404);if(!n())return C(r()??`Storage not configured`,503);let{maxUploadSize:c,acceptedTypes:u}=g(),d=await o(e,c);if(!d.ok)return C(d.error,d.status);let f=d.value,p=f.get(`file`);if(!(p instanceof File)||p.size===0)return C(`No file provided`,400);if(p.size>c){let e=s(c);return C(e.error,e.status)}let m=await E(),h=Buffer.from(await p.arrayBuffer()),{mimeType:_,mismatch:v}=await i(h,p.type||`application/octet-stream`);if(v)return C(`File content doesn't match declared type: claimed ${p.type}, detected ${_}`,400);if(!a(_,u))return C(`File type ${_} is not accepted`,415);let y=f.get(`visibility`);if(y!==null&&y!==`public`&&y!==`private`)return C(`Field 'visibility' must be 'public' or 'private'`,400);let b=await m.upload(h,{filename:p.name,mimeType:_,size:p.size,uploadedBy:t.user.id,...y!==null&&{visibility:y}}),x={id:b.media.id,title:b.media.title??null,alt:b.media.alt??null,filename:b.media.filename,mimeType:b.media.mimeType,size:b.media.size,mediaType:b.media.mediaType,url:b.url,width:b.media.width??null,height:b.media.height??null};return l(t,{action:`media.upload`,entityType:`media`,entityId:b.media.id,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},changes:{filename:b.media.filename,mimeType:b.media.mimeType,size:b.media.size,mediaType:b.media.mediaType}}),S(x,201)},k=async(e,t)=>{let{isStorageConfigured:n,getStorageConfigReason:r}=await import(`@murumets-ee/storage`);if(t.segments.length!==1)return C(`Not found`,404);if(!n())return C(r()??`Storage not configured`,503);let{regenerateAllVariants:i}=await import(`./regenerate-variants-2Lmo5kf8.mjs`),{getApp:a,getContext:o}=await import(`@murumets-ee/core`),{resolveImageStyles:s}=await import(`./resolve-image-styles-iN9JbZYf.mjs`),{createStorageClient:c}=await import(`@murumets-ee/storage`),{getStorageConfig:u}=await import(`@murumets-ee/storage/plugin`),d=a(),f=await s(d,d.logger);if(!f||Object.keys(f).length===0)return C(`No image styles configured`,400);let p=await i({app:d,storage:c(u(),{app:d}),logger:d.logger.child({media:!0}),styles:f,contextResolver:()=>{let e=o();if(!(!e?.user||!e?.checker))return{user:e.user,checker:e.checker,...e.scope!==void 0&&{scope:e.scope}}}});return l(t,{action:`media.regenerate_variants`,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},metadata:{total:p.total,processed:p.processed,errors:p.errors}}),S(p)},A=16*1024;async function j(e){let t=Number(e.headers.get(`content-length`));if(Number.isFinite(t)&&t>A)return{error:`Request body exceeds the maximum size`,status:413};let n=e.body,r;if(n===null){if(r=await e.text(),Buffer.byteLength(r,`utf8`)>A)return{error:`Request body exceeds the maximum size`,status:413}}else{let e=n.getReader(),t=[],i=0;try{for(;;){let{done:n,value:r}=await e.read();if(n)break;if(r!==void 0){if(i+=r.byteLength,i>A)return await e.cancel().catch(()=>void 0),{error:`Request body exceeds the maximum size`,status:413};t.push(r)}}}finally{e.releaseLock()}r=Buffer.concat(t).toString(`utf8`)}let i;try{i=JSON.parse(r)}catch{return{error:`Invalid JSON body`,status:400}}if(typeof i!=`object`||!i||Array.isArray(i))return{error:`Body must be a JSON object`,status:400};let a=i.ids;if(!Array.isArray(a)||a.length===0)return{error:`Body must contain "ids" array`,status:400};if(a.length>100)return{error:`Bulk delete limited to 100 items per request`,status:400};let o=[];for(let e of a){if(typeof e!=`string`||!v(e))return{error:`Body "ids" must contain valid media IDs`,status:400};o.push(e)}return{ids:o}}const M=async(e,t)=>{let n=t.segments;if(n.length===0){let n=await j(e);if(`error`in n)return C(n.error,n.status);let{isStorageConfigured:r,getStorageConfigReason:i}=await import(`@murumets-ee/storage`);if(!r())return C(i()??`Storage not configured`,503);let a=await E(),o=await c(n.ids,async e=>{try{return await a.delete(e),{status:`fulfilled`,id:e}}catch(t){return{status:`rejected`,id:e,reason:t}}}),s=o.filter(e=>e.status===`fulfilled`).map(e=>e.id);await u(t,s.map(e=>({action:`media.delete`,entityType:`media`,entityId:e,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},metadata:{bulk:!0}})));let l=o.find(e=>e.status===`rejected`);if(l&&l.status===`rejected`)throw l.reason;return S({deleted:s.length})}if(n.length>1)return C(`Not found`,404);let r=n[0];if(!v(r))return C(`Invalid media ID format`,400);let{isStorageConfigured:i,getStorageConfigReason:a}=await import(`@murumets-ee/storage`);return i()?(await(await E()).delete(r),l(t,{action:`media.delete`,entityType:`media`,entityId:r,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name}}),S({deleted:1})):C(a()??`Storage not configured`,503)},N=[`admin`,`editor`,`agent`,`viewer`],P=[`admin`,`editor`];function F(){return r([i({prefix:`media`,path:``,method:`GET`,permission:`media:view`,defaultRoles:N,matchAnyPath:!0,description:`Read media — list, single item, or referencing-entity usage report.`,handler:D}),i({prefix:`media`,path:``,method:`POST`,permission:`media:create`,defaultRoles:P,description:"Upload a media file (multipart FormData with `file` field).",handler:O}),i({prefix:`media`,path:`regenerate-variants`,method:`POST`,permission:`media:create`,defaultRoles:P,description:`Regenerate every image-style variant for every media row.`,handler:k}),i({prefix:`media`,path:``,method:`DELETE`,permission:`media:delete`,defaultRoles:P,matchAnyPath:!0,description:`Delete a media record + its storage object.`,handler:M})])}function I(){if(!s(`@murumets-ee/media`))throw Error(`@murumets-ee/media plugin not initialized. Add media() to your plugins array.`);return o(p)}function L(r){let i=h(r);return a({name:`@murumets-ee/media`,requires:[`@murumets-ee/storage`,`@murumets-ee/settings`],shared:{messages:n,settings:[t]},server:{entities:[e],routes:[...F()],config:i,init:async e=>{e.logger.info({acceptedTypes:i.acceptedTypes,maxUploadSize:i.maxUploadSize,defaultVisibility:i.defaultVisibility},`Media plugin initialized`)}},adminUi:{sidebar:[{id:`media`,group:`Library`,label:`Media`,href:`/admin/media`,iconName:`image`}],defaultRoutes:[{path:`media`,factory:`MediaListPage`},{path:`media/[id]`,factory:`MediaEditPage`}],settingRenderers:{"media.imageStyles":d},settingsActions:{"media.imageStyles":f}}})}export{I as getMediaConfig,L as media};
1
+ import{t as e}from"./entity-CsDdjKz6.mjs";import{imageStylesSettings as t}from"./image-styles-settings.mjs";import{n,r}from"./media-config-DkbnXIjC.mjs";import{t as i}from"./routes-DaE0M8h8.mjs";import{getMediaMessages as a}from"./i18n.mjs";import{definePlugin as o}from"@murumets-ee/core";import{ImageStylesManager as s,ImageVocabularyEditor as c,RegenerateVariantsAction as l}from"@murumets-ee/media/image-styles";function u(){return n()}function d(n){let u=r(n);return o({name:`@murumets-ee/media`,requires:[`@murumets-ee/storage`,`@murumets-ee/settings`],shared:{messages:a,settings:[t]},server:{entities:[e],routes:[...i()],config:u,init:async e=>{try{let{registerMediaJobs:t}=await import(`./register-CJKjNl2w.mjs`);await t(e)}catch(t){e.logger.warn({err:t},`media: could not register the variant jobs — uploads will generate variants inline`)}e.logger.info({acceptedTypes:u.acceptedTypes,maxUploadSize:u.maxUploadSize,defaultVisibility:u.defaultVisibility},`Media plugin initialized`)}},adminUi:{sidebar:[{id:`media`,group:`Library`,label:`Media`,href:`/admin/media`,iconName:`image`}],defaultRoutes:[{path:`media`,factory:`MediaListPage`},{path:`media/[id]`,factory:`MediaEditPage`}],settingRenderers:{"media.imageStyles":s,"media.imageVocabulary":c},settingsActions:{"media.imageStyles":l}}})}export{u as getMediaConfig,d as media};
2
2
  //# sourceMappingURL=plugin.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.mjs","names":[],"sources":["../src/media-config.ts","../src/admin/routes.ts","../src/plugin.ts"],"sourcesContent":["/**\n * The media plugin's resolved configuration — defaults in one place, read\n * from the framework's `PluginConfigRegistry`.\n *\n * The plugin used to stash its config in a module variable from `init`, and\n * the upload route hardcoded 50 MB with a comment explaining it could not\n * trust that variable to be set yet. So `acceptedTypes` and `maxUploadSize`\n * were declared, documented, logged at boot — and enforced nowhere (F007).\n * `server.config` is placed in the registry at the merge step, BEFORE any\n * `init`, which removes the reason the route could not read it.\n */\n\nimport { getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\nimport type { MediaPluginConfig } from './types.js'\n\ndeclare module '@murumets-ee/core' {\n interface PluginConfigRegistry {\n '@murumets-ee/media': Required<MediaPluginConfig>\n }\n}\n\nexport const MEDIA_PLUGIN_NAME = '@murumets-ee/media'\n\n/**\n * The default `acceptedTypes`, matched against what `detectMimeType` (file-type\n * 21) actually REPORTS — not what a browser claims.\n *\n * Wider than the image/video/audio/PDF the option's docs used to list, on\n * purpose: nothing enforced that list, so modern Office and OpenDocument files\n * were accepted and are in libraries today. Enforcing the narrow list would\n * have turned an F007 memory fix into a silent feature removal.\n *\n * Deliberately NOT in the default, each refused with 415 unless an app lists it:\n * - **Legacy binary Office** (`.doc`, `.xls`, `.ppt`). file-type reports them as\n * `application/x-cfb` — the same container as `.msi` installers and Outlook\n * `.msg` files, so it cannot be allowed for one without the others.\n * - **Macro-enabled Office** (`.docm`, `.xlsm`, `.pptm`), reported with their own\n * `…macroenabled…` types.\n * - **Archives and executables**, and the text types (`application/json`,\n * `text/markdown`, TSV) that `detectMimeType` trusts but the library does not\n * classify as documents.\n *\n * Named office families rather than `application/vnd.*`, which also matches\n * executables and archives.\n */\nexport const DEFAULT_MEDIA_ACCEPTED_TYPES: readonly string[] = [\n 'image/*',\n 'video/*',\n 'audio/*',\n 'application/pdf',\n 'application/vnd.openxmlformats-officedocument.*',\n 'application/vnd.oasis.opendocument.*',\n 'text/plain',\n 'text/csv',\n]\n\n/** Fill every unset option with its default. */\nexport function resolveMediaConfig(config?: MediaPluginConfig): Required<MediaPluginConfig> {\n return {\n acceptedTypes: config?.acceptedTypes ?? [...DEFAULT_MEDIA_ACCEPTED_TYPES],\n // Non-positive means unset, as it does for storage's `maxFileSize`.\n maxUploadSize:\n config?.maxUploadSize !== undefined && config.maxUploadSize > 0\n ? config.maxUploadSize\n : 50 * 1024 * 1024,\n // Resolved for the picker's own use, and deliberately NOT applied by the\n // upload route: an upload that sends no visibility stays private (rent's\n // inspection photos rely on it). See the route's deny-by-default comment.\n defaultVisibility: config?.defaultVisibility ?? 'public',\n imageStyles: config?.imageStyles ?? {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n },\n }\n}\n\n/**\n * The registered config, or the defaults when the plugin is not registered\n * (a unit test, a CLI path that never ran `createApp`). The defaults are what\n * `media()` with no arguments resolves to, so the fallback is never looser\n * than a registered plugin that configured nothing.\n */\nexport function readMediaConfig(): Required<MediaPluginConfig> {\n return hasPluginConfig(MEDIA_PLUGIN_NAME)\n ? getPluginConfig(MEDIA_PLUGIN_NAME)\n : resolveMediaConfig()\n}\n","/**\n * Media admin routes — one static `defineAdminRoute` per HTTP method.\n *\n * **URL surface served by this module:**\n *\n * - GET /api/admin/media — list with search/filter + URLs\n * - GET /api/admin/media/<id> — single record with URL\n * - GET /api/admin/media/<id>/usage — entities referencing this media\n * - POST /api/admin/media — upload (multipart FormData)\n * - POST /api/admin/media/regenerate-variants — bulk variant regeneration\n * - DELETE /api/admin/media/<id> — delete + storage cleanup\n * - DELETE /api/admin/media — bulk delete (body.ids[], ≤100)\n *\n * Standard entity CRUD (PATCH with translations) falls through to the\n * generic entity handler — add `Media` to `entities` in your handler config.\n *\n * **Why per-method `defineAdminRoute`** (vs. the previous top-level\n * `resource: 'media'` + auto-mapped permission):\n *\n * - The wrapper (`guardedHandler` from `@murumets-ee/core`) owns the\n * permission gate, `permission.denied` audit, and 403 body shape\n * uniformly with every other migrated plugin. The previous shape\n * relied on `METHOD_TO_ACTION` auto-mapping at the api-handler\n * dispatch level which couldn't emit per-route audit metadata.\n * - Inline `if (!checkPermission('media', 'create')) ...` defenses\n * at the regenerate-variants / upload / delete branches are now\n * redundant — the wrapper enforces the permission BEFORE the\n * handler runs.\n *\n * **`matchAnyPath` rationale:** media's URL surface predates the\n * static-second-segment convention every other plugin follows\n * (`/taxonomy/<vocab>/<id>`, `/settings/<namespace>/<rest>`). The\n * second segment under `/media` is the media id itself — a runtime\n * UUID — which the dispatcher's literal `segments[0]` match would\n * miss for every request. The catch-all flag declares \"this entry\n * claims every (prefix, method) sub-path no other entry matched\" so\n * the existing URL contract works under the per-method dispatch\n * model. Static POST sub-paths (`regenerate-variants`) take\n * precedence over the catch-all entry on POST. See\n * `DefineAdminRouteSpec.matchAnyPath` in `@murumets-ee/core` for the\n * full contract.\n *\n * **Default roles:** broad for view (`['admin', 'editor', 'agent',\n * 'viewer']`), narrower for writes (`['admin', 'editor']`).\n * Conservative starting point — apps can grant more via the\n * Permissions UI.\n *\n * @example\n * ```typescript\n * import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'\n * import { mediaRoutes } from '@murumets-ee/media/admin'\n * import { Media } from '@murumets-ee/media'\n *\n * const handler = createAdminApiHandler({\n * authenticate: async (req) => { ... },\n * entities: [Article, Media],\n * routes: [...mediaRoutes()],\n * })\n * ```\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n runBounded,\n safeAudit,\n safeAuditMany,\n} from '@murumets-ee/core'\nimport type { MediaClient } from '../client.js'\nimport { readMediaConfig } from '../media-config.js'\nimport type { MediaPickerItem, MediaPickerListResult } from '../picker/types.js'\n\n// ---------------------------------------------------------------------------\n// Validation\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isValidUuid(value: string | undefined): value is string {\n return value !== undefined && UUID_RE.test(value)\n}\n\n/**\n * Closed enum of allowed `mediaType` filter values, in lockstep with the\n * `mediaType` field on the Media entity. The list MUST match\n * `MediaListOptions.mediaType` (declared in `../types.js`) — TypeScript\n * doesn't link the two automatically, so the small drift risk is\n * accepted in exchange for not pulling a runtime dep on the entity\n * schema package just to read its enum.\n *\n * Used as both the runtime allowlist (`isAllowedMediaType`) and the\n * source of truth for the narrowed type the guard produces — keeping\n * `as` casts off the handler's hot path.\n */\nconst ALLOWED_MEDIA_TYPES = ['image', 'video', 'audio', 'document', 'other'] as const\ntype AllowedMediaType = (typeof ALLOWED_MEDIA_TYPES)[number]\n\nfunction isAllowedMediaType(value: string): value is AllowedMediaType {\n // Cast to readonly string[] so the typed-tuple `includes` check works\n // with an arbitrary string input — `Array.prototype.includes`'s\n // signature on a typed tuple only accepts members of the tuple\n // (TS 4.9+ literal-narrowing), which is exactly what we want to\n // sidestep here.\n return (ALLOWED_MEDIA_TYPES as readonly string[]).includes(value)\n}\n\n/**\n * Hard cap on the `offset` query param. The dispatcher's allowed-pages\n * model is `limit ∈ [1, 100]`; even with the max page size that's\n * 1000 pages before hitting MAX_OFFSET, which is well beyond any\n * realistic admin-UI scroll depth. Forces slow-discard offset attacks\n * (`?offset=999999999`) to revert to a sane upper bound. Cursor-based\n * pagination would let this go higher, but the media list is\n * offset-based by convention with the entity-list shell.\n */\nconst MAX_OFFSET = 100_000\n\n/**\n * Strict non-negative integer parser for query params.\n *\n * `Number(raw)` silently coerces blanks (`'' → 0`), hex (`'0x05' → 5`),\n * exponent notation (`'1e2' → 100`), and signed (`'+5'/'-5'`) values —\n * all of which pass a downstream `Number.isInteger(n) && n >= 0` check.\n * Pre-filtering with `/^\\d+$/` keeps the contract honest: \"if you don't\n * send a pure non-negative integer string, you get the default.\" Same\n * pattern as `packages/taxonomy/src/admin/routes.ts:handleGet` tree\n * branch from PR-C-taxonomy.\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n // The regex above stops hex / exponent / signed / whitespace / blank\n // inputs, but a long digit-only string still slips past — `Number(...)`\n // returns `Infinity` past ~309 digits, and precision is lost past\n // 2^53. Both would propagate through `client.findMany`'s\n // `offset: parsed` into a Postgres query that either crashes the\n // driver (`Infinity` is not a valid bigint) or silently rounds (the\n // 2^53 precision-loss boundary). Reject via `Number.isSafeInteger`\n // so overflowing inputs revert to `fallback` like every other\n // malformed value.\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction jsonError(message: string, status: number): Response {\n return jsonResponse({ error: message }, status)\n}\n\n// ---------------------------------------------------------------------------\n// Per-request MediaClient factory + cached storage client\n// ---------------------------------------------------------------------------\n//\n// MediaClient and its AdminClient MUST be built per-request — AdminClient's\n// context resolver is captured eagerly at construction (see\n// buildContextResolver in @murumets-ee/core/clients). A module-level singleton\n// would bake the first request's user + tenant scope into every subsequent\n// request's writes — a cross-request permission/scope leak.\n//\n// Storage config is process-global and safe to cache once.\n\ntype StorageClient = Awaited<ReturnType<typeof import('@murumets-ee/storage').createStorageClient>>\n\ninterface StorageState {\n clientPromise: Promise<StorageClient> | null\n}\n\n// Module-level state. `null` once eviction fires on a rejection so the next\n// request gets a fresh resolution attempt (see `getStorage`'s `.catch`).\nconst storageState: StorageState = { clientPromise: null }\n\n/**\n * Resolve the process-wide storage client. The pending-promise slot is\n * cleared on rejection so a transient `createStorageClient` failure\n * (DB hiccup, misconfig) doesn't poison every subsequent media request\n * for the lifetime of the Node process. Same pattern as\n * `packages/taxonomy/src/admin/routes.ts:getClient` from PR-C-taxonomy.\n */\nasync function getStorage(): Promise<StorageClient> {\n if (!storageState.clientPromise) {\n const pending = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const app = getApp()\n return createStorageClient(getStorageConfig(), { app })\n })()\n // Clear the slot on rejection so a transient failure doesn't poison\n // every subsequent request. `pending.catch(handler)` attaches a\n // synchronous rejection listener that resets the cached slot — the\n // handler returns `undefined`, NOT a re-throw — so the new\n // `.catch`-returned promise resolves, but we never await or hold\n // that promise. The ORIGINAL `pending` (stored in\n // `storageState.clientPromise`) is what callers `await`, and it's\n // still in its rejected state — so the caller observing this\n // resolution sees the underlying error. The `.catch` also marks\n // the rejection as \"handled\" for Node's unhandled-rejection\n // tracker. `storageState.clientPromise` holds the original `pending`\n // so concurrent in-flight callers share one resolution.\n pending.catch(() => {\n if (storageState.clientPromise === pending) storageState.clientPromise = null\n })\n storageState.clientPromise = pending\n }\n return storageState.clientPromise\n}\n\nasync function getMediaClient(): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { MediaClient } = await import('../client.js')\n const { Media } = await import('../entity.js')\n const storage = await getStorage()\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleGet: AdminRouteHandler = async (req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n const segments = ctx.segments\n\n // GET /media/<id>/usage — DB-only lookup, safe even when storage is\n // unconfigured. Validate the UUID first so a malformed id 400s\n // uniformly with the storage-configured branch below.\n if (segments.length === 2 && segments[1] === 'usage') {\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { findMediaUsages } = await import('../usage.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const usages = await findMediaUsages(id, app)\n return jsonResponse({ usages })\n }\n\n // Reject unknown sub-paths explicitly. The wrapper-gate has already\n // passed (caller has `media:view`); a handler-level 404 here matches\n // the dispatcher's miss-shape so unknown URLs don't fall through to\n // the single-id branch and 400/500 with a misleading reason. The\n // only valid 2-segment URL is `<id>/usage`, which was already\n // claimed by the early-return above — anything else with >=2\n // segments is not a recognized media route.\n if (segments.length >= 2) return jsonError('Not found', 404)\n\n // GET /media/<id> — validate UUID up front so a bad request still\n // 400s when storage happens to be unconfigured (matches DELETE).\n const singleId = segments.length > 0 ? segments[0] : undefined\n if (singleId !== undefined && !isValidUuid(singleId)) {\n return jsonError('Invalid media ID format', 400)\n }\n\n // Everything below needs a working storage client. If env isn't\n // wired, return a structured \"disabled\" response rather than 500 —\n // the media list page renders a banner and new admins can finish\n // onboarding without being blocked on a crash loop.\n if (!isStorageConfigured()) {\n const reason = getStorageConfigReason() ?? 'Storage not configured'\n if (segments.length > 0) {\n // Single item — treat as not-found-ish to avoid leaking\n // existence; include reason so the UI can surface it.\n return jsonResponse({ error: reason, configured: false, reason }, 503)\n }\n // GET /media — empty list + disabled flag for the picker's banner.\n const response: MediaPickerListResult & { configured: false; reason: string } = {\n items: [],\n total: 0,\n configured: false,\n reason,\n }\n return jsonResponse(response)\n }\n\n const client = await getMediaClient()\n\n // GET /media/<id> — single item with URL (full record for EntityForm + picker)\n if (singleId !== undefined) {\n const record = await client.findById(singleId)\n if (!record) return jsonError('Media not found', 404)\n\n const url = await client.getUrl(singleId)\n return jsonResponse({ ...record, url })\n }\n\n // GET /media — list with search/filter + batch URL resolution\n const url = new URL(req.url)\n const search = url.searchParams.get('search') ?? undefined\n // mediaType MUST be validated against the entity's closed enum before\n // it reaches `client.findMany` — the previous shape force-cast the raw\n // query value with `as 'image' | ...` which is a type-system lie. A\n // bogus value like `?mediaType=bogus` would parameterize into\n // `WHERE media_type = 'bogus'` (no SQL injection — Drizzle is\n // parameterized — but the row count is silently zero, which is\n // confusing). Empty string from `?mediaType=` is also passed-through\n // by the prior `?? undefined` shape since empty string is not null.\n // Allowlist + type guard drops bogus / empty values cleanly so the\n // filter is omitted instead of applied with a no-match value.\n const rawMediaType = url.searchParams.get('mediaType')\n const mediaType =\n rawMediaType !== null && isAllowedMediaType(rawMediaType) ? rawMediaType : undefined\n // Strict parsing keeps the contract honest — see `parseUintParam`\n // JSDoc. Cap limit to [1, 100] and offset to [0, MAX_OFFSET] so a\n // request can't ask for an unboundedly large page nor force a\n // slow-discard scan with `?offset=99999999`.\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 24), 1), 100)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n const result = await client.findMany({\n ...(search !== undefined && { search }),\n ...(mediaType !== undefined && { mediaType }),\n limit,\n offset,\n })\n\n // Resolve original URLs for all items, but thumbnail variants ONLY for\n // images — a video/audio/document/other id will never have a `thumbnail`\n // variant, so asking `getVariantUrls` to resolve one is pure waste (a\n // storage lookup that falls through to the original, then gets discarded\n // below anyway). `media-list-page.tsx` filters the same way.\n const ids = result.items.map((item) => item.id)\n const imageIds = result.items.filter((item) => item.mediaType === 'image').map((item) => item.id)\n const [urlMap, thumbMap] = await Promise.all([\n client.getUrls(ids),\n client.getVariantUrls(imageIds, 'thumbnail'),\n ])\n\n const items: (MediaPickerItem & { thumbnailUrl?: string })[] = result.items.map((item) => {\n // F115: `getVariantUrls` falls back to the ORIGINAL asset URL for any id\n // with no `thumbnail` variant — correct for an image that just hasn't\n // been processed yet, wrong for a video/audio/document/other row, which\n // will never have one. Gating on `mediaType` keeps `thumbnailUrl` ABSENT\n // rather than pointing a consumer's `<img src>` at a `.mp4`.\n const thumbnailUrl = item.mediaType === 'image' ? thumbMap.get(item.id) : undefined\n return {\n id: item.id,\n title: item.title ?? null,\n alt: item.alt ?? null,\n filename: item.filename,\n mimeType: item.mimeType,\n size: item.size,\n mediaType: item.mediaType,\n url: urlMap.get(item.id) ?? '',\n ...(thumbnailUrl !== undefined && { thumbnailUrl }),\n width: item.width ?? null,\n height: item.height ?? null,\n }\n })\n\n const response: MediaPickerListResult = { items, total: result.total }\n return jsonResponse(response)\n}\n\nconst handleUpload: AdminRouteHandler = async (req, ctx) => {\n const {\n isStorageConfigured,\n getStorageConfigReason,\n detectMimeType,\n isMimeTypeAllowed,\n readBoundedMultipart,\n tooLarge,\n } = await import('@murumets-ee/storage')\n\n // Upload is registered at `path: ''` (NOT matchAnyPath) — the\n // dispatcher only invokes this handler when segments=[]. The api-\n // handler ALSO rejects empty/whitespace-only first segments with a\n // 400 before any plugin dispatcher runs (see\n // `packages/admin-ui/src/server/api-handler/index.ts` segment-\n // validation block). So this guard is defense-in-depth — per HANDOFF\n // §\"Defense-in-depth `&& segments[i]` guards are fine\" — protecting\n // against a future framework invariant change that lets a non-empty\n // segment reach a `path: ''` static entry.\n if (ctx.segments.length > 0) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // The plugin's `maxUploadSize` / `acceptedTypes`, from the registry the\n // merge step filled before any init — see `media-config.ts` (F007).\n const { maxUploadSize, acceptedTypes } = readMediaConfig()\n\n // F007: bound the body BEFORE `formData()` makes it resident — and before\n // building a MediaClient for a request that is about to be refused.\n const envelope = await readBoundedMultipart(req, maxUploadSize)\n if (!envelope.ok) return jsonError(envelope.error, envelope.status)\n const formData = envelope.value\n\n const file = formData.get('file')\n // FormData `get` returns string | File | null. Reject anything that\n // isn't a File so the typed `file.size`/`file.arrayBuffer()` below\n // is safe without a runtime cast.\n if (!(file instanceof File) || file.size === 0) {\n return jsonError('No file provided', 400)\n }\n\n // The envelope bound covers the whole body; this covers the file, before\n // `arrayBuffer()` copies it.\n if (file.size > maxUploadSize) {\n const refused = tooLarge(maxUploadSize)\n return jsonError(refused.error, refused.status)\n }\n\n const client = await getMediaClient()\n\n const buffer = Buffer.from(await file.arrayBuffer())\n\n // Detect actual MIME type from file content (prevents spoofing).\n const { mimeType, mismatch } = await detectMimeType(\n buffer,\n file.type || 'application/octet-stream',\n )\n if (mismatch) {\n return jsonError(\n `File content doesn't match declared type: claimed ${file.type}, detected ${mimeType}`,\n 400,\n )\n }\n // `acceptedTypes` against the DETECTED type, so a renamed file cannot\n // claim its way past the allowlist.\n if (!isMimeTypeAllowed(mimeType, acceptedTypes)) {\n return jsonError(`File type ${mimeType} is not accepted`, 415)\n }\n\n // Per-upload visibility, DEFAULTING TO PRIVATE — the caller opts in.\n //\n // This route serves two populations with opposite needs. The media picker\n // uploads publishable web assets: a page's hero image MUST be public or the\n // public site resolves it to `null` and renders no images at all, with a 200\n // on every page and one warn log (F030). `packages/rent-ui`'s inspection\n // wizard posts to this SAME route with handover and return photos — vehicle\n // and apartment condition shots, plates, interiors, occasionally documents —\n // which must stay behind time-limited signed urls.\n //\n // Deny-by-default decides the direction, and the asymmetry is the argument:\n // forget the opt-in and an image is invisible, which is annoying, visible and\n // fixable; default to public and forget an opt-out and those photos become\n // permanently world-readable. A missed opt-in cannot leak. So the default is\n // unchanged (`private` via StorageClient's own fallback) and rent's caller is\n // untouched — it sends nothing and behaves exactly as before.\n const visibilityRaw = formData.get('visibility')\n if (visibilityRaw !== null && visibilityRaw !== 'public' && visibilityRaw !== 'private') {\n return jsonError(\"Field 'visibility' must be 'public' or 'private'\", 400)\n }\n const result = await client.upload(buffer, {\n filename: file.name,\n mimeType,\n size: file.size,\n uploadedBy: ctx.user.id,\n ...(visibilityRaw !== null && { visibility: visibilityRaw }),\n })\n\n const item: MediaPickerItem = {\n id: result.media.id,\n title: result.media.title ?? null,\n alt: result.media.alt ?? null,\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n url: result.url,\n width: result.media.width ?? null,\n height: result.media.height ?? null,\n }\n\n safeAudit(ctx, {\n action: 'media.upload',\n entityType: 'media',\n entityId: result.media.id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n changes: {\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n },\n })\n\n return jsonResponse(item, 201)\n}\n\nconst handleRegenerateVariants: AdminRouteHandler = async (_req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n\n // Static POST path='regenerate-variants' — the dispatcher only invokes\n // this handler when `segments[0] === 'regenerate-variants'`. Trailing\n // segments (e.g. POST /media/regenerate-variants/extra) aren't a\n // supported sub-route.\n if (ctx.segments.length !== 1) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const { regenerateAllVariants } = await import('../regenerate-variants.js')\n const { getApp, getContext } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('../resolve-image-styles.js')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n\n const app = getApp()\n const styles = await resolveImageStyles(app, app.logger)\n if (!styles || Object.keys(styles).length === 0) {\n return jsonError('No image styles configured', 400)\n }\n\n const storageConfig = getStorageConfig()\n const storage = createStorageClient(storageConfig, { app })\n\n const result = await regenerateAllVariants({\n app,\n storage,\n logger: app.logger.child({ media: true }),\n styles,\n contextResolver: () => {\n const requestCtx = getContext()\n if (!requestCtx?.user || !requestCtx?.checker) return undefined\n return {\n user: requestCtx.user,\n checker: requestCtx.checker,\n ...(requestCtx.scope !== undefined && { scope: requestCtx.scope }),\n }\n },\n })\n\n safeAudit(ctx, {\n action: 'media.regenerate_variants',\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: { total: result.total, processed: result.processed, errors: result.errors },\n })\n\n return jsonResponse(result)\n}\n\n/**\n * Cap on a bulk delete — the same number the generic entity CRUD route's own\n * bulk delete uses (`entity-crud.ts`'s `MAX_BULK_DELETE`), so a caller sees\n * one consistent limit regardless of which admin list it is deleting from.\n */\nconst MAX_BULK_DELETE = 100\n\n/**\n * Bound on the bulk-delete request body. A body here is `{ ids: string[] }`\n * of short UUID strings — generous headroom for {@link MAX_BULK_DELETE} of\n * them (each ~38 bytes quoted-and-comma'd).\n */\nconst MAX_BULK_DELETE_BODY_BYTES = 16 * 1024\n\n/** A validated bulk-delete request, or the error response to send instead. */\ntype BulkDeleteBody = { ids: string[] } | { error: string; status: number }\n\n/**\n * Read and parse the bulk-delete body, refusing anything past\n * {@link MAX_BULK_DELETE_BODY_BYTES}.\n *\n * `content-length` alone is NOT a bound — it is client-supplied, so a\n * chunked request (no header at all) or a spoofed small one sails through a\n * header-only check straight into an unbounded `req.json()` buffering\n * whatever the caller sends (review finding, entity-table PR06). Streamed\n * and byte-counted instead, same shape `entity-crud.ts`'s `readJsonBody`\n * uses for arbitrary write bodies — this one is sized for a much smaller,\n * closed shape, not copied wholesale, because `@murumets-ee/media` must not\n * depend on `@murumets-ee/admin-ui` (wrong dependency direction).\n */\nasync function readBulkDeleteBody(req: Request): Promise<BulkDeleteBody> {\n const declared = Number(req.headers.get('content-length'))\n if (Number.isFinite(declared) && declared > MAX_BULK_DELETE_BODY_BYTES) {\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n\n const body = req.body\n let text: string\n if (body === null) {\n text = await req.text()\n if (Buffer.byteLength(text, 'utf8') > MAX_BULK_DELETE_BODY_BYTES) {\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n } else {\n const reader = body.getReader()\n const chunks: Uint8Array[] = []\n let total = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n if (value === undefined) continue\n total += value.byteLength\n if (total > MAX_BULK_DELETE_BODY_BYTES) {\n // Stop pulling — without the cancel the peer keeps sending into a\n // reader nobody drains, which is the connection this refusal\n // exists to end.\n await reader.cancel().catch(() => undefined)\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n chunks.push(value)\n }\n } finally {\n reader.releaseLock()\n }\n text = Buffer.concat(chunks).toString('utf8')\n }\n\n let raw: unknown\n try {\n raw = JSON.parse(text)\n } catch {\n return { error: 'Invalid JSON body', status: 400 }\n }\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {\n return { error: 'Body must be a JSON object', status: 400 }\n }\n\n const ids = (raw as Record<string, unknown>).ids\n if (!Array.isArray(ids) || ids.length === 0) {\n return { error: 'Body must contain \"ids\" array', status: 400 }\n }\n if (ids.length > MAX_BULK_DELETE) {\n return { error: `Bulk delete limited to ${MAX_BULK_DELETE} items per request`, status: 400 }\n }\n\n // Reject anything that isn't a well-formed media id before any mutation —\n // `client.delete()` would otherwise throw mid-batch, leaving a partially\n // deleted set for a request that was malformed from the start.\n //\n // `Array.isArray` narrows to `any[]` (its lib.es5.d.ts signature is\n // `arg is any[]`), so without the cast below every read of the loop\n // variable counts as an `any` against `verify:types` — `as unknown[]`\n // resets it to a type `typeof`/`isValidUuid` still narrow correctly, one\n // step later than the guard that actually proves it.\n const validated: string[] = []\n for (const id of ids as unknown[]) {\n if (typeof id !== 'string' || !isValidUuid(id)) {\n return { error: 'Body \"ids\" must contain valid media IDs', status: 400 }\n }\n validated.push(id)\n }\n return { ids: validated }\n}\n\nconst handleDelete: AdminRouteHandler = async (req, ctx) => {\n const segments = ctx.segments\n\n // DELETE /media — no id supplied. Bulk delete (body: { ids: string[] }),\n // matching the generic entity CRUD route's own bulk shape — `EntityList`'s\n // \"Delete selected\" always sends this, and Media's `matchAnyPath` DELETE\n // entry claims the whole `/media` prefix, so without this branch that\n // request lands here and gets refused with \"Media ID required\" (found via\n // browser verification, entity-table PR06 — S8's demonstration on a real\n // Media list is what surfaced it: nothing before this PR had exercised a\n // bulk delete against the live Media admin page).\n if (segments.length === 0) {\n const parsed = await readBulkDeleteBody(req)\n if ('error' in parsed) return jsonError(parsed.error, parsed.status)\n\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const client = await getMediaClient()\n // MAX_BULK_DELETE bounds the TOTAL; `runBounded` bounds the CONCURRENCY —\n // each `delete()` does entity DB work AND storage object/metadata work,\n // so 100 ids fired via a bare `Promise.all` would open up to 100 of both\n // at once, which is exactly the shared-pool-contention shape CLAUDE.md's\n // \"No Unbounded Fan-Out\" rule exists to catch (CodeRabbit CLI finding,\n // entity-table PR06). The worker catches internally and returns a\n // settled-shaped result instead of letting `runBounded` see a rejection —\n // that keeps its own fail-fast behavior from firing, so this stays\n // allSettled-equivalent: every id gets attempted regardless of a sibling's\n // failure, and the audit trail for the ones that DID succeed survives one\n // that didn't (`safeAuditMany` below would otherwise be skipped entirely\n // on any rejection, auditing NONE of them). The first failure is still\n // rethrown after the audit write, so the response shape is unchanged —\n // no new 207-partial-success contract.\n const results = await runBounded(parsed.ids, async (id) => {\n try {\n await client.delete(id)\n return { status: 'fulfilled' as const, id }\n } catch (reason) {\n return { status: 'rejected' as const, id, reason }\n }\n })\n const deletedIds = results.filter((r) => r.status === 'fulfilled').map((r) => r.id)\n\n // ONE batched write, awaited — not N unawaited `safeAudit` calls racing\n // the connection pool after the response is already decided (review\n // finding, entity-table PR06; `safeAuditMany`'s own docblock names this\n // exact loop shape as the reason it exists).\n await safeAuditMany(\n ctx,\n deletedIds.map((id) => ({\n action: 'media.delete',\n entityType: 'media',\n entityId: id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: { bulk: true },\n })),\n )\n\n const failure = results.find((r) => r.status === 'rejected')\n if (failure && failure.status === 'rejected') throw failure.reason\n\n return jsonResponse({ deleted: deletedIds.length })\n }\n\n // Reject sub-paths beyond /<id> — DELETE /media/<id>/usage etc.\n if (segments.length > 1) return jsonError('Not found', 404)\n\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n if (!isStorageConfigured()) {\n // Can't safely delete — MediaClient needs storage to remove the\n // actual object, and partial delete (DB row gone, object orphaned)\n // would leak storage. Surface the reason so the UI can display it.\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // AdminClient.delete() checks entity_refs and throws\n // ReferencedEntityError if this media is still referenced. The\n // error bubbles to the caller's error handler which returns 409\n // with usage details.\n const client = await getMediaClient()\n await client.delete(id)\n\n safeAudit(ctx, {\n action: 'media.delete',\n entityType: 'media',\n entityId: id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n })\n\n return jsonResponse({ deleted: 1 })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Default roles for view: broad reach across the admin shell.\n *\n * `BUILT_IN_ROLES` in `packages/auth/src/permissions.ts` is\n * `['admin', 'public', 'authenticated', 'agent', 'customer']` — `admin` is\n * hardcoded as an unconditional yes in `buildPermissionChecker`,\n * `agent` is the only non-admin built-in that flows through\n * `buildInitialRoleDefinitions()` + `upsertBuiltInRoles` with default\n * media access (`customer` seeds with zero permissions — see that\n * function's JSDoc). The `agent` entry in this list is what gives ops\n * agents read-only media access on first boot of a fresh install.\n *\n * `editor` and `viewer` are CONVENTIONAL app-defined roles —\n * apps that follow the scaffold pattern create them as part of\n * their own role catalog. Including them in `defaultRoles` doesn't\n * seed them (the seeder only iterates the built-ins above), but\n * the values DO flow into the process-local permission catalog\n * (see `registerPermission` in `@murumets-ee/core`), which the\n * forthcoming Permission Matrix UI (PR-D) reads to show \"this\n * route's recommended default grants\" against the app's actual\n * role set. The catalog is also the mechanism via which a future\n * upgrade of `upsertBuiltInRoles` could extend the seed surface\n * to app-defined roles without each plugin having to re-declare\n * its defaults.\n */\nconst VIEW_DEFAULT_ROLES = ['admin', 'editor', 'agent', 'viewer'] as const\n\n/** Default roles for create/delete — narrower than view. */\nconst WRITE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Build admin API routes for media management.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry per\n * `(prefix, method)` after grouping. Callers spread the result into\n * their top-level routes list: `routes: [...mediaRoutes()]`.\n *\n * Four `defineAdminRoute` entries:\n *\n * - GET matchAnyPath with `media:view` defaultRoles: VIEW_DEFAULT_ROLES\n * - POST path='' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - POST path='regenerate-variants' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - DELETE matchAnyPath with `media:delete` defaultRoles: WRITE_DEFAULT_ROLES\n *\n * Permissions are auto-registered by the entity catalog\n * (`buildResourceCatalog` in `@murumets-ee/auth`) AND by\n * `registerPermission` inside `defineAdminRoute`. Idempotent —\n * `registerPermission` unions `defaultRoles` on repeat registration,\n * so the framework's two pathways agree on the final grant set.\n *\n * Standard entity CRUD (PATCH with translations) is NOT registered\n * here — it falls through to the generic entity handler. Add `Media`\n * to the `entities` array in your handler config to enable it.\n */\nexport function mediaRoutes(): AdminRoute[] {\n const entries = [\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'GET',\n permission: 'media:view',\n defaultRoles: VIEW_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Read media — list, single item, or referencing-entity usage report.',\n handler: handleGet,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description: 'Upload a media file (multipart FormData with `file` field).',\n handler: handleUpload,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: 'regenerate-variants',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description: 'Regenerate every image-style variant for every media row.',\n handler: handleRegenerateVariants,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'DELETE',\n permission: 'media:delete',\n defaultRoles: WRITE_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Delete a media record + its storage object.',\n handler: handleDelete,\n }),\n ]\n\n return combineAdminRoutes(entries)\n}\n","/**\n * Media plugin — auto-registers the Media entity, mounts admin routes, and\n * contributes sidebar + default-route scaffolding for the admin shell.\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * storage(),\n * media({ maxUploadSize: 10 * 1024 * 1024 }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin, getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\n// Self-reference the `./image-styles` subpath (not the internal source\n// files) so the plugin bundle externalizes the React components instead\n// of inlining them — the subpath is built separately with a `'use client'`\n// banner, and Next.js needs to see that boundary preserved.\nimport { ImageStylesManager, RegenerateVariantsAction } from '@murumets-ee/media/image-styles'\nimport { mediaRoutes } from './admin/routes.js'\nimport { Media } from './entity.js'\n// Relative: `src/i18n.ts` is a sibling entry of this one in the same tsdown\n// config, so the loader lands in a shared chunk rather than being\n// duplicated, and its `import('./messages/et.json')` resolves against\n// `dist/`, which this package's build script populates.\nimport { getMediaMessages } from './i18n.js'\nimport { imageStylesSettings } from './image-styles-settings.js'\nimport { MEDIA_PLUGIN_NAME, resolveMediaConfig } from './media-config.js'\nimport type { MediaPluginConfig } from './types.js'\n\n/**\n * Get the resolved media plugin configuration.\n * Throws if plugin not registered.\n *\n * Reads the framework's `PluginConfigRegistry`, which the merge step fills\n * from `server.config` below before any plugin `init` runs.\n */\nexport function getMediaConfig(): Required<MediaPluginConfig> {\n if (!hasPluginConfig(MEDIA_PLUGIN_NAME)) {\n throw new Error('@murumets-ee/media plugin not initialized. Add media() to your plugins array.')\n }\n return getPluginConfig(MEDIA_PLUGIN_NAME)\n}\n\n/**\n * Media plugin factory.\n *\n * - Registers the Media entity\n * - Mounts the media admin API routes\n * - Contributes sidebar + default-route metadata\n * - Declares @murumets-ee/storage + @murumets-ee/settings as required deps\n * (framework-validated before any plugin init runs — see `requires` field below)\n * - Declares its resolved configuration (`server.config`) for `getMediaConfig()`\n * and the upload route\n */\nexport function media(config?: MediaPluginConfig) {\n const resolvedConfig = resolveMediaConfig(config)\n\n return definePlugin({\n name: '@murumets-ee/media',\n // Declarative dep validation — replaces the per-plugin\n // `if (!app.plugins.has(...)) throw` loop that used to live in\n // `init`. The framework validates BEFORE any init runs, so a\n // missing dep surfaces immediately with the full punchlist\n // instead of throwing partway through boot.\n requires: ['@murumets-ee/storage', '@murumets-ee/settings'],\n shared: {\n // The interface-message catalogue, declared rather than left for each\n // app to remember (commerce-launch F148/F155). The framework gathers\n // it at the merge step; nothing loads a locale until one is asked for.\n messages: getMediaMessages,\n // Self-contributes the media.imageStyles namespace. The merge engine\n // auto-derives the permission resource (`settings_media.imageStyles`)\n // and the sidebar entry under the \"Settings\" group. The app's\n // admin-api-handler call site aggregates plugin-contributed\n // namespaces into `settingsRoutes(...)` (see apps/admin's\n // `getAllSettings()` for the canonical pattern); apps that follow\n // the scaffold pattern get this for free.\n // Drop the `as PluginSettingsDefinition` cast — it would widen the\n // namespace literal (e.g. `'media.imageStyles'`) to `string`,\n // leaking `settings_${string}:view|update` into the resolved\n // permission union via `<const N>` capture loss.\n // `imageStylesSettings` is structurally a superset of\n // `PluginSettingsDefinition` (full vs. narrow `SettingsDefinition`),\n // which TS accepts directly. The `readonly` outer tuple comes from\n // the parent definePlugin's `<const S>` capture.\n settings: [imageStylesSettings],\n },\n server: {\n entities: [Media],\n routes: [...mediaRoutes()],\n // Declarative config — registered at the merge step, before any init,\n // so the upload route can enforce `acceptedTypes` / `maxUploadSize`\n // (F007) instead of hardcoding a limit it could not trust to be set.\n config: resolvedConfig,\n init: async (app) => {\n // Image style defaults live in the plugin config and are exposed via\n // `getMediaConfig()`. Persistent overrides go to the settings DB\n // through the generic settings API at `/api/admin/settings/media.imageStyles`\n // — see `image-styles-settings.ts`. `resolveImageStyles` reads\n // DB-first, config-fallback.\n //\n // We deliberately do NOT seed the DB here. Init must stay lightweight:\n // it runs in every context (Next.js server, Next.js build, CLI commands).\n // The prior eager-seed path pulled in `@murumets-ee/settings`'s main entry —\n // which carries `import 'server-only'` — and crashed non-RSC Node bootstrap.\n\n app.logger.info(\n {\n acceptedTypes: resolvedConfig.acceptedTypes,\n maxUploadSize: resolvedConfig.maxUploadSize,\n defaultVisibility: resolvedConfig.defaultVisibility,\n },\n 'Media plugin initialized',\n )\n },\n },\n adminUi: {\n sidebar: [\n {\n id: 'media',\n group: 'Library',\n label: 'Media',\n href: '/admin/media',\n iconName: 'image',\n },\n ],\n defaultRoutes: [\n // Routing only — sidebar nav comes from `adminUi.sidebar` above\n // (admin-page-routing SD003).\n {\n path: 'media',\n factory: 'MediaListPage',\n },\n {\n path: 'media/[id]',\n factory: 'MediaEditPage',\n },\n ],\n // Self-contributes the rich image-styles editor and the\n // \"Regenerate All Variants\" action. Apps don't have to wire either\n // — they appear automatically on /admin/settings/media.imageStyles\n // (route + sidebar entry auto-derived from `shared.settings`).\n settingRenderers: {\n 'media.imageStyles': ImageStylesManager,\n },\n settingsActions: {\n 'media.imageStyles': RegenerateVariantsAction,\n },\n },\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * Media plugin — auto-registers the Media entity, mounts admin routes, and\n * contributes sidebar + default-route scaffolding for the admin shell.\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * storage(),\n * media({ maxUploadSize: 10 * 1024 * 1024 }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin } from '@murumets-ee/core'\n// Self-reference the `./image-styles` subpath (not the internal source\n// files) so the plugin bundle externalizes the React components instead\n// of inlining them — the subpath is built separately with a `'use client'`\n// banner, and Next.js needs to see that boundary preserved.\nimport {\n ImageStylesManager,\n ImageVocabularyEditor,\n RegenerateVariantsAction,\n} from '@murumets-ee/media/image-styles'\nimport { mediaRoutes } from './admin/routes.js'\nimport { Media } from './entity.js'\n// Relative: `src/i18n.ts` is a sibling entry of this one in the same tsdown\n// config, so the loader lands in a shared chunk rather than being\n// duplicated, and its `import('./messages/et.json')` resolves against\n// `dist/`, which this package's build script populates.\nimport { getMediaMessages } from './i18n.js'\nimport { imageStylesSettings } from './image-styles-settings.js'\nimport { requireMediaConfig, resolveMediaConfig } from './media-config.js'\nimport type { MediaPluginConfig } from './types.js'\n\n/**\n * Get the resolved media plugin configuration.\n * Throws if plugin not registered.\n *\n * Reads the framework's `PluginConfigRegistry`, which the merge step fills\n * from `server.config` below before any plugin `init` runs.\n */\nexport function getMediaConfig(): Required<MediaPluginConfig> {\n return requireMediaConfig()\n}\n\n/**\n * Media plugin factory.\n *\n * - Registers the Media entity\n * - Mounts the media admin API routes\n * - Contributes sidebar + default-route metadata\n * - Declares @murumets-ee/storage + @murumets-ee/settings as required deps\n * (framework-validated before any plugin init runs — see `requires` field below)\n * - Declares its resolved configuration (`server.config`) for `getMediaConfig()`\n * and the upload route\n */\nexport function media(config?: MediaPluginConfig) {\n const resolvedConfig = resolveMediaConfig(config)\n\n return definePlugin({\n name: '@murumets-ee/media',\n // Declarative dep validation — replaces the per-plugin\n // `if (!app.plugins.has(...)) throw` loop that used to live in\n // `init`. The framework validates BEFORE any init runs, so a\n // missing dep surfaces immediately with the full punchlist\n // instead of throwing partway through boot.\n requires: ['@murumets-ee/storage', '@murumets-ee/settings'],\n shared: {\n // The interface-message catalogue, declared rather than left for each\n // app to remember (commerce-launch F148/F155). The framework gathers\n // it at the merge step; nothing loads a locale until one is asked for.\n messages: getMediaMessages,\n // Self-contributes the media.imageStyles namespace. The merge engine\n // auto-derives the permission resource (`settings_media.imageStyles`)\n // and the sidebar entry under the \"Settings\" group. The app's\n // admin-api-handler call site aggregates plugin-contributed\n // namespaces into `settingsRoutes(...)` (see apps/admin's\n // `getAllSettings()` for the canonical pattern); apps that follow\n // the scaffold pattern get this for free.\n // Drop the `as PluginSettingsDefinition` cast — it would widen the\n // namespace literal (e.g. `'media.imageStyles'`) to `string`,\n // leaking `settings_${string}:view|update` into the resolved\n // permission union via `<const N>` capture loss.\n // `imageStylesSettings` is structurally a superset of\n // `PluginSettingsDefinition` (full vs. narrow `SettingsDefinition`),\n // which TS accepts directly. The `readonly` outer tuple comes from\n // the parent definePlugin's `<const S>` capture.\n settings: [imageStylesSettings],\n },\n server: {\n entities: [Media],\n routes: [...mediaRoutes()],\n // Declarative config — registered at the merge step, before any init,\n // so the upload route can enforce `acceptedTypes` / `maxUploadSize`\n // (F007) instead of hardcoding a limit it could not trust to be set.\n config: resolvedConfig,\n init: async (app) => {\n // Image style defaults live in the plugin config and are exposed via\n // `getMediaConfig()`. Persistent overrides go to the settings DB\n // through the generic settings API at `/api/admin/settings/media.imageStyles`\n // — see `image-styles-settings.ts`. `resolveImageStyles` reads\n // DB-first, config-fallback.\n //\n // We deliberately do NOT seed the DB here. Init must stay lightweight:\n // it runs in every context (Next.js server, Next.js build, CLI commands).\n // The prior eager-seed path pulled in `@murumets-ee/settings`'s main entry —\n // which carries `import 'server-only'` — and crashed non-RSC Node bootstrap.\n\n // The variant jobs (images D004). Imperative `registerJob`-from-init —\n // the queue's only registration pattern today, tracked as lumi-cms#497;\n // see `jobs/register.ts`. A no-op without the queue plugin, which\n // leaves uploads generating variants inline. Wrapped so a queue-load\n // failure degrades to exactly that rather than failing media's init.\n try {\n const { registerMediaJobs } = await import('./jobs/register.js')\n await registerMediaJobs(app)\n } catch (err) {\n app.logger.warn(\n { err },\n 'media: could not register the variant jobs — uploads will generate variants inline',\n )\n }\n\n app.logger.info(\n {\n acceptedTypes: resolvedConfig.acceptedTypes,\n maxUploadSize: resolvedConfig.maxUploadSize,\n defaultVisibility: resolvedConfig.defaultVisibility,\n },\n 'Media plugin initialized',\n )\n },\n },\n adminUi: {\n sidebar: [\n {\n id: 'media',\n group: 'Library',\n label: 'Media',\n href: '/admin/media',\n iconName: 'image',\n },\n ],\n defaultRoutes: [\n // Routing only — sidebar nav comes from `adminUi.sidebar` above\n // (admin-page-routing SD003).\n {\n path: 'media',\n factory: 'MediaListPage',\n },\n {\n path: 'media/[id]',\n factory: 'MediaEditPage',\n },\n ],\n // Self-contributes the rich image-styles editor and the\n // \"Regenerate All Variants\" action. Apps don't have to wire either\n // — they appear automatically on /admin/settings/media.imageStyles\n // (route + sidebar entry auto-derived from `shared.settings`).\n settingRenderers: {\n 'media.imageStyles': ImageStylesManager,\n // The shape vocabulary + width ladder, the `vocabulary` key of the same\n // namespace (images D003).\n 'media.imageVocabulary': ImageVocabularyEditor,\n },\n settingsActions: {\n 'media.imageStyles': RegenerateVariantsAction,\n },\n },\n })\n}\n"],"mappings":"gaA6CA,SAAgB,GAA8C,CAC5D,OAAO,EAAmB,CAC5B,CAaA,SAAgB,EAAM,EAA4B,CAChD,IAAM,EAAiB,EAAmB,CAAM,EAEhD,OAAO,EAAa,CAClB,KAAM,qBAMN,SAAU,CAAC,uBAAwB,uBAAuB,EAC1D,OAAQ,CAIN,SAAU,EAgBV,SAAU,CAAC,CAAmB,CAChC,EACA,OAAQ,CACN,SAAU,CAAC,CAAK,EAChB,OAAQ,CAAC,GAAG,EAAY,CAAC,EAIzB,OAAQ,EACR,KAAM,KAAO,IAAQ,CAiBnB,GAAI,CACF,GAAM,CAAE,qBAAsB,MAAM,OAAO,2BAC3C,MAAM,EAAkB,CAAG,CAC7B,OAAS,EAAK,CACZ,EAAI,OAAO,KACT,CAAE,KAAI,EACN,oFACF,CACF,CAEA,EAAI,OAAO,KACT,CACE,cAAe,EAAe,cAC9B,cAAe,EAAe,cAC9B,kBAAmB,EAAe,iBACpC,EACA,0BACF,CACF,CACF,EACA,QAAS,CACP,QAAS,CACP,CACE,GAAI,QACJ,MAAO,UACP,MAAO,QACP,KAAM,eACN,SAAU,OACZ,CACF,EACA,cAAe,CAGb,CACE,KAAM,QACN,QAAS,eACX,EACA,CACE,KAAM,aACN,QAAS,eACX,CACF,EAKA,iBAAkB,CAChB,oBAAqB,EAGrB,wBAAyB,CAC3B,EACA,gBAAiB,CACf,oBAAqB,CACvB,CACF,CACF,CAAC,CACH"}
@@ -0,0 +1,2 @@
1
+ import{n as e}from"./variant-plan-86tJKtVi.mjs";import t from"sharp";const n=new Set([`image/svg+xml`,`image/gif`]);function r(e){return e.startsWith(`image/`)&&!n.has(e)}async function i(e,n){let{width:r,height:i}=u(await t(e).metadata()),a=new Map,o=Object.entries(n);return await Promise.all(o.map(async([n,r])=>{let i=r.format??`webp`,o=r.quality??80,s=r.fit??`cover`,{data:c,info:l}=await t(e).rotate().resize({width:r.width,height:r.height,fit:s,withoutEnlargement:!0})[i]({quality:o}).toBuffer({resolveWithObject:!0});a.set(n,{buffer:c,format:i,mimeType:`image/${i}`,width:l.width,height:l.height})})),{width:r,height:i,variants:a}}async function a(n,r,i){let{width:a,height:s}=u(await t(n).metadata()),c=e({width:a,height:s},r,i);return{width:a,height:s,variants:await o(n,c.variants),dropped:c.dropped}}async function o(e,t){let n=new Map;return c(t,2,async t=>{let r=JSON.stringify([t.extract,t.width,t.height,t.quality,t.format]),i=n.get(r);return i||(i=s(e,t),n.set(r,i)),{...await i,shape:t.shape,plannedWidth:t.width}})}async function s(e,n){let{data:r,info:i}=await t(e).rotate().extract(n.extract).resize({width:n.width,height:n.height,fit:`cover`,withoutEnlargement:!0})[n.format]({quality:n.quality}).toBuffer({resolveWithObject:!0});return{buffer:r,format:n.format,mimeType:`image/${n.format}`,width:i.width,height:i.height,shape:n.shape,plannedWidth:n.width,fingerprint:n.fingerprint}}async function c(e,t,n){let r=Array(e.length),i=e.map((e,t)=>({item:e,index:t})),a=!1;return await Promise.all(Array.from({length:Math.min(t,e.length)},async()=>{for(let e=i.shift();e!==void 0&&!a;e=i.shift())try{r[e.index]=await n(e.item)}catch(e){throw a=!0,e}})),r}async function l(e){return u(await t(e).metadata())}function u(e){return{width:e.autoOrient?.width??e.width??0,height:e.autoOrient?.height??e.height??0}}async function d(e){let{data:n,info:r}=await t(e).rotate().resize(512,512,{fit:`inside`,withoutEnlargement:!0}).raw().toBuffer({resolveWithObject:!0}),{width:i,height:a,channels:o}=r;if(i<8||a<8)return null;let s=await t(n,{raw:{width:i,height:a,channels:o}}).flop().flip().raw().toBuffer(),[c,l]=await Promise.all([f(n,i,a,o),f(s,i,a,o)]);if(c===null||l===null)return null;let u=p(c.x,1-l.x),d=p(c.y,1-l.y);return u===null&&d===null?null:{x:m(u??.5),y:m(d??.5)}}async function f(e,n,r,i){let a=n>=r,{info:o}=await t(e,{raw:{width:n,height:r,channels:i}}).resize(a?n-1:n,a?r:r-1,{fit:`cover`,position:t.strategy.attention}).toBuffer({resolveWithObject:!0}),{attentionX:s,attentionY:c}=o;return s===void 0||c===void 0?null:{x:s/n,y:c/r}}function p(e,t){return Math.abs(e-t)<=.25?(e+t)/2:null}function m(e){return Number.isFinite(e)?Math.min(Math.max(Math.round(e*1e4)/1e4,0),1):.5}export{i as a,r as i,a as n,s as o,l as r,o as s,d as t};
2
+ //# sourceMappingURL=process-image-wQwiRhyk.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process-image-wQwiRhyk.mjs","names":[],"sources":["../src/process-image.ts"],"sourcesContent":["/**\n * Server-side image processing via Sharp.\n *\n * Pure processing module — no DB or storage dependencies.\n * Takes a buffer and image styles, returns metadata + variant buffers.\n */\n\nimport sharp from 'sharp'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport type { VariantConfig } from './shapes.js'\nimport type { ImageStyle } from './types.js'\nimport { type PlannedVariant, planVariants, type VariantPlanInput } from './variant-plan.js'\n\n/** Result of processing an image through Sharp */\nexport interface ProcessedImage {\n /** Original image width in pixels */\n width: number\n /** Original image height in pixels */\n height: number\n /** Generated variant buffers keyed by style name */\n variants: Map<string, ProcessedVariant>\n}\n\n/** A single processed variant */\nexport interface ProcessedVariant {\n buffer: Buffer\n format: string\n mimeType: string\n width: number\n height: number\n}\n\n/** MIME types that should NOT be processed (vectors, animations) */\nconst SKIP_MIME_TYPES = new Set(['image/svg+xml', 'image/gif'])\n\n/**\n * Check if a MIME type is eligible for Sharp processing.\n * Returns false for SVG, GIF, and non-image types.\n */\nexport function isProcessableImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !SKIP_MIME_TYPES.has(mimeType)\n}\n\n/**\n * Extract image dimensions and generate resized variants.\n *\n * @param buffer - Original image file as a Buffer\n * @param styles - Named image style presets to generate\n * @returns Metadata (width/height) and variant buffers\n */\nexport async function processImage(\n buffer: Buffer,\n styles: Record<string, ImageStyle>,\n): Promise<ProcessedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n\n const variants = new Map<string, ProcessedVariant>()\n\n const entries = Object.entries(styles)\n await Promise.all(\n entries.map(async ([name, style]) => {\n const fmt = style.format ?? 'webp'\n const quality = style.quality ?? 80\n const fit = style.fit ?? 'cover'\n\n // `.rotate()` first: without it an EXIF-rotated phone photo's variant is\n // written SIDEWAYS — the output format drops the orientation tag — while\n // the row's width/height (`displayDimensions`) and its focal point are in\n // the orientation a browser draws. Keys are unchanged; only the pixels\n // of a rotated original change, and they change to what was intended.\n const resized = sharp(buffer).rotate().resize({\n width: style.width,\n height: style.height,\n fit,\n withoutEnlargement: true,\n })\n\n const { data: variantBuffer, info } = await resized[fmt]({ quality }).toBuffer({\n resolveWithObject: true,\n })\n\n variants.set(name, {\n buffer: variantBuffer,\n format: fmt,\n mimeType: `image/${fmt}`,\n width: info.width,\n height: info.height,\n })\n }),\n )\n\n return { width, height, variants }\n}\n\n/** One generated (shape × rung) variant. */\nexport interface ShapedVariant extends ProcessedVariant {\n readonly shape: string\n /** The width the plan ASKED for. It names the key; it is not a claim about the file. */\n readonly plannedWidth: number\n /** Goes into the key — see `deriveShapedVariantKey`. */\n readonly fingerprint: string\n}\n\n/** A photograph's shaped variants. */\nexport interface ShapedImage {\n /** The image as a browser draws it (post-EXIF orientation). */\n width: number\n height: number\n variants: ShapedVariant[]\n /** Variants the per-image cap cut off (`MAX_VARIANTS_PER_IMAGE`) — for the caller to log. */\n dropped: number\n}\n\n/**\n * How many sharp pipelines one image runs at once. Each decodes the original,\n * so a 24-megapixel JPEG costs ~70 MB of pixels per pipeline in flight; the\n * seeded vocabulary plans 30 of them. libvips already parallelises inside each\n * pipeline, so a small number here loses little throughput.\n */\nconst SHAPED_CONCURRENCY = 2\n\n/**\n * Generate every (shape × rung) variant `config` calls for (images D003).\n *\n * The region comes from {@link planVariants} — `crops[shape]`, else the focal\n * point's window for that ratio, else the whole image — and sharp runs\n * `rotate() → extract() → resize()` in that order:\n *\n * - `rotate()` first, so `extract()` works in the coordinates a browser DRAWS\n * (measured, R013). The crop and the focal point are stored in that space.\n * - `extract()` BEFORE `resize()` — what sharp documents for pre-resize\n * extraction, and the only way to express a rect at all: `resize({position})`\n * takes nine gravities and two strategies and throws on coordinates (C5).\n *\n * 🔴 The variant records `info.width`/`info.height` — what sharp RETURNED —\n * never the rung it asked for (F003). `withoutEnlargement` silently returns a\n * smaller file than requested, and a `640w` descriptor on a 400px file makes a\n * browser's `srcset` choice wrong in the direction it never corrects (R007).\n * The plan never asks for more than the region supplies, so today the two\n * agree; recording the real one is what keeps them agreeing when they don't.\n */\nexport async function generateShapedVariants(\n buffer: Buffer,\n config: VariantConfig,\n input: VariantPlanInput,\n): Promise<ShapedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n const plan = planVariants({ width, height }, config, input)\n const variants = await renderPlannedVariants(buffer, plan.variants)\n return { width, height, variants, dropped: plan.dropped }\n}\n\n/**\n * Render `planned` — a whole plan, or the part of one a caller does not already\n * have (the variant job renders only keys that are missing) — at most\n * {@link SHAPED_CONCURRENCY} sharp pipelines at a time, results in input order.\n */\nexport async function renderPlannedVariants(\n buffer: Buffer,\n planned: readonly PlannedVariant[],\n): Promise<ShapedVariant[]> {\n // Two shapes can plan the SAME recipe — `wide` on a 16:9 photograph is the\n // whole image, exactly as `natural` is — and encoding it twice buys nothing.\n // Keyed by the full recipe, never by the fingerprint: a 32-bit hash that\n // collided here would hand one shape another shape's picture.\n const rendered = new Map<string, Promise<ShapedVariant>>()\n return mapBounded(planned, SHAPED_CONCURRENCY, async (planned) => {\n const recipe = JSON.stringify([\n planned.extract,\n planned.width,\n planned.height,\n planned.quality,\n planned.format,\n ])\n let pending = rendered.get(recipe)\n if (!pending) {\n pending = renderPlannedVariant(buffer, planned)\n rendered.set(recipe, pending)\n }\n const first = await pending\n return { ...first, shape: planned.shape, plannedWidth: planned.width }\n })\n}\n\nexport async function renderPlannedVariant(\n buffer: Buffer,\n planned: PlannedVariant,\n): Promise<ShapedVariant> {\n const pipeline = sharp(buffer).rotate().extract(planned.extract).resize({\n width: planned.width,\n height: planned.height,\n fit: 'cover',\n withoutEnlargement: true,\n })\n const { data, info } = await pipeline[planned.format]({ quality: planned.quality }).toBuffer({\n resolveWithObject: true,\n })\n return {\n buffer: data,\n format: planned.format,\n mimeType: `image/${planned.format}`,\n width: info.width,\n height: info.height,\n shape: planned.shape,\n plannedWidth: planned.width,\n fingerprint: planned.fingerprint,\n }\n}\n\n/** `Promise.all(items.map(fn))` with at most `limit` in flight, results in input order. */\nasync function mapBounded<T, R>(\n items: readonly T[],\n limit: number,\n fn: (item: T) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length)\n const queue = items.map((item, index) => ({ item, index }))\n // Once one job has failed the whole call rejects, so the other workers stop\n // taking jobs rather than running sharp for a result nobody will read.\n let failed = false\n const worker = async (): Promise<void> => {\n for (let job = queue.shift(); job !== undefined && !failed; job = queue.shift()) {\n try {\n results[job.index] = await fn(job.item)\n } catch (err) {\n failed = true\n throw err\n }\n }\n }\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))\n return results\n}\n\n/**\n * Extract only image dimensions (no variant generation).\n * Useful for getting width/height when styles are empty.\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width: number; height: number }> {\n return displayDimensions(await sharp(buffer).metadata())\n}\n\n/**\n * The size a browser DRAWS the image at — after EXIF orientation — not the\n * sensor's.\n *\n * `metadata().width/height` are the stored pixel grid, so a phone portrait\n * tagged orientation 6 reports 400×200 while every browser draws it 200×400\n * (`image-orientation: from-image` is the default). Those numbers become the\n * media row's `width`/`height`, which are the `<img>`'s CLS reservation AND the\n * image ratio the focal-point crop is computed against (images PR02) — a\n * swapped ratio positions the crop on the wrong axis. `autoOrient` is sharp's\n * own post-orientation size (0.33+).\n */\nfunction displayDimensions(meta: sharp.Metadata): { width: number; height: number } {\n return {\n width: meta.autoOrient?.width ?? meta.width ?? 0,\n height: meta.autoOrient?.height ?? meta.height ?? 0,\n }\n}\n\n/**\n * The longest edge the attention pass looks at. libvips scores saliency on a\n * ~32px grid, so a larger buffer buys no precision — it only costs decode time.\n */\nconst ATTENTION_EDGE = 512\n\n/** Below this on either side there is nothing to guess about — see {@link detectFocalPoint}. */\nconst MIN_ATTENTION_EDGE = 8\n\n/**\n * How far the two attention passes may disagree on an axis and still be the\n * same subject. libvips places its hotspot on a ~32-cell grid (1/32 ≈ 0.03), so\n * a real subject agrees to within a cell or two (measured ≤ 0.07); a FLAT axis\n * disagrees by the whole image (0 in one pass, 1 in the mirrored one).\n */\nconst AXIS_AGREEMENT = 0.25\n\n/**\n * Guess where the subject is, with sharp's `attention` strategy — the seed for\n * a new photograph's focal point, so an editor who opens nothing still gets\n * something other than a blind centre crop (images D002).\n *\n * ## Two passes, one of them mirrored\n *\n * When the saliency map is flat along an axis — a white product background, a\n * sky, a horizontal band, a plain texture — libvips reports the FIRST cell of\n * it: 0. Stored, that pins every shaped slot to the left or top edge, which is\n * worse than no guess. But a subject genuinely near the edge also reports 0 (a\n * disc at 5% from the corner does, measured), so \"0 means flat\" is not a rule.\n *\n * So the same buffer is scored twice: as-is, and flipped on both axes. A real\n * subject moves with the flip and the two answers agree once the second is\n * mirrored back; a flat axis reports 0 both times, i.e. 0 and 1 once mirrored.\n * Per axis: agree → their mean; disagree → that axis says nothing → 0.5. Both\n * axes flat → `null` (the caller stores it; it renders as the centre, D010).\n * The second pass runs on the already-decoded ≤512px raw buffer, so it costs a\n * saliency pass over ~0.25 MP, not a second decode.\n *\n * Also `null`: an image under {@link MIN_ATTENTION_EDGE} on either side — a\n * strip or an icon has no subject worth cropping around.\n *\n * ## Why not read `info.attentionX` off an ordinary resize\n *\n * Because it is WRONG whenever the same call resizes. sharp converts the\n * libvips hotspot with `attention_x * jpegShrinkOnLoad / scale`\n * (`src/pipeline.cc:505`, 0.34.5 and 0.35.3 alike), and those terms do not\n * describe the image smartcrop ran on: a disc drawn at x = 0.80 reported 0.50\n * from a JPEG and 0.25 from a PNG (images R012). The crop OFFSET beside it is\n * right; only the reported point is not.\n *\n * So each pass runs at SCALE 1: decode once to a bounded raw buffer, then crop\n * that buffer to its own size minus one pixel on the long axis. No\n * shrink-on-load and no scale, so the reported point is in that buffer's own\n * pixels and dividing by its size is the answer. A square image has no long\n * axis, but trimming either one still runs the strategy, and libvips takes the\n * hotspot over the whole saliency map whichever axis it crops.\n *\n * `.rotate()` first, so the point is in the orientation a browser DRAWS — an\n * EXIF-rotated phone photo would otherwise be seeded in sensor coordinates.\n *\n * ⚠️ Attention scores luminance frequency, saturation and skin tones. It is a\n * guess, not detection, and on a product shot the product can win (R012).\n */\nexport async function detectFocalPoint(buffer: Buffer): Promise<FocalPoint | null> {\n const { data, info } = await sharp(buffer)\n .rotate()\n .resize(ATTENTION_EDGE, ATTENTION_EDGE, { fit: 'inside', withoutEnlargement: true })\n .raw()\n .toBuffer({ resolveWithObject: true })\n const { width, height, channels } = info\n if (width < MIN_ATTENTION_EDGE || height < MIN_ATTENTION_EDGE) return null\n const raw = { raw: { width, height, channels } }\n const mirrored = await sharp(data, raw).flop().flip().raw().toBuffer()\n const [straight, flipped] = await Promise.all([\n attentionHotspot(data, width, height, channels),\n attentionHotspot(mirrored, width, height, channels),\n ])\n if (straight === null || flipped === null) return null\n const x = agreeingAxis(straight.x, 1 - flipped.x)\n const y = agreeingAxis(straight.y, 1 - flipped.y)\n if (x === null && y === null) return null\n return { x: clampFraction(x ?? 0.5), y: clampFraction(y ?? 0.5) }\n}\n\n/** One attention pass at scale 1, as fractions of the buffer. */\nasync function attentionHotspot(\n data: Buffer,\n width: number,\n height: number,\n channels: 1 | 2 | 3 | 4,\n): Promise<FocalPoint | null> {\n const landscape = width >= height\n const { info } = await sharp(data, { raw: { width, height, channels } })\n .resize(landscape ? width - 1 : width, landscape ? height : height - 1, {\n fit: 'cover',\n position: sharp.strategy.attention,\n })\n .toBuffer({ resolveWithObject: true })\n const { attentionX, attentionY } = info\n if (attentionX === undefined || attentionY === undefined) return null\n return { x: attentionX / width, y: attentionY / height }\n}\n\n/** The two passes' answer for one axis, or `null` when they disagree (a flat axis). */\nfunction agreeingAxis(straight: number, mirrored: number): number | null {\n return Math.abs(straight - mirrored) <= AXIS_AGREEMENT ? (straight + mirrored) / 2 : null\n}\n\n/** Round to four decimals and keep inside `[0, 1]`, so the stored value passes `isFocalPoint`. */\nfunction clampFraction(value: number): number {\n if (!Number.isFinite(value)) return 0.5\n return Math.min(Math.max(Math.round(value * 10000) / 10000, 0), 1)\n}\n"],"mappings":"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"}