@digiko-npm/cms 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +89 -5
- package/dist/media/index.d.ts +39 -0
- package/dist/media/index.js +363 -0
- package/dist/r2/index.d.ts +60 -2
- package/dist/r2/index.js +84 -0
- package/package.json +28 -4
- package/src/index.ts +8 -0
- package/src/media/MediaPicker.tsx +194 -0
- package/src/media/MediaUploader.tsx +198 -0
- package/src/media/index.ts +2 -0
- package/src/r2/index.ts +8 -0
- package/src/r2/media-crud.ts +157 -0
- package/src/r2/presign.ts +61 -0
package/dist/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,7 +1,7 @@
|
|
|
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",
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
"types": "./dist/types/index.d.ts",
|
|
47
47
|
"import": "./dist/types/index.js",
|
|
48
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"
|
|
49
54
|
}
|
|
50
55
|
},
|
|
51
56
|
"files": [
|
|
@@ -68,7 +73,10 @@
|
|
|
68
73
|
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
69
74
|
"@supabase/supabase-js": "^2.0.0",
|
|
70
75
|
"@upstash/redis": "^1.0.0",
|
|
71
|
-
"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"
|
|
72
80
|
},
|
|
73
81
|
"peerDependenciesMeta": {
|
|
74
82
|
"@aws-sdk/client-s3": {
|
|
@@ -82,6 +90,15 @@
|
|
|
82
90
|
},
|
|
83
91
|
"next": {
|
|
84
92
|
"optional": true
|
|
93
|
+
},
|
|
94
|
+
"react": {
|
|
95
|
+
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"react-dom": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
100
|
+
"lucide-react": {
|
|
101
|
+
"optional": true
|
|
85
102
|
}
|
|
86
103
|
},
|
|
87
104
|
"devDependencies": {
|
|
@@ -89,8 +106,13 @@
|
|
|
89
106
|
"@aws-sdk/s3-request-presigner": "^3.986.0",
|
|
90
107
|
"@supabase/supabase-js": "^2.95.0",
|
|
91
108
|
"@types/node": "^20",
|
|
109
|
+
"@types/react": "^19",
|
|
110
|
+
"@types/react-dom": "^19",
|
|
92
111
|
"@upstash/redis": "^1.36.0",
|
|
112
|
+
"lucide-react": "^0.469.0",
|
|
93
113
|
"next": "16.1.6",
|
|
114
|
+
"react": "^19.0.0",
|
|
115
|
+
"react-dom": "^19.0.0",
|
|
94
116
|
"tsup": "^8.0.0",
|
|
95
117
|
"typescript": "^5"
|
|
96
118
|
},
|
|
@@ -104,7 +126,9 @@
|
|
|
104
126
|
"supabase",
|
|
105
127
|
"cloudflare-r2",
|
|
106
128
|
"admin",
|
|
107
|
-
"auth"
|
|
129
|
+
"auth",
|
|
130
|
+
"media",
|
|
131
|
+
"upload"
|
|
108
132
|
],
|
|
109
133
|
"dependencies": {
|
|
110
134
|
"otpauth": "^9.5.0"
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,14 @@ 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'
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef } from 'react'
|
|
4
|
+
import { Upload, X, Image as ImageIcon, Film } from 'lucide-react'
|
|
5
|
+
import { uploadFile } from '../r2/upload'
|
|
6
|
+
import type { UploadConfig } from '../types/config'
|
|
7
|
+
|
|
8
|
+
export interface MediaUploaderProps {
|
|
9
|
+
/** Current media URL (shows preview when set) */
|
|
10
|
+
value?: string
|
|
11
|
+
/** Upload config with API endpoint paths */
|
|
12
|
+
uploadConfig: UploadConfig
|
|
13
|
+
/** Storage folder (e.g. 'media', 'shots', 'projects'). Default: 'media' */
|
|
14
|
+
folder?: string
|
|
15
|
+
/** Comma-separated MIME types to accept. Default: all image + video types */
|
|
16
|
+
accept?: string
|
|
17
|
+
/** Called after successful upload with public URL and media record ID */
|
|
18
|
+
onUpload: (url: string, mediaId: string) => void
|
|
19
|
+
/** Called when the user removes the current media */
|
|
20
|
+
onRemove?: () => void
|
|
21
|
+
/** Additional CSS class name */
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ACCEPTED_TYPES = {
|
|
26
|
+
image: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
|
|
27
|
+
video: ['video/mp4', 'video/webm'],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ALL_ACCEPTED = [...ACCEPTED_TYPES.image, ...ACCEPTED_TYPES.video]
|
|
31
|
+
|
|
32
|
+
export function MediaUploader({
|
|
33
|
+
value,
|
|
34
|
+
uploadConfig,
|
|
35
|
+
folder = 'media',
|
|
36
|
+
accept,
|
|
37
|
+
onUpload,
|
|
38
|
+
onRemove,
|
|
39
|
+
className,
|
|
40
|
+
}: MediaUploaderProps) {
|
|
41
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
42
|
+
const [isUploading, setIsUploading] = useState(false)
|
|
43
|
+
const [progress, setProgress] = useState(0)
|
|
44
|
+
const [error, setError] = useState<string | null>(null)
|
|
45
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
46
|
+
|
|
47
|
+
const acceptedTypes = accept
|
|
48
|
+
? accept.split(',').map((t) => t.trim())
|
|
49
|
+
: ALL_ACCEPTED
|
|
50
|
+
|
|
51
|
+
const handleFile = useCallback(
|
|
52
|
+
async (file: File) => {
|
|
53
|
+
if (!acceptedTypes.includes(file.type)) {
|
|
54
|
+
setError(`File type not supported: ${file.type}`)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setError(null)
|
|
59
|
+
setIsUploading(true)
|
|
60
|
+
setProgress(0)
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const result = await uploadFile(uploadConfig, {
|
|
64
|
+
file,
|
|
65
|
+
folder,
|
|
66
|
+
onProgress: setProgress,
|
|
67
|
+
})
|
|
68
|
+
onUpload(result.url, result.media.id)
|
|
69
|
+
} catch (err) {
|
|
70
|
+
setError(err instanceof Error ? err.message : 'Upload failed')
|
|
71
|
+
} finally {
|
|
72
|
+
setIsUploading(false)
|
|
73
|
+
setProgress(0)
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
[acceptedTypes, folder, onUpload, uploadConfig]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const handleDrop = useCallback(
|
|
80
|
+
(e: React.DragEvent) => {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
setIsDragging(false)
|
|
83
|
+
const file = e.dataTransfer.files[0]
|
|
84
|
+
if (file) handleFile(file)
|
|
85
|
+
},
|
|
86
|
+
[handleFile]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const handleChange = useCallback(
|
|
90
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
91
|
+
const file = e.target.files?.[0]
|
|
92
|
+
if (file) handleFile(file)
|
|
93
|
+
},
|
|
94
|
+
[handleFile]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const isImage = value?.match(/\.(jpg|jpeg|png|webp|gif)(\?|$)/i)
|
|
98
|
+
const isVideo = value?.match(/\.(mp4|webm)(\?|$)/i)
|
|
99
|
+
|
|
100
|
+
const cls = ['media-preview', className].filter(Boolean).join(' ')
|
|
101
|
+
const dropCls = [
|
|
102
|
+
'drop-zone',
|
|
103
|
+
isDragging && 'drop-zone--active',
|
|
104
|
+
isUploading && 'drop-zone--uploading',
|
|
105
|
+
className,
|
|
106
|
+
]
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join(' ')
|
|
109
|
+
|
|
110
|
+
if (value) {
|
|
111
|
+
return (
|
|
112
|
+
<div className={cls}>
|
|
113
|
+
{isImage && (
|
|
114
|
+
<div className="ds-relative ds-w-full" style={{ height: '12rem' }}>
|
|
115
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
116
|
+
<img
|
|
117
|
+
src={value}
|
|
118
|
+
alt="Uploaded"
|
|
119
|
+
className="ds-h-full ds-w-full ds-object-cover"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
{isVideo && (
|
|
124
|
+
<video
|
|
125
|
+
src={value}
|
|
126
|
+
className="ds-w-full ds-object-cover"
|
|
127
|
+
style={{ height: '12rem' }}
|
|
128
|
+
muted
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
{!isImage && !isVideo && (
|
|
132
|
+
<div
|
|
133
|
+
className="ds-flex ds-items-center ds-justify-center ds-bg-elevated ds-text-secondary"
|
|
134
|
+
style={{ height: '12rem' }}
|
|
135
|
+
>
|
|
136
|
+
<span className="ds-text-sm">{value.split('/').pop()}</span>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
{onRemove && (
|
|
140
|
+
<button onClick={onRemove} className="media-preview__remove" type="button">
|
|
141
|
+
<X size={14} className="ds-text-secondary" />
|
|
142
|
+
</button>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
onDragOver={(e) => {
|
|
151
|
+
e.preventDefault()
|
|
152
|
+
setIsDragging(true)
|
|
153
|
+
}}
|
|
154
|
+
onDragLeave={() => setIsDragging(false)}
|
|
155
|
+
onDrop={handleDrop}
|
|
156
|
+
onClick={() => inputRef.current?.click()}
|
|
157
|
+
className={dropCls}
|
|
158
|
+
>
|
|
159
|
+
<input
|
|
160
|
+
ref={inputRef}
|
|
161
|
+
type="file"
|
|
162
|
+
accept={acceptedTypes.join(',')}
|
|
163
|
+
onChange={handleChange}
|
|
164
|
+
className="ds-hidden"
|
|
165
|
+
/>
|
|
166
|
+
|
|
167
|
+
{isUploading ? (
|
|
168
|
+
<>
|
|
169
|
+
<div className="progress-bar">
|
|
170
|
+
<div
|
|
171
|
+
className="progress-bar__fill"
|
|
172
|
+
style={{ width: `${progress}%` }}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
<span className="ds-text-sm ds-text-secondary">{progress}%</span>
|
|
176
|
+
</>
|
|
177
|
+
) : (
|
|
178
|
+
<>
|
|
179
|
+
<div className="ds-flex ds-gap-2 ds-text-tertiary">
|
|
180
|
+
<ImageIcon size={20} />
|
|
181
|
+
<Film size={20} />
|
|
182
|
+
<Upload size={20} />
|
|
183
|
+
</div>
|
|
184
|
+
<div className="ds-text-center">
|
|
185
|
+
<p className="ds-text-sm ds-font-medium ds-text-secondary">
|
|
186
|
+
Drop a file or click to upload
|
|
187
|
+
</p>
|
|
188
|
+
<p className="ds-mt-1 ds-text-xs ds-text-tertiary">
|
|
189
|
+
JPG, PNG, WebP, GIF, MP4, WebM
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{error && <p className="ds-text-xs ds-text-error">{error}</p>}
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
package/src/r2/index.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
export { createR2Client, getR2Bucket, getR2PublicUrl } from './client'
|
|
2
2
|
export { uploadFile, type UploadOptions, type UploadResult } from './upload'
|
|
3
|
+
export { createPresignedUploadUrl, type PresignOptions, type PresignResult } from './presign'
|
|
4
|
+
export {
|
|
5
|
+
listMedia,
|
|
6
|
+
insertMedia,
|
|
7
|
+
deleteMedia,
|
|
8
|
+
type MediaListOptions,
|
|
9
|
+
type MediaListResult,
|
|
10
|
+
} from './media-crud'
|