@digiko-npm/cms 0.1.0 → 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.
@@ -17,4 +17,33 @@ declare function verifyPassword(password: string, salt: string, storedHash: stri
17
17
  */
18
18
  declare function generateSessionToken(bytes?: number): string;
19
19
 
20
- export { generateSessionToken, hashPassword, verifyPassword };
20
+ /** Configuration for TOTP operations */
21
+ interface TotpConfig {
22
+ /** Hash algorithm. Default: 'SHA1' (required for 1Password compatibility) */
23
+ algorithm?: string;
24
+ /** Number of digits. Default: 6 */
25
+ digits?: number;
26
+ /** Time step in seconds. Default: 30 */
27
+ period?: number;
28
+ /** Validation window (number of periods to check before/after). Default: 1 */
29
+ window?: number;
30
+ }
31
+ /**
32
+ * Generate a new TOTP secret.
33
+ * Returns a base32-encoded string suitable for storing in env vars.
34
+ * Uses 20 bytes (160 bits) as recommended by RFC 4226.
35
+ */
36
+ declare function generateTotpSecret(): string;
37
+ /**
38
+ * Generate an otpauth:// URI for registering with authenticator apps.
39
+ * Can be entered manually in 1Password or encoded as a QR code.
40
+ */
41
+ declare function generateTotpUri(secret: string, accountName: string, issuer: string, config?: TotpConfig): string;
42
+ /**
43
+ * Verify a 6-digit TOTP code against a secret.
44
+ * Returns true if the code is valid within the configured time window.
45
+ * Uses timing-safe comparison internally (handled by otpauth library).
46
+ */
47
+ declare function verifyTotpCode(code: string, secret: string, config?: TotpConfig): boolean;
48
+
49
+ export { type TotpConfig, generateSessionToken, generateTotpSecret, generateTotpUri, hashPassword, verifyPassword, verifyTotpCode };
@@ -27,8 +27,45 @@ var DEFAULT_TOKEN_BYTES = 32;
27
27
  function generateSessionToken(bytes) {
28
28
  return crypto2.randomBytes(bytes ?? DEFAULT_TOKEN_BYTES).toString("hex");
29
29
  }
30
+
31
+ // src/auth/totp.ts
32
+ import { TOTP, Secret } from "otpauth";
33
+ var DEFAULTS2 = {
34
+ algorithm: "SHA1",
35
+ digits: 6,
36
+ period: 30,
37
+ window: 1
38
+ };
39
+ function generateTotpSecret() {
40
+ const secret = new Secret({ size: 20 });
41
+ return secret.base32;
42
+ }
43
+ function generateTotpUri(secret, accountName, issuer, config) {
44
+ const totp = new TOTP({
45
+ issuer,
46
+ label: accountName,
47
+ algorithm: config?.algorithm ?? DEFAULTS2.algorithm,
48
+ digits: config?.digits ?? DEFAULTS2.digits,
49
+ period: config?.period ?? DEFAULTS2.period,
50
+ secret: Secret.fromBase32(secret)
51
+ });
52
+ return totp.toString();
53
+ }
54
+ function verifyTotpCode(code, secret, config) {
55
+ const totp = new TOTP({
56
+ algorithm: config?.algorithm ?? DEFAULTS2.algorithm,
57
+ digits: config?.digits ?? DEFAULTS2.digits,
58
+ period: config?.period ?? DEFAULTS2.period,
59
+ secret: Secret.fromBase32(secret)
60
+ });
61
+ const delta = totp.validate({ token: code, window: config?.window ?? DEFAULTS2.window });
62
+ return delta !== null;
63
+ }
30
64
  export {
31
65
  generateSessionToken,
66
+ generateTotpSecret,
67
+ generateTotpUri,
32
68
  hashPassword,
33
- verifyPassword
69
+ verifyPassword,
70
+ verifyTotpCode
34
71
  };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createAdminClient, createBrowserClient, createPublicClient } from './supabase/index.js';
2
- export { UploadOptions, UploadResult, createR2Client, getR2Bucket, getR2PublicUrl, uploadFile } from './r2/index.js';
3
- export { generateSessionToken, hashPassword, verifyPassword } from './auth/index.js';
2
+ export { MediaListOptions, MediaListResult, PresignOptions, PresignResult, UploadOptions, UploadResult, createPresignedUploadUrl, createR2Client, deleteMedia, getR2Bucket, getR2PublicUrl, insertMedia, listMedia, uploadFile } from './r2/index.js';
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';
6
6
  export { A as AuthConfig, R as R2Config, a as RateLimiterConfig, b as RequestVerifierConfig, S as SessionStoreConfig, c as SupabaseConfig, U as UploadConfig } from './config-qNdTlg1g.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,48 @@ 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");
204
+ }
205
+
206
+ // src/auth/totp.ts
207
+ import { TOTP, Secret } from "otpauth";
208
+ var DEFAULTS2 = {
209
+ algorithm: "SHA1",
210
+ digits: 6,
211
+ period: 30,
212
+ window: 1
213
+ };
214
+ function generateTotpSecret() {
215
+ const secret = new Secret({ size: 20 });
216
+ return secret.base32;
217
+ }
218
+ function generateTotpUri(secret, accountName, issuer, config) {
219
+ const totp = new TOTP({
220
+ issuer,
221
+ label: accountName,
222
+ algorithm: config?.algorithm ?? DEFAULTS2.algorithm,
223
+ digits: config?.digits ?? DEFAULTS2.digits,
224
+ period: config?.period ?? DEFAULTS2.period,
225
+ secret: Secret.fromBase32(secret)
226
+ });
227
+ return totp.toString();
228
+ }
229
+ function verifyTotpCode(code, secret, config) {
230
+ const totp = new TOTP({
231
+ algorithm: config?.algorithm ?? DEFAULTS2.algorithm,
232
+ digits: config?.digits ?? DEFAULTS2.digits,
233
+ period: config?.period ?? DEFAULTS2.period,
234
+ secret: Secret.fromBase32(secret)
235
+ });
236
+ const delta = totp.validate({ token: code, window: config?.window ?? DEFAULTS2.window });
237
+ return delta !== null;
124
238
  }
125
239
 
126
240
  // src/session/store.ts
@@ -160,7 +274,7 @@ function getDefaultSessionDuration() {
160
274
 
161
275
  // src/session/rate-limit.ts
162
276
  import { Redis as Redis2 } from "@upstash/redis";
163
- var DEFAULTS2 = {
277
+ var DEFAULTS3 = {
164
278
  maxAttempts: 10,
165
279
  windowMs: 15 * 60 * 1e3
166
280
  // 15 minutes
@@ -170,8 +284,8 @@ function createRateLimiter(config) {
170
284
  url: config.redisUrl,
171
285
  token: config.redisToken
172
286
  });
173
- const maxAttempts = config.maxAttempts ?? DEFAULTS2.maxAttempts;
174
- const windowMs = config.windowMs ?? DEFAULTS2.windowMs;
287
+ const maxAttempts = config.maxAttempts ?? DEFAULTS3.maxAttempts;
288
+ const windowMs = config.windowMs ?? DEFAULTS3.windowMs;
175
289
  const rateLimitKey = (key) => `${config.keyPrefix}ratelimit:${key}`;
176
290
  return {
177
291
  async check(key) {
@@ -219,15 +333,22 @@ export {
219
333
  HTTP_STATUS,
220
334
  createAdminClient,
221
335
  createBrowserClient,
336
+ createPresignedUploadUrl,
222
337
  createPublicClient,
223
338
  createR2Client,
224
339
  createRateLimiter,
225
340
  createSessionStore,
341
+ deleteMedia,
226
342
  generateSessionToken,
343
+ generateTotpSecret,
344
+ generateTotpUri,
227
345
  getDefaultSessionDuration,
228
346
  getR2Bucket,
229
347
  getR2PublicUrl,
230
348
  hashPassword,
349
+ insertMedia,
350
+ listMedia,
231
351
  uploadFile,
232
- verifyPassword
352
+ verifyPassword,
353
+ verifyTotpCode
233
354
  };
@@ -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
+ };