@xleddyl/nuxt-cms 0.1.26 → 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.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.1.26",
4
+ "version": "0.1.27",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -1,31 +1,33 @@
1
1
  <template>
2
- <div v-if="model" class="cms-card cms-media-tile">
3
- <img v-if="kind === 'image' && url" :src="url" alt="" class="cms-media-preview-large" />
4
- <video
5
- v-else-if="kind === 'video' && url"
6
- :src="url"
7
- controls
8
- preload="metadata"
9
- playsinline
10
- class="cms-media-preview-large"
11
- />
12
- <div class="cms-media-bar">
2
+ <div v-if="model" class="cms-card cms-media-field">
3
+ <div class="cms-media-field-preview">
4
+ <img v-if="kind === 'image' && url" :src="url" alt="" />
5
+ <video
6
+ v-else-if="kind === 'video' && url"
7
+ :src="url"
8
+ controls
9
+ preload="metadata"
10
+ playsinline
11
+ />
12
+ <CmsIcon v-else :name="icon" class="size-10" />
13
+ </div>
14
+ <div class="cms-media-field-bar">
13
15
  <CmsIcon :name="icon" class="cms-media-bar-icon size-4" />
14
16
  <span class="cms-media-bar-name" :title="model">{{ mediaFilename(model) }}</span>
15
17
  <CmsButton
16
- icon="photo"
17
- variant="ghost"
18
+ label="Replace"
19
+ icon="arrow-path"
20
+ variant="subtle"
18
21
  color="neutral"
19
22
  size="xs"
20
- aria-label="Change"
21
23
  @click="openGallery"
22
24
  />
23
25
  <CmsButton
24
- icon="x-mark"
25
- variant="ghost"
26
- color="neutral"
26
+ label="Remove"
27
+ icon="trash"
28
+ variant="subtle"
29
+ color="error"
27
30
  size="xs"
28
- aria-label="Remove"
29
31
  @click="clear"
30
32
  />
31
33
  </div>
@@ -0,0 +1,16 @@
1
+ type __VLS_Props = {
2
+ folders: string[];
3
+ };
4
+ type __VLS_ModelProps = {
5
+ modelValue?: string | null;
6
+ };
7
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
8
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ "update:modelValue": (value: string | null) => any;
10
+ create: (folder: string) => any;
11
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
12
+ "onUpdate:modelValue"?: ((value: string | null) => any) | undefined;
13
+ onCreate?: ((folder: string) => any) | undefined;
14
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
@@ -0,0 +1,65 @@
1
+ <template>
2
+ <div class="cms-folder-picker">
3
+ <button
4
+ type="button"
5
+ class="cms-pill"
6
+ :class="{ 'is-active': model === null }"
7
+ @click="model = null"
8
+ >
9
+ <CmsIcon name="circle-stack" class="size-3" />Root
10
+ </button>
11
+ <button
12
+ v-for="name in folders"
13
+ :key="name"
14
+ type="button"
15
+ class="cms-pill"
16
+ :class="{ 'is-active': model === name }"
17
+ @click="model = name"
18
+ >
19
+ <CmsIcon name="folder" class="size-3" />{{ name }}
20
+ </button>
21
+ <template v-if="creating">
22
+ <CmsInput
23
+ v-model="draft"
24
+ size="sm"
25
+ autofocus
26
+ placeholder="Folder name"
27
+ class="cms-folder-picker-input"
28
+ @keydown.enter.prevent="commit"
29
+ @keydown.esc="cancel"
30
+ />
31
+ <CmsButton label="Add" size="xs" :disabled="!normalized" @click="commit" />
32
+ <CmsButton label="Cancel" size="xs" variant="ghost" color="neutral" @click="cancel" />
33
+ </template>
34
+ <button v-else type="button" class="cms-pill" @click="startCreating">
35
+ <CmsIcon name="folder-plus" class="size-3" />New folder
36
+ </button>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup>
41
+ import { computed, ref } from "#imports";
42
+ import { normalizeMediaFolder } from "#nuxt-cms";
43
+ defineProps({
44
+ folders: { type: Array, required: true }
45
+ });
46
+ const emit = defineEmits(["create"]);
47
+ const model = defineModel({ type: [String, null], ...{ default: null } });
48
+ const creating = ref(false);
49
+ const draft = ref("");
50
+ const normalized = computed(() => normalizeMediaFolder(draft.value));
51
+ function startCreating() {
52
+ creating.value = true;
53
+ }
54
+ function cancel() {
55
+ creating.value = false;
56
+ draft.value = "";
57
+ }
58
+ function commit() {
59
+ const folder = normalized.value;
60
+ if (!folder) return;
61
+ emit("create", folder);
62
+ model.value = folder;
63
+ cancel();
64
+ }
65
+ </script>
@@ -0,0 +1,16 @@
1
+ type __VLS_Props = {
2
+ folders: string[];
3
+ };
4
+ type __VLS_ModelProps = {
5
+ modelValue?: string | null;
6
+ };
7
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
8
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ "update:modelValue": (value: string | null) => any;
10
+ create: (folder: string) => any;
11
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
12
+ "onUpdate:modelValue"?: ((value: string | null) => any) | undefined;
13
+ onCreate?: ((folder: string) => any) | undefined;
14
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
@@ -7,39 +7,58 @@
7
7
  />
8
8
 
9
9
  <div v-else class="cms-media-gallery">
10
- <CmsMediaUpload
11
- v-if="!readOnly"
12
- :multiple="!selectable"
13
- :dense="selectable"
14
- :media-type="mediaType"
15
- :accept="accept"
16
- @uploaded="onUploaded"
17
- />
10
+ <CmsInput v-model="search" icon="magnifying-glass" placeholder="Search media…" />
18
11
 
19
- <div v-if="!restricted && items.length" class="cms-media-filters">
20
- <button
21
- v-for="f in filters"
22
- :key="f"
23
- type="button"
24
- class="cms-pill"
25
- :class="{ 'is-active': filter === f }"
26
- @click="filter = f"
27
- >
28
- {{ filterLabels[f] }}
29
- </button>
30
- <template v-if="folders.length">
31
- <span class="cms-media-filter-separator" />
12
+ <div v-if="showTypeFilters || folderChips.length || !readOnly" class="cms-media-filters">
13
+ <template v-if="showTypeFilters">
32
14
  <button
33
- v-for="f in folders"
15
+ v-for="f in filters"
34
16
  :key="f"
35
17
  type="button"
36
18
  class="cms-pill"
37
- :class="{ 'is-active': folder === f }"
38
- @click="folder = folder === f ? null : f"
19
+ :class="{ 'is-active': filter === f }"
20
+ @click="filter = f"
39
21
  >
40
- <CmsIcon name="folder" class="size-3" />{{ f }}
22
+ {{ filterLabels[f] }}
41
23
  </button>
42
24
  </template>
25
+ <template v-if="folderChips.length">
26
+ <span v-if="showTypeFilters" class="cms-media-filter-separator" />
27
+ <span
28
+ v-for="chip in folderChips"
29
+ :key="chip.name"
30
+ class="cms-pill-group"
31
+ :class="{ 'is-active': folder === chip.name }"
32
+ >
33
+ <button
34
+ type="button"
35
+ class="cms-pill"
36
+ :class="{ 'is-active': folder === chip.name, 'is-empty': chip.empty }"
37
+ @click="folder = folder === chip.name ? null : chip.name"
38
+ >
39
+ <CmsIcon name="folder" class="size-3" />{{ chip.name }}
40
+ </button>
41
+ <button
42
+ v-if="chip.empty && !readOnly"
43
+ type="button"
44
+ class="cms-pill-discard"
45
+ aria-label="Discard empty folder"
46
+ @click="discardFolder(chip.name)"
47
+ >
48
+ <CmsIcon name="x-mark" class="size-3" />
49
+ </button>
50
+ </span>
51
+ </template>
52
+ <div v-if="!readOnly" class="cms-media-filters-actions">
53
+ <CmsButton
54
+ label="New folder"
55
+ icon="folder-plus"
56
+ variant="subtle"
57
+ size="sm"
58
+ @click="openNewFolder"
59
+ />
60
+ <CmsButton icon="plus" size="sm" aria-label="Upload media" @click="uploadOpen = true" />
61
+ </div>
43
62
  </div>
44
63
 
45
64
  <CmsSpinner v-if="loading" />
@@ -50,9 +69,12 @@
50
69
  v-for="item in paged"
51
70
  :key="item.key"
52
71
  :type="selectable ? 'button' : void 0"
72
+ :role="!selectable && !readOnly ? 'button' : void 0"
73
+ :tabindex="!selectable && !readOnly ? 0 : void 0"
53
74
  class="cms-card cms-media-tile"
54
- :class="{ 'is-selectable': selectable }"
55
- @click="selectable && emit('select', { key: item.key, url: item.url })"
75
+ :class="{ 'is-selectable': selectable || !readOnly }"
76
+ @click="onTileClick(item)"
77
+ @keydown.enter="!selectable && !readOnly && openEdit(item)"
56
78
  >
57
79
  <div class="cms-media-preview">
58
80
  <img
@@ -69,24 +91,14 @@
69
91
  playsinline
70
92
  />
71
93
  <CmsIcon v-else :name="mediaIconFor(item.type)" class="size-8" />
72
- <div v-if="!selectable" class="cms-media-actions">
73
- <CmsButton
74
- v-if="!readOnly"
75
- icon="pencil-square"
76
- size="xs"
77
- color="neutral"
78
- variant="solid"
79
- aria-label="Edit details"
80
- @click="openEdit(item)"
81
- />
94
+ <div v-if="!selectable && !readOnly" class="cms-media-actions">
82
95
  <CmsButton
83
- v-if="!readOnly"
84
96
  icon="trash"
85
97
  size="xs"
86
98
  color="error"
87
99
  variant="solid"
88
100
  aria-label="Delete"
89
- @click="remove(item)"
101
+ @click.stop="remove(item)"
90
102
  />
91
103
  </div>
92
104
  <div class="cms-media-info">
@@ -100,6 +112,9 @@
100
112
  <div class="cms-media-name" :title="item.key">
101
113
  {{ mediaFilename(item.key) }}
102
114
  </div>
115
+ <div v-if="item.folder" class="cms-media-folder">
116
+ <CmsIcon name="folder" class="size-3" />{{ item.folder }}
117
+ </div>
103
118
  </div>
104
119
  </component>
105
120
  </div>
@@ -108,12 +123,27 @@
108
123
  <CmsButton label="Retry" icon="arrow-path" variant="subtle" @click="reload" />
109
124
  </CmsEmptyState>
110
125
 
126
+ <CmsEmptyState
127
+ v-else-if="items.length"
128
+ icon="magnifying-glass"
129
+ title="No matching media"
130
+ body="Try a different search or filter."
131
+ />
132
+
111
133
  <CmsEmptyState
112
134
  v-else
113
135
  icon="photo"
114
136
  :title="readOnly ? 'No media' : 'No media yet'"
115
137
  :body="readOnly ? 'No media registered.' : 'Files you upload will show up here.'"
116
- />
138
+ >
139
+ <CmsButton
140
+ v-if="!readOnly"
141
+ label="Upload"
142
+ icon="plus"
143
+ variant="subtle"
144
+ @click="uploadOpen = true"
145
+ />
146
+ </CmsEmptyState>
117
147
 
118
148
  <CmsPagination
119
149
  v-if="!loading && visible.length"
@@ -122,11 +152,60 @@
122
152
  :items-per-page="PAGE_SIZE"
123
153
  />
124
154
 
155
+ <CmsModal v-if="!readOnly" v-model:open="uploadOpen" title="Upload media">
156
+ <template #body>
157
+ <div class="cms-form">
158
+ <CmsFormField label="Destination folder">
159
+ <CmsMediaFolderPicker
160
+ v-model="uploadFolder"
161
+ :folders="folderNames"
162
+ @create="registerFolder"
163
+ />
164
+ </CmsFormField>
165
+ <CmsMediaUpload
166
+ :multiple="!selectable"
167
+ :media-type="mediaType"
168
+ :accept="accept"
169
+ :folder="uploadFolder"
170
+ @uploaded="onUploaded"
171
+ />
172
+ </div>
173
+ </template>
174
+ </CmsModal>
175
+
176
+ <CmsModal v-if="!readOnly" v-model:open="newFolderOpen" title="New folder" size="sm">
177
+ <template #body>
178
+ <div class="cms-form">
179
+ <CmsFormField label="Name">
180
+ <CmsInput
181
+ v-model="newFolderName"
182
+ autofocus
183
+ placeholder="e.g. blog/covers"
184
+ @keydown.enter.prevent="createFolder"
185
+ />
186
+ <p class="cms-form-hint">
187
+ {{
188
+ newFolderSlug ? `Files uploaded here are stored under ${newFolderSlug}/` : "Letters, numbers and dashes. Use / for nesting."
189
+ }}
190
+ </p>
191
+ </CmsFormField>
192
+ <div class="cms-actions is-end">
193
+ <CmsButton
194
+ label="Cancel"
195
+ variant="ghost"
196
+ color="neutral"
197
+ @click="newFolderOpen = false"
198
+ />
199
+ <CmsButton label="Create" :disabled="!newFolderSlug" @click="createFolder" />
200
+ </div>
201
+ </div>
202
+ </template>
203
+ </CmsModal>
204
+
125
205
  <CmsModal
126
206
  v-if="!readOnly"
127
207
  :open="!!editing"
128
208
  :title="editing ? mediaFilename(editing.key) : ''"
129
- size="lg"
130
209
  @update:open="
131
210
  (open) => {
132
211
  if (!open) editing = null;
@@ -139,9 +218,19 @@
139
218
  <CmsInput v-model="editAlt" />
140
219
  </CmsFormField>
141
220
  <CmsFormField label="Folder">
142
- <CmsInput v-model="editFolder" />
221
+ <CmsMediaFolderPicker
222
+ v-model="editFolder"
223
+ :folders="folderNames"
224
+ @create="registerFolder"
225
+ />
143
226
  </CmsFormField>
144
227
  <div class="cms-actions is-end">
228
+ <CmsButton
229
+ label="Cancel"
230
+ variant="ghost"
231
+ color="neutral"
232
+ @click="editing = null"
233
+ />
145
234
  <CmsButton label="Save" :loading="editSaving" @click="saveEdit" />
146
235
  </div>
147
236
  </div>
@@ -152,7 +241,7 @@
152
241
 
153
242
  <script setup>
154
243
  import { computed, onMounted, ref, watch } from "#imports";
155
- import { MEDIA_TYPES, mediaFilename, mediaIconFor } from "#nuxt-cms";
244
+ import { MEDIA_TYPES, mediaFilename, mediaIconFor, normalizeMediaFolder } from "#nuxt-cms";
156
245
  import { useCmsConfirm } from "../../composables/cms-confirm";
157
246
  import { useCmsRuntime } from "../../composables/cms-runtime";
158
247
  import { useCmsToast } from "../../composables/cms-toast";
@@ -173,6 +262,7 @@ const toast = useCmsToast();
173
262
  const runtime = useCmsRuntime();
174
263
  const readOnly = computed(() => runtime.mediaStorage === "local");
175
264
  const endpoint = "/api/cms/admin/media";
265
+ const DRAFT_FOLDERS_KEY = "nuxt-cms:media-folders";
176
266
  const items = ref([]);
177
267
  const loading = ref(true);
178
268
  const errorCode = ref(null);
@@ -196,20 +286,67 @@ const restricted = computed(
196
286
  );
197
287
  const filters = ["all", ...MEDIA_TYPES];
198
288
  const filter = ref("all");
199
- const folders = computed(() => {
289
+ const showTypeFilters = computed(() => !restricted.value && items.value.length > 0);
290
+ const search = ref("");
291
+ const draftFolders = ref([]);
292
+ onMounted(() => {
293
+ try {
294
+ const stored = JSON.parse(localStorage.getItem(DRAFT_FOLDERS_KEY) ?? "[]");
295
+ if (Array.isArray(stored)) draftFolders.value = stored.filter((f) => typeof f === "string");
296
+ } catch {
297
+ draftFolders.value = [];
298
+ }
299
+ });
300
+ function persistDraftFolders() {
301
+ try {
302
+ localStorage.setItem(DRAFT_FOLDERS_KEY, JSON.stringify(draftFolders.value));
303
+ } catch {
304
+ }
305
+ }
306
+ const usedFolders = computed(() => {
200
307
  const set = new Set(items.value.map((item) => item.folder).filter((f) => !!f));
201
308
  return [...set].sort();
202
309
  });
310
+ const folderNames = computed(
311
+ () => [.../* @__PURE__ */ new Set([...usedFolders.value, ...draftFolders.value])].sort()
312
+ );
313
+ const folderChips = computed(
314
+ () => folderNames.value.map((name) => ({ name, empty: !usedFolders.value.includes(name) }))
315
+ );
203
316
  const folder = ref(null);
317
+ function registerFolder(name) {
318
+ if (!draftFolders.value.includes(name) && !usedFolders.value.includes(name)) {
319
+ draftFolders.value = [...draftFolders.value, name];
320
+ persistDraftFolders();
321
+ }
322
+ }
323
+ function discardFolder(name) {
324
+ draftFolders.value = draftFolders.value.filter((f) => f !== name);
325
+ persistDraftFolders();
326
+ if (folder.value === name) folder.value = null;
327
+ }
328
+ watch(usedFolders, (used) => {
329
+ const next = draftFolders.value.filter((f) => !used.includes(f));
330
+ if (next.length !== draftFolders.value.length) {
331
+ draftFolders.value = next;
332
+ persistDraftFolders();
333
+ }
334
+ });
204
335
  const visible = computed(() => {
205
336
  const active = restricted.value ?? (filter.value === "all" ? null : filter.value);
206
337
  let list = active ? items.value.filter((item) => item.type === active) : items.value;
207
338
  if (folder.value) list = list.filter((item) => item.folder === folder.value);
339
+ const query = search.value.trim().toLowerCase();
340
+ if (query) {
341
+ list = list.filter(
342
+ (item) => [item.key, item.alt, item.folder].some((value) => value?.toLowerCase().includes(query))
343
+ );
344
+ }
208
345
  return list;
209
346
  });
210
347
  const PAGE_SIZE = 24;
211
348
  const page = ref(1);
212
- watch([filter, folder], () => {
349
+ watch([filter, folder, search], () => {
213
350
  page.value = 1;
214
351
  });
215
352
  watch(visible, (list) => {
@@ -219,21 +356,48 @@ watch(visible, (list) => {
219
356
  const paged = computed(
220
357
  () => visible.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
221
358
  );
359
+ const uploadOpen = ref(false);
360
+ const uploadFolder = ref(null);
361
+ watch(uploadOpen, (open) => {
362
+ if (open) uploadFolder.value = folder.value;
363
+ });
222
364
  function onUploaded(uploaded) {
365
+ uploadOpen.value = false;
223
366
  if (props.selectable && uploaded[0]) {
224
367
  emit("select", uploaded[0]);
225
368
  return;
226
369
  }
227
370
  void reload();
228
371
  }
372
+ const newFolderOpen = ref(false);
373
+ const newFolderName = ref("");
374
+ const newFolderSlug = computed(() => normalizeMediaFolder(newFolderName.value));
375
+ function openNewFolder() {
376
+ newFolderName.value = "";
377
+ newFolderOpen.value = true;
378
+ }
379
+ function createFolder() {
380
+ const name = newFolderSlug.value;
381
+ if (!name) return;
382
+ registerFolder(name);
383
+ folder.value = name;
384
+ newFolderOpen.value = false;
385
+ }
229
386
  const editing = ref(null);
230
387
  const editAlt = ref("");
231
- const editFolder = ref("");
388
+ const editFolder = ref(null);
232
389
  const editSaving = ref(false);
390
+ function onTileClick(item) {
391
+ if (props.selectable) {
392
+ emit("select", { key: item.key, url: item.url });
393
+ return;
394
+ }
395
+ if (!readOnly.value) openEdit(item);
396
+ }
233
397
  function openEdit(item) {
234
398
  editing.value = item;
235
399
  editAlt.value = item.alt ?? "";
236
- editFolder.value = item.folder ?? "";
400
+ editFolder.value = item.folder ?? null;
237
401
  }
238
402
  async function saveEdit() {
239
403
  if (!editing.value) return;
@@ -244,7 +408,7 @@ async function saveEdit() {
244
408
  body: {
245
409
  id: editing.value.id,
246
410
  alt: editAlt.value.trim() || null,
247
- folder: editFolder.value.trim() || null
411
+ folder: editFolder.value
248
412
  }
249
413
  });
250
414
  editing.value = null;
@@ -3,7 +3,7 @@ type __VLS_Props = {
3
3
  multiple?: boolean;
4
4
  mediaType?: MediaType;
5
5
  accept?: string[];
6
- dense?: boolean;
6
+ folder?: string | null;
7
7
  };
8
8
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
9
  uploaded: (items: MediaItem[]) => any;
@@ -2,7 +2,7 @@
2
2
  <button
3
3
  type="button"
4
4
  class="cms-dropzone"
5
- :class="{ 'is-dense': dense, 'is-over': dragOver, 'is-busy': uploading }"
5
+ :class="{ 'is-over': dragOver, 'is-busy': uploading }"
6
6
  :disabled="uploading"
7
7
  @click="input?.click()"
8
8
  @dragover.prevent="dragOver = true"
@@ -28,6 +28,10 @@
28
28
  uploading ? `Uploading ${Math.min(done + 1, total)} of ${total}\u2026` : multiple ? "Drop files here or click to browse" : "Drop a file here or click to browse"
29
29
  }}
30
30
  </span>
31
+ <span v-if="!uploading" class="cms-dropzone-hint">
32
+ <CmsIcon :name="folder ? 'folder' : 'circle-stack'" class="size-3.5" />
33
+ {{ folder ?? "Library root" }}
34
+ </span>
31
35
  </button>
32
36
  </template>
33
37
 
@@ -38,7 +42,7 @@ const props = defineProps({
38
42
  multiple: { type: Boolean, required: false },
39
43
  mediaType: { type: null, required: false, default: "file" },
40
44
  accept: { type: Array, required: false },
41
- dense: { type: Boolean, required: false }
45
+ folder: { type: [String, null], required: false, default: null }
42
46
  });
43
47
  const emit = defineEmits(["uploaded"]);
44
48
  const toast = useCmsToast();
@@ -77,7 +81,8 @@ async function uploadOne(file) {
77
81
  body: {
78
82
  filename: file.name,
79
83
  contentType: file.type || "application/octet-stream",
80
- size: file.size
84
+ size: file.size,
85
+ folder: props.folder
81
86
  }
82
87
  });
83
88
  const res = await fetch(presign.uploadUrl, {
@@ -90,6 +95,7 @@ async function uploadOne(file) {
90
95
  method: "POST",
91
96
  body: {
92
97
  key: presign.key,
98
+ folder: presign.folder,
93
99
  mime: file.type || null,
94
100
  size: file.size,
95
101
  ...await imageDimensions(file)
@@ -3,7 +3,7 @@ type __VLS_Props = {
3
3
  multiple?: boolean;
4
4
  mediaType?: MediaType;
5
5
  accept?: string[];
6
- dense?: boolean;
6
+ folder?: string | null;
7
7
  };
8
8
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
9
  uploaded: (items: MediaItem[]) => any;
@@ -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
  }
@@ -15,6 +15,8 @@ export declare function mediaTypeFor(mime: string | null | undefined, key: strin
15
15
  export declare function mediaIconFor(type: MediaType): string;
16
16
  export declare function mediaPublicUrl(baseUrl: string | null | undefined, key: string): string | null;
17
17
  export declare function slugify(value: string): string;
18
+ export declare const MEDIA_FOLDER_MAX_DEPTH = 4;
19
+ export declare function normalizeMediaFolder(value: string | null | undefined): string | null;
18
20
  export interface MediaItem {
19
21
  id: number;
20
22
  key: string;
@@ -44,6 +44,12 @@ export function mediaPublicUrl(baseUrl, key) {
44
44
  export function slugify(value) {
45
45
  return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036F]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
46
46
  }
47
+ export const MEDIA_FOLDER_MAX_DEPTH = 4;
48
+ export function normalizeMediaFolder(value) {
49
+ if (!value) return null;
50
+ const segments = value.split("/").map(slugify).filter(Boolean).slice(0, MEDIA_FOLDER_MAX_DEPTH);
51
+ return segments.length ? segments.join("/") : null;
52
+ }
47
53
  export function isTranslatableField(field) {
48
54
  return !!field.translatable && (field.type === "text" || field.type === "richtext");
49
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Lightweight CMS that ships with your Nuxt app: runs on the Nitro server, content types defined in code, /cms admin panel, GraphQL API, SQLite or Postgres. No external CMS needed!",
5
5
  "license": "MIT",
6
6
  "author": "Edoardo Alberti (https://github.com/xleddyl)",