@xleddyl/nuxt-cms 0.1.9 → 0.1.11

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.
Files changed (44) hide show
  1. package/README.md +61 -1
  2. package/dist/module.d.mts +3 -1
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +18 -7
  5. package/dist/runtime/app/components/cms/BlocksField.vue +59 -16
  6. package/dist/runtime/app/components/cms/ConfirmModal.d.vue.ts +20 -0
  7. package/dist/runtime/app/components/cms/ConfirmModal.vue +43 -0
  8. package/dist/runtime/app/components/cms/ConfirmModal.vue.d.ts +20 -0
  9. package/dist/runtime/app/components/cms/MediaGallery.vue +9 -2
  10. package/dist/runtime/app/components/cms/RelationField.vue +56 -10
  11. package/dist/runtime/app/components/cms/RichTextField.vue +63 -2
  12. package/dist/runtime/app/composables/cms-confirm.d.ts +4 -0
  13. package/dist/runtime/app/composables/cms-confirm.js +7 -0
  14. package/dist/runtime/app/i18n/en.json +32 -2
  15. package/dist/runtime/app/i18n/it.json +32 -2
  16. package/dist/runtime/app/pages/admin-collection.vue +89 -14
  17. package/dist/runtime/app/pages/admin-entry.vue +12 -5
  18. package/dist/runtime/server/api/collection.get.d.ts +1 -3
  19. package/dist/runtime/server/api/collection.get.js +34 -8
  20. package/dist/runtime/server/api/collection.post.js +11 -7
  21. package/dist/runtime/server/api/collection.put.js +11 -7
  22. package/dist/runtime/server/api/item.get.js +3 -2
  23. package/dist/runtime/server/api/item.put.js +12 -8
  24. package/dist/runtime/server/api/media-presign.post.js +2 -1
  25. package/dist/runtime/server/api/media.post.js +2 -1
  26. package/dist/runtime/server/plugins/migrate-libsql.d.ts +2 -0
  27. package/dist/runtime/server/plugins/migrate-libsql.js +17 -0
  28. package/dist/runtime/server/plugins/migrate-postgres.js +9 -1
  29. package/dist/runtime/server/plugins/migrate-sqlite.js +9 -1
  30. package/dist/runtime/server/routes/auth/login.post.js +1 -1
  31. package/dist/runtime/server/utils/db-libsql.d.ts +7 -0
  32. package/dist/runtime/server/utils/db-libsql.js +18 -0
  33. package/dist/runtime/server/utils/db-postgres.d.ts +4 -0
  34. package/dist/runtime/server/utils/db-postgres.js +3 -0
  35. package/dist/runtime/server/utils/db-sqlite.d.ts +2 -0
  36. package/dist/runtime/server/utils/db-sqlite.js +19 -0
  37. package/dist/runtime/server/utils/graphql.js +10 -4
  38. package/dist/runtime/server/utils/media.d.ts +1 -0
  39. package/dist/runtime/server/utils/media.js +27 -0
  40. package/dist/runtime/server/utils/relations.d.ts +3 -2
  41. package/dist/runtime/server/utils/relations.js +2 -4
  42. package/dist/runtime/server/utils/require-admin.js +15 -1
  43. package/dist/runtime/shared/index.d.ts +69 -1
  44. package/package.json +7 -1
@@ -0,0 +1,4 @@
1
+ export declare function useCmsConfirm(): (message: string, options?: {
2
+ title?: string;
3
+ confirmLabel?: string;
4
+ }) => Promise<boolean>;
@@ -0,0 +1,7 @@
1
+ import { useOverlay } from "#imports";
2
+ import ConfirmModal from "../components/cms/ConfirmModal.vue";
3
+ export function useCmsConfirm() {
4
+ const overlay = useOverlay();
5
+ const modal = overlay.create(ConfirmModal);
6
+ return async (message, options) => await modal.open({ message, ...options }) === true;
7
+ }
@@ -27,7 +27,16 @@
27
27
  "emptyBody": "Entries you create will show up here.",
28
28
  "deleteConfirm": "Delete this row?",
29
29
  "status": "Status",
30
- "columns": "Columns"
30
+ "columns": "Columns",
31
+ "loadError": "Could not load entries",
32
+ "retry": "Retry",
33
+ "searchPlaceholder": "Search…",
34
+ "noResults": "No matching entries"
35
+ },
36
+ "confirm": {
37
+ "title": "Are you sure?",
38
+ "confirm": "Confirm",
39
+ "cancel": "Cancel"
31
40
  },
32
41
  "entry": {
33
42
  "unsavedChanges": "You have unsaved changes. Leave anyway?"
@@ -79,7 +88,28 @@
79
88
  "mediaChange": "Change",
80
89
  "mediaRemove": "Remove",
81
90
  "invalidJson": "Invalid JSON",
82
- "addBlock": "Add block"
91
+ "addBlock": "Add block",
92
+ "moveUp": "Move up",
93
+ "moveDown": "Move down",
94
+ "duplicateBlock": "Duplicate block",
95
+ "removeBlock": "Remove block",
96
+ "collapseBlock": "Collapse block",
97
+ "expandBlock": "Expand block"
98
+ },
99
+ "richtext": {
100
+ "bold": "Bold",
101
+ "italic": "Italic",
102
+ "strike": "Strikethrough",
103
+ "h2": "Heading 2",
104
+ "h3": "Heading 3",
105
+ "bulletList": "Bullet list",
106
+ "orderedList": "Numbered list",
107
+ "blockquote": "Quote",
108
+ "link": "Link",
109
+ "setLink": "Apply",
110
+ "unsetLink": "Remove link",
111
+ "undo": "Undo",
112
+ "redo": "Redo"
83
113
  },
84
114
  "validation": {
85
115
  "required": "Required field",
@@ -27,7 +27,16 @@
27
27
  "emptyBody": "Le voci che crei appariranno qui.",
28
28
  "deleteConfirm": "Eliminare questa voce?",
29
29
  "status": "Stato",
30
- "columns": "Colonne"
30
+ "columns": "Colonne",
31
+ "loadError": "Impossibile caricare le voci",
32
+ "retry": "Riprova",
33
+ "searchPlaceholder": "Cerca…",
34
+ "noResults": "Nessuna voce corrispondente"
35
+ },
36
+ "confirm": {
37
+ "title": "Sei sicuro?",
38
+ "confirm": "Conferma",
39
+ "cancel": "Annulla"
31
40
  },
32
41
  "entry": {
33
42
  "unsavedChanges": "Ci sono modifiche non salvate. Uscire comunque?"
@@ -79,7 +88,28 @@
79
88
  "mediaChange": "Cambia",
80
89
  "mediaRemove": "Rimuovi",
81
90
  "invalidJson": "JSON non valido",
82
- "addBlock": "Aggiungi blocco"
91
+ "addBlock": "Aggiungi blocco",
92
+ "moveUp": "Sposta su",
93
+ "moveDown": "Sposta giù",
94
+ "duplicateBlock": "Duplica blocco",
95
+ "removeBlock": "Rimuovi blocco",
96
+ "collapseBlock": "Comprimi blocco",
97
+ "expandBlock": "Espandi blocco"
98
+ },
99
+ "richtext": {
100
+ "bold": "Grassetto",
101
+ "italic": "Corsivo",
102
+ "strike": "Barrato",
103
+ "h2": "Titolo 2",
104
+ "h3": "Titolo 3",
105
+ "bulletList": "Elenco puntato",
106
+ "orderedList": "Elenco numerato",
107
+ "blockquote": "Citazione",
108
+ "link": "Link",
109
+ "setLink": "Applica",
110
+ "unsetLink": "Rimuovi link",
111
+ "undo": "Annulla",
112
+ "redo": "Ripristina"
83
113
  },
84
114
  "validation": {
85
115
  "required": "Campo obbligatorio",
@@ -32,7 +32,22 @@
32
32
  </div>
33
33
  </CmsPageHeader>
34
34
 
35
- <div v-if="config.kind === 'single'" class="cms-card p-7">
35
+ <CmsEmptyState
36
+ v-if="error"
37
+ icon="i-lucide-triangle-alert"
38
+ :title="t('cms.collection.loadError')"
39
+ >
40
+ <UButton
41
+ :label="t('cms.collection.retry')"
42
+ icon="i-lucide-refresh-cw"
43
+ variant="subtle"
44
+ class="mt-3 rounded-full px-4"
45
+ :loading="status === 'pending'"
46
+ @click="reload"
47
+ />
48
+ </CmsEmptyState>
49
+
50
+ <div v-else-if="config.kind === 'single'" class="cms-card p-7">
36
51
  <CmsEntryForm
37
52
  v-model="formState"
38
53
  :fields="config.fields"
@@ -43,6 +58,14 @@
43
58
  </div>
44
59
 
45
60
  <template v-else>
61
+ <UInput
62
+ v-if="total || searchTerm"
63
+ v-model="search"
64
+ icon="i-lucide-search"
65
+ :placeholder="t('cms.collection.searchPlaceholder')"
66
+ class="max-w-xs"
67
+ />
68
+
46
69
  <div v-if="rows.length" class="cms-card overflow-hidden">
47
70
  <UTable
48
71
  v-model:column-visibility="columnVisibility"
@@ -81,6 +104,19 @@
81
104
  </UTable>
82
105
  </div>
83
106
 
107
+ <div v-else-if="status === 'pending'" class="flex justify-center py-10">
108
+ <UIcon
109
+ name="i-lucide-loader-circle"
110
+ class="text-(--ui-text-dimmed) size-6 animate-spin"
111
+ />
112
+ </div>
113
+
114
+ <CmsEmptyState
115
+ v-else-if="searchTerm"
116
+ icon="i-lucide-search-x"
117
+ :title="t('cms.collection.noResults')"
118
+ />
119
+
84
120
  <CmsEmptyState
85
121
  v-else
86
122
  icon="i-lucide-sprout"
@@ -95,6 +131,10 @@
95
131
  @click="openCreate"
96
132
  />
97
133
  </CmsEmptyState>
134
+
135
+ <div v-if="total > PAGE_SIZE" class="flex justify-center">
136
+ <UPagination v-model:page="page" :total="total" :items-per-page="PAGE_SIZE" />
137
+ </div>
98
138
  </template>
99
139
  </div>
100
140
  </template>
@@ -115,6 +155,7 @@ import {
115
155
  watch
116
156
  } from "#imports";
117
157
  import cmsConfig from "#cms-config";
158
+ import { useCmsConfirm } from "../composables/cms-confirm";
118
159
  import { useCmsRuntime } from "../composables/cms-runtime";
119
160
  import { cmsApi } from "../utils/api";
120
161
  import { errorMessage } from "../utils/ui";
@@ -137,8 +178,37 @@ const fieldKeys = Object.keys(config.fields);
137
178
  const drafts = config.kind === "collection" && !!config.drafts;
138
179
  const formKeys = drafts ? [...fieldKeys, "status"] : fieldKeys;
139
180
  const endpoint = `/api/cms/admin/${name}`;
140
- const { data, refresh } = await useFetch(endpoint);
141
- const rows = computed(() => Array.isArray(data.value) ? data.value : []);
181
+ const PAGE_SIZE = 25;
182
+ const page = ref(1);
183
+ const search = ref("");
184
+ const searchTerm = ref("");
185
+ let searchTimer;
186
+ watch(search, (value) => {
187
+ clearTimeout(searchTimer);
188
+ searchTimer = setTimeout(() => {
189
+ searchTerm.value = value.trim();
190
+ page.value = 1;
191
+ }, 300);
192
+ });
193
+ const listQuery = computed(() => ({
194
+ limit: PAGE_SIZE,
195
+ offset: (page.value - 1) * PAGE_SIZE,
196
+ ...searchTerm.value ? { search: searchTerm.value } : {}
197
+ }));
198
+ const { data, refresh, error, status } = await useFetch(endpoint, {
199
+ query: config.kind === "collection" ? listQuery : void 0
200
+ });
201
+ function isList(value) {
202
+ return !!value && Array.isArray(value.items);
203
+ }
204
+ const rows = computed(() => isList(data.value) ? data.value.items : []);
205
+ const total = computed(() => isList(data.value) ? data.value.total : 0);
206
+ const reload = async () => {
207
+ await refresh();
208
+ if (config.kind === "single" && !error.value) {
209
+ formState.value = data.value && !isList(data.value) ? pickFields(data.value) : emptyState();
210
+ }
211
+ };
142
212
  const tableUi = {
143
213
  thead: "border-b border-(--cms-line)",
144
214
  th: "cms-kicker px-5 py-3 font-normal",
@@ -154,15 +224,16 @@ function pickFields(row) {
154
224
  return Object.fromEntries(formKeys.map((k) => [k, row[k] ?? null]));
155
225
  }
156
226
  async function submit(action) {
227
+ if (saving.value) return false;
157
228
  saving.value = true;
158
229
  try {
159
230
  await action();
160
231
  await refresh();
161
232
  return true;
162
- } catch (error) {
233
+ } catch (error2) {
163
234
  toast.add({
164
235
  title: t("cms.toast.saveFailed"),
165
- description: errorMessage(error),
236
+ description: errorMessage(error2),
166
237
  color: "error"
167
238
  });
168
239
  return false;
@@ -171,7 +242,7 @@ async function submit(action) {
171
242
  }
172
243
  }
173
244
  if (config.kind === "single") {
174
- formState.value = data.value && !Array.isArray(data.value) ? pickFields(data.value) : emptyState();
245
+ formState.value = data.value && !isList(data.value) ? pickFields(data.value) : emptyState();
175
246
  }
176
247
  async function saveSingle() {
177
248
  const ok = await submit(() => cmsApi(endpoint, { method: "PUT", body: formState.value }));
@@ -249,25 +320,29 @@ const columnItems = computed(
249
320
  }))
250
321
  );
251
322
  const kicker = computed(
252
- () => config.kind === "single" ? t("cms.collection.kickerSingle") : t("cms.collection.kicker", rows.value.length)
323
+ () => config.kind === "single" ? t("cms.collection.kickerSingle") : t("cms.collection.kicker", total.value)
253
324
  );
254
325
  function onSelect(_event, row) {
255
326
  navigateTo(`/cms/${name}/${row.original.id}`);
256
327
  }
257
328
  async function toggleStatus(row) {
258
329
  const next = row.status === "published" ? "draft" : "published";
259
- await submit(
260
- () => cmsApi(`${endpoint}/${row.id}`, {
330
+ await submit(async () => {
331
+ const fresh = await cmsApi(`${endpoint}/${row.id}`);
332
+ await cmsApi(`${endpoint}/${row.id}`, {
261
333
  method: "PUT",
262
- body: { ...pickFields(row), status: next }
263
- })
264
- );
334
+ body: { ...pickFields(fresh), status: next }
335
+ });
336
+ });
265
337
  }
266
338
  function openCreate() {
267
339
  navigateTo(`/cms/${name}/new`);
268
340
  }
341
+ const confirmAction = useCmsConfirm();
269
342
  async function deleteRow(row) {
270
- if (!confirm(t("cms.collection.deleteConfirm"))) return;
271
- await submit(() => cmsApi(`${endpoint}/${row.id}`, { method: "DELETE" }));
343
+ if (!await confirmAction(t("cms.collection.deleteConfirm"))) return;
344
+ const lastOnPage = rows.value.length === 1 && page.value > 1;
345
+ const ok = await submit(() => cmsApi(`${endpoint}/${row.id}`, { method: "DELETE" }));
346
+ if (ok && lastOnPage) page.value -= 1;
272
347
  }
273
348
  </script>
@@ -69,6 +69,7 @@ import {
69
69
  watch
70
70
  } from "#imports";
71
71
  import cmsConfig from "#cms-config";
72
+ import { useCmsConfirm } from "../composables/cms-confirm";
72
73
  import { cmsApi } from "../utils/api";
73
74
  import { errorMessage } from "../utils/ui";
74
75
  const FORM_ID = "cms-entry-form";
@@ -100,9 +101,14 @@ function pickFields(row) {
100
101
  }
101
102
  const formState = ref(emptyState());
102
103
  if (!isNew) {
103
- const { data } = await useFetch(`${endpoint}/${id}`);
104
- if (!data.value) {
105
- throw createError({ statusCode: 404, statusMessage: "Entry not found", fatal: true });
104
+ const { data, error } = await useFetch(`${endpoint}/${id}`);
105
+ if (error.value || !data.value) {
106
+ const statusCode = error.value?.statusCode ?? 404;
107
+ throw createError({
108
+ statusCode,
109
+ statusMessage: statusCode === 404 ? "Entry not found" : "Failed to load entry",
110
+ fatal: true
111
+ });
106
112
  }
107
113
  formState.value = pickFields(data.value);
108
114
  }
@@ -117,8 +123,9 @@ watch(dirty, (value) => {
117
123
  else window.removeEventListener("beforeunload", onBeforeUnload);
118
124
  });
119
125
  onBeforeUnmount(() => window.removeEventListener("beforeunload", onBeforeUnload));
120
- onBeforeRouteLeave(() => {
121
- if (dirty.value && !confirm(t("cms.entry.unsavedChanges"))) return false;
126
+ const confirmAction = useCmsConfirm();
127
+ onBeforeRouteLeave(async () => {
128
+ if (dirty.value && !await confirmAction(t("cms.entry.unsavedChanges"))) return false;
122
129
  });
123
130
  const published = computed({
124
131
  get: () => formState.value.status === "published",
@@ -1,6 +1,4 @@
1
1
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
2
  [x: string]: any;
3
- } | {
4
- [x: string]: any;
5
- }[] | null>>;
3
+ } | null>>;
6
4
  export default _default;
@@ -1,19 +1,45 @@
1
- import { desc } from "drizzle-orm";
2
- import { defineEventHandler } from "h3";
1
+ import { count, desc, sql } from "drizzle-orm";
2
+ import { defineEventHandler, getValidatedQuery } from "h3";
3
+ import { z } from "zod";
3
4
  import { useDb } from "#cms-db";
4
5
  import { getRegistryEntry, idColumn, tableColumns } from "../utils/registry.js";
5
6
  import { attachManyToMany } from "../utils/relations.js";
6
7
  import { requireAdmin } from "../utils/require-admin.js";
8
+ const querySchema = z.object({
9
+ limit: z.coerce.number().int().min(1).max(100).default(50),
10
+ offset: z.coerce.number().int().min(0).default(0),
11
+ search: z.string().trim().max(200).optional(),
12
+ light: z.stringbool().default(false)
13
+ });
14
+ function likePattern(term) {
15
+ return `%${term.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
16
+ }
7
17
  export default defineEventHandler(async (event) => {
8
18
  await requireAdmin(event);
9
19
  const { name, entry, table } = getRegistryEntry(event);
10
20
  const db = useDb();
11
21
  if (entry.kind === "single") {
12
- const rows2 = await db.select().from(table).limit(1);
13
- await attachManyToMany(name, entry, rows2);
14
- return rows2[0] ?? null;
22
+ const rows = await db.select().from(table).limit(1);
23
+ await attachManyToMany(db, name, entry, rows);
24
+ return rows[0] ?? null;
25
+ }
26
+ const { limit, offset, search, light } = await getValidatedQuery(event, querySchema.parse);
27
+ const columns = tableColumns(table);
28
+ const titleColumn = entry.titleField && Object.hasOwn(columns, entry.titleField) ? columns[entry.titleField] : void 0;
29
+ let where;
30
+ if (search) {
31
+ const column = titleColumn ?? idColumn(table);
32
+ where = sql`${column} like ${likePattern(search)} escape '\\'`;
15
33
  }
16
- const orderBy = tableColumns(table).createdAt ?? idColumn(table);
17
- const rows = await db.select().from(table).orderBy(desc(orderBy));
18
- return attachManyToMany(name, entry, rows);
34
+ const selection = light ? Object.fromEntries([
35
+ ["id", idColumn(table)],
36
+ ...entry.titleField && titleColumn ? [[entry.titleField, titleColumn]] : [],
37
+ ...entry.drafts && Object.hasOwn(columns, "status") ? [["status", columns.status]] : []
38
+ ]) : void 0;
39
+ const orderBy = columns.createdAt ?? idColumn(table);
40
+ const base = selection ? db.select(selection).from(table) : db.select().from(table);
41
+ const items = await (where ? base.where(where) : base).orderBy(desc(orderBy)).limit(limit).offset(offset);
42
+ const [counted] = await (where ? db.select({ total: count() }).from(table).where(where) : db.select({ total: count() }).from(table));
43
+ if (!light) await attachManyToMany(db, name, entry, items);
44
+ return { items, total: counted?.total ?? 0 };
19
45
  });
@@ -1,5 +1,5 @@
1
1
  import { createError, defineEventHandler, readValidatedBody } from "h3";
2
- import { useDb } from "#cms-db";
2
+ import { withTransaction } from "#cms-db";
3
3
  import { customId } from "../utils/custom-id.js";
4
4
  import { mapConstraintErrors } from "../utils/db-errors.js";
5
5
  import { buildValidator, getRegistryEntry } from "../utils/registry.js";
@@ -21,10 +21,14 @@ export default defineEventHandler(async (event) => {
21
21
  await assertRelationTargets(entry, lists);
22
22
  if (entry.drafts) values.status ??= "draft";
23
23
  values.id = customId(entry.id);
24
- return mapConstraintErrors(async () => {
25
- const [row] = await useDb().insert(table).values(values).returning();
26
- await saveManyToMany(name, row.id, lists);
27
- const [attached] = await attachManyToMany(name, entry, [row]);
28
- return attached;
29
- });
24
+ return mapConstraintErrors(
25
+ () => withTransaction(async (db) => {
26
+ const [row] = await db.insert(table).values(values).returning();
27
+ await saveManyToMany(db, name, row.id, lists);
28
+ const [attached] = await attachManyToMany(db, name, entry, [
29
+ row
30
+ ]);
31
+ return attached;
32
+ })
33
+ );
30
34
  });
@@ -1,5 +1,5 @@
1
1
  import { createError, defineEventHandler, readValidatedBody } from "h3";
2
- import { useDb } from "#cms-db";
2
+ import { withTransaction } from "#cms-db";
3
3
  import { mapConstraintErrors } from "../utils/db-errors.js";
4
4
  import { buildValidator, getRegistryEntry, idColumn, withUpdatedAt } from "../utils/registry.js";
5
5
  import {
@@ -19,10 +19,14 @@ export default defineEventHandler(async (event) => {
19
19
  const { values, lists } = splitRelationValues(entry, body);
20
20
  await assertRelationTargets(entry, lists);
21
21
  const set = withUpdatedAt(table, values);
22
- return mapConstraintErrors(async () => {
23
- const [row] = await useDb().insert(table).values({ id: entry.id, ...set }).onConflictDoUpdate({ target: idColumn(table), set }).returning();
24
- await saveManyToMany(name, entry.id, lists);
25
- const [attached] = await attachManyToMany(name, entry, [row]);
26
- return attached;
27
- });
22
+ return mapConstraintErrors(
23
+ () => withTransaction(async (db) => {
24
+ const [row] = await db.insert(table).values({ id: entry.id, ...set }).onConflictDoUpdate({ target: idColumn(table), set }).returning();
25
+ await saveManyToMany(db, name, entry.id, lists);
26
+ const [attached] = await attachManyToMany(db, name, entry, [
27
+ row
28
+ ]);
29
+ return attached;
30
+ })
31
+ );
28
32
  });
@@ -11,8 +11,9 @@ export default defineEventHandler(async (event) => {
11
11
  throw createError({ statusCode: 404, statusMessage: "Single objects have no items" });
12
12
  }
13
13
  const id = parseId(event);
14
- const rows = await useDb().select().from(table).where(eq(idColumn(table), id)).limit(1);
14
+ const db = useDb();
15
+ const rows = await db.select().from(table).where(eq(idColumn(table), id)).limit(1);
15
16
  if (!rows[0]) throw createError({ statusCode: 404, statusMessage: "Row not found" });
16
- const [attached] = await attachManyToMany(name, entry, [rows[0]]);
17
+ const [attached] = await attachManyToMany(db, name, entry, [rows[0]]);
17
18
  return attached;
18
19
  });
@@ -1,6 +1,6 @@
1
1
  import { eq } from "drizzle-orm";
2
2
  import { createError, defineEventHandler, readValidatedBody } from "h3";
3
- import { useDb } from "#cms-db";
3
+ import { withTransaction } from "#cms-db";
4
4
  import {
5
5
  buildValidator,
6
6
  getRegistryEntry,
@@ -30,11 +30,15 @@ export default defineEventHandler(async (event) => {
30
30
  const { values, lists } = splitRelationValues(entry, body);
31
31
  await assertRelationTargets(entry, lists);
32
32
  const set = withUpdatedAt(table, values);
33
- return mapConstraintErrors(async () => {
34
- const [row] = await useDb().update(table).set(set).where(eq(idColumn(table), id)).returning();
35
- if (!row) throw createError({ statusCode: 404, statusMessage: "Row not found" });
36
- await saveManyToMany(name, id, lists);
37
- const [attached] = await attachManyToMany(name, entry, [row]);
38
- return attached;
39
- });
33
+ return mapConstraintErrors(
34
+ () => withTransaction(async (db) => {
35
+ const [row] = await db.update(table).set(set).where(eq(idColumn(table), id)).returning();
36
+ if (!row) throw createError({ statusCode: 404, statusMessage: "Row not found" });
37
+ await saveManyToMany(db, name, id, lists);
38
+ const [attached] = await attachManyToMany(db, name, entry, [
39
+ row
40
+ ]);
41
+ return attached;
42
+ })
43
+ );
40
44
  });
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { createError, defineEventHandler, readValidatedBody } from "h3";
3
3
  import { z } from "zod";
4
4
  import { slugify } from "../../shared/index.js";
5
- import { useMediaStorage } from "../utils/media.js";
5
+ import { assertUploadContentType, useMediaStorage } from "../utils/media.js";
6
6
  import { requireAdmin } from "../utils/require-admin.js";
7
7
  const MAX_BASE_LENGTH = 80;
8
8
  const MAX_EXT_LENGTH = 10;
@@ -22,6 +22,7 @@ export default defineEventHandler(async (event) => {
22
22
  await requireAdmin(event);
23
23
  const { media, client, bucketUrl, publicUrl } = useMediaStorage(event);
24
24
  const { filename, contentType, size } = await readValidatedBody(event, bodySchema.parse);
25
+ assertUploadContentType(contentType);
25
26
  if (size > MAX_FILE_SIZE) {
26
27
  throw createError({
27
28
  statusCode: 413,
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { useDb } from "#cms-db";
4
4
  import { cms_media } from "#cms-tables";
5
5
  import { objectKeySchema } from "../../shared/validation.js";
6
- import { toMediaItem, useMediaConfig } from "../utils/media.js";
6
+ import { assertUploadContentType, toMediaItem, useMediaConfig } from "../utils/media.js";
7
7
  import { requireAdmin } from "../utils/require-admin.js";
8
8
  const bodySchema = z.object({
9
9
  key: objectKeySchema,
@@ -18,6 +18,7 @@ export default defineEventHandler(async (event) => {
18
18
  await requireAdmin(event);
19
19
  const { publicUrl } = useMediaConfig(event);
20
20
  const body = await readValidatedBody(event, bodySchema.parse);
21
+ if (body.mime) assertUploadContentType(body.mime);
21
22
  const values = {
22
23
  key: body.key,
23
24
  mime: body.mime ?? null,
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -0,0 +1,17 @@
1
+ import { existsSync } from "node:fs";
2
+ import { migrate } from "drizzle-orm/libsql/migrator";
3
+ import cmsConfig from "#cms-config";
4
+ import { useRuntimeConfig } from "#imports";
5
+ import { useDb } from "../utils/db-libsql.js";
6
+ export default async () => {
7
+ const { migrationsDir } = useRuntimeConfig().cms;
8
+ if (!existsSync(migrationsDir)) {
9
+ if (Object.keys(cmsConfig).length) {
10
+ console.error(
11
+ `[nuxt-cms] Migrations folder not found: ${migrationsDir}. CMS tables may be missing \u2014 run the dev server once and deploy the generated server/db/migrations folder.`
12
+ );
13
+ }
14
+ return;
15
+ }
16
+ await migrate(useDb(), { migrationsFolder: migrationsDir });
17
+ };
@@ -1,9 +1,17 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { migrate } from "drizzle-orm/node-postgres/migrator";
3
+ import cmsConfig from "#cms-config";
3
4
  import { useRuntimeConfig } from "#imports";
4
5
  import { useDb } from "../utils/db-postgres.js";
5
6
  export default async () => {
6
7
  const { migrationsDir } = useRuntimeConfig().cms;
7
- if (!existsSync(migrationsDir)) return;
8
+ if (!existsSync(migrationsDir)) {
9
+ if (Object.keys(cmsConfig).length) {
10
+ console.error(
11
+ `[nuxt-cms] Migrations folder not found: ${migrationsDir}. CMS tables may be missing \u2014 run the dev server once and deploy the generated server/db/migrations folder.`
12
+ );
13
+ }
14
+ return;
15
+ }
8
16
  await migrate(useDb(), { migrationsFolder: migrationsDir });
9
17
  };
@@ -1,9 +1,17 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { migrate } from "drizzle-orm/better-sqlite3/migrator";
3
+ import cmsConfig from "#cms-config";
3
4
  import { useRuntimeConfig } from "#imports";
4
5
  import { useDb } from "../utils/db-sqlite.js";
5
6
  export default () => {
6
7
  const { migrationsDir } = useRuntimeConfig().cms;
7
- if (!existsSync(migrationsDir)) return;
8
+ if (!existsSync(migrationsDir)) {
9
+ if (Object.keys(cmsConfig).length) {
10
+ console.error(
11
+ `[nuxt-cms] Migrations folder not found: ${migrationsDir}. CMS tables may be missing \u2014 run the dev server once and deploy the generated server/db/migrations folder.`
12
+ );
13
+ }
14
+ return;
15
+ }
8
16
  migrate(useDb(), { migrationsFolder: migrationsDir });
9
17
  };
@@ -24,7 +24,7 @@ function safeEqual(a, b) {
24
24
  return timingSafeEqual(hashA, hashB);
25
25
  }
26
26
  export default defineEventHandler(async (event) => {
27
- const ip = getRequestIP(event) ?? "unknown";
27
+ const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
28
28
  const now = Date.now();
29
29
  pruneFailures(now);
30
30
  if (globalFailures.resetAt > now && globalFailures.count >= RATE_GLOBAL_MAX_FAILURES) {
@@ -0,0 +1,7 @@
1
+ export declare function useDb(): import("drizzle-orm/libsql").LibSQLDatabase<Record<string, unknown>> & {
2
+ $client: import("@libsql/client").Client;
3
+ };
4
+ type Db = ReturnType<typeof useDb>;
5
+ export type CmsDb = Db | Parameters<Parameters<Db['transaction']>[0]>[0];
6
+ export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
7
+ export {};
@@ -0,0 +1,18 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { createClient } from "@libsql/client";
4
+ import { drizzle } from "drizzle-orm/libsql";
5
+ import { useRuntimeConfig } from "#imports";
6
+ let _db = null;
7
+ export function useDb() {
8
+ if (!_db) {
9
+ const { databaseUrl, databaseAuthToken, dbPath } = useRuntimeConfig().cms;
10
+ const url = databaseUrl || `file:${dbPath}`;
11
+ if (url.startsWith("file:")) mkdirSync(dirname(dbPath), { recursive: true });
12
+ _db = drizzle(createClient({ url, authToken: databaseAuthToken || void 0 }));
13
+ }
14
+ return _db;
15
+ }
16
+ export function withTransaction(fn) {
17
+ return useDb().transaction((tx) => fn(tx));
18
+ }
@@ -1,3 +1,7 @@
1
1
  export declare function useDb(): import("drizzle-orm/node-postgres").NodePgDatabase<Record<string, unknown>> & {
2
2
  $client: import("pg").Pool;
3
3
  };
4
+ type Db = ReturnType<typeof useDb>;
5
+ export type CmsDb = Db | Parameters<Parameters<Db['transaction']>[0]>[0];
6
+ export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
7
+ export {};
@@ -14,3 +14,6 @@ export function useDb() {
14
14
  }
15
15
  return _db;
16
16
  }
17
+ export function withTransaction(fn) {
18
+ return useDb().transaction((tx) => fn(tx));
19
+ }