@xleddyl/nuxt-cms 0.4.1 → 0.4.3

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 (60) hide show
  1. package/README.md +74 -96
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +86 -22
  4. package/dist/runtime/app/components/cms/BlocksField.vue +30 -5
  5. package/dist/runtime/app/components/cms/DropdownMenu.vue +1 -5
  6. package/dist/runtime/app/components/cms/EntryDrawer.d.vue.ts +3 -0
  7. package/dist/runtime/app/components/cms/EntryDrawer.vue +32 -13
  8. package/dist/runtime/app/components/cms/EntryDrawer.vue.d.ts +3 -0
  9. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
  10. package/dist/runtime/app/components/cms/EntryForm.vue +20 -3
  11. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
  12. package/dist/runtime/app/components/cms/FieldInput.vue +2 -2
  13. package/dist/runtime/app/components/cms/Form.d.vue.ts +8 -6
  14. package/dist/runtime/app/components/cms/Form.vue +26 -5
  15. package/dist/runtime/app/components/cms/Form.vue.d.ts +8 -6
  16. package/dist/runtime/app/components/cms/FormField.vue +6 -1
  17. package/dist/runtime/app/components/cms/ImportModal.d.vue.ts +18 -0
  18. package/dist/runtime/app/components/cms/ImportModal.vue +252 -0
  19. package/dist/runtime/app/components/cms/ImportModal.vue.d.ts +18 -0
  20. package/dist/runtime/app/components/cms/ListFilters.d.vue.ts +18 -0
  21. package/dist/runtime/app/components/cms/ListFilters.vue +151 -0
  22. package/dist/runtime/app/components/cms/ListFilters.vue.d.ts +18 -0
  23. package/dist/runtime/app/components/cms/RelationField.vue +3 -1
  24. package/dist/runtime/app/components/cms/VisualEditor.vue +81 -12
  25. package/dist/runtime/app/pages/admin-collection.vue +158 -7
  26. package/dist/runtime/app/pages/admin-content.vue +18 -7
  27. package/dist/runtime/app/pages/admin-entry.vue +1 -0
  28. package/dist/runtime/app/pages/admin-preview.vue +6 -3
  29. package/dist/runtime/app/preview/PreviewOverlay.vue +4 -0
  30. package/dist/runtime/app/utils/ui.d.ts +2 -1
  31. package/dist/runtime/app/utils/ui.js +1 -0
  32. package/dist/runtime/assets/main.css +1 -1
  33. package/dist/runtime/server/api/collection-export.get.d.ts +2 -0
  34. package/dist/runtime/server/api/collection-export.get.js +67 -0
  35. package/dist/runtime/server/api/collection-import.post.d.ts +2 -0
  36. package/dist/runtime/server/api/collection-import.post.js +85 -0
  37. package/dist/runtime/server/api/collection.get.js +7 -15
  38. package/dist/runtime/server/utils/import.d.ts +31 -0
  39. package/dist/runtime/server/utils/import.js +202 -0
  40. package/dist/runtime/server/utils/list-query.d.ts +15 -0
  41. package/dist/runtime/server/utils/list-query.js +54 -0
  42. package/dist/runtime/server/utils/media-check.d.ts +2 -0
  43. package/dist/runtime/server/utils/media-check.js +1 -1
  44. package/dist/runtime/server/utils/media-references.js +4 -1
  45. package/dist/runtime/server/utils/registry.js +1 -1
  46. package/dist/runtime/server/utils/search.js +23 -4
  47. package/dist/runtime/shared/duplicate.d.ts +10 -0
  48. package/dist/runtime/shared/duplicate.js +54 -0
  49. package/dist/runtime/shared/graphql-sdl.js +4 -7
  50. package/dist/runtime/shared/index.d.ts +49 -2
  51. package/dist/runtime/shared/index.js +33 -0
  52. package/dist/runtime/shared/layout.d.ts +3 -0
  53. package/dist/runtime/shared/layout.js +35 -2
  54. package/dist/runtime/shared/list-filters.d.ts +20 -0
  55. package/dist/runtime/shared/list-filters.js +83 -0
  56. package/dist/runtime/shared/transfer.d.ts +70 -0
  57. package/dist/runtime/shared/transfer.js +256 -0
  58. package/dist/runtime/shared/validation.d.ts +15 -2
  59. package/dist/runtime/shared/validation.js +102 -9
  60. package/package.json +1 -1
@@ -0,0 +1,151 @@
1
+ <template>
2
+ <div class="cms-media-filters cms-list-filters">
3
+ <template v-for="key in keys" :key="key">
4
+ <CmsDropdownMenu v-if="optionsOf(key)" :items="menuItems(key)">
5
+ <button
6
+ type="button"
7
+ class="cms-pill"
8
+ :class="{ 'is-active': isActive(key) }"
9
+ aria-haspopup="menu"
10
+ >
11
+ {{ pillLabel(key) }}
12
+ <CmsIcon name="chevron-down" class="size-3" />
13
+ </button>
14
+ </CmsDropdownMenu>
15
+ <CmsPopover
16
+ v-else
17
+ :open="openKey === key"
18
+ class="cms-list-filter"
19
+ @update:open="(value) => openKey = value ? key : null"
20
+ >
21
+ <button
22
+ type="button"
23
+ class="cms-pill"
24
+ :class="{ 'is-active': isActive(key) }"
25
+ aria-haspopup="dialog"
26
+ >
27
+ {{ pillLabel(key) }}
28
+ <CmsIcon name="chevron-down" class="size-3" />
29
+ </button>
30
+ <template #content>
31
+ <div class="cms-list-filter-panel" role="dialog" :aria-label="labelOf(key)">
32
+ <CmsRelationField
33
+ :model-value="model[key] ?? null"
34
+ :to="config.fields[key].to"
35
+ @update:model-value="(value) => pickRelation(key, value)"
36
+ />
37
+ </div>
38
+ </template>
39
+ </CmsPopover>
40
+ </template>
41
+ <CmsButton
42
+ v-if="activeCount"
43
+ label="Clear filters"
44
+ icon="x-mark"
45
+ size="xs"
46
+ variant="ghost"
47
+ color="neutral"
48
+ @click="model = {}"
49
+ />
50
+ </div>
51
+ </template>
52
+
53
+ <script setup>
54
+ import { isTranslatableField, selectDisplayValue } from "#nuxt-cms";
55
+ import { computed, reactive, ref, watch } from "#imports";
56
+ import cmsConfig from "#cms-config";
57
+ import {
58
+ listFilterLabel,
59
+ listFilterOptions,
60
+ listFilterValueLabel
61
+ } from "../../../shared/list-filters";
62
+ import { useCmsRuntime } from "../../composables/cms-runtime";
63
+ import { cmsApi } from "../../utils/api";
64
+ const props = defineProps({
65
+ config: { type: Object, required: true },
66
+ keys: { type: Array, required: true },
67
+ relations: { type: Object, required: false }
68
+ });
69
+ const model = defineModel({ type: Object, ...{ required: true } });
70
+ const { i18n } = useCmsRuntime();
71
+ const openKey = ref(null);
72
+ const fetchedTitles = reactive({});
73
+ const activeCount = computed(() => Object.values(model.value).filter(Boolean).length);
74
+ function optionsOf(key) {
75
+ return listFilterOptions(props.config, key);
76
+ }
77
+ function labelOf(key) {
78
+ return listFilterLabel(props.config, key);
79
+ }
80
+ function isActive(key) {
81
+ return !!model.value[key];
82
+ }
83
+ function set(key, value) {
84
+ const next = { ...model.value };
85
+ if (value) next[key] = value;
86
+ else delete next[key];
87
+ model.value = next;
88
+ }
89
+ function menuItems(key) {
90
+ const current = model.value[key];
91
+ return [
92
+ { label: "Any", checked: !current, onSelect: () => set(key, null) },
93
+ ...(optionsOf(key) ?? []).map((option) => ({
94
+ label: option.label,
95
+ checked: current === option.value,
96
+ onSelect: () => set(key, option.value)
97
+ }))
98
+ ];
99
+ }
100
+ function pickRelation(key, value) {
101
+ set(key, typeof value === "string" ? value : null);
102
+ openKey.value = null;
103
+ }
104
+ function titleText(entry, value) {
105
+ const field = entry?.titleField ? entry.fields[entry.titleField] : void 0;
106
+ if (value == null || value === "") return "";
107
+ if (field && isTranslatableField(field) && typeof value === "object") {
108
+ const record = value;
109
+ return record[i18n.defaultLocale] ?? Object.values(record).find(Boolean) ?? "";
110
+ }
111
+ if (field?.type === "select") return selectDisplayValue(field, value);
112
+ return String(value);
113
+ }
114
+ function relationTitle(key, id) {
115
+ const target = cmsConfig[props.config.fields[key]?.to ?? ""];
116
+ const known = props.relations?.[key]?.[id];
117
+ const title = known !== void 0 ? titleText(target, known) : fetchedTitles[`${key}:${id}`];
118
+ return title || `#${id}`;
119
+ }
120
+ async function resolveTitle(key, id) {
121
+ if (import.meta.server) return;
122
+ const cacheKey = `${key}:${id}`;
123
+ if (props.relations?.[key]?.[id] !== void 0 || fetchedTitles[cacheKey] !== void 0) return;
124
+ const to = props.config.fields[key]?.to;
125
+ if (!to) return;
126
+ fetchedTitles[cacheKey] = "";
127
+ try {
128
+ const row = await cmsApi(`/api/cms/admin/${to}/${id}`);
129
+ const target = cmsConfig[to];
130
+ fetchedTitles[cacheKey] = titleText(target, target?.titleField ? row[target.titleField] : "");
131
+ } catch {
132
+ fetchedTitles[cacheKey] = "";
133
+ }
134
+ }
135
+ watch(
136
+ model,
137
+ (filters) => {
138
+ for (const key of props.keys) {
139
+ const value = filters[key];
140
+ if (value && !optionsOf(key)) void resolveTitle(key, value);
141
+ }
142
+ },
143
+ { immediate: true }
144
+ );
145
+ function pillLabel(key) {
146
+ const value = model.value[key];
147
+ if (!value) return labelOf(key);
148
+ const shown = optionsOf(key) ? listFilterValueLabel(props.config, key, value) : relationTitle(key, value);
149
+ return `${labelOf(key)}: ${shown}`;
150
+ }
151
+ </script>
@@ -0,0 +1,18 @@
1
+ import type { CmsEntry } from '#nuxt-cms';
2
+ import type { CmsListFilters } from '../../../shared/list-filters.js';
3
+ type __VLS_Props = {
4
+ config: CmsEntry;
5
+ keys: string[];
6
+ relations?: Record<string, Record<string, unknown>>;
7
+ };
8
+ type __VLS_ModelProps = {
9
+ modelValue: CmsListFilters;
10
+ };
11
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
12
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
13
+ "update:modelValue": (value: CmsListFilters) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
15
+ "onUpdate:modelValue"?: ((value: CmsListFilters) => any) | undefined;
16
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
17
+ declare const _default: typeof __VLS_export;
18
+ export default _default;
@@ -21,7 +21,7 @@
21
21
  </template>
22
22
 
23
23
  <script setup>
24
- import { isTranslatableField } from "#nuxt-cms";
24
+ import { isTranslatableField, selectDisplayValue } from "#nuxt-cms";
25
25
  import { computed, onBeforeUnmount, onMounted, ref, watch } from "#imports";
26
26
  import cmsConfig from "#cms-config";
27
27
  import { useCmsRuntime } from "../../composables/cms-runtime";
@@ -98,6 +98,8 @@ function optionLabel(row) {
98
98
  const raw = record[contentI18n.defaultLocale] ?? Object.values(record)[0] ?? "";
99
99
  return raw || `#${row.id}`;
100
100
  }
101
+ if (value != null && titleFieldConfig?.type === "select")
102
+ return selectDisplayValue(titleFieldConfig, value) || `#${row.id}`;
101
103
  return String(value ?? `#${row.id}`);
102
104
  }
103
105
  const options = computed(() => {
@@ -80,6 +80,10 @@
80
80
  role="tabpanel"
81
81
  :aria-labelledby="tabList.length > 1 ? `cms-editor-tab-${activeTab}` : void 0"
82
82
  >
83
+ <CmsAlert v-if="errors[ROOT_KEY]" color="error">{{ errors[ROOT_KEY] }}</CmsAlert>
84
+ <CmsAlert v-for="message in rootWarnings" :key="message" color="warning">
85
+ {{ message }}
86
+ </CmsAlert>
83
87
  <template v-if="activeTab === 'block'">
84
88
  <template v-if="selectedItem && selected !== null">
85
89
  <div class="cms-editor-block-head">
@@ -129,6 +133,7 @@
129
133
  variant="soft"
130
134
  color="neutral"
131
135
  label="Duplicate"
136
+ :disabled="!canAdd"
132
137
  @click="duplicate(selected)"
133
138
  />
134
139
  <CmsButton
@@ -150,6 +155,14 @@
150
155
  @click="remove(selected)"
151
156
  />
152
157
  </div>
158
+ <p v-if="errors[`${fieldKey}.${selected}`]" class="cms-form-error">
159
+ <CmsIcon name="exclamation-circle" class="size-3.5 shrink-0" />
160
+ {{ errors[`${fieldKey}.${selected}`] }}
161
+ </p>
162
+ <p v-else-if="warnings[`${fieldKey}.${selected}`]" class="cms-form-warning">
163
+ <CmsIcon name="exclamation-triangle" class="size-3.5 shrink-0" />
164
+ {{ warnings[`${fieldKey}.${selected}`] }}
165
+ </p>
153
166
  <div class="cms-editor-fields">
154
167
  <CmsFormField
155
168
  v-for="(blockField, blockKey) in selectedBlock?.fields ?? {}"
@@ -177,6 +190,10 @@
177
190
  <CmsIcon name="exclamation-circle" class="size-3.5 shrink-0" />
178
191
  {{ errors[fieldKey] }}
179
192
  </p>
193
+ <p v-else-if="warnings[fieldKey]" class="cms-form-warning">
194
+ <CmsIcon name="exclamation-triangle" class="size-3.5 shrink-0" />
195
+ {{ warnings[fieldKey] }}
196
+ </p>
180
197
  <div v-if="blocks.length" class="cms-editor-section">
181
198
  <span class="cms-label">{{ blocksField.label }}</span>
182
199
  <CmsBlockOutline
@@ -188,13 +205,14 @@
188
205
  @move="move"
189
206
  />
190
207
  </div>
191
- <div class="cms-editor-section">
208
+ <div v-if="canAdd" class="cms-editor-section">
192
209
  <span class="cms-label">Add a block</span>
193
210
  <CmsBlockPalette
194
211
  :blocks="blocksField.blocks ?? {}"
195
212
  @pick="(type) => insert(blocks.length, type)"
196
213
  />
197
214
  </div>
215
+ <p v-else class="cms-form-description">{{ maxReached }}</p>
198
216
  </template>
199
217
  </template>
200
218
 
@@ -240,7 +258,13 @@
240
258
  </template>
241
259
 
242
260
  <script setup>
243
- import { isFieldVisible, isHiddenBlock, isTranslatableField, slugSourceValue } from "#nuxt-cms";
261
+ import {
262
+ canAddBlock,
263
+ isFieldVisible,
264
+ isHiddenBlock,
265
+ isTranslatableField,
266
+ slugSourceValue
267
+ } from "#nuxt-cms";
244
268
  import {
245
269
  computed,
246
270
  onBeforeUnmount,
@@ -251,7 +275,8 @@ import {
251
275
  shallowRef,
252
276
  watch
253
277
  } from "#imports";
254
- import { buildEntrySchema } from "../../../shared/validation";
278
+ import cmsConfig from "#cms-config";
279
+ import { buildEntrySchema, entryValidationWarnings } from "../../../shared/validation";
255
280
  import {
256
281
  DEFAULT_CMS_PREVIEW_PATH,
257
282
  canHideBlock,
@@ -273,7 +298,8 @@ import { useCmsConfirm } from "../../composables/cms-confirm";
273
298
  import { useCmsFieldFocus } from "../../composables/cms-field-focus";
274
299
  import { useCmsRuntime } from "../../composables/cms-runtime";
275
300
  import { entryFormLayout, useCmsSettingsState } from "../../composables/cms-settings";
276
- import { CMS_FORM_ERRORS, fieldIcon } from "../../utils/ui";
301
+ import { CMS_FORM_ERRORS, CMS_FORM_WARNINGS, fieldIcon } from "../../utils/ui";
302
+ const ROOT_KEY = "";
277
303
  const SEO_KEYS = ["seoTitle", "seoDescription", "seoImage"];
278
304
  const DEVICES = [
279
305
  { id: "desktop", label: "Desktop width", icon: "computer-desktop" },
@@ -305,6 +331,11 @@ const blocks = computed(() => {
305
331
  const value = state.value[props.fieldKey];
306
332
  return Array.isArray(value) ? value : [];
307
333
  });
334
+ const canAdd = computed(() => canAddBlock(blocksField.value, blocks.value.length));
335
+ const maxReached = computed(() => {
336
+ const max = blocksField.value.max ?? 0;
337
+ return `This field holds at most ${max} ${max === 1 ? "block" : "blocks"}.`;
338
+ });
308
339
  const selectedItem = computed(
309
340
  () => selected.value === null ? void 0 : blocks.value[selected.value]
310
341
  );
@@ -366,7 +397,7 @@ function blockConfigAt(index) {
366
397
  }
367
398
  function insert(index, type) {
368
399
  const block = blocksField.value.blocks?.[type];
369
- if (!block) return;
400
+ if (!block || !canAdd.value) return;
370
401
  const at = Math.min(Math.max(index, 0), blocks.value.length);
371
402
  setBlocks(insertBlock(blocks.value, at, emptyBlock(type, block)));
372
403
  select(at);
@@ -376,6 +407,7 @@ function insertFromPalette(type) {
376
407
  insert(paletteIndex.value, type);
377
408
  }
378
409
  function openPalette(index) {
410
+ if (!canAdd.value) return;
379
411
  paletteIndex.value = index;
380
412
  paletteOpen.value = true;
381
413
  }
@@ -389,6 +421,7 @@ function moveSelected(delta) {
389
421
  move(selected.value, selected.value + delta);
390
422
  }
391
423
  function duplicate(index) {
424
+ if (!canAdd.value) return;
392
425
  setBlocks(duplicateBlock(blocks.value, index));
393
426
  select(index + 1);
394
427
  }
@@ -494,30 +527,66 @@ onBeforeUnmount(() => {
494
527
  });
495
528
  const errors = reactive({});
496
529
  provide(CMS_FORM_ERRORS, errors);
530
+ const validateHook = computed(
531
+ () => props.content ? cmsConfig[props.entryName]?.validate : void 0
532
+ );
497
533
  const schema = computed(
498
- () => buildEntrySchema({ fields: props.fields, drafts: props.content }, i18n)
534
+ () => buildEntrySchema(
535
+ { fields: props.fields, drafts: props.content, validate: validateHook.value },
536
+ i18n
537
+ )
499
538
  );
500
539
  let submitted = false;
540
+ function issueKeys(path) {
541
+ const [key, index, blockKey] = path;
542
+ if (key === void 0) return [ROOT_KEY];
543
+ const keys = [String(key)];
544
+ if (String(key) === props.fieldKey && typeof index === "number")
545
+ keys.push(
546
+ blockKey === void 0 ? `${String(key)}.${index}` : `${String(key)}.${index}.${String(blockKey)}`
547
+ );
548
+ return keys;
549
+ }
501
550
  function collectErrors() {
502
551
  for (const key of Object.keys(errors)) delete errors[key];
503
552
  const result = schema.value.safeParse(state.value);
504
553
  if (result.success) return [];
505
554
  const issues = result.error.issues;
506
555
  for (const issue of issues) {
507
- const [key, index, blockKey] = issue.path;
508
- if (key === void 0) continue;
509
- errors[String(key)] ??= issue.message;
510
- if (String(key) === props.fieldKey && typeof index === "number" && blockKey !== void 0)
511
- errors[`${String(key)}.${index}.${String(blockKey)}`] ??= issue.message;
556
+ for (const key of issueKeys(issue.path)) errors[key] ??= issue.message;
512
557
  }
513
558
  return issues;
514
559
  }
560
+ const warningList = computed(
561
+ () => entryValidationWarnings(
562
+ { fields: props.fields, validate: validateHook.value },
563
+ state.value,
564
+ i18n
565
+ )
566
+ );
567
+ const warnings = computed(() => {
568
+ const grouped = {};
569
+ for (const issue of warningList.value) {
570
+ for (const key of issueKeys(issue.path)) {
571
+ if (key !== ROOT_KEY) grouped[key] ??= issue.message;
572
+ }
573
+ }
574
+ return grouped;
575
+ });
576
+ provide(CMS_FORM_WARNINGS, warnings);
577
+ const rootWarnings = computed(() => [
578
+ ...new Set(
579
+ warningList.value.filter((issue) => !issue.path.length).map((issue) => issue.message)
580
+ )
581
+ ]);
515
582
  function tabOfKey(key) {
516
583
  if (key === props.fieldKey) return "block";
517
584
  return SEO_KEYS.includes(key) ? "seo" : "content";
518
585
  }
519
586
  function tabHasError(tab) {
520
- return Object.keys(errors).some((key) => tabOfKey(key.split(".")[0]) === tab);
587
+ return Object.keys(errors).some(
588
+ (key) => key !== ROOT_KEY && tabOfKey(key.split(".")[0]) === tab
589
+ );
521
590
  }
522
591
  function validate() {
523
592
  submitted = true;
@@ -98,7 +98,7 @@
98
98
  <template v-else>
99
99
  <div class="cms-toolbar">
100
100
  <CmsInput
101
- v-if="total || searchTerm"
101
+ v-if="total || narrowed"
102
102
  v-model="search"
103
103
  icon="magnifying-glass"
104
104
  placeholder="Search…"
@@ -131,6 +131,14 @@
131
131
  @click="bulkStatus('published')"
132
132
  />
133
133
  </template>
134
+ <CmsButton
135
+ v-if="selected.length === 1"
136
+ label="Duplicate"
137
+ icon="document-duplicate"
138
+ variant="soft"
139
+ :disabled="saving"
140
+ @click="duplicateSelected"
141
+ />
134
142
  <CmsButton
135
143
  label="Delete"
136
144
  icon="trash"
@@ -140,10 +148,28 @@
140
148
  />
141
149
  </div>
142
150
  <div v-else :key="'default'" class="cms-toolbar-actions">
151
+ <CmsDropdownMenu :items="transferItems" :content="{ align: 'end' }">
152
+ <CmsButton
153
+ icon="arrows-up-down"
154
+ variant="soft"
155
+ color="neutral"
156
+ :loading="exporting"
157
+ aria-label="Import and export"
158
+ title="Import and export"
159
+ />
160
+ </CmsDropdownMenu>
143
161
  <CmsButton label="New entry" icon="plus" @click="openCreate" />
144
162
  </div>
145
163
  </div>
146
164
 
165
+ <CmsListFilters
166
+ v-if="filterKeys.length && (total || narrowed)"
167
+ v-model="filters"
168
+ :config="config"
169
+ :keys="filterKeys"
170
+ :relations="relations"
171
+ />
172
+
147
173
  <CmsTable
148
174
  v-if="rows.length"
149
175
  v-model:selected="selected"
@@ -168,11 +194,19 @@
168
194
  <CmsSpinner v-else-if="status === 'pending'" />
169
195
 
170
196
  <CmsEmptyState
171
- v-else-if="searchTerm"
197
+ v-else-if="narrowed"
172
198
  icon="magnifying-glass"
173
199
  title="No matching entries"
174
200
  fill
175
- />
201
+ >
202
+ <CmsButton
203
+ v-if="filterCount"
204
+ label="Clear filters"
205
+ icon="x-mark"
206
+ variant="soft"
207
+ @click="filters = {}"
208
+ />
209
+ </CmsEmptyState>
176
210
 
177
211
  <CmsEmptyState
178
212
  v-else
@@ -192,8 +226,17 @@
192
226
  :collection="name"
193
227
  :config="config"
194
228
  :entry-id="drawerEntryId"
229
+ :initial="drawerInitial"
195
230
  @saved="refresh"
196
231
  @deleted="onDrawerDeleted"
232
+ @duplicate="openDuplicate"
233
+ />
234
+
235
+ <CmsImportModal
236
+ v-model:open="importOpen"
237
+ :collection="name"
238
+ :config="config"
239
+ @imported="refresh"
197
240
  />
198
241
  </template>
199
242
  </div>
@@ -206,7 +249,8 @@ import {
206
249
  isTranslatableField,
207
250
  isTranslatableMediaField,
208
251
  pageParentPath,
209
- pickTranslatedMedia
252
+ pickTranslatedMedia,
253
+ selectDisplayValue
210
254
  } from "#nuxt-cms";
211
255
  import {
212
256
  computed,
@@ -220,7 +264,15 @@ import {
220
264
  watch
221
265
  } from "#imports";
222
266
  import cmsConfig from "#cms-config";
267
+ import { duplicateEntryValues } from "../../shared/duplicate";
268
+ import {
269
+ entryListFilterKeys,
270
+ listFilterQuery,
271
+ parseListFilters,
272
+ withoutListFilterQuery
273
+ } from "../../shared/list-filters";
223
274
  import { readSearchFocus } from "../../shared/search";
275
+ import { canTransferCsv, exportFileName } from "../../shared/transfer";
224
276
  import { useCmsConfirm } from "../composables/cms-confirm";
225
277
  import { useCmsRuntime } from "../composables/cms-runtime";
226
278
  import { entryListColumns, useCmsSettingsState } from "../composables/cms-settings";
@@ -231,7 +283,7 @@ definePageMeta({
231
283
  layout: "cms-admin",
232
284
  middleware: "cms-auth",
233
285
  validate: (route2) => Object.hasOwn(cmsConfig, route2.params.collection),
234
- key: (route2) => route2.fullPath
286
+ key: (route2) => `${route2.path}${JSON.stringify(withoutListFilterQuery(route2.query))}`
235
287
  });
236
288
  const FORM_ID = "cms-single-form";
237
289
  const route = useRoute();
@@ -271,11 +323,33 @@ const sort = ref(null);
271
323
  watch(sort, () => {
272
324
  page.value = 1;
273
325
  });
326
+ const filters = computed({
327
+ get: () => isCollectionKind(config) ? parseListFilters(config, route.query).filters : {},
328
+ set: (value) => {
329
+ void navigateTo(
330
+ { query: { ...withoutListFilterQuery(route.query), ...listFilterQuery(value) } },
331
+ { replace: true }
332
+ );
333
+ }
334
+ });
335
+ const filterQuery = computed(() => listFilterQuery(filters.value));
336
+ const filterCount = computed(() => Object.keys(filterQuery.value).length);
337
+ const narrowed = computed(() => !!searchTerm.value || filterCount.value > 0);
338
+ watch(
339
+ () => JSON.stringify(filterQuery.value),
340
+ () => {
341
+ page.value = 1;
342
+ }
343
+ );
344
+ const sortQuery = computed(
345
+ () => sort.value ? { sort: sort.value.key, order: sort.value.order } : {}
346
+ );
274
347
  const listQuery = computed(() => ({
275
348
  limit: PAGE_SIZE,
276
349
  offset: (page.value - 1) * PAGE_SIZE,
277
350
  ...searchTerm.value ? { search: searchTerm.value } : {},
278
- ...sort.value ? { sort: sort.value.key, order: sort.value.order } : {}
351
+ ...sortQuery.value,
352
+ ...filterQuery.value
279
353
  }));
280
354
  const { data, refresh, error, status } = await useFetch(endpoint, {
281
355
  query: config.kind === "single" ? void 0 : listQuery
@@ -384,7 +458,7 @@ function relationLabel(field, key, id) {
384
458
  if (title == null || title === "") return `#${id}`;
385
459
  const target = cmsConfig[field.to];
386
460
  const titleField = target?.titleField ? target.fields[target.titleField] : void 0;
387
- const label = titleField && isTranslatableField(titleField) ? localized(title) : String(title);
461
+ const label = titleField && isTranslatableField(titleField) ? localized(title) : titleField?.type === "select" ? selectDisplayValue(titleField, title) : String(title);
388
462
  return label || `#${id}`;
389
463
  }
390
464
  function displayValue(field, key, value) {
@@ -402,6 +476,8 @@ function displayValue(field, key, value) {
402
476
  return truncate(JSON.stringify(value));
403
477
  case "datetime":
404
478
  return formatDateTime(value);
479
+ case "select":
480
+ return truncate(selectDisplayValue(field, value));
405
481
  case "blocks":
406
482
  return `${value.length} \u25A4`;
407
483
  case "relation": {
@@ -428,13 +504,21 @@ const columns = computed(
428
504
  }
429
505
  )
430
506
  );
507
+ const filterKeys = computed(() => {
508
+ if (!isCollectionKind(config)) return [];
509
+ const shown = entryListFilterKeys(config, entryListColumns(name, settings.value));
510
+ const active = Object.keys(filters.value).filter((key) => !shown.includes(key));
511
+ return [...shown, ...active];
512
+ });
431
513
  const drawerOpen = ref(false);
432
514
  const drawerEntryId = ref(null);
515
+ const drawerInitial = ref(null);
433
516
  function onSelect(_event, row) {
434
517
  if (isContent) {
435
518
  void navigateTo(`/cms/${name}/${row.original.id}`);
436
519
  return;
437
520
  }
521
+ drawerInitial.value = null;
438
522
  drawerEntryId.value = String(row.original.id);
439
523
  drawerOpen.value = true;
440
524
  }
@@ -443,9 +527,76 @@ function openCreate() {
443
527
  void navigateTo(`/cms/${name}/new`);
444
528
  return;
445
529
  }
530
+ drawerInitial.value = null;
531
+ drawerEntryId.value = null;
532
+ drawerOpen.value = true;
533
+ }
534
+ function openDuplicate(values) {
535
+ drawerInitial.value = values;
446
536
  drawerEntryId.value = null;
447
537
  drawerOpen.value = true;
448
538
  }
539
+ async function duplicateSelected() {
540
+ const id = selected.value[0];
541
+ if (!id || saving.value) return;
542
+ if (isContent) {
543
+ void navigateTo({ path: `/cms/${name}/new`, query: { duplicate: id } });
544
+ return;
545
+ }
546
+ saving.value = true;
547
+ try {
548
+ const entry = await cmsApi(`${endpoint}/${id}`);
549
+ selected.value = [];
550
+ openDuplicate(duplicateEntryValues(config, entry));
551
+ } catch (error2) {
552
+ toast.add({ title: "Could not load entry", description: errorMessage(error2), color: "error" });
553
+ } finally {
554
+ saving.value = false;
555
+ }
556
+ }
557
+ const importOpen = ref(false);
558
+ const exporting = ref(false);
559
+ const csvAvailable = canTransferCsv(config);
560
+ async function exportEntries(format, narrow) {
561
+ if (exporting.value) return;
562
+ exporting.value = true;
563
+ try {
564
+ const query = {
565
+ format,
566
+ ...sortQuery.value,
567
+ ...narrow && searchTerm.value ? { search: searchTerm.value } : {},
568
+ ...narrow ? filterQuery.value : {}
569
+ };
570
+ const blob = await $fetch(`/api/cms/export/${name}`, { query, responseType: "blob" });
571
+ const url = URL.createObjectURL(blob);
572
+ const link = document.createElement("a");
573
+ link.href = url;
574
+ link.download = exportFileName(name, format);
575
+ link.click();
576
+ URL.revokeObjectURL(url);
577
+ } catch (error2) {
578
+ toast.add({ title: "Export failed", description: errorMessage(error2), color: "error" });
579
+ } finally {
580
+ exporting.value = false;
581
+ }
582
+ }
583
+ const transferItems = computed(() => {
584
+ const formats = csvAvailable ? ["json", "csv"] : ["json"];
585
+ const exports = (narrow) => formats.map((format) => ({
586
+ label: `Export ${narrow ? "filtered" : "all"} as ${format.toUpperCase()}`,
587
+ onSelect: () => void exportEntries(format, narrow)
588
+ }));
589
+ return [
590
+ ...narrowed.value ? exports(true) : [],
591
+ ...exports(false),
592
+ {
593
+ label: "Import from file\u2026",
594
+ onSelect: () => {
595
+ importOpen.value = true;
596
+ }
597
+ }
598
+ ];
599
+ });
449
600
  async function onDrawerDeleted() {
450
601
  if (rows.value.length === 1 && page.value > 1) page.value -= 1;
451
602
  else await refresh();