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