@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
|
@@ -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'
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { DeleteObjectCommand } from '@aws-sdk/client-s3'
|
|
2
|
+
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
3
|
+
import type { R2Config } from '../types/config'
|
|
4
|
+
import type { MediaRecord, MediaInsert } from '../types/media'
|
|
5
|
+
import { createR2Client } from './client'
|
|
6
|
+
|
|
7
|
+
/* -------------------------------------------------------------------------- */
|
|
8
|
+
/* Types */
|
|
9
|
+
/* -------------------------------------------------------------------------- */
|
|
10
|
+
|
|
11
|
+
export interface MediaListOptions {
|
|
12
|
+
/** 1-based page number. Default: 1 */
|
|
13
|
+
page?: number
|
|
14
|
+
/** Items per page. Default: 24 */
|
|
15
|
+
limit?: number
|
|
16
|
+
/** Filter by MIME type prefix: 'image' or 'video' */
|
|
17
|
+
type?: string
|
|
18
|
+
/** Search query (matches original_name and alt_text) */
|
|
19
|
+
search?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MediaListResult {
|
|
23
|
+
items: MediaRecord[]
|
|
24
|
+
total: number
|
|
25
|
+
page: number
|
|
26
|
+
limit: number
|
|
27
|
+
totalPages: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* -------------------------------------------------------------------------- */
|
|
31
|
+
/* List */
|
|
32
|
+
/* -------------------------------------------------------------------------- */
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* List media records with pagination and filtering.
|
|
36
|
+
* Requires a Supabase admin client (service role) for full access.
|
|
37
|
+
*/
|
|
38
|
+
export async function listMedia(
|
|
39
|
+
supabase: SupabaseClient,
|
|
40
|
+
tableName: string,
|
|
41
|
+
options: MediaListOptions = {}
|
|
42
|
+
): Promise<MediaListResult> {
|
|
43
|
+
const { page = 1, limit = 24, type, search } = options
|
|
44
|
+
const offset = (page - 1) * limit
|
|
45
|
+
|
|
46
|
+
let query = supabase
|
|
47
|
+
.from(tableName)
|
|
48
|
+
.select('*', { count: 'exact' })
|
|
49
|
+
.order('created_at', { ascending: false })
|
|
50
|
+
.range(offset, offset + limit - 1)
|
|
51
|
+
|
|
52
|
+
if (type) {
|
|
53
|
+
query = query.like('mime_type', `${type}/%`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (search) {
|
|
57
|
+
query = query.or(`original_name.ilike.%${search}%,alt_text.ilike.%${search}%`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { data, error, count } = await query
|
|
61
|
+
|
|
62
|
+
if (error) {
|
|
63
|
+
throw new Error(`Failed to list media: ${error.message}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
items: (data ?? []) as MediaRecord[],
|
|
68
|
+
total: count ?? 0,
|
|
69
|
+
page,
|
|
70
|
+
limit,
|
|
71
|
+
totalPages: Math.ceil((count ?? 0) / limit),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* -------------------------------------------------------------------------- */
|
|
76
|
+
/* Insert */
|
|
77
|
+
/* -------------------------------------------------------------------------- */
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Register a completed upload in the database.
|
|
81
|
+
* Called after the browser finishes uploading to R2.
|
|
82
|
+
*/
|
|
83
|
+
export async function insertMedia(
|
|
84
|
+
supabase: SupabaseClient,
|
|
85
|
+
tableName: string,
|
|
86
|
+
data: MediaInsert
|
|
87
|
+
): Promise<MediaRecord> {
|
|
88
|
+
const { data: record, error } = await supabase
|
|
89
|
+
.from(tableName)
|
|
90
|
+
.insert({
|
|
91
|
+
filename: data.filename,
|
|
92
|
+
original_name: data.original_name,
|
|
93
|
+
mime_type: data.mime_type,
|
|
94
|
+
size_bytes: data.size_bytes,
|
|
95
|
+
url: data.url,
|
|
96
|
+
width: data.width ?? null,
|
|
97
|
+
height: data.height ?? null,
|
|
98
|
+
alt_text: data.alt_text ?? null,
|
|
99
|
+
})
|
|
100
|
+
.select()
|
|
101
|
+
.single()
|
|
102
|
+
|
|
103
|
+
if (error) {
|
|
104
|
+
throw new Error(`Failed to insert media: ${error.message}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return record as MediaRecord
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* -------------------------------------------------------------------------- */
|
|
111
|
+
/* Delete */
|
|
112
|
+
/* -------------------------------------------------------------------------- */
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Delete a media record and its R2 object.
|
|
116
|
+
* Attempts R2 deletion first but always cleans up the DB record.
|
|
117
|
+
*/
|
|
118
|
+
export async function deleteMedia(
|
|
119
|
+
supabase: SupabaseClient,
|
|
120
|
+
tableName: string,
|
|
121
|
+
r2Config: R2Config,
|
|
122
|
+
id: string
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
// Get the record to extract the R2 key
|
|
125
|
+
const { data: media, error: fetchError } = await supabase
|
|
126
|
+
.from(tableName)
|
|
127
|
+
.select('*')
|
|
128
|
+
.eq('id', id)
|
|
129
|
+
.single()
|
|
130
|
+
|
|
131
|
+
if (fetchError || !media) {
|
|
132
|
+
throw new Error('Media not found')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Extract key from public URL
|
|
136
|
+
const record = media as MediaRecord
|
|
137
|
+
const key = record.url.replace(`${r2Config.publicUrl}/`, '')
|
|
138
|
+
|
|
139
|
+
// Delete from R2 (best-effort)
|
|
140
|
+
try {
|
|
141
|
+
const r2 = createR2Client(r2Config)
|
|
142
|
+
await r2.send(new DeleteObjectCommand({ Bucket: r2Config.bucketName, Key: key }))
|
|
143
|
+
} catch {
|
|
144
|
+
// Log but don't fail — DB record should still be cleaned up
|
|
145
|
+
console.error(`Failed to delete R2 object: ${key}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Delete from DB
|
|
149
|
+
const { error: deleteError } = await supabase
|
|
150
|
+
.from(tableName)
|
|
151
|
+
.delete()
|
|
152
|
+
.eq('id', id)
|
|
153
|
+
|
|
154
|
+
if (deleteError) {
|
|
155
|
+
throw new Error(`Failed to delete media record: ${deleteError.message}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { PutObjectCommand } from '@aws-sdk/client-s3'
|
|
2
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
3
|
+
import type { R2Config } from '../types/config'
|
|
4
|
+
import { createR2Client } from './client'
|
|
5
|
+
|
|
6
|
+
export interface PresignOptions {
|
|
7
|
+
/** Original filename (will be sanitized) */
|
|
8
|
+
filename: string
|
|
9
|
+
/** MIME type of the file */
|
|
10
|
+
contentType: string
|
|
11
|
+
/** Storage folder (e.g. 'media', 'shots', 'projects') */
|
|
12
|
+
folder?: string
|
|
13
|
+
/** Presigned URL expiration in seconds. Default: 600 (10 min) */
|
|
14
|
+
expiresIn?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PresignResult {
|
|
18
|
+
/** Presigned PUT URL for direct browser upload */
|
|
19
|
+
uploadUrl: string
|
|
20
|
+
/** Public URL where the file will be accessible */
|
|
21
|
+
publicUrl: string
|
|
22
|
+
/** R2 object key */
|
|
23
|
+
key: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sanitize a filename for safe R2 storage.
|
|
28
|
+
* Lowercases, replaces non-alphanumeric chars with hyphens, deduplicates hyphens.
|
|
29
|
+
*/
|
|
30
|
+
function sanitizeFilename(filename: string): string {
|
|
31
|
+
return filename
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9.\-_]/g, '-')
|
|
34
|
+
.replace(/-+/g, '-')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate a presigned PUT URL for direct browser-to-R2 upload.
|
|
39
|
+
* The server never handles file bytes — Vercel/edge-friendly.
|
|
40
|
+
*/
|
|
41
|
+
export async function createPresignedUploadUrl(
|
|
42
|
+
config: R2Config,
|
|
43
|
+
options: PresignOptions
|
|
44
|
+
): Promise<PresignResult> {
|
|
45
|
+
const { filename, contentType, folder = 'media', expiresIn = 600 } = options
|
|
46
|
+
|
|
47
|
+
const sanitized = sanitizeFilename(filename)
|
|
48
|
+
const key = `${folder}/${crypto.randomUUID()}-${sanitized}`
|
|
49
|
+
|
|
50
|
+
const r2 = createR2Client(config)
|
|
51
|
+
const command = new PutObjectCommand({
|
|
52
|
+
Bucket: config.bucketName,
|
|
53
|
+
Key: key,
|
|
54
|
+
ContentType: contentType,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const uploadUrl = await getSignedUrl(r2, command, { expiresIn })
|
|
58
|
+
const publicUrl = `${config.publicUrl}/${key}`
|
|
59
|
+
|
|
60
|
+
return { uploadUrl, publicUrl, key }
|
|
61
|
+
}
|