@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.
@@ -1,6 +1,7 @@
1
1
  import { S3Client } from '@aws-sdk/client-s3';
2
2
  import { R as R2Config, U as UploadConfig } from '../config-qNdTlg1g.js';
3
- import { a as MediaRecord } from '../media-ExBfXePZ.js';
3
+ import { a as MediaRecord, M as MediaInsert } from '../media-ExBfXePZ.js';
4
+ import { SupabaseClient } from '@supabase/supabase-js';
4
5
 
5
6
  /**
6
7
  * Create a Cloudflare R2 client (S3-compatible).
@@ -31,4 +32,61 @@ interface UploadResult {
31
32
  */
32
33
  declare function uploadFile(config: UploadConfig, { file, folder, onProgress }: UploadOptions): Promise<UploadResult>;
33
34
 
34
- export { type UploadOptions, type UploadResult, createR2Client, getR2Bucket, getR2PublicUrl, uploadFile };
35
+ interface PresignOptions {
36
+ /** Original filename (will be sanitized) */
37
+ filename: string;
38
+ /** MIME type of the file */
39
+ contentType: string;
40
+ /** Storage folder (e.g. 'media', 'shots', 'projects') */
41
+ folder?: string;
42
+ /** Presigned URL expiration in seconds. Default: 600 (10 min) */
43
+ expiresIn?: number;
44
+ }
45
+ interface PresignResult {
46
+ /** Presigned PUT URL for direct browser upload */
47
+ uploadUrl: string;
48
+ /** Public URL where the file will be accessible */
49
+ publicUrl: string;
50
+ /** R2 object key */
51
+ key: string;
52
+ }
53
+ /**
54
+ * Generate a presigned PUT URL for direct browser-to-R2 upload.
55
+ * The server never handles file bytes — Vercel/edge-friendly.
56
+ */
57
+ declare function createPresignedUploadUrl(config: R2Config, options: PresignOptions): Promise<PresignResult>;
58
+
59
+ interface MediaListOptions {
60
+ /** 1-based page number. Default: 1 */
61
+ page?: number;
62
+ /** Items per page. Default: 24 */
63
+ limit?: number;
64
+ /** Filter by MIME type prefix: 'image' or 'video' */
65
+ type?: string;
66
+ /** Search query (matches original_name and alt_text) */
67
+ search?: string;
68
+ }
69
+ interface MediaListResult {
70
+ items: MediaRecord[];
71
+ total: number;
72
+ page: number;
73
+ limit: number;
74
+ totalPages: number;
75
+ }
76
+ /**
77
+ * List media records with pagination and filtering.
78
+ * Requires a Supabase admin client (service role) for full access.
79
+ */
80
+ declare function listMedia(supabase: SupabaseClient, tableName: string, options?: MediaListOptions): Promise<MediaListResult>;
81
+ /**
82
+ * Register a completed upload in the database.
83
+ * Called after the browser finishes uploading to R2.
84
+ */
85
+ declare function insertMedia(supabase: SupabaseClient, tableName: string, data: MediaInsert): Promise<MediaRecord>;
86
+ /**
87
+ * Delete a media record and its R2 object.
88
+ * Attempts R2 deletion first but always cleans up the DB record.
89
+ */
90
+ declare function deleteMedia(supabase: SupabaseClient, tableName: string, r2Config: R2Config, id: string): Promise<void>;
91
+
92
+ export { type MediaListOptions, type MediaListResult, type PresignOptions, type PresignResult, type UploadOptions, type UploadResult, createPresignedUploadUrl, createR2Client, deleteMedia, getR2Bucket, getR2PublicUrl, insertMedia, listMedia, uploadFile };
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,43 +1,56 @@
1
1
  {
2
2
  "name": "@digiko-npm/cms",
3
- "version": "0.1.0",
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",
8
8
  "types": "./dist/index.d.ts",
9
9
  "exports": {
10
10
  ".": {
11
+ "types": "./dist/index.d.ts",
11
12
  "import": "./dist/index.js",
12
- "types": "./dist/index.d.ts"
13
+ "default": "./dist/index.js"
13
14
  },
14
15
  "./supabase": {
16
+ "types": "./dist/supabase/index.d.ts",
15
17
  "import": "./dist/supabase/index.js",
16
- "types": "./dist/supabase/index.d.ts"
18
+ "default": "./dist/supabase/index.js"
17
19
  },
18
20
  "./r2": {
21
+ "types": "./dist/r2/index.d.ts",
19
22
  "import": "./dist/r2/index.js",
20
- "types": "./dist/r2/index.d.ts"
23
+ "default": "./dist/r2/index.js"
21
24
  },
22
25
  "./auth": {
26
+ "types": "./dist/auth/index.d.ts",
23
27
  "import": "./dist/auth/index.js",
24
- "types": "./dist/auth/index.d.ts"
28
+ "default": "./dist/auth/index.js"
25
29
  },
26
30
  "./session": {
31
+ "types": "./dist/session/index.d.ts",
27
32
  "import": "./dist/session/index.js",
28
- "types": "./dist/session/index.d.ts"
33
+ "default": "./dist/session/index.js"
29
34
  },
30
35
  "./next": {
36
+ "types": "./dist/next/index.d.ts",
31
37
  "import": "./dist/next/index.js",
32
- "types": "./dist/next/index.d.ts"
38
+ "default": "./dist/next/index.js"
33
39
  },
34
40
  "./http": {
41
+ "types": "./dist/http/index.d.ts",
35
42
  "import": "./dist/http/index.js",
36
- "types": "./dist/http/index.d.ts"
43
+ "default": "./dist/http/index.js"
37
44
  },
38
45
  "./types": {
46
+ "types": "./dist/types/index.d.ts",
39
47
  "import": "./dist/types/index.js",
40
- "types": "./dist/types/index.d.ts"
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"
41
54
  }
42
55
  },
43
56
  "files": [
@@ -56,11 +69,14 @@
56
69
  "access": "public"
57
70
  },
58
71
  "peerDependencies": {
59
- "@supabase/supabase-js": "^2.0.0",
60
72
  "@aws-sdk/client-s3": "^3.0.0",
61
73
  "@aws-sdk/s3-request-presigner": "^3.0.0",
74
+ "@supabase/supabase-js": "^2.0.0",
62
75
  "@upstash/redis": "^1.0.0",
63
- "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"
64
80
  },
65
81
  "peerDependenciesMeta": {
66
82
  "@aws-sdk/client-s3": {
@@ -74,15 +90,29 @@
74
90
  },
75
91
  "next": {
76
92
  "optional": true
93
+ },
94
+ "react": {
95
+ "optional": true
96
+ },
97
+ "react-dom": {
98
+ "optional": true
99
+ },
100
+ "lucide-react": {
101
+ "optional": true
77
102
  }
78
103
  },
79
104
  "devDependencies": {
80
- "@supabase/supabase-js": "^2.95.0",
81
105
  "@aws-sdk/client-s3": "^3.986.0",
82
106
  "@aws-sdk/s3-request-presigner": "^3.986.0",
83
- "@upstash/redis": "^1.36.0",
107
+ "@supabase/supabase-js": "^2.95.0",
84
108
  "@types/node": "^20",
109
+ "@types/react": "^19",
110
+ "@types/react-dom": "^19",
111
+ "@upstash/redis": "^1.36.0",
112
+ "lucide-react": "^0.469.0",
85
113
  "next": "16.1.6",
114
+ "react": "^19.0.0",
115
+ "react-dom": "^19.0.0",
86
116
  "tsup": "^8.0.0",
87
117
  "typescript": "^5"
88
118
  },
@@ -96,6 +126,11 @@
96
126
  "supabase",
97
127
  "cloudflare-r2",
98
128
  "admin",
99
- "auth"
100
- ]
129
+ "auth",
130
+ "media",
131
+ "upload"
132
+ ],
133
+ "dependencies": {
134
+ "otpauth": "^9.5.0"
135
+ }
101
136
  }
package/src/auth/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { hashPassword, verifyPassword } from './password'
2
2
  export { generateSessionToken } from './token'
3
+ export { generateTotpSecret, generateTotpUri, verifyTotpCode, type TotpConfig } from './totp'
@@ -0,0 +1,72 @@
1
+ import { TOTP, Secret } from 'otpauth'
2
+
3
+ /** Configuration for TOTP operations */
4
+ export interface TotpConfig {
5
+ /** Hash algorithm. Default: 'SHA1' (required for 1Password compatibility) */
6
+ algorithm?: string
7
+ /** Number of digits. Default: 6 */
8
+ digits?: number
9
+ /** Time step in seconds. Default: 30 */
10
+ period?: number
11
+ /** Validation window (number of periods to check before/after). Default: 1 */
12
+ window?: number
13
+ }
14
+
15
+ const DEFAULTS = {
16
+ algorithm: 'SHA1',
17
+ digits: 6,
18
+ period: 30,
19
+ window: 1,
20
+ } as const
21
+
22
+ /**
23
+ * Generate a new TOTP secret.
24
+ * Returns a base32-encoded string suitable for storing in env vars.
25
+ * Uses 20 bytes (160 bits) as recommended by RFC 4226.
26
+ */
27
+ export function generateTotpSecret(): string {
28
+ const secret = new Secret({ size: 20 })
29
+ return secret.base32
30
+ }
31
+
32
+ /**
33
+ * Generate an otpauth:// URI for registering with authenticator apps.
34
+ * Can be entered manually in 1Password or encoded as a QR code.
35
+ */
36
+ export function generateTotpUri(
37
+ secret: string,
38
+ accountName: string,
39
+ issuer: string,
40
+ config?: TotpConfig
41
+ ): string {
42
+ const totp = new TOTP({
43
+ issuer,
44
+ label: accountName,
45
+ algorithm: config?.algorithm ?? DEFAULTS.algorithm,
46
+ digits: config?.digits ?? DEFAULTS.digits,
47
+ period: config?.period ?? DEFAULTS.period,
48
+ secret: Secret.fromBase32(secret),
49
+ })
50
+ return totp.toString()
51
+ }
52
+
53
+ /**
54
+ * Verify a 6-digit TOTP code against a secret.
55
+ * Returns true if the code is valid within the configured time window.
56
+ * Uses timing-safe comparison internally (handled by otpauth library).
57
+ */
58
+ export function verifyTotpCode(
59
+ code: string,
60
+ secret: string,
61
+ config?: TotpConfig
62
+ ): boolean {
63
+ const totp = new TOTP({
64
+ algorithm: config?.algorithm ?? DEFAULTS.algorithm,
65
+ digits: config?.digits ?? DEFAULTS.digits,
66
+ period: config?.period ?? DEFAULTS.period,
67
+ secret: Secret.fromBase32(secret),
68
+ })
69
+
70
+ const delta = totp.validate({ token: code, window: config?.window ?? DEFAULTS.window })
71
+ return delta !== null
72
+ }
package/src/index.ts CHANGED
@@ -5,10 +5,19 @@ 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'
11
19
  export { generateSessionToken } from './auth/token'
20
+ export { generateTotpSecret, generateTotpUri, verifyTotpCode, type TotpConfig } from './auth/totp'
12
21
 
13
22
  // Sessions
14
23
  export { createSessionStore, getDefaultSessionDuration, type SessionStore } from './session/store'
@@ -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
+ }