@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/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.1.1",
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
+ }
@@ -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'