@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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { MediaUploader, type MediaUploaderProps } from './MediaUploader'
2
+ export { MediaPicker, type MediaPickerProps } from './MediaPicker'
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
+ }