@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 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 crypto from "crypto";
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 crypto.pbkdf2Sync(password, salt, iterations, keyLength, digest).toString("hex");
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 crypto.timingSafeEqual(storedBuffer, inputBuffer);
196
+ return crypto2.timingSafeEqual(storedBuffer, inputBuffer);
117
197
  }
118
198
 
119
199
  // src/auth/token.ts
120
- import crypto2 from "crypto";
200
+ import crypto3 from "crypto";
121
201
  var DEFAULT_TOKEN_BYTES = 32;
122
202
  function generateSessionToken(bytes) {
123
- return crypto2.randomBytes(bytes ?? DEFAULT_TOKEN_BYTES).toString("hex");
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
+ };
@@ -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
- export { type UploadOptions, type UploadResult, createR2Client, getR2Bucket, getR2PublicUrl, uploadFile };
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 };