@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
|
@@ -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
|
+
}
|