@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.
- package/dist/auth/index.d.ts +30 -1
- package/dist/auth/index.js +38 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +130 -9
- 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 +51 -16
- package/src/auth/index.ts +1 -0
- package/src/auth/totp.ts +72 -0
- package/src/index.ts +9 -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/auth/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/auth/index.js
CHANGED
|
@@ -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
|
|
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,48 @@ 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");
|
|
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
|
|
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 ??
|
|
174
|
-
const windowMs = config.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
|
+
};
|