@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/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 };
|
package/dist/r2/index.js
CHANGED
|
@@ -74,9 +74,93 @@ function uploadToR2(url, file, contentType, onProgress) {
|
|
|
74
74
|
xhr.send(file);
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
// src/r2/presign.ts
|
|
79
|
+
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
80
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
81
|
+
function sanitizeFilename(filename) {
|
|
82
|
+
return filename.toLowerCase().replace(/[^a-z0-9.\-_]/g, "-").replace(/-+/g, "-");
|
|
83
|
+
}
|
|
84
|
+
async function createPresignedUploadUrl(config, options) {
|
|
85
|
+
const { filename, contentType, folder = "media", expiresIn = 600 } = options;
|
|
86
|
+
const sanitized = sanitizeFilename(filename);
|
|
87
|
+
const key = `${folder}/${crypto.randomUUID()}-${sanitized}`;
|
|
88
|
+
const r2 = createR2Client(config);
|
|
89
|
+
const command = new PutObjectCommand({
|
|
90
|
+
Bucket: config.bucketName,
|
|
91
|
+
Key: key,
|
|
92
|
+
ContentType: contentType
|
|
93
|
+
});
|
|
94
|
+
const uploadUrl = await getSignedUrl(r2, command, { expiresIn });
|
|
95
|
+
const publicUrl = `${config.publicUrl}/${key}`;
|
|
96
|
+
return { uploadUrl, publicUrl, key };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/r2/media-crud.ts
|
|
100
|
+
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
|
|
101
|
+
async function listMedia(supabase, tableName, options = {}) {
|
|
102
|
+
const { page = 1, limit = 24, type, search } = options;
|
|
103
|
+
const offset = (page - 1) * limit;
|
|
104
|
+
let query = supabase.from(tableName).select("*", { count: "exact" }).order("created_at", { ascending: false }).range(offset, offset + limit - 1);
|
|
105
|
+
if (type) {
|
|
106
|
+
query = query.like("mime_type", `${type}/%`);
|
|
107
|
+
}
|
|
108
|
+
if (search) {
|
|
109
|
+
query = query.or(`original_name.ilike.%${search}%,alt_text.ilike.%${search}%`);
|
|
110
|
+
}
|
|
111
|
+
const { data, error, count } = await query;
|
|
112
|
+
if (error) {
|
|
113
|
+
throw new Error(`Failed to list media: ${error.message}`);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
items: data ?? [],
|
|
117
|
+
total: count ?? 0,
|
|
118
|
+
page,
|
|
119
|
+
limit,
|
|
120
|
+
totalPages: Math.ceil((count ?? 0) / limit)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async function insertMedia(supabase, tableName, data) {
|
|
124
|
+
const { data: record, error } = await supabase.from(tableName).insert({
|
|
125
|
+
filename: data.filename,
|
|
126
|
+
original_name: data.original_name,
|
|
127
|
+
mime_type: data.mime_type,
|
|
128
|
+
size_bytes: data.size_bytes,
|
|
129
|
+
url: data.url,
|
|
130
|
+
width: data.width ?? null,
|
|
131
|
+
height: data.height ?? null,
|
|
132
|
+
alt_text: data.alt_text ?? null
|
|
133
|
+
}).select().single();
|
|
134
|
+
if (error) {
|
|
135
|
+
throw new Error(`Failed to insert media: ${error.message}`);
|
|
136
|
+
}
|
|
137
|
+
return record;
|
|
138
|
+
}
|
|
139
|
+
async function deleteMedia(supabase, tableName, r2Config, id) {
|
|
140
|
+
const { data: media, error: fetchError } = await supabase.from(tableName).select("*").eq("id", id).single();
|
|
141
|
+
if (fetchError || !media) {
|
|
142
|
+
throw new Error("Media not found");
|
|
143
|
+
}
|
|
144
|
+
const record = media;
|
|
145
|
+
const key = record.url.replace(`${r2Config.publicUrl}/`, "");
|
|
146
|
+
try {
|
|
147
|
+
const r2 = createR2Client(r2Config);
|
|
148
|
+
await r2.send(new DeleteObjectCommand({ Bucket: r2Config.bucketName, Key: key }));
|
|
149
|
+
} catch {
|
|
150
|
+
console.error(`Failed to delete R2 object: ${key}`);
|
|
151
|
+
}
|
|
152
|
+
const { error: deleteError } = await supabase.from(tableName).delete().eq("id", id);
|
|
153
|
+
if (deleteError) {
|
|
154
|
+
throw new Error(`Failed to delete media record: ${deleteError.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
77
157
|
export {
|
|
158
|
+
createPresignedUploadUrl,
|
|
78
159
|
createR2Client,
|
|
160
|
+
deleteMedia,
|
|
79
161
|
getR2Bucket,
|
|
80
162
|
getR2PublicUrl,
|
|
163
|
+
insertMedia,
|
|
164
|
+
listMedia,
|
|
81
165
|
uploadFile
|
|
82
166
|
};
|
package/package.json
CHANGED
|
@@ -1,43 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digiko-npm/cms",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Reusable CMS utilities — Supabase, Cloudflare R2, auth, sessions.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Reusable CMS utilities — Supabase, Cloudflare R2, auth, sessions, media management.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
11
12
|
"import": "./dist/index.js",
|
|
12
|
-
"
|
|
13
|
+
"default": "./dist/index.js"
|
|
13
14
|
},
|
|
14
15
|
"./supabase": {
|
|
16
|
+
"types": "./dist/supabase/index.d.ts",
|
|
15
17
|
"import": "./dist/supabase/index.js",
|
|
16
|
-
"
|
|
18
|
+
"default": "./dist/supabase/index.js"
|
|
17
19
|
},
|
|
18
20
|
"./r2": {
|
|
21
|
+
"types": "./dist/r2/index.d.ts",
|
|
19
22
|
"import": "./dist/r2/index.js",
|
|
20
|
-
"
|
|
23
|
+
"default": "./dist/r2/index.js"
|
|
21
24
|
},
|
|
22
25
|
"./auth": {
|
|
26
|
+
"types": "./dist/auth/index.d.ts",
|
|
23
27
|
"import": "./dist/auth/index.js",
|
|
24
|
-
"
|
|
28
|
+
"default": "./dist/auth/index.js"
|
|
25
29
|
},
|
|
26
30
|
"./session": {
|
|
31
|
+
"types": "./dist/session/index.d.ts",
|
|
27
32
|
"import": "./dist/session/index.js",
|
|
28
|
-
"
|
|
33
|
+
"default": "./dist/session/index.js"
|
|
29
34
|
},
|
|
30
35
|
"./next": {
|
|
36
|
+
"types": "./dist/next/index.d.ts",
|
|
31
37
|
"import": "./dist/next/index.js",
|
|
32
|
-
"
|
|
38
|
+
"default": "./dist/next/index.js"
|
|
33
39
|
},
|
|
34
40
|
"./http": {
|
|
41
|
+
"types": "./dist/http/index.d.ts",
|
|
35
42
|
"import": "./dist/http/index.js",
|
|
36
|
-
"
|
|
43
|
+
"default": "./dist/http/index.js"
|
|
37
44
|
},
|
|
38
45
|
"./types": {
|
|
46
|
+
"types": "./dist/types/index.d.ts",
|
|
39
47
|
"import": "./dist/types/index.js",
|
|
40
|
-
"
|
|
48
|
+
"default": "./dist/types/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./media": {
|
|
51
|
+
"types": "./dist/media/index.d.ts",
|
|
52
|
+
"import": "./dist/media/index.js",
|
|
53
|
+
"default": "./dist/media/index.js"
|
|
41
54
|
}
|
|
42
55
|
},
|
|
43
56
|
"files": [
|
|
@@ -56,11 +69,14 @@
|
|
|
56
69
|
"access": "public"
|
|
57
70
|
},
|
|
58
71
|
"peerDependencies": {
|
|
59
|
-
"@supabase/supabase-js": "^2.0.0",
|
|
60
72
|
"@aws-sdk/client-s3": "^3.0.0",
|
|
61
73
|
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
74
|
+
"@supabase/supabase-js": "^2.0.0",
|
|
62
75
|
"@upstash/redis": "^1.0.0",
|
|
63
|
-
"next": ">=14.0.0"
|
|
76
|
+
"next": ">=14.0.0",
|
|
77
|
+
"react": ">=18.0.0",
|
|
78
|
+
"react-dom": ">=18.0.0",
|
|
79
|
+
"lucide-react": ">=0.300.0"
|
|
64
80
|
},
|
|
65
81
|
"peerDependenciesMeta": {
|
|
66
82
|
"@aws-sdk/client-s3": {
|
|
@@ -74,15 +90,29 @@
|
|
|
74
90
|
},
|
|
75
91
|
"next": {
|
|
76
92
|
"optional": true
|
|
93
|
+
},
|
|
94
|
+
"react": {
|
|
95
|
+
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"react-dom": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
100
|
+
"lucide-react": {
|
|
101
|
+
"optional": true
|
|
77
102
|
}
|
|
78
103
|
},
|
|
79
104
|
"devDependencies": {
|
|
80
|
-
"@supabase/supabase-js": "^2.95.0",
|
|
81
105
|
"@aws-sdk/client-s3": "^3.986.0",
|
|
82
106
|
"@aws-sdk/s3-request-presigner": "^3.986.0",
|
|
83
|
-
"@
|
|
107
|
+
"@supabase/supabase-js": "^2.95.0",
|
|
84
108
|
"@types/node": "^20",
|
|
109
|
+
"@types/react": "^19",
|
|
110
|
+
"@types/react-dom": "^19",
|
|
111
|
+
"@upstash/redis": "^1.36.0",
|
|
112
|
+
"lucide-react": "^0.469.0",
|
|
85
113
|
"next": "16.1.6",
|
|
114
|
+
"react": "^19.0.0",
|
|
115
|
+
"react-dom": "^19.0.0",
|
|
86
116
|
"tsup": "^8.0.0",
|
|
87
117
|
"typescript": "^5"
|
|
88
118
|
},
|
|
@@ -96,6 +126,11 @@
|
|
|
96
126
|
"supabase",
|
|
97
127
|
"cloudflare-r2",
|
|
98
128
|
"admin",
|
|
99
|
-
"auth"
|
|
100
|
-
|
|
129
|
+
"auth",
|
|
130
|
+
"media",
|
|
131
|
+
"upload"
|
|
132
|
+
],
|
|
133
|
+
"dependencies": {
|
|
134
|
+
"otpauth": "^9.5.0"
|
|
135
|
+
}
|
|
101
136
|
}
|
package/src/auth/index.ts
CHANGED
package/src/auth/totp.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { TOTP, Secret } from 'otpauth'
|
|
2
|
+
|
|
3
|
+
/** Configuration for TOTP operations */
|
|
4
|
+
export interface TotpConfig {
|
|
5
|
+
/** Hash algorithm. Default: 'SHA1' (required for 1Password compatibility) */
|
|
6
|
+
algorithm?: string
|
|
7
|
+
/** Number of digits. Default: 6 */
|
|
8
|
+
digits?: number
|
|
9
|
+
/** Time step in seconds. Default: 30 */
|
|
10
|
+
period?: number
|
|
11
|
+
/** Validation window (number of periods to check before/after). Default: 1 */
|
|
12
|
+
window?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
algorithm: 'SHA1',
|
|
17
|
+
digits: 6,
|
|
18
|
+
period: 30,
|
|
19
|
+
window: 1,
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate a new TOTP secret.
|
|
24
|
+
* Returns a base32-encoded string suitable for storing in env vars.
|
|
25
|
+
* Uses 20 bytes (160 bits) as recommended by RFC 4226.
|
|
26
|
+
*/
|
|
27
|
+
export function generateTotpSecret(): string {
|
|
28
|
+
const secret = new Secret({ size: 20 })
|
|
29
|
+
return secret.base32
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate an otpauth:// URI for registering with authenticator apps.
|
|
34
|
+
* Can be entered manually in 1Password or encoded as a QR code.
|
|
35
|
+
*/
|
|
36
|
+
export function generateTotpUri(
|
|
37
|
+
secret: string,
|
|
38
|
+
accountName: string,
|
|
39
|
+
issuer: string,
|
|
40
|
+
config?: TotpConfig
|
|
41
|
+
): string {
|
|
42
|
+
const totp = new TOTP({
|
|
43
|
+
issuer,
|
|
44
|
+
label: accountName,
|
|
45
|
+
algorithm: config?.algorithm ?? DEFAULTS.algorithm,
|
|
46
|
+
digits: config?.digits ?? DEFAULTS.digits,
|
|
47
|
+
period: config?.period ?? DEFAULTS.period,
|
|
48
|
+
secret: Secret.fromBase32(secret),
|
|
49
|
+
})
|
|
50
|
+
return totp.toString()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Verify a 6-digit TOTP code against a secret.
|
|
55
|
+
* Returns true if the code is valid within the configured time window.
|
|
56
|
+
* Uses timing-safe comparison internally (handled by otpauth library).
|
|
57
|
+
*/
|
|
58
|
+
export function verifyTotpCode(
|
|
59
|
+
code: string,
|
|
60
|
+
secret: string,
|
|
61
|
+
config?: TotpConfig
|
|
62
|
+
): boolean {
|
|
63
|
+
const totp = new TOTP({
|
|
64
|
+
algorithm: config?.algorithm ?? DEFAULTS.algorithm,
|
|
65
|
+
digits: config?.digits ?? DEFAULTS.digits,
|
|
66
|
+
period: config?.period ?? DEFAULTS.period,
|
|
67
|
+
secret: Secret.fromBase32(secret),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const delta = totp.validate({ token: code, window: config?.window ?? DEFAULTS.window })
|
|
71
|
+
return delta !== null
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,10 +5,19 @@ export { createAdminClient, createPublicClient } from './supabase/server'
|
|
|
5
5
|
// Cloudflare R2
|
|
6
6
|
export { createR2Client, getR2Bucket, getR2PublicUrl } from './r2/client'
|
|
7
7
|
export { uploadFile, type UploadOptions, type UploadResult } from './r2/upload'
|
|
8
|
+
export { createPresignedUploadUrl, type PresignOptions, type PresignResult } from './r2/presign'
|
|
9
|
+
export {
|
|
10
|
+
listMedia,
|
|
11
|
+
insertMedia,
|
|
12
|
+
deleteMedia,
|
|
13
|
+
type MediaListOptions,
|
|
14
|
+
type MediaListResult,
|
|
15
|
+
} from './r2/media-crud'
|
|
8
16
|
|
|
9
17
|
// Auth
|
|
10
18
|
export { hashPassword, verifyPassword } from './auth/password'
|
|
11
19
|
export { generateSessionToken } from './auth/token'
|
|
20
|
+
export { generateTotpSecret, generateTotpUri, verifyTotpCode, type TotpConfig } from './auth/totp'
|
|
12
21
|
|
|
13
22
|
// Sessions
|
|
14
23
|
export { createSessionStore, getDefaultSessionDuration, type SessionStore } from './session/store'
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
4
|
+
import { X, Search, Upload } from 'lucide-react'
|
|
5
|
+
import { MediaUploader } from './MediaUploader'
|
|
6
|
+
import type { UploadConfig } from '../types/config'
|
|
7
|
+
import type { MediaRecord } from '../types/media'
|
|
8
|
+
|
|
9
|
+
export interface MediaPickerProps {
|
|
10
|
+
/** Whether the picker modal is open */
|
|
11
|
+
isOpen: boolean
|
|
12
|
+
/** Called when the user closes the picker */
|
|
13
|
+
onClose: () => void
|
|
14
|
+
/** Called when the user selects a media item */
|
|
15
|
+
onSelect: (media: MediaRecord) => void
|
|
16
|
+
/** Upload config with API endpoint paths */
|
|
17
|
+
uploadConfig: UploadConfig
|
|
18
|
+
/** Filter by MIME type prefix: 'image' or 'video' */
|
|
19
|
+
filter?: 'image' | 'video'
|
|
20
|
+
/** Custom fetch function for authenticated requests. Default: window.fetch */
|
|
21
|
+
fetchFn?: typeof fetch
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function MediaPicker({
|
|
25
|
+
isOpen,
|
|
26
|
+
onClose,
|
|
27
|
+
onSelect,
|
|
28
|
+
uploadConfig,
|
|
29
|
+
filter,
|
|
30
|
+
fetchFn,
|
|
31
|
+
}: MediaPickerProps) {
|
|
32
|
+
const [items, setItems] = useState<MediaRecord[]>([])
|
|
33
|
+
const [loading, setLoading] = useState(false)
|
|
34
|
+
const [search, setSearch] = useState('')
|
|
35
|
+
const [page, setPage] = useState(1)
|
|
36
|
+
const [totalPages, setTotalPages] = useState(1)
|
|
37
|
+
const [showUploader, setShowUploader] = useState(false)
|
|
38
|
+
|
|
39
|
+
const doFetch = fetchFn ?? fetch
|
|
40
|
+
|
|
41
|
+
const fetchMedia = useCallback(async () => {
|
|
42
|
+
setLoading(true)
|
|
43
|
+
const params = new URLSearchParams({ page: String(page), limit: '24' })
|
|
44
|
+
if (filter) params.set('type', filter)
|
|
45
|
+
if (search) params.set('q', search)
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const res = await doFetch(`${uploadConfig.mediaEndpoint}?${params}`)
|
|
49
|
+
const data = await res.json()
|
|
50
|
+
setItems(data.items ?? [])
|
|
51
|
+
setTotalPages(data.totalPages ?? 1)
|
|
52
|
+
} catch {
|
|
53
|
+
console.error('Failed to fetch media')
|
|
54
|
+
} finally {
|
|
55
|
+
setLoading(false)
|
|
56
|
+
}
|
|
57
|
+
}, [page, filter, search, doFetch, uploadConfig.mediaEndpoint])
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (isOpen) fetchMedia()
|
|
61
|
+
}, [isOpen, fetchMedia])
|
|
62
|
+
|
|
63
|
+
if (!isOpen) return null
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="media-picker">
|
|
67
|
+
{/* Backdrop */}
|
|
68
|
+
<div className="media-picker__backdrop" onClick={onClose} />
|
|
69
|
+
|
|
70
|
+
{/* Modal */}
|
|
71
|
+
<div className="media-picker__panel">
|
|
72
|
+
{/* Header */}
|
|
73
|
+
<div className="media-picker__header">
|
|
74
|
+
<h3 className="ds-text-lg ds-font-medium">Media Library</h3>
|
|
75
|
+
<div className="ds-flex ds-items-center ds-gap-2">
|
|
76
|
+
<button
|
|
77
|
+
onClick={() => setShowUploader(!showUploader)}
|
|
78
|
+
className="ds-btn ds-btn--secondary ds-btn--sm"
|
|
79
|
+
type="button"
|
|
80
|
+
>
|
|
81
|
+
<Upload size={14} />
|
|
82
|
+
Upload
|
|
83
|
+
</button>
|
|
84
|
+
<button onClick={onClose} className="ds-btn ds-btn--ghost ds-btn--sm" type="button">
|
|
85
|
+
<X size={18} />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Upload zone */}
|
|
91
|
+
{showUploader && (
|
|
92
|
+
<div className="ds-border-b ds-p-4">
|
|
93
|
+
<MediaUploader
|
|
94
|
+
uploadConfig={uploadConfig}
|
|
95
|
+
onUpload={() => {
|
|
96
|
+
setShowUploader(false)
|
|
97
|
+
fetchMedia()
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{/* Search */}
|
|
104
|
+
<div className="ds-border-b ds-px-5 ds-py-3">
|
|
105
|
+
<div className="ds-relative">
|
|
106
|
+
<Search size={16} className="search-bar__icon" />
|
|
107
|
+
<input
|
|
108
|
+
type="text"
|
|
109
|
+
placeholder="Search media..."
|
|
110
|
+
value={search}
|
|
111
|
+
onChange={(e) => {
|
|
112
|
+
setSearch(e.target.value)
|
|
113
|
+
setPage(1)
|
|
114
|
+
}}
|
|
115
|
+
onKeyDown={(e) => e.key === 'Enter' && fetchMedia()}
|
|
116
|
+
className="search-bar__input"
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Grid */}
|
|
122
|
+
<div className="ds-flex-1 ds-overflow-y-auto ds-p-4">
|
|
123
|
+
{loading ? (
|
|
124
|
+
<div className="ds-flex ds-items-center ds-justify-center ds-py-12">
|
|
125
|
+
<div className="spinner spinner--md spinner--default" />
|
|
126
|
+
</div>
|
|
127
|
+
) : items.length === 0 ? (
|
|
128
|
+
<div className="ds-flex ds-flex-col ds-items-center ds-justify-center ds-py-12 ds-text-center">
|
|
129
|
+
<p className="ds-text-sm ds-text-secondary">No media found</p>
|
|
130
|
+
<p className="ds-mt-1 ds-text-xs ds-text-tertiary">
|
|
131
|
+
Upload files to get started
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
) : (
|
|
135
|
+
<div className="ds-grid ds-grid-cols-4 ds-gap-3">
|
|
136
|
+
{items.map((item) => {
|
|
137
|
+
const isImage = item.mime_type.startsWith('image/')
|
|
138
|
+
return (
|
|
139
|
+
<button
|
|
140
|
+
key={item.id}
|
|
141
|
+
onClick={() => onSelect(item)}
|
|
142
|
+
className="media-grid-item ds-aspect-square"
|
|
143
|
+
type="button"
|
|
144
|
+
>
|
|
145
|
+
{isImage ? (
|
|
146
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
147
|
+
<img
|
|
148
|
+
src={item.url}
|
|
149
|
+
alt={item.alt_text ?? item.original_name}
|
|
150
|
+
className="ds-h-full ds-w-full ds-object-cover"
|
|
151
|
+
/>
|
|
152
|
+
) : (
|
|
153
|
+
<div className="ds-flex ds-h-full ds-w-full ds-items-center ds-justify-center ds-bg-elevated">
|
|
154
|
+
<span className="ds-text-xs ds-text-tertiary">
|
|
155
|
+
{item.mime_type.split('/')[1]?.toUpperCase()}
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
<div className="media-grid-item__overlay" />
|
|
160
|
+
</button>
|
|
161
|
+
)
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Pagination */}
|
|
168
|
+
{totalPages > 1 && (
|
|
169
|
+
<div className="ds-flex ds-items-center ds-justify-center ds-gap-2 ds-border-t ds-px-5 ds-py-3">
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
172
|
+
disabled={page === 1}
|
|
173
|
+
className="ds-btn ds-btn--ghost ds-btn--sm"
|
|
174
|
+
type="button"
|
|
175
|
+
>
|
|
176
|
+
Previous
|
|
177
|
+
</button>
|
|
178
|
+
<span className="ds-text-sm ds-text-secondary">
|
|
179
|
+
{page} / {totalPages}
|
|
180
|
+
</span>
|
|
181
|
+
<button
|
|
182
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
183
|
+
disabled={page === totalPages}
|
|
184
|
+
className="ds-btn ds-btn--ghost ds-btn--sm"
|
|
185
|
+
type="button"
|
|
186
|
+
>
|
|
187
|
+
Next
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|