@murumets-ee/media 0.69.0 → 0.71.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.
- package/dist/admin.d.mts.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/client-CSOH7Zun.mjs +2 -0
- package/dist/client-CSOH7Zun.mjs.map +1 -0
- package/dist/client.d.mts +1 -1
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/crop.d.mts +2 -0
- package/dist/crop.mjs +1 -0
- package/dist/entity-CNxAERYG.mjs +2 -0
- package/dist/entity-CNxAERYG.mjs.map +1 -0
- package/dist/entity-DKFCdQJT.mjs +2 -0
- package/dist/entity-DKFCdQJT.mjs.map +1 -0
- package/dist/focal-point-BWhtJzef.mjs +2 -0
- package/dist/focal-point-BWhtJzef.mjs.map +1 -0
- package/dist/focal-point-C_0drV03.d.mts +125 -0
- package/dist/focal-point-C_0drV03.d.mts.map +1 -0
- package/dist/image-styles-settings-DEnu-V2E.mjs +2 -0
- package/dist/{image-styles-settings-ClIA2MJI.mjs.map → image-styles-settings-DEnu-V2E.mjs.map} +1 -1
- package/dist/image-styles-settings.d.mts +1 -1
- package/dist/image-styles-settings.mjs +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/picker.d.mts +107 -5
- package/dist/picker.d.mts.map +1 -1
- package/dist/picker.mjs +1 -1
- package/dist/picker.mjs.map +1 -1
- package/dist/{plugin-BkEED6qj.mjs → plugin-B_ZtfiAc.mjs} +2 -2
- package/dist/{plugin-BkEED6qj.mjs.map → plugin-B_ZtfiAc.mjs.map} +1 -1
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/process-image-WByEHTXQ.mjs +2 -0
- package/dist/process-image-WByEHTXQ.mjs.map +1 -0
- package/dist/processing.d.mts +1 -1
- package/dist/processing.d.mts.map +1 -1
- package/dist/processing.mjs +1 -1
- package/dist/public-resolver.d.mts +14 -1
- package/dist/public-resolver.d.mts.map +1 -1
- package/dist/public-resolver.mjs +1 -1
- package/dist/public-resolver.mjs.map +1 -1
- package/dist/query-client.d.mts +1 -1
- package/dist/query-client.mjs +1 -1
- package/dist/{regenerate-variants-sit6LbUo.mjs → regenerate-variants-2Lmo5kf8.mjs} +2 -2
- package/dist/{regenerate-variants-sit6LbUo.mjs.map → regenerate-variants-2Lmo5kf8.mjs.map} +1 -1
- package/dist/{regenerate-variants-BysyeIoA.mjs → regenerate-variants-B9AXXN6N.mjs} +2 -2
- package/dist/{regenerate-variants-BysyeIoA.mjs.map → regenerate-variants-B9AXXN6N.mjs.map} +1 -1
- package/dist/{resolve-image-styles-BzSRuXUs.mjs → resolve-image-styles-Cakg1Qpp.mjs} +2 -2
- package/dist/{resolve-image-styles-BzSRuXUs.mjs.map → resolve-image-styles-Cakg1Qpp.mjs.map} +1 -1
- package/dist/routes-C7Fx-055.mjs +2 -0
- package/dist/routes-C7Fx-055.mjs.map +1 -0
- package/dist/{types-1idCpe1k.d.mts → types-CnORVkLZ.d.mts} +24 -3
- package/dist/types-CnORVkLZ.d.mts.map +1 -0
- package/dist/variant-key-RKke2RfS.mjs +2 -0
- package/dist/variant-key-RKke2RfS.mjs.map +1 -0
- package/package.json +12 -8
- package/dist/client-YlSoGADl.mjs +0 -2
- package/dist/client-YlSoGADl.mjs.map +0 -1
- package/dist/entity-Caba_NFw.mjs +0 -2
- package/dist/entity-Caba_NFw.mjs.map +0 -1
- package/dist/entity-fxw-Qywj.mjs +0 -2
- package/dist/entity-fxw-Qywj.mjs.map +0 -1
- package/dist/image-styles-settings-ClIA2MJI.mjs +0 -2
- package/dist/process-image-DYDTMGUJ.mjs +0 -2
- package/dist/process-image-DYDTMGUJ.mjs.map +0 -1
- package/dist/routes-RCy0SYsK.mjs +0 -2
- package/dist/routes-RCy0SYsK.mjs.map +0 -1
- package/dist/types-1idCpe1k.d.mts.map +0 -1
- package/dist/variant-key-CLlac3_1.mjs +0 -2
- package/dist/variant-key-CLlac3_1.mjs.map +0 -1
package/dist/picker.d.mts
CHANGED
|
@@ -119,21 +119,103 @@ type AdminMediaCallbacks = MediaPickerCallbacks & {
|
|
|
119
119
|
*/
|
|
120
120
|
declare function createAdminMediaCallbacks(apiBasePath?: string): AdminMediaCallbacks;
|
|
121
121
|
//#endregion
|
|
122
|
-
//#region src/
|
|
122
|
+
//#region src/grid/media-card.d.ts
|
|
123
|
+
/**
|
|
124
|
+
* A structural copy of `@murumets-ee/media`'s own `MediaType`, not an
|
|
125
|
+
* import — same reason `media-type-icon.ts`'s `NonImageMediaType` isn't one.
|
|
126
|
+
* This module ships from the `./picker` tsdown entry (re-exported by
|
|
127
|
+
* `picker/index.ts`), a build SEPARATE from the root `index.ts` entry that
|
|
128
|
+
* already exports `MediaType` by name; importing (and thereby re-exporting,
|
|
129
|
+
* via `MediaCardProps`) it here too duplicates its declaration across two
|
|
130
|
+
* `.d.mts` files (`verify:dts`, #767) the moment this type reaches the
|
|
131
|
+
* entry's own public surface.
|
|
132
|
+
*/
|
|
133
|
+
type MediaCardMediaType = 'image' | 'video' | 'audio' | 'document' | 'other';
|
|
134
|
+
/**
|
|
135
|
+
* One tile: a thumbnail (or a typed icon for anything that is not an image),
|
|
136
|
+
* a hover title overlay, and a selection checkmark.
|
|
137
|
+
*
|
|
138
|
+
* Promoted out of `picker/` (entity-table PR06 §2) — this is the whole point
|
|
139
|
+
* of the promotion. `<MediaPicker>`'s `MediaGrid` and the admin-ui entity
|
|
140
|
+
* list's `grid` layout renderer both draw tiles through this ONE component,
|
|
141
|
+
* so a feature added to one reaches both rather than diverging the way
|
|
142
|
+
* WordPress's Backbone grid and `WP_List_Table` did (D007's cautionary tale).
|
|
143
|
+
*
|
|
144
|
+
* Decoupled from `MediaPickerItem` on purpose: the entity list's grid layout
|
|
145
|
+
* has no `MediaPickerItem` to hand it — it has a TanStack row and a
|
|
146
|
+
* `ViewGridOptions`-resolved field name — so the props here are the minimal
|
|
147
|
+
* shape both callers can build without synthesising fake data for fields
|
|
148
|
+
* neither has (a picker item's `size`/`width`/`height`, an entity row's MIME
|
|
149
|
+
* type).
|
|
150
|
+
*/
|
|
123
151
|
interface MediaCardProps {
|
|
124
|
-
|
|
152
|
+
/** Resolved thumbnail/asset URL. `null` (or a non-`'image'` `mediaType`) draws the typed icon instead. */
|
|
153
|
+
url: string | null;
|
|
154
|
+
/** The tile's accessible name and its hover-overlay label. */
|
|
155
|
+
title: string;
|
|
156
|
+
/**
|
|
157
|
+
* The image's OWN alt text, when the caller has one — an editor-authored
|
|
158
|
+
* field distinct from `title` (a display name most often equal to the
|
|
159
|
+
* filename). Falls back to `title` only when omitted (`undefined`) or
|
|
160
|
+
* `null` — a deliberately EMPTY string (`''`, the WCAG decorative-image
|
|
161
|
+
* marker) is preserved, not overridden. Review finding, entity-table PR06:
|
|
162
|
+
* the promotion had collapsed this into `title` and silently dropped a
|
|
163
|
+
* screen-reader-meaningful alt text down to the display name; a follow-up
|
|
164
|
+
* critique pass then caught the first fix using `||` (which clobbers `''`
|
|
165
|
+
* the same way) instead of `??`.
|
|
166
|
+
*/
|
|
167
|
+
alt?: string | null | undefined;
|
|
168
|
+
/**
|
|
169
|
+
* The item's classification. `'image'` draws `url` as an `<img>`;
|
|
170
|
+
* anything else — including `undefined`, an entity with no classification
|
|
171
|
+
* field at all — draws a typed (or generic) icon, never a broken `<img>`.
|
|
172
|
+
*/
|
|
173
|
+
mediaType: MediaCardMediaType | undefined;
|
|
125
174
|
isSelected: boolean;
|
|
126
175
|
onToggle: () => void;
|
|
176
|
+
/**
|
|
177
|
+
* Renders the tile as a real `<a href>` instead of a select button — the
|
|
178
|
+
* admin-ui entity list's grid layout (entity-table D004 ported to a card:
|
|
179
|
+
* the tile is the row's link, same as the table's title cell) sets this so
|
|
180
|
+
* cmd/ctrl-click, middle-click and "open in new tab" all work, exactly the
|
|
181
|
+
* capabilities S4 requires of the table. `onToggle` and `isSelected` still
|
|
182
|
+
* apply — the caller's OWN checkbox overlay (drawn outside this component,
|
|
183
|
+
* at a higher z-index) is what fires `onToggle`, not a click on the tile.
|
|
184
|
+
*
|
|
185
|
+
* Omitted (the picker's own use, `<MediaGrid>`) keeps the whole tile a
|
|
186
|
+
* select button, unchanged from before the promotion.
|
|
187
|
+
*/
|
|
188
|
+
href?: string | undefined;
|
|
189
|
+
/**
|
|
190
|
+
* Suppresses this tile's own checkmark badge — driven by whether the
|
|
191
|
+
* CALLER is drawing a DIFFERENT overlay in the same top-right corner, not
|
|
192
|
+
* by `href`. Critique finding, entity-table PR06: the first cut of the
|
|
193
|
+
* overlay-collision fix keyed this off `!!href`, which only happens to
|
|
194
|
+
* equal "does the caller draw an actions overlay here" for today's one
|
|
195
|
+
* caller (Media, where `editHref` and `allowDelete` are both always on).
|
|
196
|
+
* The moment an entity reaches the grid through the generic list page with
|
|
197
|
+
* a permission-gated `editHref` (so `href` can be `undefined`) while still
|
|
198
|
+
* allowing delete (so an actions overlay IS drawn), `!!href` goes false
|
|
199
|
+
* and the badge reappears under the menu it was meant to stay clear of.
|
|
200
|
+
* `EntityGridLayout` passes the caller's own truth — whether it rendered
|
|
201
|
+
* an `_actions` cell for this row — instead of re-deriving it from `href`.
|
|
202
|
+
*/
|
|
203
|
+
hideSelectionBadge?: boolean | undefined;
|
|
127
204
|
classNames?: MediaPickerClassNames | undefined;
|
|
128
205
|
}
|
|
129
206
|
declare function MediaCard({
|
|
130
|
-
|
|
207
|
+
url,
|
|
208
|
+
title,
|
|
209
|
+
alt,
|
|
210
|
+
mediaType,
|
|
131
211
|
isSelected,
|
|
132
212
|
onToggle,
|
|
213
|
+
href,
|
|
214
|
+
hideSelectionBadge,
|
|
133
215
|
classNames
|
|
134
216
|
}: MediaCardProps): import("react/jsx-runtime").JSX.Element;
|
|
135
217
|
//#endregion
|
|
136
|
-
//#region src/
|
|
218
|
+
//#region src/grid/media-grid.d.ts
|
|
137
219
|
interface MediaGridProps {
|
|
138
220
|
items: MediaPickerItem[];
|
|
139
221
|
selected: Set<string>;
|
|
@@ -145,6 +227,12 @@ interface MediaGridProps {
|
|
|
145
227
|
onPageChange: (offset: number) => void;
|
|
146
228
|
classNames?: MediaPickerClassNames | undefined;
|
|
147
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* The `<MediaPicker>` dialog's own grid — its pagination (offset/limit, a
|
|
232
|
+
* Prev/Next footer) is the picker's, not the entity list's. The TILE it
|
|
233
|
+
* draws is the promoted, shared `MediaCard` (entity-table PR06 §2); this
|
|
234
|
+
* wrapper's job is adapting `MediaPickerItem[]` to that tile's generic props.
|
|
235
|
+
*/
|
|
148
236
|
declare function MediaGrid({
|
|
149
237
|
items,
|
|
150
238
|
selected,
|
|
@@ -204,6 +292,20 @@ type NonImageMediaType = 'video' | 'audio' | 'document' | 'other';
|
|
|
204
292
|
declare const NON_IMAGE_MEDIA_TYPE_ICONS: Record<NonImageMediaType, LucideIcon>;
|
|
205
293
|
/** The icon for a non-image media type, or `null` for `'image'` (render the asset itself). */
|
|
206
294
|
declare function mediaTypeIcon(mediaType: 'image' | NonImageMediaType): LucideIcon | null;
|
|
295
|
+
/**
|
|
296
|
+
* The icon for a MIME type — the admin list's MIME column, where the string
|
|
297
|
+
* `video/mp4` is replaced by a film (entity-table D008's `mimeType` display).
|
|
298
|
+
*
|
|
299
|
+
* Unlike {@link mediaTypeIcon}, an image gets one too: that function returns
|
|
300
|
+
* `null` for `'image'` because its callers draw the ASSET instead, and a column
|
|
301
|
+
* that shows a type has no asset to draw. The classification is
|
|
302
|
+
* `deriveMediaType` — the rule the upload stored the row's `mediaType` by — so
|
|
303
|
+
* the icon cannot disagree with the type column beside it.
|
|
304
|
+
*
|
|
305
|
+
* Takes a plain string and returns a `LucideIcon`, so `MediaType` is not named
|
|
306
|
+
* in this entry's public API (see the note above `NonImageMediaType`).
|
|
307
|
+
*/
|
|
308
|
+
declare function mimeTypeIcon(mimeType: string): LucideIcon;
|
|
207
309
|
//#endregion
|
|
208
310
|
//#region src/picker/provider.d.ts
|
|
209
311
|
interface MediaPickerProviderProps extends MediaPickerCallbacks {
|
|
@@ -263,5 +365,5 @@ declare function UploadZone({
|
|
|
263
365
|
classNames
|
|
264
366
|
}: UploadZoneProps): import("react/jsx-runtime").JSX.Element;
|
|
265
367
|
//#endregion
|
|
266
|
-
export { type AdminMediaCallbacks, MediaCard, MediaGrid, MediaPicker, type MediaPickerCallbacks, type MediaPickerClassNames, type MediaPickerItem, type MediaPickerListResult, type MediaPickerMode, type MediaPickerProps, MediaPickerProvider, NON_IMAGE_MEDIA_TYPE_ICONS, SearchBar, UploadZone, createAdminMediaCallbacks, mediaTypeIcon, useMediaPicker };
|
|
368
|
+
export { type AdminMediaCallbacks, MediaCard, type MediaCardProps, MediaGrid, MediaPicker, type MediaPickerCallbacks, type MediaPickerClassNames, type MediaPickerItem, type MediaPickerListResult, type MediaPickerMode, type MediaPickerProps, MediaPickerProvider, NON_IMAGE_MEDIA_TYPE_ICONS, SearchBar, UploadZone, createAdminMediaCallbacks, mediaTypeIcon, mimeTypeIcon, useMediaPicker };
|
|
267
369
|
//# sourceMappingURL=picker.d.mts.map
|
package/dist/picker.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"picker.d.mts","names":[],"sources":["../src/picker/types.ts","../src/picker/admin-callbacks.ts","../src/
|
|
1
|
+
{"version":3,"file":"picker.d.mts","names":[],"sources":["../src/picker/types.ts","../src/picker/admin-callbacks.ts","../src/grid/media-card.tsx","../src/grid/media-grid.tsx","../src/picker/media-picker.tsx","../src/picker/media-type-icon.ts","../src/picker/provider.tsx","../src/picker/search-bar.tsx","../src/picker/upload-zone.tsx"],"mappings":"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"}
|
package/dist/picker.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import{Check as e,File as t,FileText as n,Film as r,Music as i,Search as a,Upload as o,X as s}from"lucide-react";import{clsx as c}from"clsx";import{twMerge as l}from"tailwind-merge";import{Fragment as u,jsx as d,jsxs as f}from"react/jsx-runtime";import*as p from"@radix-ui/react-dialog";import*as m from"@radix-ui/react-visually-hidden";import{createContext as h,useCallback as g,useContext as _,useEffect as v,useMemo as y,useRef as b,useState as x}from"react";function S(e=`/api/admin`){let t=`${e}/media`,n=async e=>{try{let t=await e.clone().json();if(typeof t.error==`string`&&t.error.length>0)return t.error}catch{}try{let t=await e.text();if(t)return t}catch{}return`HTTP ${String(e.status)}`};return{fetchMedia:async e=>{let r=new URLSearchParams;e.search&&r.set(`search`,e.search),e.mediaType&&r.set(`mediaType`,e.mediaType),r.set(`limit`,String(e.limit)),r.set(`offset`,String(e.offset));let i=`${t}?${r.toString()}`,a=await fetch(i);if(!a.ok)throw Error(await n(a));return a.json()},uploadMedia:async e=>{let r=new FormData;r.append(`file`,e),r.append(`visibility`,`public`);let i=await fetch(t,{method:`POST`,body:r});if(!i.ok)throw Error(await n(i));return i.json()},getMediaUrl:async e=>{try{let n=await fetch(`${t}/${e}`);return n.ok?(await n.json()).url??null:null}catch{return null}}}}function C(...e){return l(c(e))}const w={video:r,audio:i,document:n,other:t};function T(e){return e===`image`?null:w[e]}function E({item:t,isSelected:n,onToggle:r,classNames:i}){let a=t.mediaType===`image`,o=T(t.mediaType);return f(`button`,{type:`button`,onClick:r,className:C(`group relative aspect-square overflow-hidden rounded-lg border-2 transition-all`,`focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-950`,n?`border-blue-500 ring-2 ring-blue-500/20`:`border-zinc-200 hover:border-zinc-300 dark:border-zinc-800 dark:hover:border-zinc-700`,i?.card,n&&i?.cardSelected),children:[a?d(`img`,{src:t.url,alt:t.alt??t.filename,className:C(`h-full w-full object-cover`,i?.cardImage),loading:`lazy`}):d(`div`,{className:`flex h-full w-full items-center justify-center bg-zinc-100 dark:bg-zinc-800`,children:o&&d(o,{className:`h-8 w-8 text-zinc-400 dark:text-zinc-500`})}),n&&d(`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`,children:d(e,{className:`h-3 w-3`})}),d(`div`,{className:C(`absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5`,`opacity-0 transition-opacity group-hover:opacity-100`,i?.cardLabel),children:d(`p`,{className:`truncate text-xs text-white`,children:t.title??t.filename})})]})}function D({items:e,selected:t,onToggle:n,isLoading:r,total:i,offset:a,limit:o,onPageChange:s,classNames:c}){if(r)return d(`div`,{className:C(`grid grid-cols-4 gap-3 sm:grid-cols-6`,c?.grid),children:Array.from({length:12}).map((e,t)=>d(`div`,{className:C(`aspect-square animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800`,c?.loading)},`skeleton-${t.toString()}`))});if(e.length===0)return d(`div`,{className:C(`py-12 text-center text-sm text-zinc-500 dark:text-zinc-400`,c?.empty),children:`No media found. Upload a file to get started.`});let l=Math.ceil(i/o),p=Math.floor(a/o)+1;return f(u,{children:[d(`div`,{className:C(`grid grid-cols-4 gap-3 sm:grid-cols-6`,c?.grid),children:e.map(e=>d(E,{item:e,isSelected:t.has(e.id),onToggle:()=>n(e),classNames:c},e.id))}),l>1&&f(`div`,{className:`mt-4 flex items-center justify-center gap-2`,children:[d(`button`,{type:`button`,disabled:p<=1,onClick:()=>s(a-o),className:`rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700`,children:`Prev`}),f(`span`,{className:`text-sm text-zinc-500 dark:text-zinc-400`,children:[p,` / `,l]}),d(`button`,{type:`button`,disabled:p>=l,onClick:()=>s(a+o),className:`rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700`,children:`Next`})]})]})}const O=h(null);function k({children:e,fetchMedia:t,uploadMedia:n}){return d(O,{value:y(()=>({fetchMedia:t,uploadMedia:n}),[t,n]),children:e})}function A(){let e=_(O);if(!e)throw Error(`useMediaPicker must be used within <MediaPickerProvider>. Wrap your admin layout with <MediaPickerProvider fetchMedia={...} uploadMedia={...}>.`);return e}const j=[{value:void 0,label:`All`},{value:`image`,label:`Images`},{value:`video`,label:`Videos`},{value:`audio`,label:`Audio`},{value:`document`,label:`Docs`}];function M({value:e,onChange:t,mediaType:n,onMediaTypeChange:r,locked:i,classNames:o}){let s=!i;return f(`div`,{className:`flex items-center gap-3`,children:[f(`div`,{className:`relative flex-1`,children:[d(a,{className:`absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400`}),d(`input`,{type:`text`,value:e,onChange:e=>t(e.target.value),placeholder:`Search media...`,className:C(`w-full rounded-md border border-zinc-300 bg-transparent py-2 pl-10 pr-3 text-sm`,`placeholder:text-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500`,`dark:border-zinc-700 dark:placeholder:text-zinc-500`,o?.searchInput)})]}),s&&d(`div`,{className:C(`flex gap-1`,o?.filterTabs),children:j.map(e=>d(`button`,{type:`button`,onClick:()=>r(e.value),className:C(`rounded-md px-3 py-1.5 text-xs font-medium transition-colors`,n===e.value?`bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900`:`text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800`),children:e.label},e.label))})]})}function N({onUpload:e,isUploading:t,accept:n,classNames:r}){let i=b(null),[a,s]=x(!1),c=g(async t=>{t.preventDefault(),s(!1);let n=t.dataTransfer.files[0];n&&await e(n)},[e]),l=g(async t=>{let n=t.target.files?.[0];n&&(await e(n),t.target.value=``)},[e]);return f(`button`,{type:`button`,onDragOver:e=>{e.preventDefault(),s(!0)},onDragLeave:()=>s(!1),onDrop:c,onClick:()=>i.current?.click(),className:C(`mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-6 transition-colors`,a?`border-blue-500 bg-blue-50 dark:bg-blue-950/20`:`border-zinc-300 hover:border-zinc-400 dark:border-zinc-700 dark:hover:border-zinc-600`,r?.uploadZone,a&&r?.uploadZoneActive),children:[d(o,{className:`mb-2 h-6 w-6 text-zinc-400 dark:text-zinc-500`}),d(`p`,{className:`text-sm text-zinc-600 dark:text-zinc-400`,children:t?`Uploading...`:`Drop a file here or click to upload`}),d(`input`,{ref:i,type:`file`,accept:n?.join(`,`),onChange:l,className:`hidden`})]})}const P=new Set([`image`,`video`,`audio`]);function F(e){return e!==void 0&&P.has(e)?[`${e}/*`]:void 0}const I=`file`;function ee(e,t){return e===void 0||t.type===``?!0:e===I?!t.type.startsWith(`image/`)&&!t.type.startsWith(`video/`):P.has(e)?t.type.startsWith(`${e}/`):!0}function L(e,t){if(e===void 0||t.mediaType===e)return null;let n=t.title??t.filename;return e===I?t.mediaType!==`image`&&t.mediaType!==`video`?null:`This field takes a document or other file — “${n}” is ${R(t.mediaType)}.`:`This field takes ${e} files — “${n}” is ${t.mediaType===`other`?`not one`:R(t.mediaType)}.`}function R(e){return/^[aeiou]/.test(e)?`an ${e}`:`a ${e}`}function z({open:e,onOpenChange:t,onSelect:n,mode:r=`single`,accept:i,mediaType:a,maxSelect:o,selectedIds:c=[],title:l=`Select Media`,description:h,classNames:_,className:y,children:S}){let{fetchMedia:w,uploadMedia:T}=A(),[E,O]=x([]),[k,j]=x(0),[P,R]=x(()=>new Set(c)),[z,B]=x(``),[te,ne]=x(void 0),V=a??te,H=a===I?void 0:V,re=i??F(a),[ie,U]=x(!1),[ae,W]=x(!1),[G,K]=x(0),[q,J]=x(null),[Y,X]=x(null),Z=g(async()=>{U(!0),J(null);try{let e=await w({search:z||void 0,mediaType:H,limit:24,offset:G});e.configured===!1?(X(e.reason??`Media storage is not configured on this server.`),O([]),j(0)):(X(null),O(e.items),j(e.total))}catch(e){J(e instanceof Error&&e.message?`Failed to load media: ${e.message}`:`Failed to load media.`)}finally{U(!1)}},[w,z,H,G]);v(()=>{e&&Z()},[e,Z]);let Q=b(e),$=b(c);$.current=c,v(()=>{Q.current&&!e&&(B(``),K(0),R(new Set($.current))),Q.current=e},[e]);let oe=g(e=>{let i=L(a,e);if(i!==null){J(i);return}if(r===`single`){n([e]),t(!1);return}R(t=>{let n=new Set(t);if(n.has(e.id))n.delete(e.id);else{if(o&&n.size>=o)return t;n.add(e.id)}return n})},[r,o,a,n,t]),se=g(()=>{n(E.filter(e=>P.has(e.id))),t(!1)},[E,P,n,t]),ce=g(async e=>{if(J(null),!ee(a,e)){J(a===I?`This field takes a document or other file — “${e.name}” is an image or a video.`:`This field takes ${a} files — “${e.name}” is not one.`);return}W(!0);try{let i=await T(e),o=L(a,i);if(o!==null){J(o);return}if(r===`single`){n([i]),t(!1);return}O(e=>[i,...e]),j(e=>e+1),R(e=>new Set([...e,i.id]))}catch(e){J(e instanceof Error&&e.message?`Upload failed: ${e.message}`:`Upload failed. Please try again.`)}finally{W(!1)}},[T,r,a,n,t]);return d(p.Root,{open:e,onOpenChange:t,children:f(p.Portal,{children:[d(p.Overlay,{className:C(`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`,_?.overlay)}),f(p.Content,{...!h&&{"aria-describedby":void 0},className:C(`fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2`,`flex max-h-[85vh] w-[90vw] max-w-4xl flex-col`,`rounded-xl border border-zinc-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950`,_?.content,y),children:[f(`div`,{className:C(`flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-800`,_?.header),children:[d(p.Title,{className:C(`text-lg font-semibold text-zinc-900 dark:text-zinc-50`,_?.title),children:l}),h&&d(m.Root,{asChild:!0,children:d(p.Description,{children:h})}),f(p.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`,children:[d(s,{className:`h-4 w-4`}),d(m.Root,{children:`Close`})]})]}),d(`div`,{className:C(`border-b border-zinc-200 px-6 py-3 dark:border-zinc-800`,_?.toolbar),children:d(M,{value:z,onChange:B,mediaType:V,onMediaTypeChange:ne,locked:!!a,classNames:_})}),f(`div`,{className:`flex-1 overflow-y-auto px-6 py-4`,children:[q&&d(`div`,{role:`alert`,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`,children:q}),Y?f(`div`,{role:`alert`,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`,children:[d(`p`,{className:`text-sm font-medium text-amber-900 dark:text-amber-200`,children:`Media storage is not configured`}),d(`p`,{className:`mt-1 text-xs text-amber-800 dark:text-amber-300`,children:Y}),d(`p`,{className:`mt-3 text-xs text-amber-700 dark:text-amber-400`,children:`Set the required environment variables and restart the app to enable uploads.`})]}):f(u,{children:[d(N,{onUpload:ce,isUploading:ae,accept:re,classNames:_}),d(D,{items:E,selected:P,onToggle:oe,isLoading:ie,total:k,offset:G,limit:24,onPageChange:K,classNames:_})]})]}),f(`div`,{className:C(`flex items-center justify-between border-t border-zinc-200 px-6 py-4 dark:border-zinc-800`,_?.footer),children:[d(`span`,{className:`text-sm text-zinc-500 dark:text-zinc-400`,children:r===`single`?`${k.toString()} item${k===1?``:`s`} — click to select`:P.size>0?`${P.size.toString()} selected`:`${k.toString()} item${k===1?``:`s`}`}),f(`div`,{className:`flex gap-2`,children:[S,d(p.Close,{asChild:!0,children:d(`button`,{type:`button`,className:C(`rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50`,`dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-900`,_?.cancelButton),children:`Cancel`})}),r!==`single`&&d(`button`,{type:`button`,onClick:se,disabled:P.size===0,className:C(`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500`,`disabled:cursor-not-allowed disabled:opacity-50`,_?.confirmButton),children:`Select (${P.size.toString()})`})]})]})]})]})})}export{E as MediaCard,D as MediaGrid,z as MediaPicker,k as MediaPickerProvider,w as NON_IMAGE_MEDIA_TYPE_ICONS,M as SearchBar,N as UploadZone,S as createAdminMediaCallbacks,T as mediaTypeIcon,A as useMediaPicker};
|
|
2
|
+
import{Check as e,File as t,FileText as n,Film as r,Image as i,ImageIcon as a,Music as o,Search as s,Upload as c,X as l}from"lucide-react";import{clsx as u}from"clsx";import{twMerge as d}from"tailwind-merge";import{Fragment as f,jsx as p,jsxs as m}from"react/jsx-runtime";import*as h from"@radix-ui/react-dialog";import*as g from"@radix-ui/react-visually-hidden";import{createContext as _,useCallback as v,useContext as y,useEffect as b,useMemo as x,useRef as S,useState as C}from"react";function w(e=`/api/admin`){let t=`${e}/media`,n=async e=>{try{let t=await e.clone().json();if(typeof t.error==`string`&&t.error.length>0)return t.error}catch{}try{let t=await e.text();if(t)return t}catch{}return`HTTP ${String(e.status)}`};return{fetchMedia:async e=>{let r=new URLSearchParams;e.search&&r.set(`search`,e.search),e.mediaType&&r.set(`mediaType`,e.mediaType),r.set(`limit`,String(e.limit)),r.set(`offset`,String(e.offset));let i=`${t}?${r.toString()}`,a=await fetch(i);if(!a.ok)throw Error(await n(a));return a.json()},uploadMedia:async e=>{let r=new FormData;r.append(`file`,e),r.append(`visibility`,`public`);let i=await fetch(t,{method:`POST`,body:r});if(!i.ok)throw Error(await n(i));return i.json()},getMediaUrl:async e=>{try{let n=await fetch(`${t}/${e}`);return n.ok?(await n.json()).url??null:null}catch{return null}}}}function T(...e){return d(u(e))}function E(e){return e.startsWith(`image/`)?`image`:e.startsWith(`video/`)?`video`:e.startsWith(`audio/`)?`audio`:e===`application/pdf`||e.startsWith(`application/msword`)||e.startsWith(`application/vnd.`)?`document`:`other`}const D={video:r,audio:o,document:n,other:t};function O(e){return e===`image`?null:D[e]}function k(e){let t=E(e);return t===`image`?i:D[t]}function A({url:t,title:n,alt:r,mediaType:i,isSelected:o,onToggle:s,href:c,hideSelectionBadge:l,classNames:u}){let d=i===`image`&&!!t,h=r??n,g=i===void 0?null:i===`image`?a:O(i),_=T(`group relative block aspect-square overflow-hidden rounded-lg border-2 transition-all`,`focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-950`,o?`border-blue-500 ring-2 ring-blue-500/20`:`border-zinc-200 hover:border-zinc-300 dark:border-zinc-800 dark:hover:border-zinc-700`,u?.card,o&&u?.cardSelected),v=m(f,{children:[d?p(`img`,{src:t,alt:h,className:T(`h-full w-full object-cover`,u?.cardImage),loading:`lazy`}):p(`div`,{className:`flex h-full w-full items-center justify-center bg-zinc-100 dark:bg-zinc-800`,children:g&&p(g,{className:`h-8 w-8 text-zinc-400 dark:text-zinc-500`})}),o&&!l&&p(`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`,children:p(e,{className:`h-3 w-3`})}),p(`div`,{className:T(`absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5`,`opacity-0 transition-opacity group-hover:opacity-100`,u?.cardLabel),children:p(`p`,{className:`truncate text-xs text-white`,children:n})})]});return c?p(`a`,{href:c,"aria-label":n,className:_,children:v}):p(`button`,{type:`button`,onClick:s,"aria-pressed":o,"aria-label":n,className:_,children:v})}function j({items:e,selected:t,onToggle:n,isLoading:r,total:i,offset:a,limit:o,onPageChange:s,classNames:c}){if(r)return p(`div`,{className:T(`grid grid-cols-4 gap-3 sm:grid-cols-6`,c?.grid),children:Array.from({length:12}).map((e,t)=>p(`div`,{className:T(`aspect-square animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800`,c?.loading)},`skeleton-${t.toString()}`))});if(e.length===0)return p(`div`,{className:T(`py-12 text-center text-sm text-zinc-500 dark:text-zinc-400`,c?.empty),children:`No media found. Upload a file to get started.`});let l=Math.ceil(i/o),u=Math.floor(a/o)+1;return m(f,{children:[p(`div`,{className:T(`grid grid-cols-4 gap-3 sm:grid-cols-6`,c?.grid),children:e.map(e=>p(A,{url:e.mediaType===`image`?e.url:null,title:e.title||e.filename,alt:e.alt,mediaType:e.mediaType,isSelected:t.has(e.id),onToggle:()=>n(e),classNames:c},e.id))}),l>1&&m(`div`,{className:`mt-4 flex items-center justify-center gap-2`,children:[p(`button`,{type:`button`,disabled:u<=1,onClick:()=>s(a-o),className:`rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700`,children:`Prev`}),m(`span`,{className:`text-sm text-zinc-500 dark:text-zinc-400`,children:[u,` / `,l]}),p(`button`,{type:`button`,disabled:u>=l,onClick:()=>s(a+o),className:`rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700`,children:`Next`})]})]})}const M=_(null);function N({children:e,fetchMedia:t,uploadMedia:n}){return p(M,{value:x(()=>({fetchMedia:t,uploadMedia:n}),[t,n]),children:e})}function P(){let e=y(M);if(!e)throw Error(`useMediaPicker must be used within <MediaPickerProvider>. Wrap your admin layout with <MediaPickerProvider fetchMedia={...} uploadMedia={...}>.`);return e}const F=[{value:void 0,label:`All`},{value:`image`,label:`Images`},{value:`video`,label:`Videos`},{value:`audio`,label:`Audio`},{value:`document`,label:`Docs`}];function I({value:e,onChange:t,mediaType:n,onMediaTypeChange:r,locked:i,classNames:a}){let o=!i;return m(`div`,{className:`flex items-center gap-3`,children:[m(`div`,{className:`relative flex-1`,children:[p(s,{className:`absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400`}),p(`input`,{type:`text`,value:e,onChange:e=>t(e.target.value),placeholder:`Search media...`,className:T(`w-full rounded-md border border-zinc-300 bg-transparent py-2 pl-10 pr-3 text-sm`,`placeholder:text-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500`,`dark:border-zinc-700 dark:placeholder:text-zinc-500`,a?.searchInput)})]}),o&&p(`div`,{className:T(`flex gap-1`,a?.filterTabs),children:F.map(e=>p(`button`,{type:`button`,onClick:()=>r(e.value),className:T(`rounded-md px-3 py-1.5 text-xs font-medium transition-colors`,n===e.value?`bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900`:`text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800`),children:e.label},e.label))})]})}function L({onUpload:e,isUploading:t,accept:n,classNames:r}){let i=S(null),[a,o]=C(!1),s=v(async t=>{t.preventDefault(),o(!1);let n=t.dataTransfer.files[0];n&&await e(n)},[e]),l=v(async t=>{let n=t.target.files?.[0];n&&(await e(n),t.target.value=``)},[e]);return m(`button`,{type:`button`,onDragOver:e=>{e.preventDefault(),o(!0)},onDragLeave:()=>o(!1),onDrop:s,onClick:()=>i.current?.click(),className:T(`mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-6 transition-colors`,a?`border-blue-500 bg-blue-50 dark:bg-blue-950/20`:`border-zinc-300 hover:border-zinc-400 dark:border-zinc-700 dark:hover:border-zinc-600`,r?.uploadZone,a&&r?.uploadZoneActive),children:[p(c,{className:`mb-2 h-6 w-6 text-zinc-400 dark:text-zinc-500`}),p(`p`,{className:`text-sm text-zinc-600 dark:text-zinc-400`,children:t?`Uploading...`:`Drop a file here or click to upload`}),p(`input`,{ref:i,type:`file`,accept:n?.join(`,`),onChange:l,className:`hidden`})]})}const R=new Set([`image`,`video`,`audio`]);function ee(e){return e!==void 0&&R.has(e)?[`${e}/*`]:void 0}const z=`file`;function te(e,t){return e===void 0||t.type===``?!0:e===z?!t.type.startsWith(`image/`)&&!t.type.startsWith(`video/`):R.has(e)?t.type.startsWith(`${e}/`):!0}function B(e,t){if(e===void 0||t.mediaType===e)return null;let n=t.title??t.filename;return e===z?t.mediaType!==`image`&&t.mediaType!==`video`?null:`This field takes a document or other file — “${n}” is ${V(t.mediaType)}.`:`This field takes ${e} files — “${n}” is ${t.mediaType===`other`?`not one`:V(t.mediaType)}.`}function V(e){return/^[aeiou]/.test(e)?`an ${e}`:`a ${e}`}function H({open:e,onOpenChange:t,onSelect:n,mode:r=`single`,accept:i,mediaType:a,maxSelect:o,selectedIds:s=[],title:c=`Select Media`,description:u,classNames:d,className:_,children:y}){let{fetchMedia:x,uploadMedia:w}=P(),[E,D]=C([]),[O,k]=C(0),[A,M]=C(()=>new Set(s)),[N,F]=C(``),[R,V]=C(void 0),H=a??R,U=a===z?void 0:H,ne=i??ee(a),[re,W]=C(!1),[ie,G]=C(!1),[K,q]=C(0),[J,Y]=C(null),[X,Z]=C(null),Q=v(async()=>{W(!0),Y(null);try{let e=await x({search:N||void 0,mediaType:U,limit:24,offset:K});e.configured===!1?(Z(e.reason??`Media storage is not configured on this server.`),D([]),k(0)):(Z(null),D(e.items),k(e.total))}catch(e){Y(e instanceof Error&&e.message?`Failed to load media: ${e.message}`:`Failed to load media.`)}finally{W(!1)}},[x,N,U,K]);b(()=>{e&&Q()},[e,Q]);let $=S(e),ae=S(s);ae.current=s,b(()=>{$.current&&!e&&(F(``),q(0),M(new Set(ae.current))),$.current=e},[e]);let oe=v(e=>{let i=B(a,e);if(i!==null){Y(i);return}if(r===`single`){n([e]),t(!1);return}M(t=>{let n=new Set(t);if(n.has(e.id))n.delete(e.id);else{if(o&&n.size>=o)return t;n.add(e.id)}return n})},[r,o,a,n,t]),se=v(()=>{n(E.filter(e=>A.has(e.id))),t(!1)},[E,A,n,t]),ce=v(async e=>{if(Y(null),!te(a,e)){Y(a===z?`This field takes a document or other file — “${e.name}” is an image or a video.`:`This field takes ${a} files — “${e.name}” is not one.`);return}G(!0);try{let i=await w(e),o=B(a,i);if(o!==null){Y(o);return}if(r===`single`){n([i]),t(!1);return}D(e=>[i,...e]),k(e=>e+1),M(e=>new Set([...e,i.id]))}catch(e){Y(e instanceof Error&&e.message?`Upload failed: ${e.message}`:`Upload failed. Please try again.`)}finally{G(!1)}},[w,r,a,n,t]);return p(h.Root,{open:e,onOpenChange:t,children:m(h.Portal,{children:[p(h.Overlay,{className:T(`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`,d?.overlay)}),m(h.Content,{...!u&&{"aria-describedby":void 0},className:T(`fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2`,`flex max-h-[85vh] w-[90vw] max-w-4xl flex-col`,`rounded-xl border border-zinc-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950`,d?.content,_),children:[m(`div`,{className:T(`flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-800`,d?.header),children:[p(h.Title,{className:T(`text-lg font-semibold text-zinc-900 dark:text-zinc-50`,d?.title),children:c}),u&&p(g.Root,{asChild:!0,children:p(h.Description,{children:u})}),m(h.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`,children:[p(l,{className:`h-4 w-4`}),p(g.Root,{children:`Close`})]})]}),p(`div`,{className:T(`border-b border-zinc-200 px-6 py-3 dark:border-zinc-800`,d?.toolbar),children:p(I,{value:N,onChange:F,mediaType:H,onMediaTypeChange:V,locked:!!a,classNames:d})}),m(`div`,{className:`flex-1 overflow-y-auto px-6 py-4`,children:[J&&p(`div`,{role:`alert`,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`,children:J}),X?m(`div`,{role:`alert`,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`,children:[p(`p`,{className:`text-sm font-medium text-amber-900 dark:text-amber-200`,children:`Media storage is not configured`}),p(`p`,{className:`mt-1 text-xs text-amber-800 dark:text-amber-300`,children:X}),p(`p`,{className:`mt-3 text-xs text-amber-700 dark:text-amber-400`,children:`Set the required environment variables and restart the app to enable uploads.`})]}):m(f,{children:[p(L,{onUpload:ce,isUploading:ie,accept:ne,classNames:d}),p(j,{items:E,selected:A,onToggle:oe,isLoading:re,total:O,offset:K,limit:24,onPageChange:q,classNames:d})]})]}),m(`div`,{className:T(`flex items-center justify-between border-t border-zinc-200 px-6 py-4 dark:border-zinc-800`,d?.footer),children:[p(`span`,{className:`text-sm text-zinc-500 dark:text-zinc-400`,children:r===`single`?`${O.toString()} item${O===1?``:`s`} — click to select`:A.size>0?`${A.size.toString()} selected`:`${O.toString()} item${O===1?``:`s`}`}),m(`div`,{className:`flex gap-2`,children:[y,p(h.Close,{asChild:!0,children:p(`button`,{type:`button`,className:T(`rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50`,`dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-900`,d?.cancelButton),children:`Cancel`})}),r!==`single`&&p(`button`,{type:`button`,onClick:se,disabled:A.size===0,className:T(`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500`,`disabled:cursor-not-allowed disabled:opacity-50`,d?.confirmButton),children:`Select (${A.size.toString()})`})]})]})]})]})})}export{A as MediaCard,j as MediaGrid,H as MediaPicker,N as MediaPickerProvider,D as NON_IMAGE_MEDIA_TYPE_ICONS,I as SearchBar,L as UploadZone,w as createAdminMediaCallbacks,O as mediaTypeIcon,k as mimeTypeIcon,P as useMediaPicker};
|
|
3
3
|
//# sourceMappingURL=picker.mjs.map
|
package/dist/picker.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"picker.mjs","names":[],"sources":["../src/picker/admin-callbacks.ts","../src/lib/cn.ts","../src/picker/media-type-icon.ts","../src/picker/media-card.tsx","../src/picker/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, type LucideIcon, Music } from 'lucide-react'\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","import { Check } from 'lucide-react'\nimport { cn } from '../lib/cn'\nimport { mediaTypeIcon } from './media-type-icon'\nimport type { MediaPickerClassNames, MediaPickerItem } from './types'\n\ninterface MediaCardProps {\n item: MediaPickerItem\n isSelected: boolean\n onToggle: () => void\n classNames?: MediaPickerClassNames | undefined\n}\n\nexport function MediaCard({ item, isSelected, onToggle, classNames }: MediaCardProps) {\n const isImage = item.mediaType === 'image'\n const Icon = mediaTypeIcon(item.mediaType)\n\n return (\n <button\n type=\"button\"\n onClick={onToggle}\n className={cn(\n 'group relative 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 {/* Thumbnail */}\n {isImage ? (\n <img\n src={item.url}\n alt={item.alt ?? item.filename}\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 {/* Selection checkmark */}\n {isSelected && (\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 {/* Filename 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\">{item.title ?? item.filename}</p>\n </div>\n </button>\n )\n}\n","import { cn } from '../lib/cn'\nimport { MediaCard } from './media-card'\nimport type { MediaPickerClassNames, MediaPickerItem } from './types'\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\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 item={item}\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 './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/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,2 +1,2 @@
|
|
|
1
|
-
import{n as e}from"./routes-
|
|
2
|
-
//# sourceMappingURL=plugin-
|
|
1
|
+
import{n as e}from"./routes-C7Fx-055.mjs";import"./entity-CNxAERYG.mjs";import"./image-styles-settings-DEnu-V2E.mjs";import{getPluginConfig as t,hasPluginConfig as n}from"@murumets-ee/core";import"@murumets-ee/media/image-styles";function r(){if(!n(`@murumets-ee/media`))throw Error(`@murumets-ee/media plugin not initialized. Add media() to your plugins array.`);return t(e)}export{r as getMediaConfig};
|
|
2
|
+
//# sourceMappingURL=plugin-B_ZtfiAc.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin-
|
|
1
|
+
{"version":3,"file":"plugin-B_ZtfiAc.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, 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":"sOAyCA,SAAgB,GAA8C,CAC5D,GAAI,CAAC,EAAA,oBAAiC,EACpC,MAAU,MAAM,+EAA+E,EAEjG,OAAO,EAAgB,CAAiB,CAC1C"}
|
package/dist/plugin.d.mts
CHANGED
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./entity-fxw-Qywj.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,safeAudit as c}from"@murumets-ee/core";import{ImageStylesManager as l,RegenerateVariantsAction as u}from"@murumets-ee/media/image-styles";const d=`@murumets-ee/media`,f=[`image/*`,`video/*`,`audio/*`,`application/pdf`,`application/vnd.openxmlformats-officedocument.*`,`application/vnd.oasis.opendocument.*`,`text/plain`,`text/csv`];function p(e){return{acceptedTypes:e?.acceptedTypes??[...f],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 m(){return s(`@murumets-ee/media`)?o(d):p()}const h=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function g(e){return e!==void 0&&h.test(e)}const _=[`image`,`video`,`audio`,`document`,`other`];function v(e){return _.includes(e)}function y(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function b(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function x(e,t){return b({error:e},t)}const S={clientPromise:null};async function C(){if(!S.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(()=>{S.clientPromise===e&&(S.clientPromise=null)}),S.clientPromise=e}return S.clientPromise}async function w(){let{createAdminClient:e}=await import(`@murumets-ee/core/clients`),{MediaClient:t}=await import(`./client.mjs`),{Media:n}=await import(`./entity-fxw-Qywj.mjs`).then(e=>e.n),r=await C();return new t({admin:e(n),storage:r})}const T=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(!g(e))return x(`Invalid media ID format`,400);let{findMediaUsages:t}=await import(`./usage.mjs`),{getApp:n}=await import(`@murumets-ee/core`);return b({usages:await t(e,n())})}if(i.length>=2)return x(`Not found`,404);let a=i.length>0?i[0]:void 0;if(a!==void 0&&!g(a))return x(`Invalid media ID format`,400);if(!n()){let e=r()??`Storage not configured`;return i.length>0?b({error:e,configured:!1,reason:e},503):b({items:[],total:0,configured:!1,reason:e})}let o=await w();if(a!==void 0){let e=await o.findById(a);if(!e)return x(`Media not found`,404);let t=await o.getUrl(a);return b({...e,url:t})}let s=new URL(e.url),c=s.searchParams.get(`search`)??void 0,l=s.searchParams.get(`mediaType`),u=l!==null&&v(l)?l:void 0,d=Math.min(Math.max(y(s.searchParams.get(`limit`),24),1),100),f=Math.min(y(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),[_,S]=await Promise.all([o.getUrls(m),o.getVariantUrls(h,`thumbnail`)]);return b({items:p.items.map(e=>{let t=e.mediaType===`image`?S.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:_.get(e.id)??``,...t!==void 0&&{thumbnailUrl:t},width:e.width??null,height:e.height??null}}),total:p.total})},E=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 x(`Not found`,404);if(!n())return x(r()??`Storage not configured`,503);let{maxUploadSize:l,acceptedTypes:u}=m(),d=await o(e,l);if(!d.ok)return x(d.error,d.status);let f=d.value,p=f.get(`file`);if(!(p instanceof File)||p.size===0)return x(`No file provided`,400);if(p.size>l){let e=s(l);return x(e.error,e.status)}let h=await w(),g=Buffer.from(await p.arrayBuffer()),{mimeType:_,mismatch:v}=await i(g,p.type||`application/octet-stream`);if(v)return x(`File content doesn't match declared type: claimed ${p.type}, detected ${_}`,400);if(!a(_,u))return x(`File type ${_} is not accepted`,415);let y=f.get(`visibility`);if(y!==null&&y!==`public`&&y!==`private`)return x(`Field 'visibility' must be 'public' or 'private'`,400);let S=await h.upload(g,{filename:p.name,mimeType:_,size:p.size,uploadedBy:t.user.id,...y!==null&&{visibility:y}}),C={id:S.media.id,title:S.media.title??null,alt:S.media.alt??null,filename:S.media.filename,mimeType:S.media.mimeType,size:S.media.size,mediaType:S.media.mediaType,url:S.url,width:S.media.width??null,height:S.media.height??null};return c(t,{action:`media.upload`,entityType:`media`,entityId:S.media.id,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name},changes:{filename:S.media.filename,mimeType:S.media.mimeType,size:S.media.size,mediaType:S.media.mediaType}}),b(C,201)},D=async(e,t)=>{let{isStorageConfigured:n,getStorageConfigReason:r}=await import(`@murumets-ee/storage`);if(t.segments.length!==1)return x(`Not found`,404);if(!n())return x(r()??`Storage not configured`,503);let{regenerateAllVariants:i}=await import(`./regenerate-variants-sit6LbUo.mjs`),{getApp:a,getContext:o}=await import(`@murumets-ee/core`),{resolveImageStyles:s}=await import(`./resolve-image-styles-iN9JbZYf.mjs`),{createStorageClient:l}=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 x(`No image styles configured`,400);let p=await i({app:d,storage:l(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 c(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}}),b(p)},O=async(e,t)=>{let n=t.segments;if(n.length===0)return x(`Media ID required`,400);if(n.length>1)return x(`Not found`,404);let r=n[0];if(!g(r))return x(`Invalid media ID format`,400);let{isStorageConfigured:i,getStorageConfigReason:a}=await import(`@murumets-ee/storage`);return i()?(await(await w()).delete(r),c(t,{action:`media.delete`,entityType:`media`,entityId:r,userId:t.user.id,...t.user.name!==void 0&&{userName:t.user.name}}),b({deleted:1})):x(a()??`Storage not configured`,503)},k=[`admin`,`editor`,`agent`,`viewer`],A=[`admin`,`editor`];function j(){return r([i({prefix:`media`,path:``,method:`GET`,permission:`media:view`,defaultRoles:k,matchAnyPath:!0,description:`Read media — list, single item, or referencing-entity usage report.`,handler:T}),i({prefix:`media`,path:``,method:`POST`,permission:`media:create`,defaultRoles:A,description:"Upload a media file (multipart FormData with `file` field).",handler:E}),i({prefix:`media`,path:`regenerate-variants`,method:`POST`,permission:`media:create`,defaultRoles:A,description:`Regenerate every image-style variant for every media row.`,handler:D}),i({prefix:`media`,path:``,method:`DELETE`,permission:`media:delete`,defaultRoles:A,matchAnyPath:!0,description:`Delete a media record + its storage object.`,handler:O})])}function M(){if(!s(`@murumets-ee/media`))throw Error(`@murumets-ee/media plugin not initialized. Add media() to your plugins array.`);return o(d)}function N(r){let i=p(r);return a({name:`@murumets-ee/media`,requires:[`@murumets-ee/storage`,`@murumets-ee/settings`],shared:{messages:n,settings:[t]},server:{entities:[e],routes:[...j()],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":l},settingsActions:{"media.imageStyles":u}}})}export{M as getMediaConfig,N as media};
|
|
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};
|
|
2
2
|
//# sourceMappingURL=plugin.mjs.map
|