@digiko-npm/cms 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +89 -5
- package/dist/media/index.d.ts +39 -0
- package/dist/media/index.js +363 -0
- package/dist/r2/index.d.ts +60 -2
- package/dist/r2/index.js +84 -0
- package/package.json +28 -4
- package/src/index.ts +8 -0
- package/src/media/MediaPicker.tsx +194 -0
- package/src/media/MediaUploader.tsx +198 -0
- package/src/media/index.ts +2 -0
- package/src/r2/index.ts +8 -0
- package/src/r2/media-crud.ts +157 -0
- package/src/r2/presign.ts +61 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createAdminClient, createBrowserClient, createPublicClient } from './supabase/index.js';
|
|
2
|
-
export { UploadOptions, UploadResult, createR2Client, getR2Bucket, getR2PublicUrl, uploadFile } from './r2/index.js';
|
|
2
|
+
export { MediaListOptions, MediaListResult, PresignOptions, PresignResult, UploadOptions, UploadResult, createPresignedUploadUrl, createR2Client, deleteMedia, getR2Bucket, getR2PublicUrl, insertMedia, listMedia, uploadFile } from './r2/index.js';
|
|
3
3
|
export { TotpConfig, generateSessionToken, generateTotpSecret, generateTotpUri, hashPassword, verifyPassword, verifyTotpCode } from './auth/index.js';
|
|
4
4
|
export { RateLimiter, SessionStore, createRateLimiter, createSessionStore, getDefaultSessionDuration } from './session/index.js';
|
|
5
5
|
export { HTTP_STATUS, HttpStatus } from './http/index.js';
|
package/dist/index.js
CHANGED
|
@@ -93,8 +93,88 @@ function uploadToR2(url, file, contentType, onProgress) {
|
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// src/r2/presign.ts
|
|
97
|
+
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
98
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
99
|
+
function sanitizeFilename(filename) {
|
|
100
|
+
return filename.toLowerCase().replace(/[^a-z0-9.\-_]/g, "-").replace(/-+/g, "-");
|
|
101
|
+
}
|
|
102
|
+
async function createPresignedUploadUrl(config, options) {
|
|
103
|
+
const { filename, contentType, folder = "media", expiresIn = 600 } = options;
|
|
104
|
+
const sanitized = sanitizeFilename(filename);
|
|
105
|
+
const key = `${folder}/${crypto.randomUUID()}-${sanitized}`;
|
|
106
|
+
const r2 = createR2Client(config);
|
|
107
|
+
const command = new PutObjectCommand({
|
|
108
|
+
Bucket: config.bucketName,
|
|
109
|
+
Key: key,
|
|
110
|
+
ContentType: contentType
|
|
111
|
+
});
|
|
112
|
+
const uploadUrl = await getSignedUrl(r2, command, { expiresIn });
|
|
113
|
+
const publicUrl = `${config.publicUrl}/${key}`;
|
|
114
|
+
return { uploadUrl, publicUrl, key };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/r2/media-crud.ts
|
|
118
|
+
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
|
|
119
|
+
async function listMedia(supabase, tableName, options = {}) {
|
|
120
|
+
const { page = 1, limit = 24, type, search } = options;
|
|
121
|
+
const offset = (page - 1) * limit;
|
|
122
|
+
let query = supabase.from(tableName).select("*", { count: "exact" }).order("created_at", { ascending: false }).range(offset, offset + limit - 1);
|
|
123
|
+
if (type) {
|
|
124
|
+
query = query.like("mime_type", `${type}/%`);
|
|
125
|
+
}
|
|
126
|
+
if (search) {
|
|
127
|
+
query = query.or(`original_name.ilike.%${search}%,alt_text.ilike.%${search}%`);
|
|
128
|
+
}
|
|
129
|
+
const { data, error, count } = await query;
|
|
130
|
+
if (error) {
|
|
131
|
+
throw new Error(`Failed to list media: ${error.message}`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
items: data ?? [],
|
|
135
|
+
total: count ?? 0,
|
|
136
|
+
page,
|
|
137
|
+
limit,
|
|
138
|
+
totalPages: Math.ceil((count ?? 0) / limit)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function insertMedia(supabase, tableName, data) {
|
|
142
|
+
const { data: record, error } = await supabase.from(tableName).insert({
|
|
143
|
+
filename: data.filename,
|
|
144
|
+
original_name: data.original_name,
|
|
145
|
+
mime_type: data.mime_type,
|
|
146
|
+
size_bytes: data.size_bytes,
|
|
147
|
+
url: data.url,
|
|
148
|
+
width: data.width ?? null,
|
|
149
|
+
height: data.height ?? null,
|
|
150
|
+
alt_text: data.alt_text ?? null
|
|
151
|
+
}).select().single();
|
|
152
|
+
if (error) {
|
|
153
|
+
throw new Error(`Failed to insert media: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
return record;
|
|
156
|
+
}
|
|
157
|
+
async function deleteMedia(supabase, tableName, r2Config, id) {
|
|
158
|
+
const { data: media, error: fetchError } = await supabase.from(tableName).select("*").eq("id", id).single();
|
|
159
|
+
if (fetchError || !media) {
|
|
160
|
+
throw new Error("Media not found");
|
|
161
|
+
}
|
|
162
|
+
const record = media;
|
|
163
|
+
const key = record.url.replace(`${r2Config.publicUrl}/`, "");
|
|
164
|
+
try {
|
|
165
|
+
const r2 = createR2Client(r2Config);
|
|
166
|
+
await r2.send(new DeleteObjectCommand({ Bucket: r2Config.bucketName, Key: key }));
|
|
167
|
+
} catch {
|
|
168
|
+
console.error(`Failed to delete R2 object: ${key}`);
|
|
169
|
+
}
|
|
170
|
+
const { error: deleteError } = await supabase.from(tableName).delete().eq("id", id);
|
|
171
|
+
if (deleteError) {
|
|
172
|
+
throw new Error(`Failed to delete media record: ${deleteError.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
96
176
|
// src/auth/password.ts
|
|
97
|
-
import
|
|
177
|
+
import crypto2 from "crypto";
|
|
98
178
|
var DEFAULTS = {
|
|
99
179
|
iterations: 1e5,
|
|
100
180
|
keyLength: 64,
|
|
@@ -104,7 +184,7 @@ function hashPassword(password, salt, config) {
|
|
|
104
184
|
const iterations = config?.iterations ?? DEFAULTS.iterations;
|
|
105
185
|
const keyLength = config?.keyLength ?? DEFAULTS.keyLength;
|
|
106
186
|
const digest = config?.digest ?? DEFAULTS.digest;
|
|
107
|
-
return
|
|
187
|
+
return crypto2.pbkdf2Sync(password, salt, iterations, keyLength, digest).toString("hex");
|
|
108
188
|
}
|
|
109
189
|
function verifyPassword(password, salt, storedHash, config) {
|
|
110
190
|
const inputHash = hashPassword(password, salt, config);
|
|
@@ -113,14 +193,14 @@ function verifyPassword(password, salt, storedHash, config) {
|
|
|
113
193
|
if (storedBuffer.length !== inputBuffer.length) {
|
|
114
194
|
return false;
|
|
115
195
|
}
|
|
116
|
-
return
|
|
196
|
+
return crypto2.timingSafeEqual(storedBuffer, inputBuffer);
|
|
117
197
|
}
|
|
118
198
|
|
|
119
199
|
// src/auth/token.ts
|
|
120
|
-
import
|
|
200
|
+
import crypto3 from "crypto";
|
|
121
201
|
var DEFAULT_TOKEN_BYTES = 32;
|
|
122
202
|
function generateSessionToken(bytes) {
|
|
123
|
-
return
|
|
203
|
+
return crypto3.randomBytes(bytes ?? DEFAULT_TOKEN_BYTES).toString("hex");
|
|
124
204
|
}
|
|
125
205
|
|
|
126
206
|
// src/auth/totp.ts
|
|
@@ -253,10 +333,12 @@ export {
|
|
|
253
333
|
HTTP_STATUS,
|
|
254
334
|
createAdminClient,
|
|
255
335
|
createBrowserClient,
|
|
336
|
+
createPresignedUploadUrl,
|
|
256
337
|
createPublicClient,
|
|
257
338
|
createR2Client,
|
|
258
339
|
createRateLimiter,
|
|
259
340
|
createSessionStore,
|
|
341
|
+
deleteMedia,
|
|
260
342
|
generateSessionToken,
|
|
261
343
|
generateTotpSecret,
|
|
262
344
|
generateTotpUri,
|
|
@@ -264,6 +346,8 @@ export {
|
|
|
264
346
|
getR2Bucket,
|
|
265
347
|
getR2PublicUrl,
|
|
266
348
|
hashPassword,
|
|
349
|
+
insertMedia,
|
|
350
|
+
listMedia,
|
|
267
351
|
uploadFile,
|
|
268
352
|
verifyPassword,
|
|
269
353
|
verifyTotpCode
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { U as UploadConfig } from '../config-qNdTlg1g.js';
|
|
3
|
+
import { a as MediaRecord } from '../media-ExBfXePZ.js';
|
|
4
|
+
|
|
5
|
+
interface MediaUploaderProps {
|
|
6
|
+
/** Current media URL (shows preview when set) */
|
|
7
|
+
value?: string;
|
|
8
|
+
/** Upload config with API endpoint paths */
|
|
9
|
+
uploadConfig: UploadConfig;
|
|
10
|
+
/** Storage folder (e.g. 'media', 'shots', 'projects'). Default: 'media' */
|
|
11
|
+
folder?: string;
|
|
12
|
+
/** Comma-separated MIME types to accept. Default: all image + video types */
|
|
13
|
+
accept?: string;
|
|
14
|
+
/** Called after successful upload with public URL and media record ID */
|
|
15
|
+
onUpload: (url: string, mediaId: string) => void;
|
|
16
|
+
/** Called when the user removes the current media */
|
|
17
|
+
onRemove?: () => void;
|
|
18
|
+
/** Additional CSS class name */
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
declare function MediaUploader({ value, uploadConfig, folder, accept, onUpload, onRemove, className, }: MediaUploaderProps): react_jsx_runtime.JSX.Element;
|
|
22
|
+
|
|
23
|
+
interface MediaPickerProps {
|
|
24
|
+
/** Whether the picker modal is open */
|
|
25
|
+
isOpen: boolean;
|
|
26
|
+
/** Called when the user closes the picker */
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
/** Called when the user selects a media item */
|
|
29
|
+
onSelect: (media: MediaRecord) => void;
|
|
30
|
+
/** Upload config with API endpoint paths */
|
|
31
|
+
uploadConfig: UploadConfig;
|
|
32
|
+
/** Filter by MIME type prefix: 'image' or 'video' */
|
|
33
|
+
filter?: 'image' | 'video';
|
|
34
|
+
/** Custom fetch function for authenticated requests. Default: window.fetch */
|
|
35
|
+
fetchFn?: typeof fetch;
|
|
36
|
+
}
|
|
37
|
+
declare function MediaPicker({ isOpen, onClose, onSelect, uploadConfig, filter, fetchFn, }: MediaPickerProps): react_jsx_runtime.JSX.Element | null;
|
|
38
|
+
|
|
39
|
+
export { MediaPicker, type MediaPickerProps, MediaUploader, type MediaUploaderProps };
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// src/media/MediaUploader.tsx
|
|
2
|
+
import { useState, useCallback, useRef } from "react";
|
|
3
|
+
import { Upload, X, Image as ImageIcon, Film } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
// src/r2/upload.ts
|
|
6
|
+
async function uploadFile(config, { file, folder = "media", onProgress }) {
|
|
7
|
+
const presignRes = await fetch(config.uploadEndpoint, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { "Content-Type": "application/json" },
|
|
10
|
+
body: JSON.stringify({
|
|
11
|
+
filename: file.name,
|
|
12
|
+
contentType: file.type,
|
|
13
|
+
folder
|
|
14
|
+
})
|
|
15
|
+
});
|
|
16
|
+
if (!presignRes.ok) {
|
|
17
|
+
throw new Error(`Failed to get upload URL: ${presignRes.status}`);
|
|
18
|
+
}
|
|
19
|
+
const { uploadUrl, publicUrl, key } = await presignRes.json();
|
|
20
|
+
await uploadToR2(uploadUrl, file, file.type, onProgress);
|
|
21
|
+
const confirmRes = await fetch(config.mediaEndpoint, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
filename: key.split("/").pop(),
|
|
26
|
+
original_name: file.name,
|
|
27
|
+
mime_type: file.type,
|
|
28
|
+
size_bytes: file.size,
|
|
29
|
+
url: publicUrl
|
|
30
|
+
})
|
|
31
|
+
});
|
|
32
|
+
if (!confirmRes.ok) {
|
|
33
|
+
throw new Error(`Failed to register upload: ${confirmRes.status}`);
|
|
34
|
+
}
|
|
35
|
+
const media = await confirmRes.json();
|
|
36
|
+
return { url: publicUrl, key, media };
|
|
37
|
+
}
|
|
38
|
+
function uploadToR2(url, file, contentType, onProgress) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const xhr = new XMLHttpRequest();
|
|
41
|
+
xhr.upload.addEventListener("progress", (e) => {
|
|
42
|
+
if (e.lengthComputable && onProgress) {
|
|
43
|
+
onProgress(Math.round(e.loaded / e.total * 100));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
xhr.addEventListener("load", () => {
|
|
47
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
48
|
+
resolve();
|
|
49
|
+
} else {
|
|
50
|
+
reject(new Error(`Upload failed with status ${xhr.status}`));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
|
|
54
|
+
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
|
55
|
+
xhr.open("PUT", url);
|
|
56
|
+
xhr.setRequestHeader("Content-Type", contentType);
|
|
57
|
+
xhr.send(file);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/media/MediaUploader.tsx
|
|
62
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
63
|
+
var ACCEPTED_TYPES = {
|
|
64
|
+
image: ["image/jpeg", "image/png", "image/webp", "image/gif"],
|
|
65
|
+
video: ["video/mp4", "video/webm"]
|
|
66
|
+
};
|
|
67
|
+
var ALL_ACCEPTED = [...ACCEPTED_TYPES.image, ...ACCEPTED_TYPES.video];
|
|
68
|
+
function MediaUploader({
|
|
69
|
+
value,
|
|
70
|
+
uploadConfig,
|
|
71
|
+
folder = "media",
|
|
72
|
+
accept,
|
|
73
|
+
onUpload,
|
|
74
|
+
onRemove,
|
|
75
|
+
className
|
|
76
|
+
}) {
|
|
77
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
78
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
79
|
+
const [progress, setProgress] = useState(0);
|
|
80
|
+
const [error, setError] = useState(null);
|
|
81
|
+
const inputRef = useRef(null);
|
|
82
|
+
const acceptedTypes = accept ? accept.split(",").map((t) => t.trim()) : ALL_ACCEPTED;
|
|
83
|
+
const handleFile = useCallback(
|
|
84
|
+
async (file) => {
|
|
85
|
+
if (!acceptedTypes.includes(file.type)) {
|
|
86
|
+
setError(`File type not supported: ${file.type}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setError(null);
|
|
90
|
+
setIsUploading(true);
|
|
91
|
+
setProgress(0);
|
|
92
|
+
try {
|
|
93
|
+
const result = await uploadFile(uploadConfig, {
|
|
94
|
+
file,
|
|
95
|
+
folder,
|
|
96
|
+
onProgress: setProgress
|
|
97
|
+
});
|
|
98
|
+
onUpload(result.url, result.media.id);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setError(err instanceof Error ? err.message : "Upload failed");
|
|
101
|
+
} finally {
|
|
102
|
+
setIsUploading(false);
|
|
103
|
+
setProgress(0);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[acceptedTypes, folder, onUpload, uploadConfig]
|
|
107
|
+
);
|
|
108
|
+
const handleDrop = useCallback(
|
|
109
|
+
(e) => {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
setIsDragging(false);
|
|
112
|
+
const file = e.dataTransfer.files[0];
|
|
113
|
+
if (file) handleFile(file);
|
|
114
|
+
},
|
|
115
|
+
[handleFile]
|
|
116
|
+
);
|
|
117
|
+
const handleChange = useCallback(
|
|
118
|
+
(e) => {
|
|
119
|
+
const file = e.target.files?.[0];
|
|
120
|
+
if (file) handleFile(file);
|
|
121
|
+
},
|
|
122
|
+
[handleFile]
|
|
123
|
+
);
|
|
124
|
+
const isImage = value?.match(/\.(jpg|jpeg|png|webp|gif)(\?|$)/i);
|
|
125
|
+
const isVideo = value?.match(/\.(mp4|webm)(\?|$)/i);
|
|
126
|
+
const cls = ["media-preview", className].filter(Boolean).join(" ");
|
|
127
|
+
const dropCls = [
|
|
128
|
+
"drop-zone",
|
|
129
|
+
isDragging && "drop-zone--active",
|
|
130
|
+
isUploading && "drop-zone--uploading",
|
|
131
|
+
className
|
|
132
|
+
].filter(Boolean).join(" ");
|
|
133
|
+
if (value) {
|
|
134
|
+
return /* @__PURE__ */ jsxs("div", { className: cls, children: [
|
|
135
|
+
isImage && /* @__PURE__ */ jsx("div", { className: "ds-relative ds-w-full", style: { height: "12rem" }, children: /* @__PURE__ */ jsx(
|
|
136
|
+
"img",
|
|
137
|
+
{
|
|
138
|
+
src: value,
|
|
139
|
+
alt: "Uploaded",
|
|
140
|
+
className: "ds-h-full ds-w-full ds-object-cover"
|
|
141
|
+
}
|
|
142
|
+
) }),
|
|
143
|
+
isVideo && /* @__PURE__ */ jsx(
|
|
144
|
+
"video",
|
|
145
|
+
{
|
|
146
|
+
src: value,
|
|
147
|
+
className: "ds-w-full ds-object-cover",
|
|
148
|
+
style: { height: "12rem" },
|
|
149
|
+
muted: true
|
|
150
|
+
}
|
|
151
|
+
),
|
|
152
|
+
!isImage && !isVideo && /* @__PURE__ */ jsx(
|
|
153
|
+
"div",
|
|
154
|
+
{
|
|
155
|
+
className: "ds-flex ds-items-center ds-justify-center ds-bg-elevated ds-text-secondary",
|
|
156
|
+
style: { height: "12rem" },
|
|
157
|
+
children: /* @__PURE__ */ jsx("span", { className: "ds-text-sm", children: value.split("/").pop() })
|
|
158
|
+
}
|
|
159
|
+
),
|
|
160
|
+
onRemove && /* @__PURE__ */ jsx("button", { onClick: onRemove, className: "media-preview__remove", type: "button", children: /* @__PURE__ */ jsx(X, { size: 14, className: "ds-text-secondary" }) })
|
|
161
|
+
] });
|
|
162
|
+
}
|
|
163
|
+
return /* @__PURE__ */ jsxs(
|
|
164
|
+
"div",
|
|
165
|
+
{
|
|
166
|
+
onDragOver: (e) => {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
setIsDragging(true);
|
|
169
|
+
},
|
|
170
|
+
onDragLeave: () => setIsDragging(false),
|
|
171
|
+
onDrop: handleDrop,
|
|
172
|
+
onClick: () => inputRef.current?.click(),
|
|
173
|
+
className: dropCls,
|
|
174
|
+
children: [
|
|
175
|
+
/* @__PURE__ */ jsx(
|
|
176
|
+
"input",
|
|
177
|
+
{
|
|
178
|
+
ref: inputRef,
|
|
179
|
+
type: "file",
|
|
180
|
+
accept: acceptedTypes.join(","),
|
|
181
|
+
onChange: handleChange,
|
|
182
|
+
className: "ds-hidden"
|
|
183
|
+
}
|
|
184
|
+
),
|
|
185
|
+
isUploading ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
186
|
+
/* @__PURE__ */ jsx("div", { className: "progress-bar", children: /* @__PURE__ */ jsx(
|
|
187
|
+
"div",
|
|
188
|
+
{
|
|
189
|
+
className: "progress-bar__fill",
|
|
190
|
+
style: { width: `${progress}%` }
|
|
191
|
+
}
|
|
192
|
+
) }),
|
|
193
|
+
/* @__PURE__ */ jsxs("span", { className: "ds-text-sm ds-text-secondary", children: [
|
|
194
|
+
progress,
|
|
195
|
+
"%"
|
|
196
|
+
] })
|
|
197
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
198
|
+
/* @__PURE__ */ jsxs("div", { className: "ds-flex ds-gap-2 ds-text-tertiary", children: [
|
|
199
|
+
/* @__PURE__ */ jsx(ImageIcon, { size: 20 }),
|
|
200
|
+
/* @__PURE__ */ jsx(Film, { size: 20 }),
|
|
201
|
+
/* @__PURE__ */ jsx(Upload, { size: 20 })
|
|
202
|
+
] }),
|
|
203
|
+
/* @__PURE__ */ jsxs("div", { className: "ds-text-center", children: [
|
|
204
|
+
/* @__PURE__ */ jsx("p", { className: "ds-text-sm ds-font-medium ds-text-secondary", children: "Drop a file or click to upload" }),
|
|
205
|
+
/* @__PURE__ */ jsx("p", { className: "ds-mt-1 ds-text-xs ds-text-tertiary", children: "JPG, PNG, WebP, GIF, MP4, WebM" })
|
|
206
|
+
] })
|
|
207
|
+
] }),
|
|
208
|
+
error && /* @__PURE__ */ jsx("p", { className: "ds-text-xs ds-text-error", children: error })
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/media/MediaPicker.tsx
|
|
215
|
+
import { useState as useState2, useEffect, useCallback as useCallback2 } from "react";
|
|
216
|
+
import { X as X2, Search, Upload as Upload2 } from "lucide-react";
|
|
217
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
218
|
+
function MediaPicker({
|
|
219
|
+
isOpen,
|
|
220
|
+
onClose,
|
|
221
|
+
onSelect,
|
|
222
|
+
uploadConfig,
|
|
223
|
+
filter,
|
|
224
|
+
fetchFn
|
|
225
|
+
}) {
|
|
226
|
+
const [items, setItems] = useState2([]);
|
|
227
|
+
const [loading, setLoading] = useState2(false);
|
|
228
|
+
const [search, setSearch] = useState2("");
|
|
229
|
+
const [page, setPage] = useState2(1);
|
|
230
|
+
const [totalPages, setTotalPages] = useState2(1);
|
|
231
|
+
const [showUploader, setShowUploader] = useState2(false);
|
|
232
|
+
const doFetch = fetchFn ?? fetch;
|
|
233
|
+
const fetchMedia = useCallback2(async () => {
|
|
234
|
+
setLoading(true);
|
|
235
|
+
const params = new URLSearchParams({ page: String(page), limit: "24" });
|
|
236
|
+
if (filter) params.set("type", filter);
|
|
237
|
+
if (search) params.set("q", search);
|
|
238
|
+
try {
|
|
239
|
+
const res = await doFetch(`${uploadConfig.mediaEndpoint}?${params}`);
|
|
240
|
+
const data = await res.json();
|
|
241
|
+
setItems(data.items ?? []);
|
|
242
|
+
setTotalPages(data.totalPages ?? 1);
|
|
243
|
+
} catch {
|
|
244
|
+
console.error("Failed to fetch media");
|
|
245
|
+
} finally {
|
|
246
|
+
setLoading(false);
|
|
247
|
+
}
|
|
248
|
+
}, [page, filter, search, doFetch, uploadConfig.mediaEndpoint]);
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (isOpen) fetchMedia();
|
|
251
|
+
}, [isOpen, fetchMedia]);
|
|
252
|
+
if (!isOpen) return null;
|
|
253
|
+
return /* @__PURE__ */ jsxs2("div", { className: "media-picker", children: [
|
|
254
|
+
/* @__PURE__ */ jsx2("div", { className: "media-picker__backdrop", onClick: onClose }),
|
|
255
|
+
/* @__PURE__ */ jsxs2("div", { className: "media-picker__panel", children: [
|
|
256
|
+
/* @__PURE__ */ jsxs2("div", { className: "media-picker__header", children: [
|
|
257
|
+
/* @__PURE__ */ jsx2("h3", { className: "ds-text-lg ds-font-medium", children: "Media Library" }),
|
|
258
|
+
/* @__PURE__ */ jsxs2("div", { className: "ds-flex ds-items-center ds-gap-2", children: [
|
|
259
|
+
/* @__PURE__ */ jsxs2(
|
|
260
|
+
"button",
|
|
261
|
+
{
|
|
262
|
+
onClick: () => setShowUploader(!showUploader),
|
|
263
|
+
className: "ds-btn ds-btn--secondary ds-btn--sm",
|
|
264
|
+
type: "button",
|
|
265
|
+
children: [
|
|
266
|
+
/* @__PURE__ */ jsx2(Upload2, { size: 14 }),
|
|
267
|
+
"Upload"
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
),
|
|
271
|
+
/* @__PURE__ */ jsx2("button", { onClick: onClose, className: "ds-btn ds-btn--ghost ds-btn--sm", type: "button", children: /* @__PURE__ */ jsx2(X2, { size: 18 }) })
|
|
272
|
+
] })
|
|
273
|
+
] }),
|
|
274
|
+
showUploader && /* @__PURE__ */ jsx2("div", { className: "ds-border-b ds-p-4", children: /* @__PURE__ */ jsx2(
|
|
275
|
+
MediaUploader,
|
|
276
|
+
{
|
|
277
|
+
uploadConfig,
|
|
278
|
+
onUpload: () => {
|
|
279
|
+
setShowUploader(false);
|
|
280
|
+
fetchMedia();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
) }),
|
|
284
|
+
/* @__PURE__ */ jsx2("div", { className: "ds-border-b ds-px-5 ds-py-3", children: /* @__PURE__ */ jsxs2("div", { className: "ds-relative", children: [
|
|
285
|
+
/* @__PURE__ */ jsx2(Search, { size: 16, className: "search-bar__icon" }),
|
|
286
|
+
/* @__PURE__ */ jsx2(
|
|
287
|
+
"input",
|
|
288
|
+
{
|
|
289
|
+
type: "text",
|
|
290
|
+
placeholder: "Search media...",
|
|
291
|
+
value: search,
|
|
292
|
+
onChange: (e) => {
|
|
293
|
+
setSearch(e.target.value);
|
|
294
|
+
setPage(1);
|
|
295
|
+
},
|
|
296
|
+
onKeyDown: (e) => e.key === "Enter" && fetchMedia(),
|
|
297
|
+
className: "search-bar__input"
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
] }) }),
|
|
301
|
+
/* @__PURE__ */ jsx2("div", { className: "ds-flex-1 ds-overflow-y-auto ds-p-4", children: loading ? /* @__PURE__ */ jsx2("div", { className: "ds-flex ds-items-center ds-justify-center ds-py-12", children: /* @__PURE__ */ jsx2("div", { className: "spinner spinner--md spinner--default" }) }) : items.length === 0 ? /* @__PURE__ */ jsxs2("div", { className: "ds-flex ds-flex-col ds-items-center ds-justify-center ds-py-12 ds-text-center", children: [
|
|
302
|
+
/* @__PURE__ */ jsx2("p", { className: "ds-text-sm ds-text-secondary", children: "No media found" }),
|
|
303
|
+
/* @__PURE__ */ jsx2("p", { className: "ds-mt-1 ds-text-xs ds-text-tertiary", children: "Upload files to get started" })
|
|
304
|
+
] }) : /* @__PURE__ */ jsx2("div", { className: "ds-grid ds-grid-cols-4 ds-gap-3", children: items.map((item) => {
|
|
305
|
+
const isImage = item.mime_type.startsWith("image/");
|
|
306
|
+
return /* @__PURE__ */ jsxs2(
|
|
307
|
+
"button",
|
|
308
|
+
{
|
|
309
|
+
onClick: () => onSelect(item),
|
|
310
|
+
className: "media-grid-item ds-aspect-square",
|
|
311
|
+
type: "button",
|
|
312
|
+
children: [
|
|
313
|
+
isImage ? (
|
|
314
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
315
|
+
/* @__PURE__ */ jsx2(
|
|
316
|
+
"img",
|
|
317
|
+
{
|
|
318
|
+
src: item.url,
|
|
319
|
+
alt: item.alt_text ?? item.original_name,
|
|
320
|
+
className: "ds-h-full ds-w-full ds-object-cover"
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
) : /* @__PURE__ */ jsx2("div", { className: "ds-flex ds-h-full ds-w-full ds-items-center ds-justify-center ds-bg-elevated", children: /* @__PURE__ */ jsx2("span", { className: "ds-text-xs ds-text-tertiary", children: item.mime_type.split("/")[1]?.toUpperCase() }) }),
|
|
324
|
+
/* @__PURE__ */ jsx2("div", { className: "media-grid-item__overlay" })
|
|
325
|
+
]
|
|
326
|
+
},
|
|
327
|
+
item.id
|
|
328
|
+
);
|
|
329
|
+
}) }) }),
|
|
330
|
+
totalPages > 1 && /* @__PURE__ */ jsxs2("div", { className: "ds-flex ds-items-center ds-justify-center ds-gap-2 ds-border-t ds-px-5 ds-py-3", children: [
|
|
331
|
+
/* @__PURE__ */ jsx2(
|
|
332
|
+
"button",
|
|
333
|
+
{
|
|
334
|
+
onClick: () => setPage((p) => Math.max(1, p - 1)),
|
|
335
|
+
disabled: page === 1,
|
|
336
|
+
className: "ds-btn ds-btn--ghost ds-btn--sm",
|
|
337
|
+
type: "button",
|
|
338
|
+
children: "Previous"
|
|
339
|
+
}
|
|
340
|
+
),
|
|
341
|
+
/* @__PURE__ */ jsxs2("span", { className: "ds-text-sm ds-text-secondary", children: [
|
|
342
|
+
page,
|
|
343
|
+
" / ",
|
|
344
|
+
totalPages
|
|
345
|
+
] }),
|
|
346
|
+
/* @__PURE__ */ jsx2(
|
|
347
|
+
"button",
|
|
348
|
+
{
|
|
349
|
+
onClick: () => setPage((p) => Math.min(totalPages, p + 1)),
|
|
350
|
+
disabled: page === totalPages,
|
|
351
|
+
className: "ds-btn ds-btn--ghost ds-btn--sm",
|
|
352
|
+
type: "button",
|
|
353
|
+
children: "Next"
|
|
354
|
+
}
|
|
355
|
+
)
|
|
356
|
+
] })
|
|
357
|
+
] })
|
|
358
|
+
] });
|
|
359
|
+
}
|
|
360
|
+
export {
|
|
361
|
+
MediaPicker,
|
|
362
|
+
MediaUploader
|
|
363
|
+
};
|
package/dist/r2/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { S3Client } from '@aws-sdk/client-s3';
|
|
2
2
|
import { R as R2Config, U as UploadConfig } from '../config-qNdTlg1g.js';
|
|
3
|
-
import { a as MediaRecord } from '../media-ExBfXePZ.js';
|
|
3
|
+
import { a as MediaRecord, M as MediaInsert } from '../media-ExBfXePZ.js';
|
|
4
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Create a Cloudflare R2 client (S3-compatible).
|
|
@@ -31,4 +32,61 @@ interface UploadResult {
|
|
|
31
32
|
*/
|
|
32
33
|
declare function uploadFile(config: UploadConfig, { file, folder, onProgress }: UploadOptions): Promise<UploadResult>;
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
interface PresignOptions {
|
|
36
|
+
/** Original filename (will be sanitized) */
|
|
37
|
+
filename: string;
|
|
38
|
+
/** MIME type of the file */
|
|
39
|
+
contentType: string;
|
|
40
|
+
/** Storage folder (e.g. 'media', 'shots', 'projects') */
|
|
41
|
+
folder?: string;
|
|
42
|
+
/** Presigned URL expiration in seconds. Default: 600 (10 min) */
|
|
43
|
+
expiresIn?: number;
|
|
44
|
+
}
|
|
45
|
+
interface PresignResult {
|
|
46
|
+
/** Presigned PUT URL for direct browser upload */
|
|
47
|
+
uploadUrl: string;
|
|
48
|
+
/** Public URL where the file will be accessible */
|
|
49
|
+
publicUrl: string;
|
|
50
|
+
/** R2 object key */
|
|
51
|
+
key: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Generate a presigned PUT URL for direct browser-to-R2 upload.
|
|
55
|
+
* The server never handles file bytes — Vercel/edge-friendly.
|
|
56
|
+
*/
|
|
57
|
+
declare function createPresignedUploadUrl(config: R2Config, options: PresignOptions): Promise<PresignResult>;
|
|
58
|
+
|
|
59
|
+
interface MediaListOptions {
|
|
60
|
+
/** 1-based page number. Default: 1 */
|
|
61
|
+
page?: number;
|
|
62
|
+
/** Items per page. Default: 24 */
|
|
63
|
+
limit?: number;
|
|
64
|
+
/** Filter by MIME type prefix: 'image' or 'video' */
|
|
65
|
+
type?: string;
|
|
66
|
+
/** Search query (matches original_name and alt_text) */
|
|
67
|
+
search?: string;
|
|
68
|
+
}
|
|
69
|
+
interface MediaListResult {
|
|
70
|
+
items: MediaRecord[];
|
|
71
|
+
total: number;
|
|
72
|
+
page: number;
|
|
73
|
+
limit: number;
|
|
74
|
+
totalPages: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* List media records with pagination and filtering.
|
|
78
|
+
* Requires a Supabase admin client (service role) for full access.
|
|
79
|
+
*/
|
|
80
|
+
declare function listMedia(supabase: SupabaseClient, tableName: string, options?: MediaListOptions): Promise<MediaListResult>;
|
|
81
|
+
/**
|
|
82
|
+
* Register a completed upload in the database.
|
|
83
|
+
* Called after the browser finishes uploading to R2.
|
|
84
|
+
*/
|
|
85
|
+
declare function insertMedia(supabase: SupabaseClient, tableName: string, data: MediaInsert): Promise<MediaRecord>;
|
|
86
|
+
/**
|
|
87
|
+
* Delete a media record and its R2 object.
|
|
88
|
+
* Attempts R2 deletion first but always cleans up the DB record.
|
|
89
|
+
*/
|
|
90
|
+
declare function deleteMedia(supabase: SupabaseClient, tableName: string, r2Config: R2Config, id: string): Promise<void>;
|
|
91
|
+
|
|
92
|
+
export { type MediaListOptions, type MediaListResult, type PresignOptions, type PresignResult, type UploadOptions, type UploadResult, createPresignedUploadUrl, createR2Client, deleteMedia, getR2Bucket, getR2PublicUrl, insertMedia, listMedia, uploadFile };
|