@plutocms/supabase 0.16.0 → 0.17.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.0](https://github.com/plutocms/supabase/compare/v0.16.0...v0.17.0) (2026-10-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * media previews, sort and rename in the Supabase driver ([#139](https://github.com/plutocms/supabase/issues/139)) ([8cdf071](https://github.com/plutocms/supabase/commit/8cdf0715c5a11db8e99423751708d2ab0feea455))
9
+
3
10
  ## [0.16.0](https://github.com/plutocms/supabase/compare/v0.15.5...v0.16.0) (2026-10-30)
4
11
 
5
12
 
@@ -0,0 +1,7 @@
1
+ -- The preview image of a file that is not an image (a 3D model), made in the
2
+ -- browser by the kind of the file. Always in the `media` bucket, even for an
3
+ -- older file in another bucket. See the media skill of @plutocms/pluto.
4
+
5
+ alter table public.media add column if not exists preview_path text null;
6
+ alter table public.media add column if not exists preview_width integer null;
7
+ alter table public.media add column if not exists preview_height integer null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.16.0",
4
+ "version": "0.17.0",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -39,7 +39,7 @@
39
39
  "@nuxt/eslint": "^1.17.0",
40
40
  "@nuxt/ui": "^4.12.0",
41
41
  "@nuxtjs/supabase": "^2.1.0",
42
- "@plutocms/pluto": ">=0.35.0 <1.0.0",
42
+ "@plutocms/pluto": ">=0.36.0 <1.0.0",
43
43
  "@plutocms/utils": ">=0.3.0 <1.0.0",
44
44
  "@supabase/supabase-js": "^2.117.3",
45
45
  "postgres": "^3.4.9",
@@ -17,6 +17,23 @@ interface MediaRow {
17
17
  size: number | null
18
18
  width: number | null
19
19
  height: number | null
20
+ preview_path: string | null
21
+ preview_width: number | null
22
+ preview_height: number | null
23
+ }
24
+
25
+ /** `<yyyy>/<mm>/<name>`: the object key of a file, by the month of its record. */
26
+ function monthKey(createdAt: string, fileName: string): string {
27
+ const date = new Date(createdAt)
28
+ return `${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(2, '0')}/${fileName}`
29
+ }
30
+
31
+ /** The order of a list. The id breaks a tie, so pages stay stable. */
32
+ const ORDER: Record<PlutoMediaSort, { column: string; ascending: boolean }> = {
33
+ newest: { column: 'created_at', ascending: false },
34
+ oldest: { column: 'created_at', ascending: true },
35
+ name: { column: 'name', ascending: true },
36
+ size: { column: 'size', ascending: false },
20
37
  }
21
38
 
22
39
  function toRecord(client: SupabaseClient, row: MediaRow): PlutoMedia {
@@ -34,6 +51,16 @@ function toRecord(client: SupabaseClient, row: MediaRow): PlutoMedia {
34
51
  height: row.height,
35
52
  alt: row.alt ?? '',
36
53
  createdAt: row.created_at,
54
+ // A preview is always in the `media` bucket.
55
+ preview:
56
+ row.preview_path && row.preview_width && row.preview_height
57
+ ? {
58
+ url: client.storage.from(MEDIA_BUCKET).getPublicUrl(row.preview_path)
59
+ .data.publicUrl,
60
+ width: row.preview_width,
61
+ height: row.preview_height,
62
+ }
63
+ : null,
37
64
  }
38
65
  }
39
66
 
@@ -58,7 +85,8 @@ function numericIds(ids: string[]): number[] {
58
85
  * The media driver of the Supabase backend: the bytes in Supabase Storage,
59
86
  * the record in `public.media` (db/migrations/009_media.sql). Row Level
60
87
  * Security checks the capabilities again (`has_capability`). New files go to
61
- * `media/<yyyy>/<mm>/<name>-<suffix>.<ext>`. See the media skill of
88
+ * `media/<yyyy>/<mm>/<name>-<suffix>.<ext>`, and their previews next to them
89
+ * (`db/migrations/010_media_preview.sql`). See the media skill of
62
90
  * `@plutocms/pluto`.
63
91
  */
64
92
  export function createSupabaseMediaDriver(): PlutoMediaDriver {
@@ -90,11 +118,12 @@ export function createSupabaseMediaDriver(): PlutoMediaDriver {
90
118
 
91
119
  async list(event, query) {
92
120
  const client = await supabaseRequestClient(event)
121
+ const order = ORDER[query.sort ?? 'newest']
93
122
  let request = client
94
123
  .from('media')
95
124
  .select('*', { count: 'exact' })
96
- .order('created_at', { ascending: false })
97
- .order('id', { ascending: false })
125
+ .order(order.column, { ascending: order.ascending, nullsFirst: false })
126
+ .order('id', { ascending: order.ascending })
98
127
  .range(query.offset, query.offset + query.limit - 1)
99
128
  if (query.search) {
100
129
  // Commas and brackets have a meaning in a PostgREST `or` filter.
@@ -135,8 +164,10 @@ export function createSupabaseMediaDriver(): PlutoMediaDriver {
135
164
 
136
165
  async upload(event, input) {
137
166
  const client = await supabaseRequestClient(event)
138
- const date = new Date(input.createdAt)
139
- const path = `${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(2, '0')}/${input.fileName}`
167
+ const path = monthKey(input.createdAt, input.fileName)
168
+ const previewPath = input.preview
169
+ ? monthKey(input.createdAt, input.preview.fileName)
170
+ : null
140
171
  const storage = client.storage.from(MEDIA_BUCKET)
141
172
  const { error: uploadError } = await storage.upload(path, input.data, {
142
173
  contentType: input.mimeType,
@@ -146,6 +177,17 @@ export function createSupabaseMediaDriver(): PlutoMediaDriver {
146
177
  if (uploadError) {
147
178
  throw failed(uploadError.message, 'pluto.media.errors.saveFailed')
148
179
  }
180
+ if (input.preview && previewPath) {
181
+ const { error: previewError } = await storage.upload(
182
+ previewPath,
183
+ input.preview.data,
184
+ { contentType: input.preview.mimeType, cacheControl: '31536000', upsert: true }
185
+ )
186
+ if (previewError) {
187
+ await storage.remove([path])
188
+ throw failed(previewError.message, 'pluto.media.errors.saveFailed')
189
+ }
190
+ }
149
191
  const { data, error } = await client
150
192
  .from('media')
151
193
  .insert({
@@ -158,12 +200,17 @@ export function createSupabaseMediaDriver(): PlutoMediaDriver {
158
200
  width: input.width,
159
201
  height: input.height,
160
202
  created_at: input.createdAt,
203
+ preview_path: previewPath,
204
+ preview_width: input.preview?.width ?? null,
205
+ preview_height: input.preview?.height ?? null,
161
206
  })
162
207
  .select('*')
163
208
  .single()
164
209
  if (error) {
165
210
  // No record without its file, and no file without its record.
166
- const { error: cleanup } = await storage.remove([path])
211
+ const { error: cleanup } = await storage.remove(
212
+ previewPath ? [path, previewPath] : [path]
213
+ )
167
214
  if (cleanup) {
168
215
  console.warn('[media] could not remove the file of a failed upload:', path, cleanup.message)
169
216
  }
@@ -176,6 +223,9 @@ export function createSupabaseMediaDriver(): PlutoMediaDriver {
176
223
  const client = await supabaseRequestClient(event)
177
224
  const row = await readRow(client, id)
178
225
  const changes: Partial<MediaRow> = {}
226
+ if (patch.name !== undefined) {
227
+ changes.name = patch.name
228
+ }
179
229
  if (patch.alt !== undefined) {
180
230
  changes.alt = patch.alt || null
181
231
  }
@@ -197,6 +247,39 @@ export function createSupabaseMediaDriver(): PlutoMediaDriver {
197
247
  return toRecord(client, data as MediaRow)
198
248
  },
199
249
 
250
+ async setPreview(event, id, preview) {
251
+ const client = await supabaseRequestClient(event)
252
+ const row = await readRow(client, id)
253
+ const storage = client.storage.from(MEDIA_BUCKET)
254
+ const path = monthKey(row.created_at, preview.fileName)
255
+ const { error: uploadError } = await storage.upload(path, preview.data, {
256
+ contentType: preview.mimeType,
257
+ cacheControl: '31536000',
258
+ upsert: true,
259
+ })
260
+ if (uploadError) {
261
+ throw failed(uploadError.message, 'pluto.media.errors.saveFailed')
262
+ }
263
+ const { data, error } = await client
264
+ .from('media')
265
+ .update({
266
+ preview_path: path,
267
+ preview_width: preview.width,
268
+ preview_height: preview.height,
269
+ })
270
+ .eq('id', row.id)
271
+ .select('*')
272
+ .single()
273
+ if (error) {
274
+ throw failed(error.message, 'pluto.media.errors.saveFailed')
275
+ }
276
+ // An older preview with another name (another type) goes.
277
+ if (row.preview_path && row.preview_path !== path) {
278
+ await storage.remove([row.preview_path])
279
+ }
280
+ return toRecord(client, data as MediaRow)
281
+ },
282
+
200
283
  async remove(event, id) {
201
284
  const client = await supabaseRequestClient(event)
202
285
  const row = await readRow(client, id)
@@ -209,6 +292,9 @@ export function createSupabaseMediaDriver(): PlutoMediaDriver {
209
292
  throw failed(error.message, 'pluto.media.errors.deleteFailed')
210
293
  }
211
294
  }
295
+ if (row.preview_path) {
296
+ await client.storage.from(MEDIA_BUCKET).remove([row.preview_path])
297
+ }
212
298
  const { error } = await client.from('media').delete().eq('id', row.id)
213
299
  if (error) {
214
300
  throw failed(error.message, 'pluto.media.errors.deleteFailed')