@murumets-ee/media 0.1.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/LICENSE +94 -0
- package/README.md +15 -0
- package/dist/admin.d.ts +47 -0
- package/dist/admin.js +1 -0
- package/dist/chunk-JGE5BDIT.js +1 -0
- package/dist/chunk-L6BDKI76.js +1 -0
- package/dist/chunk-QTUXM53A.js +1 -0
- package/dist/chunk-YAHM4C5J.js +1 -0
- package/dist/client-37WG2Y6P.js +1 -0
- package/dist/client.d.ts +102 -0
- package/dist/client.js +1 -0
- package/dist/entity-QOBW3TFU.js +1 -0
- package/dist/image-styles-settings-AB5WFEFF.js +1 -0
- package/dist/image-styles-settings.d.ts +9 -0
- package/dist/image-styles-settings.js +1 -0
- package/dist/image-styles.d.ts +62 -0
- package/dist/image-styles.js +2 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +1 -0
- package/dist/picker.d.ts +183 -0
- package/dist/picker.js +2 -0
- package/dist/plugin-CAV5BZMF.js +1 -0
- package/dist/plugin.d.ts +35 -0
- package/dist/plugin.js +1 -0
- package/dist/query-client.d.ts +36 -0
- package/dist/query-client.js +1 -0
- package/dist/ref.d.ts +100 -0
- package/dist/ref.js +1 -0
- package/dist/regenerate-variants-IUDIIWXU.js +1 -0
- package/dist/types-ChlTxvlq.d.ts +101 -0
- package/dist/usage-E5RZMWE4.js +1 -0
- package/dist/usage.d.ts +28 -0
- package/dist/usage.js +1 -0
- package/package.json +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Elastic License 2.0 (ELv2)
|
|
2
|
+
|
|
3
|
+
URL: https://www.elastic.co/licensing/elastic-license
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
12
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
13
|
+
available, and prepare derivative works of the software, in each case subject
|
|
14
|
+
to the limitations and conditions below.
|
|
15
|
+
|
|
16
|
+
## Limitations
|
|
17
|
+
|
|
18
|
+
You may not provide the software to third parties as a hosted or managed
|
|
19
|
+
service, where the service provides users with access to any substantial set
|
|
20
|
+
of the features or functionality of the software.
|
|
21
|
+
|
|
22
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
23
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
24
|
+
software that is protected by the license key.
|
|
25
|
+
|
|
26
|
+
You may not alter, remove, or obscure any licensing, copyright, or other
|
|
27
|
+
notices of the licensor in the software. Any use of the licensor's trademarks
|
|
28
|
+
is subject to applicable law.
|
|
29
|
+
|
|
30
|
+
## Patents
|
|
31
|
+
|
|
32
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
33
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
34
|
+
sale, import and have imported the software, in each case subject to the
|
|
35
|
+
limitations and conditions in this license. This license does not cover any
|
|
36
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
37
|
+
the software. If you or your company make any written claim that the software
|
|
38
|
+
infringes or contributes to infringement of any patent, your patent license
|
|
39
|
+
for the software granted under these terms ends immediately. If your company
|
|
40
|
+
makes such a claim, your patent license ends immediately for work on behalf
|
|
41
|
+
of your company.
|
|
42
|
+
|
|
43
|
+
## Notices
|
|
44
|
+
|
|
45
|
+
You must ensure that anyone who gets a copy of any part of the software from
|
|
46
|
+
you also gets a copy of these terms.
|
|
47
|
+
|
|
48
|
+
If you modify the software, you must include in any modified copies of the
|
|
49
|
+
software prominent notices stating that you have modified the software.
|
|
50
|
+
|
|
51
|
+
## No Other Rights
|
|
52
|
+
|
|
53
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
54
|
+
these terms.
|
|
55
|
+
|
|
56
|
+
## Termination
|
|
57
|
+
|
|
58
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
59
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
60
|
+
with a notice of your violation, and you cease all violation of this license
|
|
61
|
+
no later than 30 days after you receive that notice, your licenses will be
|
|
62
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
63
|
+
reinstatement, any additional violation of these terms will cause your
|
|
64
|
+
licenses to terminate automatically and permanently.
|
|
65
|
+
|
|
66
|
+
## No Liability
|
|
67
|
+
|
|
68
|
+
As far as the law allows, the software comes as is, without any warranty or
|
|
69
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
70
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
71
|
+
legal claim.
|
|
72
|
+
|
|
73
|
+
## Definitions
|
|
74
|
+
|
|
75
|
+
The **licensor** is the entity offering these terms, and the **software** is
|
|
76
|
+
the software the licensor makes available under these terms, including any
|
|
77
|
+
portion of it.
|
|
78
|
+
|
|
79
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
80
|
+
|
|
81
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
82
|
+
organization that you work for, plus all organizations that have control over,
|
|
83
|
+
are under the control of, or are under common control with that organization.
|
|
84
|
+
**control** means ownership of substantially all the assets of an entity, or
|
|
85
|
+
the power to direct the management and policies of an entity (for example, by
|
|
86
|
+
voting right, contract, or otherwise). Control can be direct or indirect.
|
|
87
|
+
|
|
88
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
89
|
+
these terms.
|
|
90
|
+
|
|
91
|
+
**use** means anything you do with the software requiring one of your
|
|
92
|
+
licenses.
|
|
93
|
+
|
|
94
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @murumets-ee/media
|
|
2
|
+
|
|
3
|
+
Media management — image processing, variant generation, media picker, and storage integration.
|
|
4
|
+
|
|
5
|
+
Part of [Lumi CMS](https://github.com/murumets-ee/lumi-cms) — a modular, type-safe CMS toolkit for Node.js.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @murumets-ee/media
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## License
|
|
14
|
+
|
|
15
|
+
[Elastic License 2.0 (ELv2)](../../LICENSE)
|
package/dist/admin.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { AdminRoute } from '@murumets-ee/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Media admin routes for the centralized admin API handler.
|
|
5
|
+
*
|
|
6
|
+
* Provides media-specific operations: upload, URL resolution, delete with storage cleanup,
|
|
7
|
+
* image style settings, and variant regeneration.
|
|
8
|
+
*
|
|
9
|
+
* Standard entity CRUD (PATCH with translations) falls through to the generic entity
|
|
10
|
+
* handler — add `Media` to the `entities` array in your handler config.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'
|
|
15
|
+
* import { mediaRoutes } from '@murumets-ee/media/admin'
|
|
16
|
+
* import { Media } from '@murumets-ee/media'
|
|
17
|
+
*
|
|
18
|
+
* const handler = createAdminApiHandler({
|
|
19
|
+
* authenticate: async (req) => { ... },
|
|
20
|
+
* entities: [Article, Media],
|
|
21
|
+
* routes: [mediaRoutes()],
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create admin API routes for media-specific operations.
|
|
28
|
+
*
|
|
29
|
+
* Standard entity CRUD (PATCH with translations) is handled by the generic entity
|
|
30
|
+
* handler — add `Media` to the `entities` array in your handler config.
|
|
31
|
+
*
|
|
32
|
+
* Routes handled by this plugin:
|
|
33
|
+
* - `GET /api/admin/media` — List media with search/filter + URLs
|
|
34
|
+
* - `GET /api/admin/media/settings` — Get current image styles
|
|
35
|
+
* - `GET /api/admin/media/:id` — Get single media item with URL
|
|
36
|
+
* - `GET /api/admin/media/:id/usage` — Find entities referencing this media
|
|
37
|
+
* - `POST /api/admin/media` — Upload file (FormData with `file` field)
|
|
38
|
+
* - `POST /api/admin/media/settings` — Save image styles (admin only)
|
|
39
|
+
* - `POST /api/admin/media/regenerate-variants` — Regenerate all image variants (admin only)
|
|
40
|
+
* - `DELETE /api/admin/media/:id` — Delete media + storage file (blocked if in use)
|
|
41
|
+
*
|
|
42
|
+
* Routes handled by generic entity handler (via fallthrough):
|
|
43
|
+
* - `PATCH /api/admin/media/:id` — Update metadata with translation support
|
|
44
|
+
*/
|
|
45
|
+
declare function mediaRoutes(): AdminRoute;
|
|
46
|
+
|
|
47
|
+
export { mediaRoutes };
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var P=["cover","contain","inside","outside","fill"],j=["webp","jpeg","png","avif"];function F(r){for(let[e,a]of Object.entries(r)){if(typeof e!="string"||e.length===0||!/^[a-z][a-z0-9_-]*$/.test(e))return{valid:!1,error:`Invalid style name "${e}": must be lowercase alphanumeric (a-z, 0-9, -, _)`};if(!a||typeof a!="object")return{valid:!1,error:`Style "${e}" must be an object`};let t=a;if(t.width!==void 0&&(typeof t.width!="number"||t.width<=0))return{valid:!1,error:`Invalid width for style "${e}": must be a positive number`};if(t.height!==void 0&&(typeof t.height!="number"||t.height<=0))return{valid:!1,error:`Invalid height for style "${e}": must be a positive number`};if(t.width===void 0&&t.height===void 0)return{valid:!1,error:`Style "${e}" must have at least width or height`};if(t.quality!==void 0&&(typeof t.quality!="number"||t.quality<1||t.quality>100))return{valid:!1,error:`Invalid quality for style "${e}": must be 1-100`};if(t.fit!==void 0&&!P.includes(t.fit))return{valid:!1,error:`Invalid fit for style "${e}": must be one of ${P.join(", ")}`};if(t.format!==void 0&&!j.includes(t.format))return{valid:!1,error:`Invalid format for style "${e}": must be one of ${j.join(", ")}`}}return{valid:!0,styles:r}}var k=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function U(r){return k.test(r)}var A=null;function I(){return A||(A=(async()=>{let{getApp:r}=await import("@murumets-ee/core"),{createStorageClient:e}=await import("@murumets-ee/storage"),{getStorageConfig:a}=await import("@murumets-ee/storage/plugin"),{MediaClient:t}=await import("./client-37WG2Y6P.js"),o=r(),g=a(),b=e(g,{app:o});return new t({db:o.db.readWrite,storage:b,logger:o.logger.child({media:!0})})})()),A}function c(r,e=200){return new Response(JSON.stringify(r),{status:e,headers:{"Content-Type":"application/json"}})}function l(r,e){return c({error:r},e)}async function q(r,{segments:e}){let a=await I();if(e.length===1&&e[0]==="settings"){let{createSettingsClient:i}=await import("@murumets-ee/settings"),{imageStylesSettings:d}=await import("./image-styles-settings-AB5WFEFF.js"),{getApp:s}=await import("@murumets-ee/core"),u=s(),h=await i(d,{app:u}).get("imageStyles");return c({imageStyles:h??{}})}if(e.length===2&&e[1]==="usage"){let i=e[0];if(!U(i))return l("Invalid media ID format",400);let{findMediaUsages:d}=await import("./usage-E5RZMWE4.js"),{getApp:s}=await import("@murumets-ee/core"),u=s(),f=await d(i,u.db.readWrite);return c({usages:f})}if(e.length>0){let i=e[0],d=await a.findById(i);if(!d)return l("Media not found",404);let s=await a.getUrl(i);return c({...d,url:s})}let t=new URL(r.url),o=t.searchParams.get("search")??void 0,g=t.searchParams.get("mediaType")??void 0,b=Math.min(Math.max(Number(t.searchParams.get("limit"))||24,1),100),m=Math.max(Number(t.searchParams.get("offset"))||0,0),p=await a.findMany({search:o,mediaType:g,limit:b,offset:m}),y=p.items.map(i=>i.id),[T,v]=await Promise.all([a.getUrls(y),a.getVariantUrls(y,"thumbnail")]),n={items:p.items.map(i=>({id:i.id,title:i.title,alt:i.alt,filename:i.filename,mimeType:i.mimeType,size:i.size,mediaType:i.mediaType,url:T.get(i.id)??"",thumbnailUrl:v.get(i.id),width:i.width,height:i.height})),total:p.total};return c(n)}async function L(r,{segments:e,user:a,audit:t,checkPermission:o}){if(e.length===1&&e[0]==="regenerate-variants"){if(!o("media","update"))return l("Forbidden: media update permission required for variant regeneration",403);let{createSettingsClient:d}=await import("@murumets-ee/settings"),{imageStylesSettings:s}=await import("./image-styles-settings-AB5WFEFF.js"),{regenerateAllVariants:u}=await import("./regenerate-variants-IUDIIWXU.js"),{getApp:f}=await import("@murumets-ee/core"),{createStorageClient:h}=await import("@murumets-ee/storage"),{getStorageConfig:M}=await import("@murumets-ee/storage/plugin"),w=f(),C=await d(s,{app:w}).get("imageStyles");if(!C||Object.keys(C).length===0)return l("No image styles configured",400);let z=M(),D=h(z,{app:w}),S=await u({db:w.db.readWrite,storage:D,logger:w.logger.child({media:!0}),styles:C});return t?.({action:"media.regenerate_variants",userId:a.id,userName:a.name,metadata:{total:S.total,processed:S.processed,errors:S.errors}}),c(S)}if(e.length===1&&e[0]==="settings"){if(!o("media","update"))return l("Forbidden: media update permission required for image style management",403);let d=await r.json();if(!d.imageStyles||typeof d.imageStyles!="object")return l('Body must contain "imageStyles" object',400);let s=F(d.imageStyles);if(!s.valid)return l(s.error,400);let{createSettingsClient:u}=await import("@murumets-ee/settings"),{imageStylesSettings:f}=await import("./image-styles-settings-AB5WFEFF.js"),{getApp:h}=await import("@murumets-ee/core"),M=h();return await u(f,{app:M}).set("imageStyles",s.styles),(await I()).invalidateImageStylesCache(),t?.({action:"media.settings.update",entityType:"settings",userId:a.id,userName:a.name,changes:{imageStyles:s.styles}}),c({imageStyles:s.styles})}let g=await I(),m=(await r.formData()).get("file");if(!m||m.size===0)return l("No file provided",400);let p=50*1024*1024;if(m.size>p)return l(`File too large: ${(m.size/1024/1024).toFixed(1)} MB exceeds 50 MB limit`,400);let y=Buffer.from(await m.arrayBuffer()),{detectMimeType:T}=await import("@murumets-ee/storage"),{mimeType:v,mismatch:R}=await T(y,m.type||"application/octet-stream");if(R)return l(`File content doesn't match declared type: claimed ${m.type}, detected ${v}`,400);let n=await g.upload(y,{filename:m.name,mimeType:v,size:m.size,uploadedBy:a.id}),i={id:n.media.id,title:n.media.title,alt:n.media.alt,filename:n.media.filename,mimeType:n.media.mimeType,size:n.media.size,mediaType:n.media.mediaType,url:n.url,width:n.media.width,height:n.media.height};return t?.({action:"media.upload",entityType:"media",entityId:n.media.id,userId:a.id,userName:a.name,changes:{filename:n.media.filename,mimeType:n.media.mimeType,size:n.media.size,mediaType:n.media.mediaType}}),c(i,201)}async function N(r,{segments:e,user:a,audit:t}){if(e.length===0)return l("Media ID required",400);let o=e[0];return U(o)?(await(await I()).delete(o),t?.({action:"media.delete",entityType:"media",entityId:o,userId:a.id,userName:a.name}),c({deleted:1})):l("Invalid media ID format",400)}function _(){return{prefix:"media",resource:"media",actions:["view","create","update","delete"],handlers:{GET:q,POST:L,DELETE:N}}}export{_ as mediaRoutes};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{defineSettings as e,setting as t}from"@murumets-ee/settings";var a=e({namespace:"media.imageStyles",scope:"global",label:"Image Styles",schema:{imageStyles:t.json({default:{thumbnail:{width:200,height:200,fit:"cover",format:"webp",quality:80}},label:"Image processing presets"})}});export{a};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{behavior as t,defineEntity as r,field as e}from"@murumets-ee/entity";var u=r({name:"media",fields:{title:e.text({translatable:!0}),alt:e.text({translatable:!0}),description:e.text({translatable:!0}),fileKey:e.text({required:!0,indexed:!0}),filename:e.text({required:!0}),mimeType:e.text({required:!0,indexed:!0}),size:e.number({required:!0,integer:!0}),width:e.number({integer:!0}),height:e.number({integer:!0}),mediaType:e.select({options:["image","video","audio","document","other"],required:!0,indexed:!0})},behaviors:[t.auditable()],scope:"global",access:{view:"public",create:"group.editor",update:"group.editor",delete:"group.admin"}});export{u as a};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import c from"sharp";var p=new Set(["image/svg+xml","image/gif"]);function l(t){return t.startsWith("image/")&&!p.has(t)}async function P(t,a){let i=await c(t).metadata(),r=i.width??0,n=i.height??0,s=new Map,o=Object.entries(a);return await Promise.all(o.map(async([g,e])=>{let m=e.format??"webp",f=e.quality??80,u=e.fit??"cover",d=c(t).resize({width:e.width,height:e.height,fit:u,withoutEnlargement:!0}),{data:w,info:h}=await d[m]({quality:f}).toBuffer({resolveWithObject:!0});s.set(g,{buffer:w,format:m,mimeType:`image/${m}`,width:h.width,height:h.height})})),{width:r,height:n,variants:s}}function x(t,a,i="webp"){let r=t.lastIndexOf("/"),n=t.substring(0,r),o=t.substring(r+1).replace(/\.[^.]+$/,"");return`${n}/${a}_${o}.${i}`}export{l as a,P as b,x as c};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{behavior as t,defineEntity as r,field as e}from"@murumets-ee/entity";var u=r({name:"media",fields:{title:e.text({translatable:!0}),alt:e.text({translatable:!0}),description:e.text({translatable:!0}),fileKey:e.text({required:!0,indexed:!0}),filename:e.text({required:!0}),mimeType:e.text({required:!0,indexed:!0}),size:e.number({required:!0,integer:!0}),width:e.number({integer:!0}),height:e.number({integer:!0}),mediaType:e.select({options:["image","video","audio","document","other"],required:!0,indexed:!0})},behaviors:[t.auditable()],scope:"global",access:{view:"public",create:"group.editor",update:"group.editor",delete:"group.admin"}});export{u as a};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as b}from"./chunk-YAHM4C5J.js";import{a as v,b as k,c as h}from"./chunk-QTUXM53A.js";import"server-only";var f=class{db;storage;logger;admin=null;imageStyles;constructor(e){this.db=e.db,this.storage=e.storage,this.logger=e.logger,this.imageStyles=e.imageStyles??null}async getAdmin(){if(!this.admin){let{AdminClient:e}=await import("@murumets-ee/entity/admin");this.admin=new e({entity:b,db:this.db,logger:this.logger})}return this.admin}async resolveImageStyles(){if(this.imageStyles)return this.imageStyles;try{let{createSettingsClient:e}=await import("@murumets-ee/settings"),{imageStylesSettings:t}=await import("./image-styles-settings-AB5WFEFF.js"),{getApp:i}=await import("@murumets-ee/core"),a=i(),r=await e(t,{app:a}).get("imageStyles");if(r&&Object.keys(r).length>0)return this.imageStyles=r,r}catch{}try{let{getMediaConfig:e}=await import("./plugin-CAV5BZMF.js"),t=e();return this.imageStyles=t.imageStyles,this.imageStyles}catch{let e={thumbnail:{width:200,height:200,fit:"cover",format:"webp",quality:80}};return this.imageStyles=e,e}}invalidateImageStylesCache(){this.imageStyles=null}async upload(e,t){let i=await this.storage.upload(e,{filename:t.filename,mimeType:t.mimeType,size:t.size,visibility:t.visibility,uploadedBy:t.uploadedBy}),a=t.width??null,s=t.height??null,r={};if(e instanceof Buffer&&v(t.mimeType))try{let l=await this.resolveImageStyles(),n=await k(e,l);a=n.width,s=n.height;let m=i.visibility;await Promise.all([...n.variants.entries()].map(async([d,y])=>{let u=h(i.key,d,y.format);try{await this.storage.upload(y.buffer,{key:u,filename:`${d}_${t.filename}`,mimeType:y.mimeType,size:y.buffer.byteLength,visibility:m,metadata:{variantOf:i.key,style:d},uploadedBy:t.uploadedBy}),r[d]=u,this.logger?.debug({style:d,key:u,width:y.width,height:y.height},"Variant uploaded")}catch(w){this.logger?.warn({style:d,key:u,error:w},"Failed to upload variant (non-fatal)")}})),Object.keys(r).length>0&&await this.storage.updateMetadata(i.key,{metadata:{...i.metadata??{},variants:r}}).catch(d=>{this.logger?.warn({key:i.key,error:d},"Failed to update original file metadata with variant keys (non-fatal)")}),this.logger?.info({width:a,height:s,variants:Object.keys(r)},"Image processed")}catch(l){this.logger?.warn({filename:t.filename,error:l},"Image processing failed (non-fatal, original saved)")}let o=C(t.mimeType),c=await this.getAdmin();try{let l=await c.create({title:t.title??P(t.filename),alt:t.alt??null,description:t.description??null,fileKey:i.key,filename:t.filename,mimeType:t.mimeType,size:t.size,width:a,height:s,mediaType:o}),n=await this.storage.getUrl(i.key);return this.logger?.info({id:l.id,fileKey:i.key,mediaType:o},"Media uploaded"),{media:l,url:n}}catch(l){this.logger?.error({fileKey:i.key,error:l},"Media entity creation failed, rolling back storage uploads");for(let n of Object.values(r))await this.storage.delete(n).catch(()=>{});throw await this.storage.delete(i.key).catch(n=>{this.logger?.error({fileKey:i.key,error:n},"Storage rollback also failed")}),l}}async findById(e,t){return await(await this.getAdmin()).findById(e,t)}async findMany(e){let t=await this.getAdmin(),{schemaRegistry:i}=await import("@murumets-ee/db"),{and:a,asc:s,desc:r,eq:o,ilike:c,sql:l}=await import("drizzle-orm"),n=i.get("media");if(!n)throw new Error("Media schema not registered. Is the media() plugin loaded?");let m=[];if(e?.mediaType&&m.push(o(n.mediaType,e.mediaType)),e?.mimeTypePrefix&&m.push(c(n.mimeType,`${e.mimeTypePrefix}%`)),e?.search){let M=`%${e.search}%`;m.push(l`(${c(n.filename,M)} OR ${n.fields} ->> 'title' ILIKE ${M})`)}let d=e?.limit??50,y=e?.offset??0,u=m.length>0?a(...m):void 0,[w]=await this.db.select({count:l`count(*)::int`}).from(n).where(u),S=e?.orderBy==="filename"?n.filename:n.createdAt,R=(e?.orderDirection??"desc")==="asc"?s:r;return{items:await t.findMany({where:u,limit:d,offset:y,orderBy:R(S)}),total:w?.count??0,limit:d,offset:y}}async update(e,t){return await(await this.getAdmin()).update(e,t)}async delete(e){let t=await this.getAdmin(),i=await t.findById(e);if(!i)throw new Error(`Media not found: ${e}`);let a=i.fileKey;await t.delete(e);let r=(await this.storage.getMetadata(a))?.metadata?.variants;if(r)for(let o of Object.values(r))await this.storage.delete(o).catch(c=>{this.logger?.warn({variantKey:o,error:c},"Failed to delete variant file")});await this.storage.delete(a).catch(o=>{this.logger?.error({id:e,fileKey:a,error:o},"Failed to delete file from storage after entity deletion")}),this.logger?.info({id:e,fileKey:a,deletedVariants:r?Object.keys(r).length:0},"Media deleted")}async getUrl(e){let i=await(await this.getAdmin()).findById(e);if(!i)throw new Error(`Media not found: ${e}`);return this.storage.getUrl(i.fileKey)}async getUrls(e){if(e.length===0)return new Map;let t=await this.getAdmin(),{schemaRegistry:i}=await import("@murumets-ee/db"),{inArray:a}=await import("drizzle-orm"),s=i.get("media");if(!s)return new Map;let r=await t.findMany({where:a(s.id,e),limit:e.length}),o=new Map;return await Promise.all(r.map(async c=>{let l=await this.storage.getUrl(c.fileKey);o.set(c.id,l)})),o}async getVariantUrl(e,t){let a=await(await this.getAdmin()).findById(e);if(!a)return null;let s=a.fileKey,o=(await this.resolveImageStyles())[t];if(o){let c=h(s,t,o.format??"webp");try{return await this.storage.getUrl(c)}catch{}}try{return await this.storage.getUrl(s)}catch{return null}}async getVariantUrls(e,t){if(e.length===0)return new Map;let i=await this.getAdmin(),{schemaRegistry:a}=await import("@murumets-ee/db"),{inArray:s}=await import("drizzle-orm"),r=a.get("media");if(!r)return new Map;let o=await i.findMany({where:s(r.id,e),limit:e.length}),l=(await this.resolveImageStyles())[t],n=new Map;return await Promise.all(o.map(async m=>{if(l){let d=h(m.fileKey,t,l.format??"webp");try{let y=await this.storage.getUrl(d);n.set(m.id,y);return}catch{}}try{let d=await this.storage.getUrl(m.fileKey);n.set(m.id,d)}catch{}})),n}};function C(g){return g.startsWith("image/")?"image":g.startsWith("video/")?"video":g.startsWith("audio/")?"audio":g==="application/pdf"||g.startsWith("application/msword")||g.startsWith("application/vnd.")?"document":"other"}function P(g){return g.replace(/\.[^.]+$/,"").replace(/[-_]/g," ")}async function B(g){let{getApp:e}=await import("@murumets-ee/core"),t=e();return new f({db:t.db.readWrite,storage:g,logger:t.logger.child({media:!0})})}var p=null;function F(){return p||(p=(async()=>{let{getApp:g}=await import("@murumets-ee/core"),{createStorageClient:e}=await import("@murumets-ee/storage"),{getStorageConfig:t}=await import("@murumets-ee/storage/plugin"),i=g(),a=t(),s=e(a,{app:i});return new f({db:i.db.readWrite,storage:s,logger:i.logger.child({media:!0})})})()),p}export{f as MediaClient,B as createMediaClient,F as getMediaClient};
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Logger } from '@murumets-ee/core';
|
|
2
|
+
import { StorageClient } from '@murumets-ee/storage';
|
|
3
|
+
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
|
4
|
+
import { I as ImageStyle, M as MediaUploadOptions, a as MediaUploadResult, b as MediaRecord, c as MediaListOptions, d as MediaListResult } from './types-ChlTxvlq.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MediaClient — wraps AdminClient + StorageClient for media management.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { createMediaClient } from '@murumets-ee/media/client'
|
|
11
|
+
* const media = await createMediaClient(storageClient)
|
|
12
|
+
* const result = await media.upload(buffer, { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 12345 })
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface MediaClientConfig {
|
|
16
|
+
db: PostgresJsDatabase;
|
|
17
|
+
storage: StorageClient;
|
|
18
|
+
logger?: Logger;
|
|
19
|
+
/** Image styles to generate on upload. Loaded from plugin config if not provided. */
|
|
20
|
+
imageStyles?: Record<string, ImageStyle>;
|
|
21
|
+
}
|
|
22
|
+
declare class MediaClient {
|
|
23
|
+
private db;
|
|
24
|
+
private storage;
|
|
25
|
+
private logger?;
|
|
26
|
+
private admin;
|
|
27
|
+
private imageStyles;
|
|
28
|
+
constructor(config: MediaClientConfig);
|
|
29
|
+
private getAdmin;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve image styles: settings DB → plugin config → hardcoded defaults.
|
|
32
|
+
* Result is cached; call `invalidateImageStylesCache()` after settings update.
|
|
33
|
+
*/
|
|
34
|
+
private resolveImageStyles;
|
|
35
|
+
/** Clear cached styles so next access re-reads from settings DB. */
|
|
36
|
+
invalidateImageStylesCache(): void;
|
|
37
|
+
/**
|
|
38
|
+
* Upload a file and create a media entity record in one step.
|
|
39
|
+
* 1. Uploads original to storage (StorageClient)
|
|
40
|
+
* 2. If image: extracts dimensions via Sharp + generates variants
|
|
41
|
+
* 3. Creates media entity record (AdminClient)
|
|
42
|
+
* 4. Returns the media record + URL
|
|
43
|
+
*
|
|
44
|
+
* Rolls back storage upload if entity creation fails.
|
|
45
|
+
* Variant generation failures are logged but don't fail the upload.
|
|
46
|
+
*/
|
|
47
|
+
upload(data: Buffer | ReadableStream<Uint8Array>, options: MediaUploadOptions): Promise<MediaUploadResult>;
|
|
48
|
+
findById(id: string, options?: {
|
|
49
|
+
locale?: string;
|
|
50
|
+
}): Promise<MediaRecord | null>;
|
|
51
|
+
findMany(options?: MediaListOptions): Promise<MediaListResult>;
|
|
52
|
+
update(id: string, data: {
|
|
53
|
+
title?: string;
|
|
54
|
+
alt?: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
}): Promise<MediaRecord>;
|
|
57
|
+
/**
|
|
58
|
+
* Delete a media entity, its variants, and its original file in storage.
|
|
59
|
+
*/
|
|
60
|
+
delete(id: string): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Get URL for a media entity by its ID.
|
|
63
|
+
* Resolves entity -> fileKey -> storage URL.
|
|
64
|
+
*/
|
|
65
|
+
getUrl(id: string): Promise<string>;
|
|
66
|
+
/**
|
|
67
|
+
* Get URLs for multiple media entities (batch).
|
|
68
|
+
* Returns a Map of mediaId -> url.
|
|
69
|
+
*/
|
|
70
|
+
getUrls(ids: string[]): Promise<Map<string, string>>;
|
|
71
|
+
/**
|
|
72
|
+
* Get variant URL for a specific image style.
|
|
73
|
+
* Falls back to original URL if the variant doesn't exist.
|
|
74
|
+
*
|
|
75
|
+
* @param id - Media entity ID
|
|
76
|
+
* @param styleName - Image style name (e.g., 'thumbnail')
|
|
77
|
+
* @returns The variant URL, or original URL as fallback, or null if media not found
|
|
78
|
+
*/
|
|
79
|
+
getVariantUrl(id: string, styleName: string): Promise<string | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Get variant URLs for multiple media entities (batch).
|
|
82
|
+
* Falls back to original URL per item if the variant doesn't exist.
|
|
83
|
+
*
|
|
84
|
+
* @param ids - Media entity IDs
|
|
85
|
+
* @param styleName - Image style name (e.g., 'thumbnail')
|
|
86
|
+
* @returns Map of mediaId -> variant URL (or original URL as fallback)
|
|
87
|
+
*/
|
|
88
|
+
getVariantUrls(ids: string[], styleName: string): Promise<Map<string, string>>;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Factory — creates a MediaClient with an explicit StorageClient.
|
|
92
|
+
* Must be called after createApp().
|
|
93
|
+
*/
|
|
94
|
+
declare function createMediaClient(storage: StorageClient): Promise<MediaClient>;
|
|
95
|
+
/**
|
|
96
|
+
* Lazy singleton — returns a shared MediaClient instance.
|
|
97
|
+
* Auto-discovers storage configuration from the storage plugin.
|
|
98
|
+
* Must be called after createApp().
|
|
99
|
+
*/
|
|
100
|
+
declare function getMediaClient(): Promise<MediaClient>;
|
|
101
|
+
|
|
102
|
+
export { MediaClient, type MediaClientConfig, createMediaClient, getMediaClient };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as b}from"./chunk-L6BDKI76.js";import"server-only";import v from"sharp";var I=new Set(["image/svg+xml","image/gif"]);function S(s){return s.startsWith("image/")&&!I.has(s)}async function k(s,e){let t=await v(s).metadata(),i=t.width??0,a=t.height??0,o=new Map,r=Object.entries(e);return await Promise.all(r.map(async([d,g])=>{let l=g.format??"webp",n=g.quality??80,m=g.fit??"cover",c=v(s).resize({width:g.width,height:g.height,fit:m,withoutEnlargement:!0}),{data:u,info:h}=await c[l]({quality:n}).toBuffer({resolveWithObject:!0});o.set(d,{buffer:u,format:l,mimeType:`image/${l}`,width:h.width,height:h.height})})),{width:i,height:a,variants:o}}function f(s,e,t="webp"){let i=s.lastIndexOf("/"),a=s.substring(0,i),r=s.substring(i+1).replace(/\.[^.]+$/,"");return`${a}/${e}_${r}.${t}`}var y=class{db;storage;logger;admin=null;imageStyles;constructor(e){this.db=e.db,this.storage=e.storage,this.logger=e.logger,this.imageStyles=e.imageStyles??null}async getAdmin(){if(!this.admin){let{AdminClient:e}=await import("@murumets-ee/entity/admin");this.admin=new e({entity:b,db:this.db,logger:this.logger})}return this.admin}async resolveImageStyles(){if(this.imageStyles)return this.imageStyles;try{let{createSettingsClient:e}=await import("@murumets-ee/settings"),{imageStylesSettings:t}=await import("./image-styles-settings.js"),{getApp:i}=await import("@murumets-ee/core"),a=i(),r=await e(t,{app:a}).get("imageStyles");if(r&&Object.keys(r).length>0)return this.imageStyles=r,r}catch{}try{let{getMediaConfig:e}=await import("./plugin.js"),t=e();return this.imageStyles=t.imageStyles,this.imageStyles}catch{let e={thumbnail:{width:200,height:200,fit:"cover",format:"webp",quality:80}};return this.imageStyles=e,e}}invalidateImageStylesCache(){this.imageStyles=null}async upload(e,t){let i=await this.storage.upload(e,{filename:t.filename,mimeType:t.mimeType,size:t.size,visibility:t.visibility,uploadedBy:t.uploadedBy}),a=t.width??null,o=t.height??null,r={};if(e instanceof Buffer&&S(t.mimeType))try{let l=await this.resolveImageStyles(),n=await k(e,l);a=n.width,o=n.height;let m=i.visibility;await Promise.all([...n.variants.entries()].map(async([c,u])=>{let h=f(i.key,c,u.format);try{await this.storage.upload(u.buffer,{key:h,filename:`${c}_${t.filename}`,mimeType:u.mimeType,size:u.buffer.byteLength,visibility:m,metadata:{variantOf:i.key,style:c},uploadedBy:t.uploadedBy}),r[c]=h,this.logger?.debug({style:c,key:h,width:u.width,height:u.height},"Variant uploaded")}catch(w){this.logger?.warn({style:c,key:h,error:w},"Failed to upload variant (non-fatal)")}})),Object.keys(r).length>0&&await this.storage.updateMetadata(i.key,{metadata:{...i.metadata??{},variants:r}}).catch(c=>{this.logger?.warn({key:i.key,error:c},"Failed to update original file metadata with variant keys (non-fatal)")}),this.logger?.info({width:a,height:o,variants:Object.keys(r)},"Image processed")}catch(l){this.logger?.warn({filename:t.filename,error:l},"Image processing failed (non-fatal, original saved)")}let d=C(t.mimeType),g=await this.getAdmin();try{let l=await g.create({title:t.title??A(t.filename),alt:t.alt??null,description:t.description??null,fileKey:i.key,filename:t.filename,mimeType:t.mimeType,size:t.size,width:a,height:o,mediaType:d}),n=await this.storage.getUrl(i.key);return this.logger?.info({id:l.id,fileKey:i.key,mediaType:d},"Media uploaded"),{media:l,url:n}}catch(l){this.logger?.error({fileKey:i.key,error:l},"Media entity creation failed, rolling back storage uploads");for(let n of Object.values(r))await this.storage.delete(n).catch(()=>{});throw await this.storage.delete(i.key).catch(n=>{this.logger?.error({fileKey:i.key,error:n},"Storage rollback also failed")}),l}}async findById(e,t){return await(await this.getAdmin()).findById(e,t)}async findMany(e){let t=await this.getAdmin(),{schemaRegistry:i}=await import("@murumets-ee/db"),{and:a,asc:o,desc:r,eq:d,ilike:g,sql:l}=await import("drizzle-orm"),n=i.get("media");if(!n)throw new Error("Media schema not registered. Is the media() plugin loaded?");let m=[];if(e?.mediaType&&m.push(d(n.mediaType,e.mediaType)),e?.mimeTypePrefix&&m.push(g(n.mimeType,`${e.mimeTypePrefix}%`)),e?.search){let M=`%${e.search}%`;m.push(l`(${g(n.filename,M)} OR ${n.fields} ->> 'title' ILIKE ${M})`)}let c=e?.limit??50,u=e?.offset??0,h=m.length>0?a(...m):void 0,[w]=await this.db.select({count:l`count(*)::int`}).from(n).where(h),R=e?.orderBy==="filename"?n.filename:n.createdAt,P=(e?.orderDirection??"desc")==="asc"?o:r;return{items:await t.findMany({where:h,limit:c,offset:u,orderBy:P(R)}),total:w?.count??0,limit:c,offset:u}}async update(e,t){return await(await this.getAdmin()).update(e,t)}async delete(e){let t=await this.getAdmin(),i=await t.findById(e);if(!i)throw new Error(`Media not found: ${e}`);let a=i.fileKey;await t.delete(e);let r=(await this.storage.getMetadata(a))?.metadata?.variants;if(r)for(let d of Object.values(r))await this.storage.delete(d).catch(g=>{this.logger?.warn({variantKey:d,error:g},"Failed to delete variant file")});await this.storage.delete(a).catch(d=>{this.logger?.error({id:e,fileKey:a,error:d},"Failed to delete file from storage after entity deletion")}),this.logger?.info({id:e,fileKey:a,deletedVariants:r?Object.keys(r).length:0},"Media deleted")}async getUrl(e){let i=await(await this.getAdmin()).findById(e);if(!i)throw new Error(`Media not found: ${e}`);return this.storage.getUrl(i.fileKey)}async getUrls(e){if(e.length===0)return new Map;let t=await this.getAdmin(),{schemaRegistry:i}=await import("@murumets-ee/db"),{inArray:a}=await import("drizzle-orm"),o=i.get("media");if(!o)return new Map;let r=await t.findMany({where:a(o.id,e),limit:e.length}),d=new Map;return await Promise.all(r.map(async g=>{let l=await this.storage.getUrl(g.fileKey);d.set(g.id,l)})),d}async getVariantUrl(e,t){let a=await(await this.getAdmin()).findById(e);if(!a)return null;let o=a.fileKey,d=(await this.resolveImageStyles())[t];if(d){let g=f(o,t,d.format??"webp");try{return await this.storage.getUrl(g)}catch{}}try{return await this.storage.getUrl(o)}catch{return null}}async getVariantUrls(e,t){if(e.length===0)return new Map;let i=await this.getAdmin(),{schemaRegistry:a}=await import("@murumets-ee/db"),{inArray:o}=await import("drizzle-orm"),r=a.get("media");if(!r)return new Map;let d=await i.findMany({where:o(r.id,e),limit:e.length}),l=(await this.resolveImageStyles())[t],n=new Map;return await Promise.all(d.map(async m=>{if(l){let c=f(m.fileKey,t,l.format??"webp");try{let u=await this.storage.getUrl(c);n.set(m.id,u);return}catch{}}try{let c=await this.storage.getUrl(m.fileKey);n.set(m.id,c)}catch{}})),n}};function C(s){return s.startsWith("image/")?"image":s.startsWith("video/")?"video":s.startsWith("audio/")?"audio":s==="application/pdf"||s.startsWith("application/msword")||s.startsWith("application/vnd.")?"document":"other"}function A(s){return s.replace(/\.[^.]+$/,"").replace(/[-_]/g," ")}async function F(s){let{getApp:e}=await import("@murumets-ee/core"),t=e();return new y({db:t.db.readWrite,storage:s,logger:t.logger.child({media:!0})})}var p=null;function V(){return p||(p=(async()=>{let{getApp:s}=await import("@murumets-ee/core"),{createStorageClient:e}=await import("@murumets-ee/storage"),{getStorageConfig:t}=await import("@murumets-ee/storage/plugin"),i=s(),a=t(),o=e(a,{app:i});return new y({db:i.db.readWrite,storage:o,logger:i.logger.child({media:!0})})})()),p}export{y as MediaClient,F as createMediaClient,V as getMediaClient};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a}from"./chunk-YAHM4C5J.js";export{a as Media};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{defineSettings as e,setting as t}from"@murumets-ee/settings";var a=e({namespace:"media.imageStyles",scope:"global",label:"Image Styles",schema:{imageStyles:t.json({default:{thumbnail:{width:200,height:200,fit:"cover",format:"webp",quality:80}},label:"Image processing presets"})}});export{a as imageStylesSettings};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as _murumets_ee_settings from '@murumets-ee/settings';
|
|
2
|
+
import { I as ImageStyle } from './types-ChlTxvlq.js';
|
|
3
|
+
import '@murumets-ee/storage';
|
|
4
|
+
|
|
5
|
+
declare const imageStylesSettings: _murumets_ee_settings.SettingsDefinition<{
|
|
6
|
+
readonly imageStyles: _murumets_ee_settings.JsonSettingConfig<Record<string, ImageStyle>>;
|
|
7
|
+
}>;
|
|
8
|
+
|
|
9
|
+
export { imageStylesSettings };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a}from"./chunk-JGE5BDIT.js";export{a as imageStylesSettings};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
/** Named image processing preset — defines how to resize/convert an image variant */
|
|
4
|
+
interface ImageStyle {
|
|
5
|
+
/** Max width in pixels (omit for height-only constraint) */
|
|
6
|
+
width?: number;
|
|
7
|
+
/** Max height in pixels (omit for height-only constraint) */
|
|
8
|
+
height?: number;
|
|
9
|
+
/** Resize strategy (default: 'cover') */
|
|
10
|
+
fit?: 'cover' | 'contain' | 'inside' | 'outside' | 'fill';
|
|
11
|
+
/** Output format (default: 'webp') */
|
|
12
|
+
format?: 'webp' | 'jpeg' | 'png' | 'avif';
|
|
13
|
+
/** Output quality 1–100 (default: 80) */
|
|
14
|
+
quality?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ImageStylesManagerLabels {
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
addStyle?: string;
|
|
21
|
+
editStyle?: string;
|
|
22
|
+
styleName?: string;
|
|
23
|
+
width?: string;
|
|
24
|
+
height?: string;
|
|
25
|
+
fit?: string;
|
|
26
|
+
format?: string;
|
|
27
|
+
quality?: string;
|
|
28
|
+
save?: string;
|
|
29
|
+
cancel?: string;
|
|
30
|
+
delete?: string;
|
|
31
|
+
deleteConfirmTitle?: string;
|
|
32
|
+
deleteConfirmDescription?: string;
|
|
33
|
+
systemBadge?: string;
|
|
34
|
+
noStyles?: string;
|
|
35
|
+
regenerate?: string;
|
|
36
|
+
regenerateDescription?: string;
|
|
37
|
+
regenerating?: string;
|
|
38
|
+
saving?: string;
|
|
39
|
+
px?: string;
|
|
40
|
+
}
|
|
41
|
+
interface ImageStylesManagerClassNames {
|
|
42
|
+
root?: string;
|
|
43
|
+
header?: string;
|
|
44
|
+
table?: string;
|
|
45
|
+
dialog?: string;
|
|
46
|
+
actions?: string;
|
|
47
|
+
regenerateSection?: string;
|
|
48
|
+
}
|
|
49
|
+
interface ImageStylesManagerProps {
|
|
50
|
+
/** Current image styles from server (required, server-first pattern) */
|
|
51
|
+
initialStyles: Record<string, ImageStyle>;
|
|
52
|
+
/** API base path for media admin routes (default: '/api/admin/media') */
|
|
53
|
+
apiBasePath?: string;
|
|
54
|
+
/** i18n labels with English defaults */
|
|
55
|
+
labels?: ImageStylesManagerLabels;
|
|
56
|
+
/** Per-element class overrides */
|
|
57
|
+
classNames?: ImageStylesManagerClassNames;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
declare function ImageStylesManager({ initialStyles, apiBasePath, labels: userLabels, classNames, }: ImageStylesManagerProps): react_jsx_runtime.JSX.Element;
|
|
61
|
+
|
|
62
|
+
export { ImageStylesManager, type ImageStylesManagerClassNames, type ImageStylesManagerLabels, type ImageStylesManagerProps };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import{Badge as fe,Button as d,cn as b,Dialog as X,DialogContent as Y,DialogDescription as Z,DialogFooter as ee,DialogHeader as te,DialogTitle as ae,Input as I,Label as h,Select as se,Table as ve,TableBody as be,TableCell as g,TableHead as u,TableHeader as Ne,TableRow as re}from"@murumets-ee/ui";import{Loader2 as z,Pencil as xe,Plus as Se,RefreshCw as we,Trash2 as Ce}from"lucide-react";import{useCallback as p,useState as i}from"react";import{Fragment as P,jsx as t,jsxs as a}from"react/jsx-runtime";var Te=["cover","contain","inside","outside","fill"],De=["webp","jpeg","png","avif"],Ie={title:"Image Styles",description:'Configure image processing presets. Variants are generated on upload for each style. Use "Regenerate" to update existing images.',addStyle:"Add Style",editStyle:"Edit Style",styleName:"Style Name",width:"Width",height:"Height",fit:"Fit",format:"Format",quality:"Quality",save:"Save",cancel:"Cancel",delete:"Delete",deleteConfirmTitle:"Delete Image Style",deleteConfirmDescription:"Are you sure? This will not remove existing variant files.",systemBadge:"system",noStyles:"No image styles configured.",regenerate:"Regenerate All Variants",regenerateDescription:"Reprocess all images with the current styles. This may take a while for large libraries.",regenerating:"Regenerating...",saving:"Saving...",px:"px"};function Fe({initialStyles:ie,apiBasePath:N="/api/admin/media",labels:le,classNames:y}){let s={...Ie,...le},[m,ne]=i(ie),[x,_]=i(!1),[K,F]=i(null),[oe,f]=i(!1),[n,Q]=i(null),[k,R]=i(""),[S,H]=i(""),[w,O]=i(""),[E,B]=i("cover"),[L,$]=i("webp"),[j,q]=i("80"),[W,o]=i(null),[me,C]=i(!1),[v,J]=i(null),[U,V]=i(!1),[c,G]=i(null),T=p(async e=>{_(!0),F(null);try{let r=await fetch(`${N}/settings`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({imageStyles:e})});if(!r.ok){let l=await r.json();throw new Error(l.error??`Save failed (${r.status})`)}ne(e)}catch(r){let l=r instanceof Error?r.message:"Save failed";throw F(l),r}finally{_(!1)}},[N]),de=p(()=>{Q(null),R(""),H(""),O(""),B("cover"),$("webp"),q("80"),o(null),f(!0)},[]),ce=p(e=>{let r=m[e];r&&(Q(e),R(e),H(r.width?.toString()??""),O(r.height?.toString()??""),B(r.fit??"cover"),$(r.format??"webp"),q((r.quality??80).toString()),o(null),f(!0))},[m]),ge=p(async()=>{o(null);let e=k.trim().toLowerCase();if(!e||!/^[a-z][a-z0-9_-]*$/.test(e)){o("Name must be lowercase alphanumeric (a-z, 0-9, -, _)");return}if(e!==n&&m[e]){o(`A style named "${e}" already exists`);return}let r=S?Number.parseInt(S,10):void 0,l=w?Number.parseInt(w,10):void 0;if(!r&&!l){o("At least width or height is required");return}if(r!==void 0&&(Number.isNaN(r)||r<=0)){o("Width must be a positive number");return}if(l!==void 0&&(Number.isNaN(l)||l<=0)){o("Height must be a positive number");return}let D=Number.parseInt(j,10);if(Number.isNaN(D)||D<1||D>100){o("Quality must be 1-100");return}let he={...r?{width:r}:{},...l?{height:l}:{},fit:E,format:L,quality:D},M={...m};n&&n!==e&&delete M[n],M[e]=he;try{await T(M),f(!1)}catch{}},[m,n,k,S,w,E,L,j,T]),ue=p(e=>{J(e),C(!0)},[]),pe=p(async()=>{if(!v)return;let e={...m};delete e[v];try{await T(e),C(!1),J(null)}catch{}},[v,m,T]),ye=p(async()=>{V(!0),G(null);try{let e=await fetch(`${N}/regenerate-variants`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({})});if(!e.ok){let l=await e.json();throw new Error(l.error??`Regeneration failed (${e.status})`)}let r=await e.json();G(r)}catch(e){let r=e instanceof Error?e.message:"Regeneration failed";F(r)}finally{V(!1)}},[N]),A=Object.entries(m).sort(([e],[r])=>e.localeCompare(r));return a("div",{className:b("space-y-6",y?.root),children:[a("div",{className:b("flex items-start justify-between",y?.header),children:[a("div",{children:[t("h2",{className:"text-2xl font-semibold tracking-tight",children:s.title}),t("p",{className:"text-sm text-muted-foreground mt-1",children:s.description})]}),a(d,{onClick:de,size:"sm",children:[t(Se,{className:"h-4 w-4 mr-1"}),s.addStyle]})]}),K&&t("div",{className:"rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive",children:K}),A.length===0?t("p",{className:"text-sm text-muted-foreground py-8 text-center",children:s.noStyles}):t("div",{className:b("rounded-md border",y?.table),children:a(ve,{children:[t(Ne,{children:a(re,{children:[t(u,{children:s.styleName}),t(u,{className:"text-center",children:s.width}),t(u,{className:"text-center",children:s.height}),t(u,{className:"text-center",children:s.fit}),t(u,{className:"text-center",children:s.format}),t(u,{className:"text-center",children:s.quality}),t(u,{className:"w-24"})]})}),t(be,{children:A.map(([e,r])=>a(re,{children:[a(g,{className:"font-mono text-sm",children:[e,e==="thumbnail"&&t(fe,{variant:"secondary",className:"ml-2 text-xs",children:s.systemBadge})]}),t(g,{className:"text-center tabular-nums",children:r.width?`${r.width}${s.px}`:"\u2014"}),t(g,{className:"text-center tabular-nums",children:r.height?`${r.height}${s.px}`:"\u2014"}),t(g,{className:"text-center text-sm text-muted-foreground",children:r.fit??"cover"}),t(g,{className:"text-center text-sm text-muted-foreground",children:r.format??"webp"}),t(g,{className:"text-center tabular-nums",children:r.quality??80}),t(g,{children:a("div",{className:b("flex items-center gap-1 justify-end",y?.actions),children:[t(d,{variant:"ghost",size:"sm",onClick:()=>ce(e),title:s.editStyle,children:t(xe,{className:"h-3.5 w-3.5"})}),t(d,{variant:"ghost",size:"sm",onClick:()=>ue(e),title:s.delete,className:"text-destructive hover:text-destructive",children:t(Ce,{className:"h-3.5 w-3.5"})})]})})]},e))})]})}),a("div",{className:b("rounded-md border p-4 space-y-3",y?.regenerateSection),children:[a("div",{className:"flex items-center justify-between",children:[a("div",{children:[t("h3",{className:"text-sm font-medium",children:s.regenerate}),t("p",{className:"text-xs text-muted-foreground mt-0.5",children:s.regenerateDescription})]}),t(d,{variant:"outline",size:"sm",onClick:ye,disabled:U||A.length===0,children:U?a(P,{children:[t(z,{className:"h-4 w-4 mr-1 animate-spin"}),s.regenerating]}):a(P,{children:[t(we,{className:"h-4 w-4 mr-1"}),s.regenerate]})})]}),c&&a("div",{className:"text-sm text-muted-foreground bg-muted/50 rounded px-3 py-2",children:["Processed ",c.processed," of ",c.total," images",c.skipped>0&&`, ${c.skipped} skipped`,c.errors>0&&a("span",{className:"text-destructive",children:[", ",c.errors," errors"]})]})]}),t(X,{open:oe,onOpenChange:f,children:a(Y,{className:y?.dialog,children:[a(te,{children:[t(ae,{children:n?s.editStyle:s.addStyle}),t(Z,{children:n?`Editing "${n}" image style.`:"Create a new image processing preset."})]}),a("div",{className:"space-y-4 py-2",children:[W&&t("div",{className:"text-sm text-destructive",children:W}),a("div",{className:"space-y-2",children:[t(h,{htmlFor:"style-name",children:s.styleName}),t(I,{id:"style-name",value:k,onChange:e=>R(e.target.value),placeholder:"e.g. thumbnail, medium, large",disabled:!!n})]}),a("div",{className:"grid grid-cols-2 gap-4",children:[a("div",{className:"space-y-2",children:[a(h,{htmlFor:"style-width",children:[s.width," (",s.px,")"]}),t(I,{id:"style-width",type:"number",value:S,onChange:e=>H(e.target.value),placeholder:"e.g. 200",min:1})]}),a("div",{className:"space-y-2",children:[a(h,{htmlFor:"style-height",children:[s.height," (",s.px,")"]}),t(I,{id:"style-height",type:"number",value:w,onChange:e=>O(e.target.value),placeholder:"e.g. 200",min:1})]})]}),a("div",{className:"grid grid-cols-2 gap-4",children:[a("div",{className:"space-y-2",children:[t(h,{htmlFor:"style-fit",children:s.fit}),t(se,{id:"style-fit",value:E,onChange:e=>B(e.target.value),children:Te.map(e=>t("option",{value:e,children:e},e))})]}),a("div",{className:"space-y-2",children:[t(h,{htmlFor:"style-format",children:s.format}),t(se,{id:"style-format",value:L,onChange:e=>$(e.target.value),children:De.map(e=>t("option",{value:e,children:e},e))})]})]}),a("div",{className:"space-y-2",children:[a(h,{htmlFor:"style-quality",children:[s.quality," (1-100)"]}),t(I,{id:"style-quality",type:"number",value:j,onChange:e=>q(e.target.value),min:1,max:100})]})]}),a(ee,{children:[t(d,{variant:"outline",onClick:()=>f(!1),children:s.cancel}),t(d,{onClick:ge,disabled:x,children:x?a(P,{children:[t(z,{className:"h-4 w-4 mr-1 animate-spin"}),s.saving]}):s.save})]})]})}),t(X,{open:me,onOpenChange:C,children:a(Y,{children:[a(te,{children:[t(ae,{children:s.deleteConfirmTitle}),a(Z,{children:[s.deleteConfirmDescription,v&&t("span",{className:"block mt-2 font-mono text-foreground",children:v})]})]}),a(ee,{children:[t(d,{variant:"outline",onClick:()=>C(!1),children:s.cancel}),a(d,{variant:"destructive",onClick:pe,disabled:x,children:[x?t(z,{className:"h-4 w-4 mr-1 animate-spin"}):null,s.delete]})]})]})})]})}export{Fe as ImageStylesManager};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as _murumets_ee_entity from '@murumets-ee/entity';
|
|
2
|
+
export { imageStylesSettings } from './image-styles-settings.js';
|
|
3
|
+
export { I as ImageStyle, c as MediaListOptions, d as MediaListResult, e as MediaPluginConfig, b as MediaRecord, f as MediaType, M as MediaUploadOptions, a as MediaUploadResult } from './types-ChlTxvlq.js';
|
|
4
|
+
import '@murumets-ee/settings';
|
|
5
|
+
import '@murumets-ee/storage';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Server-side utility to enrich entity list items with resolved media URLs.
|
|
9
|
+
*
|
|
10
|
+
* For entities with `field.media()` columns, this scans items for media UUIDs,
|
|
11
|
+
* batch-resolves them via MediaClient, and injects `${fieldName}Url` into each item.
|
|
12
|
+
*
|
|
13
|
+
* Convention: a media field named `coverImage` (UUID) gets enriched with
|
|
14
|
+
* `coverImageUrl` (resolved URL string).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { enrichWithMediaUrls } from '@murumets-ee/media'
|
|
19
|
+
*
|
|
20
|
+
* const items = await adminClient.findMany({ limit: 20 })
|
|
21
|
+
* await enrichWithMediaUrls(Article, items)
|
|
22
|
+
* // items[0].coverImageUrl → 'https://cdn.example.com/uploads/.../thumbnail_photo.webp'
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Enrich entity list items by resolving media field UUIDs to variant URLs.
|
|
28
|
+
*
|
|
29
|
+
* Mutates items in place — injects `${fieldName}Url` for each media field.
|
|
30
|
+
*
|
|
31
|
+
* @param entity - Entity definition (or any object with allFields containing type info)
|
|
32
|
+
* @param items - Array of entity records to enrich
|
|
33
|
+
* @param styleName - Image style to resolve (default: 'thumbnail')
|
|
34
|
+
*/
|
|
35
|
+
declare function enrichWithMediaUrls(entity: {
|
|
36
|
+
allFields: Record<string, {
|
|
37
|
+
type: string;
|
|
38
|
+
}>;
|
|
39
|
+
}, items: Record<string, unknown>[], styleName?: string): Promise<void>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pre-defined Media entity.
|
|
43
|
+
*
|
|
44
|
+
* Auto-registered by the media() plugin — users do NOT add this to their entities array.
|
|
45
|
+
* Connected to toolkit_files via the fileKey field (stores the StorageClient file key).
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* import { media } from '@murumets-ee/media/plugin'
|
|
50
|
+
*
|
|
51
|
+
* export default defineConfig({
|
|
52
|
+
* plugins: [storage(), media()],
|
|
53
|
+
* // Media entity is auto-added — no need to list it here
|
|
54
|
+
* })
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
declare const Media: _murumets_ee_entity.Entity<{
|
|
58
|
+
id: _murumets_ee_entity.IdField;
|
|
59
|
+
} & _murumets_ee_entity.AuditableFields & {
|
|
60
|
+
title: _murumets_ee_entity.TextField & {
|
|
61
|
+
readonly translatable: true;
|
|
62
|
+
};
|
|
63
|
+
alt: _murumets_ee_entity.TextField & {
|
|
64
|
+
readonly translatable: true;
|
|
65
|
+
};
|
|
66
|
+
description: _murumets_ee_entity.TextField & {
|
|
67
|
+
readonly translatable: true;
|
|
68
|
+
};
|
|
69
|
+
/** Key into toolkit_files table (e.g., 'uploads/2026/02/uuid/photo.jpg') */
|
|
70
|
+
fileKey: _murumets_ee_entity.TextField & {
|
|
71
|
+
readonly required: true;
|
|
72
|
+
readonly indexed: true;
|
|
73
|
+
};
|
|
74
|
+
filename: _murumets_ee_entity.TextField & {
|
|
75
|
+
readonly required: true;
|
|
76
|
+
};
|
|
77
|
+
mimeType: _murumets_ee_entity.TextField & {
|
|
78
|
+
readonly required: true;
|
|
79
|
+
readonly indexed: true;
|
|
80
|
+
};
|
|
81
|
+
size: _murumets_ee_entity.NumberField & {
|
|
82
|
+
readonly required: true;
|
|
83
|
+
readonly integer: true;
|
|
84
|
+
};
|
|
85
|
+
width: _murumets_ee_entity.NumberField & {
|
|
86
|
+
readonly integer: true;
|
|
87
|
+
};
|
|
88
|
+
height: _murumets_ee_entity.NumberField & {
|
|
89
|
+
readonly integer: true;
|
|
90
|
+
};
|
|
91
|
+
mediaType: _murumets_ee_entity.SelectField & {
|
|
92
|
+
options: readonly ["image", "video", "audio", "document", "other"];
|
|
93
|
+
} & {
|
|
94
|
+
readonly options: readonly ["image", "video", "audio", "document", "other"];
|
|
95
|
+
readonly required: true;
|
|
96
|
+
readonly indexed: true;
|
|
97
|
+
};
|
|
98
|
+
}>;
|
|
99
|
+
|
|
100
|
+
/** Lightweight media item for the picker (no server-only deps) */
|
|
101
|
+
interface MediaPickerItem {
|
|
102
|
+
id: string;
|
|
103
|
+
title: string | null;
|
|
104
|
+
alt: string | null;
|
|
105
|
+
filename: string;
|
|
106
|
+
mimeType: string;
|
|
107
|
+
size: number;
|
|
108
|
+
width: number | null;
|
|
109
|
+
height: number | null;
|
|
110
|
+
mediaType: 'image' | 'video' | 'audio' | 'document' | 'other';
|
|
111
|
+
url: string;
|
|
112
|
+
}
|
|
113
|
+
/** Paginated result from fetchMedia callback */
|
|
114
|
+
interface MediaPickerListResult {
|
|
115
|
+
items: MediaPickerItem[];
|
|
116
|
+
total: number;
|
|
117
|
+
}
|
|
118
|
+
/** Callbacks the consumer provides — framework-agnostic */
|
|
119
|
+
interface MediaPickerCallbacks {
|
|
120
|
+
/** Fetch media items with filtering and pagination */
|
|
121
|
+
fetchMedia: (options: {
|
|
122
|
+
search?: string;
|
|
123
|
+
mediaType?: string;
|
|
124
|
+
limit: number;
|
|
125
|
+
offset: number;
|
|
126
|
+
}) => Promise<MediaPickerListResult>;
|
|
127
|
+
/** Upload a file and return the created media item */
|
|
128
|
+
uploadMedia: (file: File) => Promise<MediaPickerItem>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { Media, type MediaPickerCallbacks, type MediaPickerItem, type MediaPickerListResult, enrichWithMediaUrls };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as f}from"./chunk-JGE5BDIT.js";import{a as c}from"./chunk-L6BDKI76.js";import"server-only";async function p(n,s,l="thumbnail"){let o=Object.entries(n.allFields).filter(([,e])=>e.type==="media").map(([e])=>e);if(o.length===0)return;let r=new Set;for(let e of s)for(let i of o){let t=e[i];typeof t=="string"&&t.length>0&&r.add(t)}if(r.size===0)return;let{getMediaClient:d}=await import("./client.js"),a=await(await d()).getVariantUrls([...r],l);for(let e of s)for(let i of o){let t=e[i];typeof t=="string"&&a.has(t)&&(e[`${i}Url`]=a.get(t))}}export{c as Media,p as enrichWithMediaUrls,f as imageStylesSettings};
|
package/dist/picker.d.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
/** Lightweight media item for the picker (no server-only deps) */
|
|
5
|
+
interface MediaPickerItem {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string | null;
|
|
8
|
+
alt: string | null;
|
|
9
|
+
filename: string;
|
|
10
|
+
mimeType: string;
|
|
11
|
+
size: number;
|
|
12
|
+
width: number | null;
|
|
13
|
+
height: number | null;
|
|
14
|
+
mediaType: 'image' | 'video' | 'audio' | 'document' | 'other';
|
|
15
|
+
url: string;
|
|
16
|
+
}
|
|
17
|
+
/** Paginated result from fetchMedia callback */
|
|
18
|
+
interface MediaPickerListResult {
|
|
19
|
+
items: MediaPickerItem[];
|
|
20
|
+
total: number;
|
|
21
|
+
}
|
|
22
|
+
/** Callbacks the consumer provides — framework-agnostic */
|
|
23
|
+
interface MediaPickerCallbacks {
|
|
24
|
+
/** Fetch media items with filtering and pagination */
|
|
25
|
+
fetchMedia: (options: {
|
|
26
|
+
search?: string;
|
|
27
|
+
mediaType?: string;
|
|
28
|
+
limit: number;
|
|
29
|
+
offset: number;
|
|
30
|
+
}) => Promise<MediaPickerListResult>;
|
|
31
|
+
/** Upload a file and return the created media item */
|
|
32
|
+
uploadMedia: (file: File) => Promise<MediaPickerItem>;
|
|
33
|
+
}
|
|
34
|
+
/** Selection mode */
|
|
35
|
+
type MediaPickerMode = 'single' | 'multiple';
|
|
36
|
+
/** Per-element class overrides for MediaPicker */
|
|
37
|
+
interface MediaPickerClassNames {
|
|
38
|
+
overlay?: string;
|
|
39
|
+
content?: string;
|
|
40
|
+
header?: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
toolbar?: string;
|
|
43
|
+
searchInput?: string;
|
|
44
|
+
filterTabs?: string;
|
|
45
|
+
grid?: string;
|
|
46
|
+
card?: string;
|
|
47
|
+
cardSelected?: string;
|
|
48
|
+
cardImage?: string;
|
|
49
|
+
cardLabel?: string;
|
|
50
|
+
uploadZone?: string;
|
|
51
|
+
uploadZoneActive?: string;
|
|
52
|
+
footer?: string;
|
|
53
|
+
confirmButton?: string;
|
|
54
|
+
cancelButton?: string;
|
|
55
|
+
loading?: string;
|
|
56
|
+
empty?: string;
|
|
57
|
+
}
|
|
58
|
+
/** Props for the MediaPicker dialog */
|
|
59
|
+
interface MediaPickerProps {
|
|
60
|
+
/** Whether the dialog is open */
|
|
61
|
+
open: boolean;
|
|
62
|
+
/** Called when open state changes */
|
|
63
|
+
onOpenChange: (open: boolean) => void;
|
|
64
|
+
/** Called when user confirms selection */
|
|
65
|
+
onSelect: (items: MediaPickerItem[]) => void;
|
|
66
|
+
/** Selection mode (default: 'single') */
|
|
67
|
+
mode?: MediaPickerMode;
|
|
68
|
+
/** MIME patterns for upload restriction (e.g., ['image/*', 'video/*']) — passed to file input */
|
|
69
|
+
accept?: string[];
|
|
70
|
+
/** Filter browse results to a specific media classification (e.g., 'image', 'video') */
|
|
71
|
+
mediaType?: string;
|
|
72
|
+
/** Maximum items selectable in multi mode */
|
|
73
|
+
maxSelect?: number;
|
|
74
|
+
/** Currently selected item IDs (for pre-selection) */
|
|
75
|
+
selectedIds?: string[];
|
|
76
|
+
/** Dialog title (default: 'Select Media') */
|
|
77
|
+
title?: string;
|
|
78
|
+
/** Dialog description for screen readers (optional) */
|
|
79
|
+
description?: string;
|
|
80
|
+
/** Per-element class overrides */
|
|
81
|
+
classNames?: MediaPickerClassNames;
|
|
82
|
+
/** Additional className for the dialog content */
|
|
83
|
+
className?: string;
|
|
84
|
+
/** Children rendered in dialog footer (extra actions) */
|
|
85
|
+
children?: ReactNode;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pre-built media callbacks that talk to the admin API.
|
|
90
|
+
*
|
|
91
|
+
* Eliminates boilerplate in every project — just call:
|
|
92
|
+
* const media = createAdminMediaCallbacks('/api/admin')
|
|
93
|
+
*
|
|
94
|
+
* Returns callbacks compatible with both MediaPickerProvider and
|
|
95
|
+
* BlockEditor's `media` prop (with getMediaUrl for thumbnail previews).
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/** Extended callbacks with getMediaUrl (for editor thumbnail previews) */
|
|
99
|
+
type AdminMediaCallbacks = MediaPickerCallbacks & {
|
|
100
|
+
getMediaUrl: (id: string) => Promise<string | null>;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Create media callbacks that talk to the admin API.
|
|
104
|
+
*
|
|
105
|
+
* @param apiBasePath - Base path for admin API (default: '/api/admin')
|
|
106
|
+
* @returns Callbacks for MediaPickerProvider + BlockEditor media prop
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* import { createAdminMediaCallbacks } from '@murumets-ee/media/picker'
|
|
111
|
+
*
|
|
112
|
+
* const media = createAdminMediaCallbacks()
|
|
113
|
+
* // Use with BlockEditor:
|
|
114
|
+
* <BlockEditor media={media} ... />
|
|
115
|
+
* // Use with MediaPickerProvider:
|
|
116
|
+
* <MediaPickerProvider fetchMedia={media.fetchMedia} uploadMedia={media.uploadMedia}>
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
declare function createAdminMediaCallbacks(apiBasePath?: string): AdminMediaCallbacks;
|
|
120
|
+
|
|
121
|
+
interface MediaCardProps {
|
|
122
|
+
item: MediaPickerItem;
|
|
123
|
+
isSelected: boolean;
|
|
124
|
+
onToggle: () => void;
|
|
125
|
+
classNames?: MediaPickerClassNames;
|
|
126
|
+
}
|
|
127
|
+
declare function MediaCard({ item, isSelected, onToggle, classNames }: MediaCardProps): react_jsx_runtime.JSX.Element;
|
|
128
|
+
|
|
129
|
+
interface MediaGridProps {
|
|
130
|
+
items: MediaPickerItem[];
|
|
131
|
+
selected: Set<string>;
|
|
132
|
+
onToggle: (item: MediaPickerItem) => void;
|
|
133
|
+
isLoading: boolean;
|
|
134
|
+
total: number;
|
|
135
|
+
offset: number;
|
|
136
|
+
limit: number;
|
|
137
|
+
onPageChange: (offset: number) => void;
|
|
138
|
+
classNames?: MediaPickerClassNames;
|
|
139
|
+
}
|
|
140
|
+
declare function MediaGrid({ items, selected, onToggle, isLoading, total, offset, limit, onPageChange, classNames, }: MediaGridProps): react_jsx_runtime.JSX.Element;
|
|
141
|
+
|
|
142
|
+
declare function MediaPicker({ open, onOpenChange, onSelect, mode, accept, mediaType, maxSelect, selectedIds, title, description, classNames, className, children, }: MediaPickerProps): react_jsx_runtime.JSX.Element;
|
|
143
|
+
|
|
144
|
+
interface MediaPickerProviderProps extends MediaPickerCallbacks {
|
|
145
|
+
children: ReactNode;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Provides media picker callbacks to all picker instances below.
|
|
149
|
+
* Wrap your admin layout with this provider.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```tsx
|
|
153
|
+
* <MediaPickerProvider
|
|
154
|
+
* fetchMedia={fetchMediaAction}
|
|
155
|
+
* uploadMedia={uploadMediaAction}
|
|
156
|
+
* >
|
|
157
|
+
* <AdminShell>...</AdminShell>
|
|
158
|
+
* </MediaPickerProvider>
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
declare function MediaPickerProvider({ children, fetchMedia, uploadMedia, }: MediaPickerProviderProps): react_jsx_runtime.JSX.Element;
|
|
162
|
+
declare function useMediaPicker(): MediaPickerCallbacks;
|
|
163
|
+
|
|
164
|
+
interface SearchBarProps {
|
|
165
|
+
value: string;
|
|
166
|
+
onChange: (value: string) => void;
|
|
167
|
+
mediaType: string | undefined;
|
|
168
|
+
onMediaTypeChange: (type: string | undefined) => void;
|
|
169
|
+
/** When true, the media type filter is locked (no tabs shown) */
|
|
170
|
+
locked?: boolean;
|
|
171
|
+
classNames?: MediaPickerClassNames;
|
|
172
|
+
}
|
|
173
|
+
declare function SearchBar({ value, onChange, mediaType, onMediaTypeChange, locked, classNames, }: SearchBarProps): react_jsx_runtime.JSX.Element;
|
|
174
|
+
|
|
175
|
+
interface UploadZoneProps {
|
|
176
|
+
onUpload: (file: File) => Promise<void>;
|
|
177
|
+
isUploading: boolean;
|
|
178
|
+
accept?: string[];
|
|
179
|
+
classNames?: MediaPickerClassNames;
|
|
180
|
+
}
|
|
181
|
+
declare function UploadZone({ onUpload, isUploading, accept, classNames }: UploadZoneProps): react_jsx_runtime.JSX.Element;
|
|
182
|
+
|
|
183
|
+
export { type AdminMediaCallbacks, MediaCard, MediaGrid, MediaPicker, type MediaPickerCallbacks, type MediaPickerClassNames, type MediaPickerItem, type MediaPickerListResult, type MediaPickerMode, type MediaPickerProps, MediaPickerProvider, SearchBar, UploadZone, createAdminMediaCallbacks, useMediaPicker };
|
package/dist/picker.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
function ce(e="/api/admin"){let r=`${e}/media`;return{fetchMedia:async i=>{let t=new URLSearchParams;i.search&&t.set("search",i.search),i.mediaType&&t.set("mediaType",i.mediaType),t.set("limit",String(i.limit)),t.set("offset",String(i.offset));let s=`${r}?${t.toString()}`,n=await fetch(s);if(!n.ok)throw new Error(`Failed to fetch media: ${n.status}`);return n.json()},uploadMedia:async i=>{let t=new FormData;t.append("file",i);let s=await fetch(r,{method:"POST",body:t});if(!s.ok)throw new Error(`Upload failed: ${s.status}`);return s.json()},getMediaUrl:async i=>{try{let t=await fetch(`${r}/${i}`);return t.ok?(await t.json()).url??null:null}catch{return null}}}}import{Check as pe,File as K,FileText as fe,Film as be,Music as ge}from"lucide-react";import{clsx as me}from"clsx";import{twMerge as ue}from"tailwind-merge";function a(...e){return ue(me(e))}import{jsx as P,jsxs as ye}from"react/jsx-runtime";var ke={video:be,audio:ge,document:fe,other:K};function R({item:e,isSelected:r,onToggle:i,classNames:t}){let s=e.mediaType==="image",n=s?null:ke[e.mediaType]??K;return ye("button",{type:"button",onClick:i,className:a("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",r?"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",t?.card,r&&t?.cardSelected),children:[s?P("img",{src:e.url,alt:e.alt??e.filename,className:a("h-full w-full object-cover",t?.cardImage),loading:"lazy"}):P("div",{className:"flex h-full w-full items-center justify-center bg-zinc-100 dark:bg-zinc-800",children:n&&P(n,{className:"h-8 w-8 text-zinc-400 dark:text-zinc-500"})}),r&&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(pe,{className:"h-3 w-3"})}),P("div",{className:a("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",t?.cardLabel),children:P("p",{className:"truncate text-xs text-white",children:e.title??e.filename})})]})}import{Fragment as ve,jsx as x,jsxs as U}from"react/jsx-runtime";function L({items:e,selected:r,onToggle:i,isLoading:t,total:s,offset:n,limit:u,onPageChange:d,classNames:b}){if(t)return x("div",{className:a("grid grid-cols-4 gap-3 sm:grid-cols-6",b?.grid),children:Array.from({length:12}).map((y,w)=>x("div",{className:a("aspect-square animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800",b?.loading)},`skeleton-${w.toString()}`))});if(e.length===0)return x("div",{className:a("py-12 text-center text-sm text-zinc-500 dark:text-zinc-400",b?.empty),children:"No media found. Upload a file to get started."});let l=Math.ceil(s/u),o=Math.floor(n/u)+1;return U(ve,{children:[x("div",{className:a("grid grid-cols-4 gap-3 sm:grid-cols-6",b?.grid),children:e.map(y=>x(R,{item:y,isSelected:r.has(y.id),onToggle:()=>i(y),classNames:b},y.id))}),l>1&&U("div",{className:"mt-4 flex items-center justify-center gap-2",children:[x("button",{type:"button",disabled:o<=1,onClick:()=>d(n-u),className:"rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700",children:"Prev"}),U("span",{className:"text-sm text-zinc-500 dark:text-zinc-400",children:[o," / ",l]}),x("button",{type:"button",disabled:o>=l,onClick:()=>d(n+u),className:"rounded-md border border-zinc-300 px-3 py-1 text-sm disabled:opacity-50 dark:border-zinc-700",children:"Next"})]})]})}import*as m from"@radix-ui/react-dialog";import*as V from"@radix-ui/react-visually-hidden";import{X as Se}from"lucide-react";import{useCallback as C,useEffect as te,useRef as ie,useState as k}from"react";import{createContext as Pe,useContext as xe,useMemo as he}from"react";import{jsx as ze}from"react/jsx-runtime";var Q=Pe(null);function Me({children:e,fetchMedia:r,uploadMedia:i}){let t=he(()=>({fetchMedia:r,uploadMedia:i}),[r,i]);return ze(Q,{value:t,children:e})}function $(){let e=xe(Q);if(!e)throw new Error("useMediaPicker must be used within <MediaPickerProvider>. Wrap your admin layout with <MediaPickerProvider fetchMedia={...} uploadMedia={...}>.");return e}import{Search as Ce}from"lucide-react";import{jsx as z,jsxs as Y}from"react/jsx-runtime";var we=[{value:void 0,label:"All"},{value:"image",label:"Images"},{value:"video",label:"Videos"},{value:"audio",label:"Audio"},{value:"document",label:"Docs"}];function E({value:e,onChange:r,mediaType:i,onMediaTypeChange:t,locked:s,classNames:n}){let u=!s;return Y("div",{className:"flex items-center gap-3",children:[Y("div",{className:"relative flex-1",children:[z(Ce,{className:"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400"}),z("input",{type:"text",value:e,onChange:d=>r(d.target.value),placeholder:"Search media...",className:a("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",n?.searchInput)})]}),u&&z("div",{className:a("flex gap-1",n?.filterTabs),children:we.map(d=>z("button",{type:"button",onClick:()=>t(d.value),className:a("rounded-md px-3 py-1.5 text-xs font-medium transition-colors",i===d.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:d.label},d.label))})]})}import{Upload as Ne}from"lucide-react";import{useCallback as ee,useRef as De,useState as Ie}from"react";import{jsx as F,jsxs as Te}from"react/jsx-runtime";function A({onUpload:e,isUploading:r,accept:i,classNames:t}){let s=De(null),[n,u]=Ie(!1),d=ee(async l=>{l.preventDefault(),u(!1);let o=l.dataTransfer.files[0];o&&await e(o)},[e]),b=ee(async l=>{let o=l.target.files?.[0];o&&(await e(o),l.target.value="")},[e]);return Te("button",{type:"button",onDragOver:l=>{l.preventDefault(),u(!0)},onDragLeave:()=>u(!1),onDrop:d,onClick:()=>s.current?.click(),className:a("mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-6 transition-colors",n?"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",t?.uploadZone,n&&t?.uploadZoneActive),children:[F(Ne,{className:"mb-2 h-6 w-6 text-zinc-400 dark:text-zinc-500"}),F("p",{className:"text-sm text-zinc-600 dark:text-zinc-400",children:r?"Uploading...":"Drop a file here or click to upload"}),F("input",{ref:s,type:"file",accept:i?.join(","),onChange:b,className:"hidden"})]})}import{jsx as c,jsxs as h}from"react/jsx-runtime";var re=24;function Re({open:e,onOpenChange:r,onSelect:i,mode:t="single",accept:s,mediaType:n,maxSelect:u,selectedIds:d=[],title:b="Select Media",description:l,classNames:o,className:y,children:w}){let{fetchMedia:B,uploadMedia:H}=$(),[N,Z]=k([]),[M,G]=k(0),[v,D]=k(()=>new Set(d)),[I,j]=k(""),[T,ae]=k(n),[oe,_]=k(!1),[ne,q]=k(!1),[S,O]=k(0),W=C(async()=>{_(!0);try{let p=await B({search:I||void 0,mediaType:T,limit:re,offset:S});Z(p.items),G(p.total)}catch{}finally{_(!1)}},[B,I,T,S]);te(()=>{e&&W()},[e,W]);let X=ie(e),J=ie(d);J.current=d,te(()=>{X.current&&!e&&(j(""),O(0),D(new Set(J.current))),X.current=e},[e]);let se=C(p=>{if(t==="single"){i([p]),r(!1);return}D(g=>{let f=new Set(g);if(f.has(p.id))f.delete(p.id);else{if(u&&f.size>=u)return g;f.add(p.id)}return f})},[t,u,i,r]),de=C(()=>{let p=N.filter(g=>v.has(g.id));i(p),r(!1)},[N,v,i,r]),le=C(async p=>{q(!0);try{let g=await H(p);if(t==="single"){i([g]),r(!1);return}Z(f=>[g,...f]),G(f=>f+1),D(f=>new Set([...f,g.id]))}finally{q(!1)}},[H,t,i,r]);return c(m.Root,{open:e,onOpenChange:r,children:h(m.Portal,{children:[c(m.Overlay,{className:a("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",o?.overlay)}),h(m.Content,{...!l&&{"aria-describedby":void 0},className:a("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",o?.content,y),children:[h("div",{className:a("flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-800",o?.header),children:[c(m.Title,{className:a("text-lg font-semibold text-zinc-900 dark:text-zinc-50",o?.title),children:b}),l&&c(V.Root,{asChild:!0,children:c(m.Description,{children:l})}),h(m.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:[c(Se,{className:"h-4 w-4"}),c(V.Root,{children:"Close"})]})]}),c("div",{className:a("border-b border-zinc-200 px-6 py-3 dark:border-zinc-800",o?.toolbar),children:c(E,{value:I,onChange:j,mediaType:T,onMediaTypeChange:ae,locked:!!n,classNames:o})}),h("div",{className:"flex-1 overflow-y-auto px-6 py-4",children:[c(A,{onUpload:le,isUploading:ne,accept:s,classNames:o}),c(L,{items:N,selected:v,onToggle:se,isLoading:oe,total:M,offset:S,limit:re,onPageChange:O,classNames:o})]}),h("div",{className:a("flex items-center justify-between border-t border-zinc-200 px-6 py-4 dark:border-zinc-800",o?.footer),children:[c("span",{className:"text-sm text-zinc-500 dark:text-zinc-400",children:t==="single"?`${M.toString()} item${M!==1?"s":""} \u2014 click to select`:v.size>0?`${v.size.toString()} selected`:`${M.toString()} item${M!==1?"s":""}`}),h("div",{className:"flex gap-2",children:[w,c(m.Close,{asChild:!0,children:c("button",{type:"button",className:a("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",o?.cancelButton),children:"Cancel"})}),t!=="single"&&c("button",{type:"button",onClick:de,disabled:v.size===0,className:a("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",o?.confirmButton),children:`Select (${v.size.toString()})`})]})]})]})]})})}export{R as MediaCard,L as MediaGrid,Re as MediaPicker,Me as MediaPickerProvider,E as SearchBar,A as UploadZone,ce as createAdminMediaCallbacks,$ as useMediaPicker};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as n}from"./chunk-YAHM4C5J.js";var r=null;function d(){if(!r)throw new Error("@murumets-ee/media plugin not initialized. Add media() to your plugins array.");return r}function m(t){let e={acceptedTypes:t?.acceptedTypes??["image/*","video/*","audio/*","application/pdf"],maxUploadSize:t?.maxUploadSize??52428800,defaultVisibility:t?.defaultVisibility??"public",imageStyles:t?.imageStyles??{thumbnail:{width:200,height:200,fit:"cover",format:"webp",quality:80}}};return{name:"@murumets-ee/media",entities:[n],init:async i=>{if(!i.plugins.has("@murumets-ee/storage"))throw new Error("@murumets-ee/media requires @murumets-ee/storage plugin. Add storage() before media() in your plugins array.");if(!i.plugins.has("@murumets-ee/settings"))throw new Error("@murumets-ee/media requires @murumets-ee/settings plugin. Add settings() before media() in your plugins array.");r=e;try{let{createSettingsClient:a}=await import("@murumets-ee/settings"),{imageStylesSettings:o}=await import("./image-styles-settings-AB5WFEFF.js"),s=a(o,{app:i});await s.has("imageStyles")||(await s.set("imageStyles",e.imageStyles),i.logger.info({styles:Object.keys(e.imageStyles)},"Seeded default image styles to settings DB"))}catch(a){i.logger.warn({error:a},"Failed to seed image styles to settings DB (non-fatal)")}i.logger.info({acceptedTypes:e.acceptedTypes,maxUploadSize:e.maxUploadSize,defaultVisibility:e.defaultVisibility},"Media plugin initialized")}}}export{d as getMediaConfig,m as media};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Plugin } from '@murumets-ee/core';
|
|
2
|
+
import { e as MediaPluginConfig } from './types-ChlTxvlq.js';
|
|
3
|
+
import '@murumets-ee/storage';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Media plugin — auto-registers the Media entity and validates storage dependency.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { media } from '@murumets-ee/media/plugin'
|
|
11
|
+
*
|
|
12
|
+
* export default defineConfig({
|
|
13
|
+
* plugins: [
|
|
14
|
+
* storage(),
|
|
15
|
+
* media({ maxUploadSize: 10 * 1024 * 1024 }),
|
|
16
|
+
* ],
|
|
17
|
+
* })
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the resolved media plugin configuration.
|
|
23
|
+
* Throws if plugin not initialized.
|
|
24
|
+
*/
|
|
25
|
+
declare function getMediaConfig(): Required<MediaPluginConfig>;
|
|
26
|
+
/**
|
|
27
|
+
* Media plugin factory.
|
|
28
|
+
*
|
|
29
|
+
* - Auto-registers the Media entity (no need to add to entities array)
|
|
30
|
+
* - Validates that @murumets-ee/storage plugin is registered
|
|
31
|
+
* - Stores resolved configuration
|
|
32
|
+
*/
|
|
33
|
+
declare function media(config?: MediaPluginConfig): Plugin;
|
|
34
|
+
|
|
35
|
+
export { getMediaConfig, media };
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as n}from"./chunk-L6BDKI76.js";var r=null;function d(){if(!r)throw new Error("@murumets-ee/media plugin not initialized. Add media() to your plugins array.");return r}function m(t){let e={acceptedTypes:t?.acceptedTypes??["image/*","video/*","audio/*","application/pdf"],maxUploadSize:t?.maxUploadSize??52428800,defaultVisibility:t?.defaultVisibility??"public",imageStyles:t?.imageStyles??{thumbnail:{width:200,height:200,fit:"cover",format:"webp",quality:80}}};return{name:"@murumets-ee/media",entities:[n],init:async i=>{if(!i.plugins.has("@murumets-ee/storage"))throw new Error("@murumets-ee/media requires @murumets-ee/storage plugin. Add storage() before media() in your plugins array.");if(!i.plugins.has("@murumets-ee/settings"))throw new Error("@murumets-ee/media requires @murumets-ee/settings plugin. Add settings() before media() in your plugins array.");r=e;try{let{createSettingsClient:a}=await import("@murumets-ee/settings"),{imageStylesSettings:o}=await import("./image-styles-settings.js"),s=a(o,{app:i});await s.has("imageStyles")||(await s.set("imageStyles",e.imageStyles),i.logger.info({styles:Object.keys(e.imageStyles)},"Seeded default image styles to settings DB"))}catch(a){i.logger.warn({error:a},"Failed to seed image styles to settings DB (non-fatal)")}i.logger.info({acceptedTypes:e.acceptedTypes,maxUploadSize:e.maxUploadSize,defaultVisibility:e.defaultVisibility},"Media plugin initialized")}}}export{d as getMediaConfig,m as media};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Logger } from '@murumets-ee/core';
|
|
2
|
+
import { FindByIdOptions, FindManyOptions, CountOptions } from '@murumets-ee/entity/query';
|
|
3
|
+
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
|
4
|
+
import { b as MediaRecord } from './types-ChlTxvlq.js';
|
|
5
|
+
import '@murumets-ee/storage';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* MediaQueryClient — read-only media client for frontends.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { createMediaQueryClient } from '@murumets-ee/media/query'
|
|
12
|
+
* const media = await createMediaQueryClient()
|
|
13
|
+
* const image = await media.findById(id)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface MediaQueryClientConfig {
|
|
17
|
+
db: PostgresJsDatabase;
|
|
18
|
+
logger?: Logger;
|
|
19
|
+
}
|
|
20
|
+
declare class MediaQueryClient {
|
|
21
|
+
private db;
|
|
22
|
+
private logger?;
|
|
23
|
+
private query;
|
|
24
|
+
constructor(config: MediaQueryClientConfig);
|
|
25
|
+
private getQuery;
|
|
26
|
+
findById(id: string, options?: FindByIdOptions): Promise<MediaRecord | null>;
|
|
27
|
+
findMany(options?: FindManyOptions): Promise<MediaRecord[]>;
|
|
28
|
+
count(options?: CountOptions): Promise<number>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Factory — creates a MediaQueryClient.
|
|
32
|
+
* Must be called after createApp().
|
|
33
|
+
*/
|
|
34
|
+
declare function createMediaQueryClient(): Promise<MediaQueryClient>;
|
|
35
|
+
|
|
36
|
+
export { MediaQueryClient, type MediaQueryClientConfig, createMediaQueryClient };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as n}from"./chunk-L6BDKI76.js";var r=class{db;logger;query=null;constructor(e){this.db=e.db,this.logger=e.logger}async getQuery(){if(!this.query){let{QueryClient:e}=await import("@murumets-ee/entity/query");this.query=new e({entity:n,db:this.db,logger:this.logger})}return this.query}async findById(e,t){return await(await this.getQuery()).findById(e,t)}async findMany(e){return await(await this.getQuery()).findMany(e)}async count(e){return(await this.getQuery()).count(e)}};async function u(){let{getApp:i}=await import("@murumets-ee/core"),e=i();return new r({db:e.db.readOnly,logger:e.logger.child({mediaQuery:!0})})}export{r as MediaQueryClient,u as createMediaQueryClient};
|
package/dist/ref.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media reference system — parse, extract, and resolve [media:type:id:variant] tags.
|
|
3
|
+
*
|
|
4
|
+
* Two complementary ways to reference media:
|
|
5
|
+
* 1. field.media() — stores UUID directly (cover image, hero background)
|
|
6
|
+
* 2. [media:type:id:variant] — inline in text/richtext prose
|
|
7
|
+
*
|
|
8
|
+
* Both resolve to the same Media entity.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { parseMediaRefs, resolveMediaRefs, createMediaRef } from '@murumets-ee/media/ref'
|
|
13
|
+
*
|
|
14
|
+
* // Create a tag
|
|
15
|
+
* const tag = createMediaRef('image', 'abc-123', 'thumbnail')
|
|
16
|
+
* // → '[media:image:abc-123:thumbnail]'
|
|
17
|
+
*
|
|
18
|
+
* // Parse tags from text
|
|
19
|
+
* const refs = parseMediaRefs('Here is [media:image:abc-123:hero] a photo')
|
|
20
|
+
* // → [{ raw: '[media:image:abc-123:hero]', type: 'image', id: 'abc-123', variant: 'hero' }]
|
|
21
|
+
*
|
|
22
|
+
* // Resolve tags to HTML
|
|
23
|
+
* const html = await resolveMediaRefs(text, myResolver, {
|
|
24
|
+
* image: (ref) => ref.variant === 'url' ? ref.url : `<img src="${ref.url}" />`,
|
|
25
|
+
* })
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
/** Supported media reference types in tags */
|
|
29
|
+
type MediaRefType = 'image' | 'video' | 'audio' | 'file';
|
|
30
|
+
/** Parsed media reference from a [media:type:id] or [media:type:id:variant] tag */
|
|
31
|
+
interface MediaRef {
|
|
32
|
+
/** The full original tag string */
|
|
33
|
+
raw: string;
|
|
34
|
+
/** The type prefix (image, video, audio, file) */
|
|
35
|
+
type: MediaRefType;
|
|
36
|
+
/** The media entity UUID */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Optional rendering variant hint (thumbnail, hero, url, banner, etc.) */
|
|
39
|
+
variant: string | null;
|
|
40
|
+
}
|
|
41
|
+
/** Resolved media reference with URL and metadata */
|
|
42
|
+
interface ResolvedMediaRef extends MediaRef {
|
|
43
|
+
/** Resolved URL for the media file */
|
|
44
|
+
url: string;
|
|
45
|
+
/** Alt text (if available from entity) */
|
|
46
|
+
alt?: string;
|
|
47
|
+
/** Original filename */
|
|
48
|
+
filename?: string;
|
|
49
|
+
/** MIME type */
|
|
50
|
+
mimeType?: string;
|
|
51
|
+
/** Image width */
|
|
52
|
+
width?: number;
|
|
53
|
+
/** Image height */
|
|
54
|
+
height?: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolver function — provided by consumer to batch-resolve media IDs.
|
|
58
|
+
* Receives parsed references, returns a Map of id → resolved reference.
|
|
59
|
+
*/
|
|
60
|
+
type MediaRefResolver = (refs: MediaRef[]) => Promise<Map<string, ResolvedMediaRef>>;
|
|
61
|
+
/**
|
|
62
|
+
* Renderer function — converts a resolved reference to a string representation.
|
|
63
|
+
* Consumer provides custom renderers per type.
|
|
64
|
+
*/
|
|
65
|
+
type MediaRefRenderer = (ref: ResolvedMediaRef) => string;
|
|
66
|
+
/**
|
|
67
|
+
* Parse all media reference tags from a text string.
|
|
68
|
+
*/
|
|
69
|
+
declare function parseMediaRefs(text: string): MediaRef[];
|
|
70
|
+
/**
|
|
71
|
+
* Extract unique media entity UUIDs from text.
|
|
72
|
+
* Useful for preloading media before rendering.
|
|
73
|
+
*/
|
|
74
|
+
declare function extractMediaIds(text: string, options?: {
|
|
75
|
+
type?: MediaRefType;
|
|
76
|
+
}): string[];
|
|
77
|
+
/**
|
|
78
|
+
* Extract unique media entity UUIDs from multiple text strings.
|
|
79
|
+
*/
|
|
80
|
+
declare function extractAllMediaIds(texts: string[], options?: {
|
|
81
|
+
type?: MediaRefType;
|
|
82
|
+
}): string[];
|
|
83
|
+
/**
|
|
84
|
+
* Create a media reference tag string.
|
|
85
|
+
*/
|
|
86
|
+
declare function createMediaRef(type: MediaRefType, id: string, variant?: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* Resolve all media reference tags in a text string.
|
|
89
|
+
*
|
|
90
|
+
* 1. Parses all [media:type:id:variant] tags
|
|
91
|
+
* 2. Calls the resolver function to get URLs + metadata
|
|
92
|
+
* 3. Replaces each tag with rendered output
|
|
93
|
+
*
|
|
94
|
+
* @param text - Input text containing media reference tags
|
|
95
|
+
* @param resolver - Function that batch-resolves MediaRef[] → Map<id, ResolvedMediaRef>
|
|
96
|
+
* @param renderers - Optional custom renderers per type (override defaults)
|
|
97
|
+
*/
|
|
98
|
+
declare function resolveMediaRefs(text: string, resolver: MediaRefResolver, renderers?: Partial<Record<MediaRefType, MediaRefRenderer>>): Promise<string>;
|
|
99
|
+
|
|
100
|
+
export { type MediaRef, type MediaRefRenderer, type MediaRefResolver, type MediaRefType, type ResolvedMediaRef, createMediaRef, extractAllMediaIds, extractMediaIds, parseMediaRefs, resolveMediaRefs };
|
package/dist/ref.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var s=/\[media:(image|video|audio|file):([a-f0-9-]{36})(?::([a-z0-9-]+))?\]/g;function f(e){let t=[];s.lastIndex=0;let i=s.exec(e);for(;i!==null;)t.push({raw:i[0],type:i[1],id:i[2],variant:i[3]??null}),i=s.exec(e);return t}function R(e,t){let i=f(e),r=t?.type?i.filter(a=>a.type===t.type):i;return[...new Set(r.map(a=>a.id))]}function p(e,t){let i=e.flatMap(r=>R(r,t));return[...new Set(i)]}function u(e,t,i){return i?`[media:${e}:${t}:${i}]`:`[media:${e}:${t}]`}var c={image:e=>`<img ${[`src="${e.url}"`,`alt="${e.alt??e.filename??""}"`,e.width?`width="${e.width}"`:"",e.height?`height="${e.height}"`:""].filter(Boolean).join(" ")} />`,video:e=>`<video src="${e.url}" controls></video>`,audio:e=>`<audio src="${e.url}" controls></audio>`,file:e=>`<a href="${e.url}" download="${e.filename??""}">${e.filename??e.url}</a>`};async function g(e,t,i){let r=f(e);if(r.length===0)return e;let a=await t(r),d=e;for(let n of r){let o=a.get(n.id);if(!o)continue;let l=i?.[n.type]??c[n.type];l&&(d=d.replace(n.raw,l(o)))}return d}export{u as createMediaRef,p as extractAllMediaIds,R as extractMediaIds,f as parseMediaRefs,g as resolveMediaRefs};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as w,b as h,c as v}from"./chunk-QTUXM53A.js";import"server-only";var g=100;async function C(R){let{db:k,storage:o,logger:r,styles:y}=R,{AdminClient:K}=await import("@murumets-ee/entity/admin"),{Media:M}=await import("./entity-QOBW3TFU.js"),{schemaRegistry:O}=await import("@murumets-ee/db"),{eq:j}=await import("drizzle-orm"),S=new K({entity:M,db:k,logger:r}),p=O.get("media");if(!p)throw new Error("Media schema not registered");let a={total:0,processed:0,skipped:0,errors:0},u=0;for(r?.info({styles:Object.keys(y)},"Starting variant regeneration");;){let d=await S.findMany({where:j(p.mediaType,"image"),limit:g,offset:u});if(d.length===0)break;a.total+=d.length;for(let t of d)try{if(!w(t.mimeType)){a.skipped++;continue}let n=await o.download(t.fileKey),f;if(Buffer.isBuffer(n.body))f=n.body;else{let e=[],i=n.body.getReader();for(;;){let{done:s,value:c}=await i.read();if(s)break;c&&e.push(c)}f=Buffer.concat(e)}let B=await h(f,y),m=await o.getMetadata(t.fileKey),b=m?.metadata?.variants;if(b)for(let e of Object.values(b))await o.delete(e).catch(()=>{});let l={},I=m?.visibility??"public";for(let[e,i]of B.variants.entries()){let s=v(t.fileKey,e,i.format);try{await o.upload(i.buffer,{key:s,filename:`${e}_${t.filename}`,mimeType:i.mimeType,size:i.buffer.byteLength,visibility:I,metadata:{variantOf:t.fileKey,style:e}}),l[e]=s}catch(c){r?.warn({style:e,key:s,error:c},"Failed to upload regenerated variant (non-fatal)")}}Object.keys(l).length>0&&await o.updateMetadata(t.fileKey,{metadata:{...m?.metadata??{},variants:l}}).catch(e=>{r?.warn({key:t.fileKey,error:e},"Failed to update variant metadata (non-fatal)")}),a.processed++,r?.debug({id:t.id,variants:Object.keys(l)},"Regenerated variants")}catch(n){a.errors++,r?.error({id:t.id,fileKey:t.fileKey,error:n},"Failed to regenerate variants for media record")}if(u+=g,d.length<g)break}return r?.info(a,"Variant regeneration complete"),a}export{C as regenerateAllVariants};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { FileVisibility } from '@murumets-ee/storage';
|
|
2
|
+
|
|
3
|
+
/** Media type derived from MIME type */
|
|
4
|
+
type MediaType = 'image' | 'video' | 'audio' | 'document' | 'other';
|
|
5
|
+
/** Options for uploading media through MediaClient */
|
|
6
|
+
interface MediaUploadOptions {
|
|
7
|
+
/** Original filename */
|
|
8
|
+
filename: string;
|
|
9
|
+
/** MIME type */
|
|
10
|
+
mimeType: string;
|
|
11
|
+
/** File size in bytes */
|
|
12
|
+
size: number;
|
|
13
|
+
/** File visibility (default: from plugin config) */
|
|
14
|
+
visibility?: FileVisibility;
|
|
15
|
+
/** Display title (defaults to filename without extension) */
|
|
16
|
+
title?: string;
|
|
17
|
+
/** Alt text for images */
|
|
18
|
+
alt?: string;
|
|
19
|
+
/** Description */
|
|
20
|
+
description?: string;
|
|
21
|
+
/** Image width in pixels (client-measured) */
|
|
22
|
+
width?: number;
|
|
23
|
+
/** Image height in pixels (client-measured) */
|
|
24
|
+
height?: number;
|
|
25
|
+
/** UUID of the uploading user */
|
|
26
|
+
uploadedBy?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Result of a media upload: entity record + storage URL */
|
|
29
|
+
interface MediaUploadResult {
|
|
30
|
+
/** The created media entity record */
|
|
31
|
+
media: MediaRecord;
|
|
32
|
+
/** The file's public/signed URL */
|
|
33
|
+
url: string;
|
|
34
|
+
}
|
|
35
|
+
/** Lightweight view of a media entity for API consumers */
|
|
36
|
+
interface MediaRecord {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string | null;
|
|
39
|
+
alt: string | null;
|
|
40
|
+
description: string | null;
|
|
41
|
+
fileKey: string;
|
|
42
|
+
filename: string;
|
|
43
|
+
mimeType: string;
|
|
44
|
+
size: number;
|
|
45
|
+
width: number | null;
|
|
46
|
+
height: number | null;
|
|
47
|
+
mediaType: MediaType;
|
|
48
|
+
createdBy: string | null;
|
|
49
|
+
createdAt: Date | string;
|
|
50
|
+
updatedAt: Date | string;
|
|
51
|
+
}
|
|
52
|
+
/** Options for listing media */
|
|
53
|
+
interface MediaListOptions {
|
|
54
|
+
/** Filter by media type */
|
|
55
|
+
mediaType?: MediaType;
|
|
56
|
+
/** Filter by MIME type prefix (e.g., 'image/') */
|
|
57
|
+
mimeTypePrefix?: string;
|
|
58
|
+
/** Search in title and filename */
|
|
59
|
+
search?: string;
|
|
60
|
+
/** Pagination limit (default: 50) */
|
|
61
|
+
limit?: number;
|
|
62
|
+
/** Pagination offset (default: 0) */
|
|
63
|
+
offset?: number;
|
|
64
|
+
/** Order by field (default: 'createdAt') */
|
|
65
|
+
orderBy?: 'createdAt' | 'filename' | 'size';
|
|
66
|
+
/** Order direction (default: 'desc') */
|
|
67
|
+
orderDirection?: 'asc' | 'desc';
|
|
68
|
+
}
|
|
69
|
+
/** Paginated media list result */
|
|
70
|
+
interface MediaListResult {
|
|
71
|
+
items: MediaRecord[];
|
|
72
|
+
total: number;
|
|
73
|
+
limit: number;
|
|
74
|
+
offset: number;
|
|
75
|
+
}
|
|
76
|
+
/** Named image processing preset — defines how to resize/convert an image variant */
|
|
77
|
+
interface ImageStyle {
|
|
78
|
+
/** Max width in pixels (omit for height-only constraint) */
|
|
79
|
+
width?: number;
|
|
80
|
+
/** Max height in pixels (omit for height-only constraint) */
|
|
81
|
+
height?: number;
|
|
82
|
+
/** Resize strategy (default: 'cover') */
|
|
83
|
+
fit?: 'cover' | 'contain' | 'inside' | 'outside' | 'fill';
|
|
84
|
+
/** Output format (default: 'webp') */
|
|
85
|
+
format?: 'webp' | 'jpeg' | 'png' | 'avif';
|
|
86
|
+
/** Output quality 1–100 (default: 80) */
|
|
87
|
+
quality?: number;
|
|
88
|
+
}
|
|
89
|
+
/** Plugin configuration for @murumets-ee/media */
|
|
90
|
+
interface MediaPluginConfig {
|
|
91
|
+
/** Accepted MIME type patterns (default: ['image/*', 'video/*', 'audio/*', 'application/pdf']) */
|
|
92
|
+
acceptedTypes?: string[];
|
|
93
|
+
/** Max upload size in bytes (default: 50MB) */
|
|
94
|
+
maxUploadSize?: number;
|
|
95
|
+
/** Default file visibility (default: 'public') */
|
|
96
|
+
defaultVisibility?: FileVisibility;
|
|
97
|
+
/** Named image processing presets. Variants generated on upload for each style. */
|
|
98
|
+
imageStyles?: Record<string, ImageStyle>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type { ImageStyle as I, MediaUploadOptions as M, MediaUploadResult as a, MediaRecord as b, MediaListOptions as c, MediaListResult as d, MediaPluginConfig as e, MediaType as f };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"server-only";import{findEntityUsages as d}from"@murumets-ee/entity/refs";async function m(o,n){let r=await d("media",o,n),{getApp:a}=await import("@murumets-ee/core"),c=a();return r.map(t=>{let e="field",i=c.entities.get(t.sourceEntity);if(i){let s=i.allFields[t.sourceField];s&&s.type==="blocks"&&(e="block")}return{entityName:t.sourceEntity,entityId:t.sourceId,fieldName:t.sourceField,context:e}})}export{m as findMediaUsages};
|
package/dist/usage.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Media usage lookup — finds all entity references to a media item.
|
|
5
|
+
*
|
|
6
|
+
* Delegates to the universal entity_refs tracking table (populated at write time
|
|
7
|
+
* by AdminClient). One indexed query instead of scanning every table.
|
|
8
|
+
*
|
|
9
|
+
* Used by delete protection (409 Conflict) and the usage API endpoint.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface MediaUsage {
|
|
13
|
+
entityName: string;
|
|
14
|
+
entityId: string;
|
|
15
|
+
fieldName: string;
|
|
16
|
+
/** 'field' = entity column, 'block' = layout/blocks field */
|
|
17
|
+
context: 'field' | 'block';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Find all entities that reference a given media ID.
|
|
21
|
+
*
|
|
22
|
+
* @param mediaId - UUID of the media item to check
|
|
23
|
+
* @param db - Database connection
|
|
24
|
+
* @returns Array of usage records (empty if unreferenced)
|
|
25
|
+
*/
|
|
26
|
+
declare function findMediaUsages(mediaId: string, db: PostgresJsDatabase): Promise<MediaUsage[]>;
|
|
27
|
+
|
|
28
|
+
export { type MediaUsage, findMediaUsages };
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"server-only";import{findEntityUsages as d}from"@murumets-ee/entity/refs";async function m(o,n){let r=await d("media",o,n),{getApp:a}=await import("@murumets-ee/core"),c=a();return r.map(t=>{let e="field",i=c.entities.get(t.sourceEntity);if(i){let s=i.allFields[t.sourceField];s&&s.type==="blocks"&&(e="block")}return{entityName:t.sourceEntity,entityId:t.sourceId,fieldName:t.sourceField,context:e}})}export{m as findMediaUsages};
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@murumets-ee/media",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"types": "./dist/client.d.ts",
|
|
13
|
+
"import": "./dist/client.js"
|
|
14
|
+
},
|
|
15
|
+
"./query": {
|
|
16
|
+
"types": "./dist/query-client.d.ts",
|
|
17
|
+
"import": "./dist/query-client.js"
|
|
18
|
+
},
|
|
19
|
+
"./plugin": {
|
|
20
|
+
"types": "./dist/plugin.d.ts",
|
|
21
|
+
"import": "./dist/plugin.js"
|
|
22
|
+
},
|
|
23
|
+
"./ref": {
|
|
24
|
+
"types": "./dist/ref.d.ts",
|
|
25
|
+
"import": "./dist/ref.js"
|
|
26
|
+
},
|
|
27
|
+
"./picker": {
|
|
28
|
+
"types": "./dist/picker.d.ts",
|
|
29
|
+
"import": "./dist/picker.js"
|
|
30
|
+
},
|
|
31
|
+
"./admin": {
|
|
32
|
+
"types": "./dist/admin.d.ts",
|
|
33
|
+
"import": "./dist/admin.js"
|
|
34
|
+
},
|
|
35
|
+
"./usage": {
|
|
36
|
+
"types": "./dist/usage.d.ts",
|
|
37
|
+
"import": "./dist/usage.js"
|
|
38
|
+
},
|
|
39
|
+
"./image-styles": {
|
|
40
|
+
"types": "./dist/image-styles.d.ts",
|
|
41
|
+
"import": "./dist/image-styles.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist"
|
|
46
|
+
],
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@radix-ui/react-dialog": "^1.1.0",
|
|
49
|
+
"@radix-ui/react-visually-hidden": "^1.1.0",
|
|
50
|
+
"clsx": "^2.1.0",
|
|
51
|
+
"drizzle-orm": "^0.45.1",
|
|
52
|
+
"server-only": "^0.0.1",
|
|
53
|
+
"sharp": "^0.34.5",
|
|
54
|
+
"tailwind-merge": "^2.6.0",
|
|
55
|
+
"@murumets-ee/core": "0.1.0",
|
|
56
|
+
"@murumets-ee/db": "0.1.0",
|
|
57
|
+
"@murumets-ee/entity": "0.1.0",
|
|
58
|
+
"@murumets-ee/settings": "0.1.0",
|
|
59
|
+
"@murumets-ee/logging": "0.1.0",
|
|
60
|
+
"@murumets-ee/storage": "0.1.0"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"lucide-react": ">=0.400.0",
|
|
64
|
+
"react": ">=19.0.0",
|
|
65
|
+
"react-dom": ">=19.0.0",
|
|
66
|
+
"@murumets-ee/ui": "0.1.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"@murumets-ee/ui": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/node": "^22.10.5",
|
|
75
|
+
"@types/react": "^19",
|
|
76
|
+
"@types/react-dom": "^19",
|
|
77
|
+
"file-type": "^21.3.0",
|
|
78
|
+
"lucide-react": "^0.563.0",
|
|
79
|
+
"react": "^19.0.0",
|
|
80
|
+
"react-dom": "^19.0.0",
|
|
81
|
+
"tsup": "^8.3.5",
|
|
82
|
+
"typescript": "^5.7.3",
|
|
83
|
+
"vitest": "^2.1.8"
|
|
84
|
+
},
|
|
85
|
+
"scripts": {
|
|
86
|
+
"build": "rm -rf dist && tsup",
|
|
87
|
+
"dev": "tsup --watch",
|
|
88
|
+
"test": "vitest"
|
|
89
|
+
}
|
|
90
|
+
}
|