@xleddyl/nuxt-cms 0.1.25 → 0.1.27

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 (28) hide show
  1. package/README.md +2 -2
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +4 -0
  4. package/dist/runtime/app/components/cms/MediaField.vue +20 -18
  5. package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +16 -0
  6. package/dist/runtime/app/components/cms/MediaFolderPicker.vue +65 -0
  7. package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +16 -0
  8. package/dist/runtime/app/components/cms/MediaGallery.vue +212 -48
  9. package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -1
  10. package/dist/runtime/app/components/cms/MediaUpload.vue +9 -3
  11. package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -1
  12. package/dist/runtime/app/components/cms/Table.d.vue.ts +10 -2
  13. package/dist/runtime/app/components/cms/Table.vue +30 -2
  14. package/dist/runtime/app/components/cms/Table.vue.d.ts +10 -2
  15. package/dist/runtime/app/pages/admin-collection.vue +14 -3
  16. package/dist/runtime/assets/main.css +1 -1
  17. package/dist/runtime/server/api/collection.get.js +14 -4
  18. package/dist/runtime/server/api/media-presign.post.d.ts +1 -0
  19. package/dist/runtime/server/api/media-presign.post.js +7 -4
  20. package/dist/runtime/server/api/media.post.js +2 -1
  21. package/dist/runtime/server/api/media.put.js +2 -1
  22. package/dist/runtime/server/plugins/media-sync-local.d.ts +2 -0
  23. package/dist/runtime/server/plugins/media-sync-local.js +41 -0
  24. package/dist/runtime/server/utils/media-sync.d.ts +39 -0
  25. package/dist/runtime/server/utils/media-sync.js +227 -0
  26. package/dist/runtime/shared/index.d.ts +2 -0
  27. package/dist/runtime/shared/index.js +6 -0
  28. package/package.json +1 -1
@@ -5,6 +5,11 @@ interface Column {
5
5
  accessorFn?: (row: Row) => unknown;
6
6
  header?: string;
7
7
  reorderable?: boolean;
8
+ sortable?: boolean;
9
+ }
10
+ interface TableSort {
11
+ key: string;
12
+ order: 'asc' | 'desc';
8
13
  }
9
14
  type __VLS_Props = {
10
15
  data: Row[];
@@ -12,15 +17,16 @@ type __VLS_Props = {
12
17
  };
13
18
  type __VLS_ModelProps = {
14
19
  'columnVisibility'?: Record<string, boolean>;
20
+ 'sort'?: TableSort | null;
15
21
  };
16
22
  type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
17
- declare var __VLS_2: `${string}-cell`, __VLS_3: {
23
+ declare var __VLS_7: `${string}-cell`, __VLS_8: {
18
24
  row: {
19
25
  original: Row;
20
26
  };
21
27
  };
22
28
  type __VLS_Slots = {} & {
23
- [K in NonNullable<typeof __VLS_2>]?: (props: typeof __VLS_3) => any;
29
+ [K in NonNullable<typeof __VLS_7>]?: (props: typeof __VLS_8) => any;
24
30
  };
25
31
  declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
26
32
  select: (event: Event, row: {
@@ -28,12 +34,14 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {
28
34
  }) => any;
29
35
  reorder: (from: string, to: string) => any;
30
36
  "update:columnVisibility": (value: Record<string, boolean>) => any;
37
+ "update:sort": (value: TableSort | null) => any;
31
38
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
32
39
  onSelect?: ((event: Event, row: {
33
40
  original: Row;
34
41
  }) => any) | undefined;
35
42
  onReorder?: ((from: string, to: string) => any) | undefined;
36
43
  "onUpdate:columnVisibility"?: ((value: Record<string, boolean>) => any) | undefined;
44
+ "onUpdate:sort"?: ((value: TableSort | null) => any) | undefined;
37
45
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
38
46
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
39
47
  declare const _default: typeof __VLS_export;
@@ -9,16 +9,27 @@
9
9
  :key="columnId(column)"
10
10
  :class="{
11
11
  'is-dragging': draggingId === columnId(column),
12
- 'is-drop-target': dropTargetId === columnId(column)
12
+ 'is-drop-target': dropTargetId === columnId(column),
13
+ 'is-sortable': column.sortable,
14
+ 'is-sorted': sort?.key === columnId(column)
13
15
  }"
14
16
  :draggable="column.reorderable || void 0"
17
+ :aria-sort="ariaSort(column)"
18
+ @click="toggleSort(column)"
15
19
  @dragstart="onDragStart($event, column)"
16
20
  @dragover="onDragOver($event, column)"
17
21
  @dragleave="onDragLeave(column)"
18
22
  @drop="onDrop($event, column)"
19
23
  @dragend="resetDrag"
20
24
  >
21
- {{ column.header }}
25
+ <span class="cms-th-label">
26
+ {{ column.header }}
27
+ <CmsIcon
28
+ v-if="column.sortable"
29
+ :name="sortIcon(column)"
30
+ class="cms-th-sort size-3"
31
+ />
32
+ </span>
22
33
  </th>
23
34
  </tr>
24
35
  </thead>
@@ -48,10 +59,27 @@ const props = defineProps({
48
59
  columns: { type: Array, required: true }
49
60
  });
50
61
  const visibility = defineModel("columnVisibility", { type: Object, ...{ default: () => ({}) } });
62
+ const sort = defineModel("sort", { type: [Object, null], ...{ default: null } });
51
63
  const emit = defineEmits(["select", "reorder"]);
52
64
  function columnId(column) {
53
65
  return column.id ?? column.accessorKey ?? "";
54
66
  }
67
+ function toggleSort(column) {
68
+ if (!column.sortable) return;
69
+ const key = columnId(column);
70
+ if (sort.value?.key !== key) sort.value = { key, order: "asc" };
71
+ else if (sort.value.order === "asc") sort.value = { key, order: "desc" };
72
+ else sort.value = null;
73
+ }
74
+ function sortIcon(column) {
75
+ if (sort.value?.key !== columnId(column)) return "arrows-up-down";
76
+ return sort.value.order === "asc" ? "arrow-small-up" : "arrow-small-down";
77
+ }
78
+ function ariaSort(column) {
79
+ if (!column.sortable) return void 0;
80
+ if (sort.value?.key !== columnId(column)) return "none";
81
+ return sort.value.order === "asc" ? "ascending" : "descending";
82
+ }
55
83
  const draggingId = ref(null);
56
84
  const dropTargetId = ref(null);
57
85
  function resetDrag() {
@@ -5,6 +5,11 @@ interface Column {
5
5
  accessorFn?: (row: Row) => unknown;
6
6
  header?: string;
7
7
  reorderable?: boolean;
8
+ sortable?: boolean;
9
+ }
10
+ interface TableSort {
11
+ key: string;
12
+ order: 'asc' | 'desc';
8
13
  }
9
14
  type __VLS_Props = {
10
15
  data: Row[];
@@ -12,15 +17,16 @@ type __VLS_Props = {
12
17
  };
13
18
  type __VLS_ModelProps = {
14
19
  'columnVisibility'?: Record<string, boolean>;
20
+ 'sort'?: TableSort | null;
15
21
  };
16
22
  type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
17
- declare var __VLS_2: `${string}-cell`, __VLS_3: {
23
+ declare var __VLS_7: `${string}-cell`, __VLS_8: {
18
24
  row: {
19
25
  original: Row;
20
26
  };
21
27
  };
22
28
  type __VLS_Slots = {} & {
23
- [K in NonNullable<typeof __VLS_2>]?: (props: typeof __VLS_3) => any;
29
+ [K in NonNullable<typeof __VLS_7>]?: (props: typeof __VLS_8) => any;
24
30
  };
25
31
  declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
26
32
  select: (event: Event, row: {
@@ -28,12 +34,14 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {
28
34
  }) => any;
29
35
  reorder: (from: string, to: string) => any;
30
36
  "update:columnVisibility": (value: Record<string, boolean>) => any;
37
+ "update:sort": (value: TableSort | null) => any;
31
38
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
32
39
  onSelect?: ((event: Event, row: {
33
40
  original: Row;
34
41
  }) => any) | undefined;
35
42
  onReorder?: ((from: string, to: string) => any) | undefined;
36
43
  "onUpdate:columnVisibility"?: ((value: Record<string, boolean>) => any) | undefined;
44
+ "onUpdate:sort"?: ((value: TableSort | null) => any) | undefined;
37
45
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
38
46
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
39
47
  declare const _default: typeof __VLS_export;
@@ -50,6 +50,7 @@
50
50
  <CmsTable
51
51
  v-if="rows.length"
52
52
  v-model:column-visibility="columnVisibility"
53
+ v-model:sort="sort"
53
54
  :data="rows"
54
55
  :columns="columns"
55
56
  @select="onSelect"
@@ -156,10 +157,15 @@ watch(search, (value) => {
156
157
  page.value = 1;
157
158
  }, 300);
158
159
  });
160
+ const sort = ref(null);
161
+ watch(sort, () => {
162
+ page.value = 1;
163
+ });
159
164
  const listQuery = computed(() => ({
160
165
  limit: PAGE_SIZE,
161
166
  offset: (page.value - 1) * PAGE_SIZE,
162
- ...searchTerm.value ? { search: searchTerm.value } : {}
167
+ ...searchTerm.value ? { search: searchTerm.value } : {},
168
+ ...sort.value ? { sort: sort.value.key, order: sort.value.order } : {}
163
169
  }));
164
170
  const { data, refresh, error, status } = await useFetch(endpoint, {
165
171
  query: config.kind === "collection" ? listQuery : void 0
@@ -257,14 +263,19 @@ const orderedKeys = computed(() => {
257
263
  const known = columnOrder.value.filter((key) => fieldKeys.includes(key));
258
264
  return [...known, ...fieldKeys.filter((key) => !known.includes(key))];
259
265
  });
266
+ function isSortable(field) {
267
+ if (field.type === "blocks") return false;
268
+ return !(field.type === "relation" && field.cardinality === "many-to-many");
269
+ }
260
270
  const columns = computed(() => [
261
271
  ...orderedKeys.value.map((key) => ({
262
272
  id: key,
263
273
  accessorFn: (row) => displayValue(config.fields[key], key, row[key]),
264
274
  header: config.fields[key].label,
265
- reorderable: true
275
+ reorderable: true,
276
+ sortable: isSortable(config.fields[key])
266
277
  })),
267
- ...drafts ? [{ accessorKey: "status", header: "Status" }] : [],
278
+ ...drafts ? [{ accessorKey: "status", header: "Status", sortable: true }] : [],
268
279
  { id: "actions", header: "" }
269
280
  ]);
270
281
  function reorderColumns(from, to) {
@@ -1 +1 @@
1
- @import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600&family=Hanken+Grotesk:ital,wght@0,300..800;1,300..800&family=Fragment+Mono:ital@0;1&display=swap");@layer theme, base, components, utilities;@import "tailwindcss/theme.css" layer(theme) theme(inline);.cms-scope{@import "tailwindcss/preflight.css" layer(base);@import "tailwindcss/utilities.css" layer(utilities) source(none)}@source "../app";.cms-scope,body:has(.cms-scope){--cms-paper:#f5f5f5;--cms-surface:#fff;--cms-field:#f0f0f0;--cms-ink:#171717;--cms-line:#e5e5e5;--cms-line-strong:#d4d4d4;--cms-fern:#171717;--cms-sage:#ebebeb;--cms-sage-ink:#171717;--cms-shadow-soft:none;color-scheme:light;--ui-text-dimmed:#a3a3a3;--ui-text-muted:#737373;--ui-text-toned:#525252;--ui-text:#404040;--ui-text-highlighted:var(--cms-ink);--ui-text-inverted:#fff;--ui-bg:var(--cms-surface);--ui-bg-muted:var(--cms-paper);--ui-bg-elevated:var(--cms-field);--ui-bg-accented:#e0e0e0;--ui-bg-inverted:var(--cms-ink);--ui-border:var(--cms-line);--ui-border-muted:var(--cms-line);--ui-border-accented:var(--cms-line-strong);--ui-border-inverted:var(--cms-ink);--ui-primary:var(--cms-ink);--ui-error:#dc2626;--ui-radius:0.4rem;color:var(--ui-text);font-family:Hanken Grotesk,ui-sans-serif,system-ui,sans-serif;margin:0;padding:0;-webkit-font-smoothing:antialiased}body:has(.cms-scope) ::-moz-selection{background:var(--cms-sage);color:var(--cms-sage-ink)}body:has(.cms-scope) ::selection{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-display,.cms-title{font-family:Poppins,ui-sans-serif,system-ui,sans-serif;letter-spacing:-.02em}.cms-title{color:var(--ui-text-highlighted);font-weight:500}.cms-title-sm{font-size:1.25rem;line-height:1.4}.cms-title-md{font-size:2rem;line-height:1}.cms-title-lg{font-size:2.25rem;line-height:1.2}.cms-subtitle{color:var(--ui-text-muted);font-size:.875rem}.cms-accent{color:var(--cms-fern)}.cms-kicker{color:var(--ui-text-dimmed);font-family:Fragment Mono,ui-monospace,monospace;font-size:.875rem;letter-spacing:.16em;line-height:1.4;text-transform:uppercase}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]){@apply cursor-pointer transition-all duration-200 select-none}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]):active{@apply transition-none;scale:.99}.cms-canvas{background-color:var(--cms-paper)}.cms-grain:before{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='a'%3E%3CfeTurbulence baseFrequency='.8' numOctaves='2' stitchTiles='stitch' type='fractalNoise'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");content:"";inset:0;mix-blend-mode:multiply;opacity:.035;pointer-events:none;position:fixed;z-index:50}.cms-card{background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:1rem;box-shadow:var(--cms-shadow-soft)}.cms-panel{padding:1.75rem}.cms-shell{display:flex;min-height:100vh}.cms-sidebar{border-right:1px solid var(--cms-line);display:flex;flex-direction:column;flex-shrink:0;gap:1.75rem;height:100vh;padding:1.75rem 1rem;position:sticky;top:0;width:16rem}.cms-sidebar-brand{color:var(--ui-text-highlighted);font-family:Poppins,ui-sans-serif,system-ui,sans-serif;font-size:1.375rem;font-weight:600;letter-spacing:-.02em;padding-inline:.75rem}.cms-sidebar-nav{display:flex;flex:1;flex-direction:column;gap:1.5rem;overflow-y:auto}.cms-sidebar-group{display:flex;flex-direction:column;gap:.25rem}.cms-sidebar-group>.cms-kicker{margin-bottom:.5rem;padding-inline:.75rem}.cms-sidebar-footer{border-top:1px solid var(--cms-line);display:flex;flex-direction:column;gap:.75rem;padding:1.25rem .75rem 0}.cms-main{flex:1;height:100vh;min-width:0;overflow-y:auto;padding:2.5rem}.cms-main-inner{margin-inline:auto;max-width:64rem;min-height:100%;width:100%}.cms-main-inner,.cms-page{display:flex;flex-direction:column}.cms-page{gap:1.75rem}.cms-page.is-fill{flex:1;min-height:0}.cms-page-header{align-items:flex-end;display:flex;gap:1rem;justify-content:space-between}.cms-page-heading{display:flex;flex-direction:column;gap:.5rem}.cms-toolbar{align-items:center;display:flex;gap:1.5rem}.cms-toolbar-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-actions{align-items:center;display:flex;gap:.75rem}.cms-actions.is-end{justify-content:flex-end}.cms-spinner{color:var(--ui-text-dimmed);flex:1;justify-content:center;padding-block:2.5rem}.cms-empty,.cms-spinner{align-items:center;display:flex}.cms-empty{background:rgba(253,252,250,.5);border:1.5px dashed var(--cms-line-strong);border-radius:1rem;flex-direction:column;gap:.5rem;padding:3.5rem;text-align:center}.cms-empty.is-fill{flex:1;justify-content:center}.cms-empty-icon{color:var(--cms-fern);margin-bottom:.25rem}.cms-empty>.cms-btn{margin-top:.75rem}.cms-auth{align-items:center;display:flex;justify-content:center;min-height:100vh;padding:1.5rem}.cms-auth-panel{gap:2rem;max-width:24rem;width:100%}.cms-auth-header,.cms-auth-panel{display:flex;flex-direction:column}.cms-auth-header{align-items:center;gap:.75rem;text-align:center}.cms-auth-card{padding:1.75rem}.cms-navlink{align-items:center;border-radius:.75rem;color:var(--ui-text-toned);display:flex;font-size:.875rem;font-weight:500;gap:.625rem;padding:.5rem .75rem;transition:background-color .15s ease,color .15s ease}.cms-navlink:hover{background:rgba(38,33,25,.05);color:var(--ui-text-highlighted)}.cms-navlink.is-active{background:var(--cms-sage);color:var(--cms-sage-ink);font-weight:600}.cms-navlink-icon{color:var(--ui-text-dimmed)}.cms-navlink.is-active .cms-navlink-icon{color:var(--cms-fern)}.cms-badge{align-items:center;border:1px solid transparent;border-radius:9999px;display:inline-flex;font-family:Fragment Mono,ui-monospace,monospace;font-size:.6875rem;letter-spacing:.1em;padding:.1875rem .5rem;pointer-events:none;text-transform:uppercase;transition:background-color .15s ease,border-color .15s ease;white-space:nowrap}.cms-badge.is-published{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-badge.is-muted{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-muted)}.cms-badge.is-draft{background:transparent;border:1px dashed var(--cms-line-strong);color:var(--ui-text-muted)}.cms-dropzone{align-items:center;background:rgba(253,252,250,.5);border:1.5px dashed var(--cms-line-strong);border-radius:1rem;color:var(--ui-text-muted);cursor:pointer;display:flex;flex-direction:column;gap:.5rem;justify-content:center;padding:2rem 1.5rem;transition:border-color .15s ease,background-color .15s ease,color .15s ease;width:100%}.cms-dropzone.is-over,.cms-dropzone:hover{background:var(--cms-sage);border-color:var(--cms-fern);color:var(--cms-sage-ink)}.cms-dropzone.is-dense{border-radius:.75rem;flex-direction:row;gap:.625rem;padding:.875rem 1.25rem}.cms-dropzone.is-busy{opacity:.7;pointer-events:none}.cms-pill{align-items:center;background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:9999px;color:var(--ui-text-dimmed);cursor:pointer;display:inline-flex;flex:none;font-family:Fragment Mono,ui-monospace,monospace;font-size:.875rem;gap:.35rem;letter-spacing:.12em;padding:.3rem .8rem;transition:background-color .15s ease,color .15s ease;white-space:nowrap}.cms-pill:hover{background:rgba(38,33,25,.05);color:var(--ui-text)}.cms-pill.is-active{background:var(--cms-sage);color:var(--cms-sage-ink)}@keyframes cms-rise{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}.cms-rise{animation:cms-rise .55s cubic-bezier(.22,1,.36,1) both}.cms-rise.is-delayed{animation-delay:90ms}@media (prefers-reduced-motion:reduce){.cms-rise{animation:none}}.cms-richtext{border:1px solid var(--cms-line);border-radius:.75rem;overflow:hidden}.cms-richtext-toolbar{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;flex-wrap:wrap;gap:2px;padding:4px}.cms-richtext-body{font-size:.875rem;min-height:9rem;outline:none;padding:.75rem 1rem}.cms-richtext-body h2{font-size:1.25rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body h3{font-size:1.1rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body p{margin:.35em 0}.cms-richtext-body ul{list-style:disc;padding-left:1.25rem}.cms-richtext-body ol{list-style:decimal;padding-left:1.25rem}.cms-richtext-body blockquote{border-left:3px solid var(--cms-line);color:var(--ui-text-muted);padding-left:.75rem}.cms-btn{align-items:center;border:1px solid transparent;border-radius:9999px;display:inline-flex;font-weight:500;gap:.375rem;justify-content:space-between;line-height:1;min-height:var(--cms-btn-size);transition:background-color .15s ease,border-color .15s ease,color .15s ease,opacity .15s ease;white-space:nowrap}.cms-btn:disabled,.cms-btn[aria-disabled=true]{opacity:.5;pointer-events:none}.cms-btn.is-block{width:100%}.cms-btn.is-icon{justify-content:center;padding-inline:0;width:var(--cms-btn-size)}.cms-btn-xs{--cms-btn-size:1.75rem;font-size:.875rem;padding:.25rem .75rem}.cms-btn-sm{--cms-btn-size:2rem;font-size:.875rem;padding:.25rem .875rem}.cms-btn-md{--cms-btn-size:2.25rem;font-size:.875rem;padding:.25rem 1.125rem}.cms-btn-lg{--cms-btn-size:2.625rem;font-size:.9375rem;padding:.375rem 1.375rem}.cms-btn-primary.cms-btn-solid{background:var(--ui-primary);color:var(--ui-text-inverted)}.cms-btn-primary.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-primary) 88%,#000)}.cms-btn-primary.cms-btn-subtle{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-btn-primary.cms-btn-subtle:hover{background:#e0e0e0}.cms-btn-primary.cms-btn-soft{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-btn-neutral.cms-btn-solid{background:var(--ui-bg-inverted);color:var(--ui-text-inverted)}.cms-btn-neutral.cms-btn-subtle{background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-subtle:hover{background:var(--ui-bg-accented)}.cms-btn-neutral.cms-btn-soft{background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-ghost{color:var(--ui-text-toned)}.cms-btn-neutral.cms-btn-ghost:hover{background:rgba(38,33,25,.06);color:var(--ui-text-highlighted)}.cms-btn-error.cms-btn-solid{background:var(--ui-error);color:#fff}.cms-btn-error.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-error) 88%,#000)}.cms-btn-error.cms-btn-subtle{background:color-mix(in oklab,var(--ui-error) 12%,transparent);color:var(--ui-error)}.cms-btn-error.cms-btn-ghost{color:var(--ui-error)}.cms-btn-error.cms-btn-ghost:hover{background:color-mix(in oklab,var(--ui-error) 10%,transparent)}.cms-btn-success.cms-btn-solid{background:var(--cms-fern);color:var(--ui-text-inverted)}.cms-form{gap:1.25rem}.cms-form,.cms-form-field{display:flex;flex-direction:column}.cms-form-field{gap:.375rem}.cms-form-label-row{align-items:center;display:flex;gap:.75rem;justify-content:space-between;min-height:1.75rem}.cms-form-label{color:var(--ui-text-toned);font-size:.875rem;font-weight:500}.cms-form-error,.cms-form-required{color:var(--ui-error)}.cms-form-error{font-size:.875rem}.cms-form-actions{align-items:center;display:flex;gap:.75rem;justify-content:flex-end;padding-top:.25rem}.cms-field{background:var(--ui-bg);border:1px solid var(--cms-line-strong);border-radius:var(--ui-radius);color:var(--ui-text-highlighted);line-height:1.25;transition:border-color .15s ease,box-shadow .15s ease;width:100%}textarea.cms-field{line-height:1.5;min-height:0;padding-bottom:.625rem;padding-top:.625rem;resize:vertical}.cms-field::-moz-placeholder{color:var(--ui-text-dimmed)}.cms-field::placeholder{color:var(--ui-text-dimmed)}.cms-field:focus,.cms-field:focus-within{border-color:var(--ui-primary);box-shadow:0 0 0 3px color-mix(in oklab,var(--ui-primary) 18%,transparent);outline:none}.cms-field.is-error{border-color:var(--ui-error)}.cms-field-sm{font-size:.875rem;min-height:2rem;padding:.25rem .625rem}.cms-field-md{font-size:.875rem;min-height:2.25rem;padding:.25rem .75rem}.cms-field-lg{font-size:.9375rem;min-height:2.625rem;padding:.375rem .875rem}.cms-select-chevron{align-items:center;cursor:pointer;display:inline-flex;transition:transform .18s ease,color .15s ease}.cms-select-chevron:hover{color:var(--ui-text-muted)}.cms-select-chevron.is-open{transform:rotate(180deg)}.cms-locale-switch{align-items:center;background:var(--cms-paper);border:1px solid var(--cms-line);border-radius:999px;display:inline-flex;gap:2px;padding:2px}.cms-locale-option{border-radius:999px;color:var(--ui-text-muted);cursor:pointer;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.6875rem;letter-spacing:.06em;line-height:1.4;padding:.125rem .5rem;text-transform:uppercase;transition:background-color .15s ease,color .15s ease}.cms-locale-option:hover:not(.is-active){color:var(--ui-text-highlighted)}.cms-locale-option.is-empty{color:var(--ui-text-dimmed)}.cms-locale-option.is-active{background:var(--ui-bg);box-shadow:0 1px 2px rgba(38,33,25,.1);color:var(--ui-text-highlighted)}.cms-input-wrap{align-items:center;display:flex;position:relative;width:100%}.cms-input-wrap .cms-field{width:100%}.cms-input-lead,.cms-input-trail{align-items:center;color:var(--ui-text-dimmed);display:flex;position:absolute}.cms-input-lead{left:.625rem}.cms-input-trail{right:.375rem}.cms-input-wrap.has-lead .cms-field{padding-left:2rem}.cms-switch{align-items:center;background:var(--cms-line-strong);border-radius:9999px;display:inline-flex;flex-shrink:0;height:1.25rem;position:relative;transition:background-color .15s ease;width:2.25rem}.cms-switch.is-on{background:var(--ui-primary)}.cms-switch:after{background:#fff;border-radius:9999px;content:"";height:.875rem;left:.1875rem;position:absolute;transition:transform .15s ease;width:.875rem}.cms-switch.is-on:after{transform:translateX(1rem)}.cms-overlay{align-items:center;backdrop-filter:blur(2px);background:rgba(38,33,25,.32);display:flex;inset:0;justify-content:center;overflow-y:auto;padding:2rem 1rem;position:fixed;z-index:60}.cms-modal{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:1rem;box-shadow:var(--cms-shadow-soft);max-width:32rem;width:100%}.cms-modal.is-sm{max-width:28rem}.cms-modal.is-lg{max-width:48rem}.cms-modal-header{padding:1.25rem 1.5rem 0}.cms-modal-body{padding:1.25rem 1.5rem 1.5rem}.cms-overlay-drawer{align-items:stretch;justify-content:flex-end;overflow:hidden;padding:0;z-index:55}.cms-drawer{background:var(--ui-bg);border-left:1px solid var(--cms-line);box-shadow:-18px 0 48px rgba(38,33,25,.12);display:flex;flex-direction:column;height:100%;max-width:46rem;width:100%}.cms-drawer-header{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-body{flex:1;min-height:0;overflow-y:auto;padding:1.75rem}.cms-drawer-footer{align-items:center;border-top:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-enter-active,.cms-drawer-leave-active{transition:opacity .3s ease}.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active .cms-drawer{transition:transform .36s cubic-bezier(.32,.72,0,1);will-change:transform}.cms-drawer-enter-from,.cms-drawer-leave-to{opacity:0}.cms-drawer-enter-from .cms-drawer,.cms-drawer-leave-to .cms-drawer{transform:translateX(100%)}@media (prefers-reduced-motion:reduce){.cms-drawer-enter-active,.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active,.cms-drawer-leave-active .cms-drawer{transition-duration:.01ms}}.cms-menu{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);margin-top:.375rem;min-width:12rem;padding:.25rem;position:absolute;z-index:50}.cms-menu.align-end{right:0}.cms-menu-item{align-items:center;border-radius:.5rem;color:var(--ui-text);cursor:pointer;display:flex;font-size:.875rem;gap:.5rem;padding:.4rem .6rem;text-align:left;width:100%}.cms-menu-item:hover{background:var(--ui-bg-elevated)}.cms-menu-check{color:var(--ui-primary);margin-left:auto}.cms-popover{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);margin-top:.375rem;position:absolute;z-index:50}.cms-selectmenu-input{background:transparent;color:var(--ui-text-highlighted);flex:1;min-width:4rem;outline:none}.cms-selectmenu-affix{color:var(--ui-text-dimmed);flex-shrink:0}.cms-selectmenu-panel{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);left:0;margin-top:.375rem;max-height:15rem;overflow-y:auto;padding:.25rem;position:absolute;right:0;z-index:50}.cms-tag{align-items:center;background:var(--ui-bg-elevated);border-radius:9999px;display:inline-flex;font-size:.875rem;gap:.25rem;padding:.125rem .5rem}.cms-media-gallery{display:flex;flex-direction:column;gap:1.25rem}.cms-media-filters{align-items:center;display:flex;flex-wrap:wrap;gap:.25rem}.cms-media-filter-separator{background:var(--cms-line);flex:none;height:1rem;margin-inline:.25rem;width:1px}.cms-media-grid{display:grid;gap:1rem;grid-template-columns:repeat(2,minmax(0,1fr))}@media (min-width:640px){.cms-media-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.cms-media-grid:not(.is-compact){grid-template-columns:repeat(4,minmax(0,1fr))}}.cms-media-tile{overflow:hidden;text-align:left}.cms-media-tile.is-selectable:hover{border-color:var(--cms-line-strong)}.cms-media-preview{align-items:center;aspect-ratio:1;background:var(--cms-field);color:var(--ui-text-dimmed);display:flex;justify-content:center;position:relative}.cms-media-preview>img,.cms-media-preview>video{height:100%;inset:0;-o-object-fit:cover;object-fit:cover;position:absolute;width:100%}.cms-media-actions{display:flex;gap:.25rem;opacity:0;position:absolute;right:.5rem;top:.5rem;transition:opacity .15s ease}.cms-media-actions:focus-within,.cms-media-tile:hover .cms-media-actions{opacity:1}.cms-media-info{background:linear-gradient(0deg,rgba(0,0,0,.7),transparent);bottom:0;color:#fff;font-family:Fragment Mono,ui-monospace,monospace;font-size:.75rem;left:0;letter-spacing:.1em;line-height:1.4;opacity:0;padding:1.5rem .75rem .5rem;pointer-events:none;position:absolute;right:0;text-transform:uppercase;transition:opacity .15s ease}.cms-media-tile:focus-visible .cms-media-info,.cms-media-tile:hover .cms-media-info{opacity:1}.cms-media-meta{display:flex;flex-direction:column;gap:.25rem;padding:.625rem .875rem}.cms-media-name{font-size:.875rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-preview-large{background:var(--cms-field);max-height:14rem;-o-object-fit:cover;object-fit:cover;width:100%}.cms-media-bar{align-items:center;display:flex;gap:.625rem;padding:.75rem 1rem}.cms-media-bar-icon{color:var(--ui-text-dimmed);flex-shrink:0}.cms-media-bar-name{flex:1;font-size:.875rem;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-thumb{background:var(--cms-field);border-radius:.375rem;height:2.25rem;-o-object-fit:cover;object-fit:cover;width:2.25rem}.cms-media-thumb.is-fallback{align-items:center;color:var(--ui-text-dimmed);display:flex;justify-content:center}.cms-media-thumb-empty{color:var(--ui-text-dimmed)}.cms-table-shell{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.cms-table-scroll{flex:1;min-height:0;overflow:auto}.cms-table{border-collapse:collapse;min-width:-moz-max-content;min-width:max-content;text-align:left;width:100%}.cms-table td,.cms-table th{white-space:nowrap}.cms-table thead{background:var(--cms-surface);position:sticky;top:0;z-index:10}.cms-table th{box-shadow:inset 0 -1px 0 var(--cms-line);color:var(--ui-text-dimmed);font-family:Fragment Mono,ui-monospace,monospace;font-size:.6875rem;font-weight:400;letter-spacing:.1em;line-height:1.4;padding:.75rem 1.25rem;text-transform:uppercase}.cms-table td{color:var(--ui-text);font-size:.875rem;padding:.875rem 1.25rem}.cms-table tbody tr{border-bottom:1px solid var(--cms-line)}.cms-table tbody tr:last-child{border-bottom:0}.cms-table tbody tr:hover{background:color-mix(in oklab,var(--cms-paper) 50%,transparent)}.cms-table-cell-actions{display:flex;justify-content:flex-end}.cms-table th[draggable=true]{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.cms-table th[draggable=true]:active{cursor:grabbing}.cms-table th.is-dragging{opacity:.4}.cms-table th.is-drop-target{background:var(--cms-paper);box-shadow:inset 0 -1px 0 var(--cms-line),inset 0 0 0 2px var(--cms-line-strong)}.cms-alert{border-radius:.75rem;font-size:.875rem;padding:.75rem 1rem}.cms-alert-error{background:color-mix(in oklab,var(--ui-error) 10%,transparent);color:var(--ui-error)}.cms-toaster{bottom:1rem;display:flex;flex-direction:column;gap:.5rem;max-width:calc(100vw - 2rem);position:fixed;right:1rem;width:20rem;z-index:80}.cms-toast{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);padding:.75rem .875rem}.cms-toast-row{align-items:flex-start;display:flex;gap:.75rem}.cms-toast-title{color:var(--ui-text-highlighted);font-size:.875rem;font-weight:500}.cms-toast-description{color:var(--ui-text-muted);font-size:.875rem;margin-top:.125rem}.cms-toast-dismiss{color:var(--ui-text-dimmed);flex-shrink:0}.cms-toast-dismiss:hover{color:var(--ui-text)}.cms-toast.is-success{border-left:3px solid var(--cms-fern)}.cms-toast.is-error{border-left:3px solid var(--ui-error)}.cms-pagination{align-items:center;display:flex;gap:.25rem;justify-content:center}.cms-page-btn{border-radius:.5rem;color:var(--ui-text-toned);font-size:.875rem;height:2rem;min-width:2rem;padding:0 .5rem}.cms-page-btn:hover:not(:disabled){background:var(--ui-bg-elevated)}.cms-page-btn.is-active{background:var(--cms-sage);color:var(--cms-sage-ink);font-weight:600}.cms-page-btn:disabled{opacity:.4}
1
+ @import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600&family=Hanken+Grotesk:ital,wght@0,300..800;1,300..800&family=Fragment+Mono:ital@0;1&display=swap");@layer theme, base, components, utilities;@import "tailwindcss/theme.css" layer(theme) theme(inline);.cms-scope{@import "tailwindcss/preflight.css" layer(base);@import "tailwindcss/utilities.css" layer(utilities) source(none)}@source "../app";.cms-scope,body:has(.cms-scope){--cms-paper:#f5f5f5;--cms-surface:#fff;--cms-field:#f0f0f0;--cms-ink:#171717;--cms-line:#e5e5e5;--cms-line-strong:#d4d4d4;--cms-fern:#171717;--cms-sage:#ebebeb;--cms-sage-ink:#171717;--cms-shadow-soft:none;color-scheme:light;--ui-text-dimmed:#a3a3a3;--ui-text-muted:#737373;--ui-text-toned:#525252;--ui-text:#404040;--ui-text-highlighted:var(--cms-ink);--ui-text-inverted:#fff;--ui-bg:var(--cms-surface);--ui-bg-muted:var(--cms-paper);--ui-bg-elevated:var(--cms-field);--ui-bg-accented:#e0e0e0;--ui-bg-inverted:var(--cms-ink);--ui-border:var(--cms-line);--ui-border-muted:var(--cms-line);--ui-border-accented:var(--cms-line-strong);--ui-border-inverted:var(--cms-ink);--ui-primary:var(--cms-ink);--ui-error:#dc2626;--ui-radius:0.4rem;color:var(--ui-text);font-family:Hanken Grotesk,ui-sans-serif,system-ui,sans-serif;margin:0;padding:0;-webkit-font-smoothing:antialiased}body:has(.cms-scope) ::-moz-selection{background:var(--cms-sage);color:var(--cms-sage-ink)}body:has(.cms-scope) ::selection{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-display,.cms-title{font-family:Poppins,ui-sans-serif,system-ui,sans-serif;letter-spacing:-.02em}.cms-title{color:var(--ui-text-highlighted);font-weight:500}.cms-title-sm{font-size:1.25rem;line-height:1.4}.cms-title-md{font-size:2rem;line-height:1}.cms-title-lg{font-size:2.25rem;line-height:1.2}.cms-subtitle{color:var(--ui-text-muted);font-size:.875rem}.cms-accent{color:var(--cms-fern)}.cms-kicker{color:var(--ui-text-dimmed);font-family:Fragment Mono,ui-monospace,monospace;font-size:.875rem;letter-spacing:.16em;line-height:1.4;text-transform:uppercase}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]){@apply cursor-pointer transition-all duration-200 select-none}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]):active{@apply transition-none;scale:.99}.cms-canvas{background-color:var(--cms-paper)}.cms-grain:before{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='a'%3E%3CfeTurbulence baseFrequency='.8' numOctaves='2' stitchTiles='stitch' type='fractalNoise'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");content:"";inset:0;mix-blend-mode:multiply;opacity:.035;pointer-events:none;position:fixed;z-index:50}.cms-card{background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:1rem;box-shadow:var(--cms-shadow-soft)}.cms-panel{padding:1.75rem}.cms-shell{display:flex;min-height:100vh}.cms-sidebar{border-right:1px solid var(--cms-line);display:flex;flex-direction:column;flex-shrink:0;gap:1.75rem;height:100vh;padding:1.75rem 1rem;position:sticky;top:0;width:16rem}.cms-sidebar-brand{color:var(--ui-text-highlighted);font-family:Poppins,ui-sans-serif,system-ui,sans-serif;font-size:1.375rem;font-weight:600;letter-spacing:-.02em;padding-inline:.75rem}.cms-sidebar-nav{display:flex;flex:1;flex-direction:column;gap:1.5rem;overflow-y:auto}.cms-sidebar-group{display:flex;flex-direction:column;gap:.25rem}.cms-sidebar-group>.cms-kicker{margin-bottom:.5rem;padding-inline:.75rem}.cms-sidebar-footer{border-top:1px solid var(--cms-line);display:flex;flex-direction:column;gap:.75rem;padding:1.25rem .75rem 0}.cms-main{flex:1;height:100vh;min-width:0;overflow-y:auto;padding:2.5rem}.cms-main-inner{margin-inline:auto;max-width:64rem;min-height:100%;width:100%}.cms-main-inner,.cms-page{display:flex;flex-direction:column}.cms-page{gap:1.75rem}.cms-page.is-fill{flex:1;min-height:0}.cms-page-header{align-items:flex-end;display:flex;gap:1rem;justify-content:space-between}.cms-page-heading{display:flex;flex-direction:column;gap:.5rem}.cms-toolbar{align-items:center;display:flex;gap:1.5rem}.cms-toolbar-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-actions{align-items:center;display:flex;gap:.75rem}.cms-actions.is-end{justify-content:flex-end}.cms-spinner{color:var(--ui-text-dimmed);flex:1;justify-content:center;padding-block:2.5rem}.cms-empty,.cms-spinner{align-items:center;display:flex}.cms-empty{background:rgba(253,252,250,.5);border:1.5px dashed var(--cms-line-strong);border-radius:1rem;flex-direction:column;gap:.5rem;padding:3.5rem;text-align:center}.cms-empty.is-fill{flex:1;justify-content:center}.cms-empty-icon{color:var(--cms-fern);margin-bottom:.25rem}.cms-empty>.cms-btn{margin-top:.75rem}.cms-auth{align-items:center;display:flex;justify-content:center;min-height:100vh;padding:1.5rem}.cms-auth-panel{gap:2rem;max-width:24rem;width:100%}.cms-auth-header,.cms-auth-panel{display:flex;flex-direction:column}.cms-auth-header{align-items:center;gap:.75rem;text-align:center}.cms-auth-card{padding:1.75rem}.cms-navlink{align-items:center;border-radius:.75rem;color:var(--ui-text-toned);display:flex;font-size:.875rem;font-weight:500;gap:.625rem;padding:.5rem .75rem;transition:background-color .15s ease,color .15s ease}.cms-navlink:hover{background:rgba(38,33,25,.05);color:var(--ui-text-highlighted)}.cms-navlink.is-active{background:var(--cms-sage);color:var(--cms-sage-ink);font-weight:600}.cms-navlink-icon{color:var(--ui-text-dimmed)}.cms-navlink.is-active .cms-navlink-icon{color:var(--cms-fern)}.cms-badge{align-items:center;border:1px solid transparent;border-radius:9999px;display:inline-flex;font-family:Fragment Mono,ui-monospace,monospace;font-size:.6875rem;letter-spacing:.1em;padding:.1875rem .5rem;pointer-events:none;text-transform:uppercase;transition:background-color .15s ease,border-color .15s ease;white-space:nowrap}.cms-badge.is-published{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-badge.is-muted{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-muted)}.cms-badge.is-draft{background:transparent;border:1px dashed var(--cms-line-strong);color:var(--ui-text-muted)}.cms-dropzone{align-items:center;background:rgba(253,252,250,.5);border:1.5px dashed var(--cms-line-strong);border-radius:1rem;color:var(--ui-text-muted);cursor:pointer;display:flex;flex-direction:column;gap:.5rem;justify-content:center;padding:2rem 1.5rem;transition:border-color .15s ease,background-color .15s ease,color .15s ease;width:100%}.cms-dropzone.is-over,.cms-dropzone:hover{background:var(--cms-sage);border-color:var(--cms-fern);color:var(--cms-sage-ink)}.cms-dropzone.is-dense{border-radius:.75rem;flex-direction:row;gap:.625rem;padding:.875rem 1.25rem}.cms-dropzone.is-busy{opacity:.7;pointer-events:none}.cms-pill{align-items:center;background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:9999px;color:var(--ui-text-dimmed);cursor:pointer;display:inline-flex;flex:none;font-family:Fragment Mono,ui-monospace,monospace;font-size:.875rem;gap:.35rem;letter-spacing:.12em;padding:.3rem .8rem;transition:background-color .15s ease,color .15s ease;white-space:nowrap}.cms-pill:hover{background:rgba(38,33,25,.05);color:var(--ui-text)}.cms-pill.is-active{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-pill.is-empty{border-style:dashed}.cms-pill-group{align-items:center;display:inline-flex;gap:.125rem}.cms-pill-group .cms-pill{padding-right:.6rem}.cms-pill-discard{align-items:center;border-radius:9999px;color:var(--ui-text-dimmed);cursor:pointer;display:inline-flex;height:1.25rem;justify-content:center;transition:color .15s ease;width:1.25rem}.cms-pill-discard:hover{color:var(--ui-error)}.cms-folder-picker{align-items:center;display:flex;flex-wrap:wrap;gap:.375rem}.cms-folder-picker-input{width:11rem}.cms-form-hint{color:var(--ui-text-dimmed);font-size:.8125rem}.cms-dropzone-hint{align-items:center;display:inline-flex;font-family:Fragment Mono,ui-monospace,monospace;font-size:.75rem;gap:.3rem;letter-spacing:.08em;opacity:.7;text-transform:uppercase}@keyframes cms-rise{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}.cms-rise{animation:cms-rise .55s cubic-bezier(.22,1,.36,1) both}.cms-rise.is-delayed{animation-delay:90ms}@media (prefers-reduced-motion:reduce){.cms-rise{animation:none}}.cms-richtext{border:1px solid var(--cms-line);border-radius:.75rem;overflow:hidden}.cms-richtext-toolbar{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;flex-wrap:wrap;gap:2px;padding:4px}.cms-richtext-body{font-size:.875rem;min-height:9rem;outline:none;padding:.75rem 1rem}.cms-richtext-body h2{font-size:1.25rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body h3{font-size:1.1rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body p{margin:.35em 0}.cms-richtext-body ul{list-style:disc;padding-left:1.25rem}.cms-richtext-body ol{list-style:decimal;padding-left:1.25rem}.cms-richtext-body blockquote{border-left:3px solid var(--cms-line);color:var(--ui-text-muted);padding-left:.75rem}.cms-btn{align-items:center;border:1px solid transparent;border-radius:9999px;display:inline-flex;font-weight:500;gap:.375rem;justify-content:space-between;line-height:1;min-height:var(--cms-btn-size);transition:background-color .15s ease,border-color .15s ease,color .15s ease,opacity .15s ease;white-space:nowrap}.cms-btn:disabled,.cms-btn[aria-disabled=true]{opacity:.5;pointer-events:none}.cms-btn.is-block{width:100%}.cms-btn.is-icon{justify-content:center;padding-inline:0;width:var(--cms-btn-size)}.cms-btn-xs{--cms-btn-size:1.75rem;font-size:.875rem;padding:.25rem .75rem}.cms-btn-sm{--cms-btn-size:2rem;font-size:.875rem;padding:.25rem .875rem}.cms-btn-md{--cms-btn-size:2.25rem;font-size:.875rem;padding:.25rem 1.125rem}.cms-btn-lg{--cms-btn-size:2.625rem;font-size:.9375rem;padding:.375rem 1.375rem}.cms-btn-primary.cms-btn-solid{background:var(--ui-primary);color:var(--ui-text-inverted)}.cms-btn-primary.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-primary) 88%,#000)}.cms-btn-primary.cms-btn-subtle{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-btn-primary.cms-btn-subtle:hover{background:#e0e0e0}.cms-btn-primary.cms-btn-soft{background:var(--cms-sage);color:var(--cms-sage-ink)}.cms-btn-neutral.cms-btn-solid{background:var(--ui-bg-inverted);color:var(--ui-text-inverted)}.cms-btn-neutral.cms-btn-subtle{background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-subtle:hover{background:var(--ui-bg-accented)}.cms-btn-neutral.cms-btn-soft{background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-ghost{color:var(--ui-text-toned)}.cms-btn-neutral.cms-btn-ghost:hover{background:rgba(38,33,25,.06);color:var(--ui-text-highlighted)}.cms-btn-error.cms-btn-solid{background:var(--ui-error);color:#fff}.cms-btn-error.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-error) 88%,#000)}.cms-btn-error.cms-btn-subtle{background:color-mix(in oklab,var(--ui-error) 12%,transparent);color:var(--ui-error)}.cms-btn-error.cms-btn-ghost{color:var(--ui-error)}.cms-btn-error.cms-btn-ghost:hover{background:color-mix(in oklab,var(--ui-error) 10%,transparent)}.cms-btn-success.cms-btn-solid{background:var(--cms-fern);color:var(--ui-text-inverted)}.cms-form{gap:1.25rem}.cms-form,.cms-form-field{display:flex;flex-direction:column}.cms-form-field{gap:.375rem}.cms-form-label-row{align-items:center;display:flex;gap:.75rem;justify-content:space-between;min-height:1.75rem}.cms-form-label{color:var(--ui-text-toned);font-size:.875rem;font-weight:500}.cms-form-error,.cms-form-required{color:var(--ui-error)}.cms-form-error{font-size:.875rem}.cms-form-actions{align-items:center;display:flex;gap:.75rem;justify-content:flex-end;padding-top:.25rem}.cms-field{background:var(--ui-bg);border:1px solid var(--cms-line-strong);border-radius:var(--ui-radius);color:var(--ui-text-highlighted);line-height:1.25;transition:border-color .15s ease,box-shadow .15s ease;width:100%}textarea.cms-field{line-height:1.5;min-height:0;padding-bottom:.625rem;padding-top:.625rem;resize:vertical}.cms-field::-moz-placeholder{color:var(--ui-text-dimmed)}.cms-field::placeholder{color:var(--ui-text-dimmed)}.cms-field:focus,.cms-field:focus-within{border-color:var(--ui-primary);box-shadow:0 0 0 3px color-mix(in oklab,var(--ui-primary) 18%,transparent);outline:none}.cms-field.is-error{border-color:var(--ui-error)}.cms-field-sm{font-size:.875rem;min-height:2rem;padding:.25rem .625rem}.cms-field-md{font-size:.875rem;min-height:2.25rem;padding:.25rem .75rem}.cms-field-lg{font-size:.9375rem;min-height:2.625rem;padding:.375rem .875rem}.cms-select-chevron{align-items:center;cursor:pointer;display:inline-flex;transition:transform .18s ease,color .15s ease}.cms-select-chevron:hover{color:var(--ui-text-muted)}.cms-select-chevron.is-open{transform:rotate(180deg)}.cms-locale-switch{align-items:center;background:var(--cms-paper);border:1px solid var(--cms-line);border-radius:999px;display:inline-flex;gap:2px;padding:2px}.cms-locale-option{border-radius:999px;color:var(--ui-text-muted);cursor:pointer;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.6875rem;letter-spacing:.06em;line-height:1.4;padding:.125rem .5rem;text-transform:uppercase;transition:background-color .15s ease,color .15s ease}.cms-locale-option:hover:not(.is-active){color:var(--ui-text-highlighted)}.cms-locale-option.is-empty{color:var(--ui-text-dimmed)}.cms-locale-option.is-active{background:var(--ui-bg);box-shadow:0 1px 2px rgba(38,33,25,.1);color:var(--ui-text-highlighted)}.cms-input-wrap{align-items:center;display:flex;position:relative;width:100%}.cms-input-wrap .cms-field{width:100%}.cms-input-lead,.cms-input-trail{align-items:center;color:var(--ui-text-dimmed);display:flex;position:absolute}.cms-input-lead{left:.625rem}.cms-input-trail{right:.375rem}.cms-input-wrap.has-lead .cms-field{padding-left:2rem}.cms-switch{align-items:center;background:var(--cms-line-strong);border-radius:9999px;display:inline-flex;flex-shrink:0;height:1.25rem;position:relative;transition:background-color .15s ease;width:2.25rem}.cms-switch.is-on{background:var(--ui-primary)}.cms-switch:after{background:#fff;border-radius:9999px;content:"";height:.875rem;left:.1875rem;position:absolute;transition:transform .15s ease;width:.875rem}.cms-switch.is-on:after{transform:translateX(1rem)}.cms-overlay{align-items:center;backdrop-filter:blur(2px);background:rgba(38,33,25,.32);display:flex;inset:0;justify-content:center;overflow-y:auto;padding:2rem 1rem;position:fixed;z-index:60}.cms-modal{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:1rem;box-shadow:var(--cms-shadow-soft);display:flex;flex-direction:column;max-height:calc(100dvh - 4rem);max-width:32rem;width:100%}.cms-modal.is-sm{max-width:28rem}.cms-modal.is-lg{max-width:48rem}.cms-modal-header{padding:1.25rem 1.5rem 0}.cms-modal-body{flex:1;min-height:0;overflow-y:auto;padding:1.25rem 1.5rem 1.5rem}.cms-overlay-drawer{align-items:stretch;justify-content:flex-end;overflow:hidden;padding:0;z-index:55}.cms-drawer{background:var(--ui-bg);border-left:1px solid var(--cms-line);box-shadow:-18px 0 48px rgba(38,33,25,.12);display:flex;flex-direction:column;height:100%;max-width:46rem;width:100%}.cms-drawer-header{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-body{flex:1;min-height:0;overflow-y:auto;padding:1.75rem}.cms-drawer-footer{align-items:center;border-top:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-enter-active,.cms-drawer-leave-active{transition:opacity .3s ease}.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active .cms-drawer{transition:transform .36s cubic-bezier(.32,.72,0,1);will-change:transform}.cms-drawer-enter-from,.cms-drawer-leave-to{opacity:0}.cms-drawer-enter-from .cms-drawer,.cms-drawer-leave-to .cms-drawer{transform:translateX(100%)}@media (prefers-reduced-motion:reduce){.cms-drawer-enter-active,.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active,.cms-drawer-leave-active .cms-drawer{transition-duration:.01ms}}.cms-menu{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);margin-top:.375rem;min-width:12rem;padding:.25rem;position:absolute;z-index:50}.cms-menu.align-end{right:0}.cms-menu-item{align-items:center;border-radius:.5rem;color:var(--ui-text);cursor:pointer;display:flex;font-size:.875rem;gap:.5rem;padding:.4rem .6rem;text-align:left;width:100%}.cms-menu-item:hover{background:var(--ui-bg-elevated)}.cms-menu-check{color:var(--ui-primary);margin-left:auto}.cms-popover{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);margin-top:.375rem;position:absolute;z-index:50}.cms-selectmenu-input{background:transparent;color:var(--ui-text-highlighted);flex:1;min-width:4rem;outline:none}.cms-selectmenu-affix{color:var(--ui-text-dimmed);flex-shrink:0}.cms-selectmenu-panel{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);left:0;margin-top:.375rem;max-height:15rem;overflow-y:auto;padding:.25rem;position:absolute;right:0;z-index:50}.cms-tag{align-items:center;background:var(--ui-bg-elevated);border-radius:9999px;display:inline-flex;font-size:.875rem;gap:.25rem;padding:.125rem .5rem}.cms-media-gallery{display:flex;flex-direction:column;gap:1.25rem}.cms-media-filters{align-items:center;display:flex;flex-wrap:wrap;gap:.25rem}.cms-media-filters-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-media-filter-separator{background:var(--cms-line);flex:none;height:1rem;margin-inline:.25rem;width:1px}.cms-media-grid{display:grid;gap:1rem;grid-template-columns:repeat(2,minmax(0,1fr))}@media (min-width:640px){.cms-media-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.cms-media-grid:not(.is-compact){grid-template-columns:repeat(4,minmax(0,1fr))}}.cms-media-tile{overflow:hidden;text-align:left}.cms-media-tile.is-selectable{cursor:pointer}.cms-media-tile.is-selectable:hover{border-color:var(--cms-line-strong)}.cms-media-preview{align-items:center;aspect-ratio:1;background:var(--cms-field);color:var(--ui-text-dimmed);display:flex;justify-content:center;position:relative}.cms-media-preview>img,.cms-media-preview>video{height:100%;inset:0;-o-object-fit:cover;object-fit:cover;position:absolute;width:100%}.cms-media-actions{display:flex;gap:.25rem;opacity:0;position:absolute;right:.5rem;top:.5rem;transition:opacity .15s ease}.cms-media-actions:focus-within,.cms-media-tile:hover .cms-media-actions{opacity:1}.cms-media-info{background:linear-gradient(0deg,rgba(0,0,0,.7),transparent);bottom:0;color:#fff;font-family:Fragment Mono,ui-monospace,monospace;font-size:.75rem;left:0;letter-spacing:.1em;line-height:1.4;opacity:0;padding:1.5rem .75rem .5rem;pointer-events:none;position:absolute;right:0;text-transform:uppercase;transition:opacity .15s ease}.cms-media-tile:focus-visible .cms-media-info,.cms-media-tile:hover .cms-media-info{opacity:1}.cms-media-meta{display:flex;flex-direction:column;gap:.25rem;padding:.625rem .875rem}.cms-media-name{font-size:.875rem;font-weight:500}.cms-media-folder,.cms-media-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-folder{align-items:center;color:var(--ui-text-dimmed);display:inline-flex;font-size:.75rem;gap:.25rem}.cms-media-field{overflow:hidden}.cms-media-field-preview{align-items:center;background:repeating-conic-gradient(from 0deg,rgba(38,33,25,.04) 0 25%,transparent 0 50%) 50% /16px 16px;color:var(--ui-text-dimmed);display:flex;justify-content:center;max-height:16rem;min-height:8rem;padding:.75rem}.cms-media-field-preview>img,.cms-media-field-preview>video{max-height:14.5rem;max-width:100%;-o-object-fit:contain;object-fit:contain}.cms-media-field-bar{align-items:center;background:var(--ui-bg);border-top:1px solid var(--cms-line);display:flex;gap:.5rem;padding:.625rem .75rem}.cms-media-bar-icon{color:var(--ui-text-dimmed);flex-shrink:0}.cms-media-bar-name{flex:1;font-size:.875rem;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-thumb{background:var(--cms-field);border-radius:.375rem;height:2.25rem;-o-object-fit:cover;object-fit:cover;width:2.25rem}.cms-media-thumb.is-fallback{align-items:center;color:var(--ui-text-dimmed);display:flex;justify-content:center}.cms-media-thumb-empty{color:var(--ui-text-dimmed)}.cms-table-shell{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.cms-table-scroll{flex:1;min-height:0;overflow:auto}.cms-table{border-collapse:collapse;min-width:-moz-max-content;min-width:max-content;text-align:left;width:100%}.cms-table td,.cms-table th{white-space:nowrap}.cms-table thead{background:var(--cms-surface);position:sticky;top:0;z-index:10}.cms-table th{box-shadow:inset 0 -1px 0 var(--cms-line);color:var(--ui-text-dimmed);font-family:Fragment Mono,ui-monospace,monospace;font-size:.6875rem;font-weight:400;letter-spacing:.1em;line-height:1.4;padding:.75rem 1.25rem;text-transform:uppercase}.cms-table td{color:var(--ui-text);font-size:.875rem;padding:.875rem 1.25rem}.cms-table tbody tr{border-bottom:1px solid var(--cms-line)}.cms-table tbody tr:last-child{border-bottom:0}.cms-table tbody tr:hover{background:color-mix(in oklab,var(--cms-paper) 50%,transparent)}.cms-table-cell-actions{display:flex;justify-content:flex-end}.cms-table th[draggable=true]{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.cms-table th[draggable=true]:active{cursor:grabbing}.cms-table th.is-dragging{opacity:.4}.cms-table th.is-drop-target{background:var(--cms-paper);box-shadow:inset 0 -1px 0 var(--cms-line),inset 0 0 0 2px var(--cms-line-strong)}.cms-th-label{align-items:center;display:inline-flex;gap:.35rem}.cms-table th.is-sortable{cursor:pointer}.cms-table th.is-sortable:hover,.cms-table th.is-sorted{color:var(--ui-text)}.cms-th-sort{flex:none;opacity:0;transition:opacity .15s ease}.cms-table th.is-sortable:hover .cms-th-sort,.cms-table th.is-sorted .cms-th-sort{opacity:1}.cms-alert{border-radius:.75rem;font-size:.875rem;padding:.75rem 1rem}.cms-alert-error{background:color-mix(in oklab,var(--ui-error) 10%,transparent);color:var(--ui-error)}.cms-toaster{bottom:1rem;display:flex;flex-direction:column;gap:.5rem;max-width:calc(100vw - 2rem);position:fixed;right:1rem;width:20rem;z-index:80}.cms-toast{background:var(--ui-bg);border:1px solid var(--cms-line);border-radius:.75rem;box-shadow:var(--cms-shadow-soft);padding:.75rem .875rem}.cms-toast-row{align-items:flex-start;display:flex;gap:.75rem}.cms-toast-title{color:var(--ui-text-highlighted);font-size:.875rem;font-weight:500}.cms-toast-description{color:var(--ui-text-muted);font-size:.875rem;margin-top:.125rem}.cms-toast-dismiss{color:var(--ui-text-dimmed);flex-shrink:0}.cms-toast-dismiss:hover{color:var(--ui-text)}.cms-toast.is-success{border-left:3px solid var(--cms-fern)}.cms-toast.is-error{border-left:3px solid var(--ui-error)}.cms-pagination{align-items:center;display:flex;gap:.25rem;justify-content:center}.cms-page-btn{border-radius:.5rem;color:var(--ui-text-toned);font-size:.875rem;height:2rem;min-width:2rem;padding:0 .5rem}.cms-page-btn:hover:not(:disabled){background:var(--ui-bg-elevated)}.cms-page-btn.is-active{background:var(--cms-sage);color:var(--cms-sage-ink);font-weight:600}.cms-page-btn:disabled{opacity:.4}
@@ -1,4 +1,4 @@
1
- import { count, desc, sql } from "drizzle-orm";
1
+ import { asc, count, desc, sql } from "drizzle-orm";
2
2
  import { defineEventHandler, getValidatedQuery } from "h3";
3
3
  import { z } from "zod";
4
4
  import { useDb } from "#cms-db";
@@ -9,6 +9,8 @@ const querySchema = z.object({
9
9
  limit: z.coerce.number().int().min(1).max(100).default(50),
10
10
  offset: z.coerce.number().int().min(0).default(0),
11
11
  search: z.string().trim().max(200).optional(),
12
+ sort: z.string().trim().max(64).optional(),
13
+ order: z.enum(["asc", "desc"]).default("desc"),
12
14
  light: z.stringbool().default(false)
13
15
  });
14
16
  function likePattern(term) {
@@ -23,7 +25,10 @@ export default defineEventHandler(async (event) => {
23
25
  await attachManyToMany(db, name, entry, rows);
24
26
  return rows[0] ?? null;
25
27
  }
26
- const { limit, offset, search, light } = await getValidatedQuery(event, querySchema.parse);
28
+ const { limit, offset, search, sort, order, light } = await getValidatedQuery(
29
+ event,
30
+ querySchema.parse
31
+ );
27
32
  const columns = tableColumns(table);
28
33
  const titleColumn = entry.titleField && Object.hasOwn(columns, entry.titleField) ? columns[entry.titleField] : void 0;
29
34
  let where;
@@ -36,9 +41,14 @@ export default defineEventHandler(async (event) => {
36
41
  ...entry.titleField && titleColumn ? [[entry.titleField, titleColumn]] : [],
37
42
  ...entry.drafts && Object.hasOwn(columns, "status") ? [["status", columns.status]] : []
38
43
  ]) : void 0;
39
- const orderBy = columns.createdAt ?? idColumn(table);
44
+ const sortColumn = sort && Object.hasOwn(columns, sort) ? columns[sort] : void 0;
45
+ const orderBy = sortColumn ?? columns.createdAt ?? idColumn(table);
46
+ const direction = sortColumn && order === "asc" ? asc : desc;
47
+ const tiebreaker = idColumn(table);
40
48
  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);
49
+ const items = await (where ? base.where(where) : base).orderBy(
50
+ ...orderBy === tiebreaker ? [direction(orderBy)] : [direction(orderBy), desc(tiebreaker)]
51
+ ).limit(limit).offset(offset);
42
52
  const [counted] = await (where ? db.select({ total: count() }).from(table).where(where) : db.select({ total: count() }).from(table));
43
53
  if (light) return { items, total: counted?.total ?? 0, relations: {} };
44
54
  await attachManyToMany(db, name, entry, items);
@@ -1,5 +1,6 @@
1
1
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
2
  key: string;
3
+ folder: string | null;
3
4
  uploadUrl: string;
4
5
  method: string;
5
6
  headers: {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createError, defineEventHandler, readValidatedBody } from "h3";
3
3
  import { z } from "zod";
4
- import { slugify } from "../../shared/index.js";
4
+ import { normalizeMediaFolder, slugify } from "../../shared/index.js";
5
5
  import { assertUploadContentType, useMediaStorage } from "../utils/media.js";
6
6
  import { requireAdmin } from "../utils/require-admin.js";
7
7
  const MAX_BASE_LENGTH = 80;
@@ -10,7 +10,8 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
10
10
  const bodySchema = z.object({
11
11
  filename: z.string().trim().min(1).max(255),
12
12
  contentType: z.string().regex(/^[-\w.+]+\/[-\w.+]+$/, "Invalid content type"),
13
- size: z.number().int().positive()
13
+ size: z.number().int().positive(),
14
+ folder: z.string().max(255).nullish()
14
15
  });
15
16
  function slugifyFilename(filename) {
16
17
  const dot = filename.lastIndexOf(".");
@@ -21,7 +22,7 @@ function slugifyFilename(filename) {
21
22
  export default defineEventHandler(async (event) => {
22
23
  await requireAdmin(event);
23
24
  const { media, client, bucketUrl, publicUrl } = useMediaStorage(event);
24
- const { filename, contentType, size } = await readValidatedBody(event, bodySchema.parse);
25
+ const { filename, contentType, size, folder } = await readValidatedBody(event, bodySchema.parse);
25
26
  assertUploadContentType(contentType);
26
27
  if (size > MAX_FILE_SIZE) {
27
28
  throw createError({
@@ -30,7 +31,8 @@ export default defineEventHandler(async (event) => {
30
31
  });
31
32
  }
32
33
  const now = /* @__PURE__ */ new Date();
33
- const prefix = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
34
+ const normalizedFolder = normalizeMediaFolder(folder);
35
+ const prefix = normalizedFolder ?? `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
34
36
  const key = `${prefix}/${randomUUID()}-${slugifyFilename(filename)}`;
35
37
  const url = new URL(`${bucketUrl}/${key}`);
36
38
  url.searchParams.set("X-Amz-Expires", String(media.presignExpiry));
@@ -43,6 +45,7 @@ export default defineEventHandler(async (event) => {
43
45
  );
44
46
  return {
45
47
  key,
48
+ folder: normalizedFolder,
46
49
  uploadUrl: signed.url,
47
50
  method: "PUT",
48
51
  headers: { "content-type": contentType },
@@ -2,6 +2,7 @@ import { defineEventHandler, readValidatedBody } from "h3";
2
2
  import { z } from "zod";
3
3
  import { useDb } from "#cms-db";
4
4
  import { cms_media } from "#cms-tables";
5
+ import { normalizeMediaFolder } from "../../shared/index.js";
5
6
  import { objectKeySchema } from "../../shared/validation.js";
6
7
  import {
7
8
  assertMediaWritable,
@@ -32,7 +33,7 @@ export default defineEventHandler(async (event) => {
32
33
  width: body.width ?? null,
33
34
  height: body.height ?? null,
34
35
  alt: body.alt ?? null,
35
- folder: body.folder ?? null
36
+ folder: normalizeMediaFolder(body.folder)
36
37
  };
37
38
  const [row] = await useDb().insert(cms_media).values(values).onConflictDoUpdate({ target: cms_media.key, set: values }).returning();
38
39
  return toMediaItem(row, publicUrl);
@@ -3,6 +3,7 @@ import { createError, defineEventHandler, readValidatedBody } from "h3";
3
3
  import { z } from "zod";
4
4
  import { useDb } from "#cms-db";
5
5
  import { cms_media } from "#cms-tables";
6
+ import { normalizeMediaFolder } from "../../shared/index.js";
6
7
  import { assertMediaWritable, toMediaItem, useMediaConfig } from "../utils/media.js";
7
8
  import { requireAdmin } from "../utils/require-admin.js";
8
9
  const bodySchema = z.object({
@@ -17,7 +18,7 @@ export default defineEventHandler(async (event) => {
17
18
  const body = await readValidatedBody(event, bodySchema.parse);
18
19
  const updates = {};
19
20
  if (body.alt !== void 0) updates.alt = body.alt;
20
- if (body.folder !== void 0) updates.folder = body.folder;
21
+ if (body.folder !== void 0) updates.folder = normalizeMediaFolder(body.folder);
21
22
  if (Object.keys(updates).length === 0) {
22
23
  throw createError({ statusCode: 400, statusMessage: "No fields to update" });
23
24
  }
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -0,0 +1,41 @@
1
+ import { existsSync } from "node:fs";
2
+ import { eq, inArray } from "drizzle-orm";
3
+ import { useDb } from "#cms-db";
4
+ import { cms_media } from "#cms-tables";
5
+ import { useRuntimeConfig } from "#imports";
6
+ import { chunked, planMediaSync, readMediaFileMeta, scanMediaDirectory } from "../utils/media-sync.js";
7
+ const CHUNK_SIZE = 100;
8
+ export default async () => {
9
+ try {
10
+ const { media } = useRuntimeConfig().cms;
11
+ if (media?.storage !== "local") return;
12
+ const root = media.localRoot;
13
+ if (!root) return;
14
+ if (!existsSync(root)) {
15
+ console.info(
16
+ `[nuxt-cms] Local media folder not found: ${root}. Skipping media library sync.`
17
+ );
18
+ return;
19
+ }
20
+ const files = await scanMediaDirectory(root);
21
+ const db = useDb();
22
+ const rows = await db.select({ key: cms_media.key, size: cms_media.size }).from(cms_media);
23
+ const { insert, update, remove } = planMediaSync(files, rows);
24
+ for (const chunk of chunked(insert, CHUNK_SIZE)) {
25
+ const values = await Promise.all(chunk.map((file) => readMediaFileMeta(root, file)));
26
+ await db.insert(cms_media).values(values.map((value) => ({ ...value, alt: null }))).onConflictDoNothing();
27
+ }
28
+ for (const file of update) {
29
+ const { mime, size, width, height } = await readMediaFileMeta(root, file);
30
+ await db.update(cms_media).set({ mime, size, width, height }).where(eq(cms_media.key, file.key));
31
+ }
32
+ for (const chunk of chunked(remove, CHUNK_SIZE)) {
33
+ await db.delete(cms_media).where(inArray(cms_media.key, chunk));
34
+ }
35
+ console.info(
36
+ `[nuxt-cms] Local media sync: ${insert.length} added, ${remove.length} removed, ${update.length} updated (${files.length} file${files.length === 1 ? "" : "s"} in ${root})`
37
+ );
38
+ } catch (error) {
39
+ console.warn("[nuxt-cms] Local media sync failed:", error);
40
+ }
41
+ };
@@ -0,0 +1,39 @@
1
+ export interface ScannedMediaFile {
2
+ key: string;
3
+ size: number;
4
+ }
5
+ export interface MediaSyncRow {
6
+ key: string;
7
+ size: number | null;
8
+ }
9
+ export interface MediaSyncPlan {
10
+ insert: ScannedMediaFile[];
11
+ update: ScannedMediaFile[];
12
+ remove: string[];
13
+ }
14
+ export interface MediaFileMeta {
15
+ key: string;
16
+ folder: string | null;
17
+ mime: string | null;
18
+ size: number;
19
+ width: number | null;
20
+ height: number | null;
21
+ }
22
+ export interface ImageSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+ export declare const MEDIA_SYNC_MIME_TYPES: Record<string, string>;
27
+ export declare function mediaSyncExtension(key: string): string | null;
28
+ export declare function isSyncableMediaKey(key: string): boolean;
29
+ export declare function mediaMimeForKey(key: string): string | null;
30
+ export declare function mediaSyncFolder(key: string): string | null;
31
+ export declare function pngImageSize(bytes: Uint8Array): ImageSize | null;
32
+ export declare function jpegImageSize(bytes: Uint8Array): ImageSize | null;
33
+ export declare function webpImageSize(bytes: Uint8Array): ImageSize | null;
34
+ export declare function gifImageSize(bytes: Uint8Array): ImageSize | null;
35
+ export declare function imageSizeFromBuffer(bytes: Uint8Array): ImageSize | null;
36
+ export declare function scanMediaDirectory(root: string): Promise<ScannedMediaFile[]>;
37
+ export declare function planMediaSync(files: ScannedMediaFile[], rows: MediaSyncRow[]): MediaSyncPlan;
38
+ export declare function readMediaFileMeta(root: string, file: ScannedMediaFile): Promise<MediaFileMeta>;
39
+ export declare function chunked<T>(items: T[], size: number): T[][];