@xleddyl/nuxt-cms 0.2.0 → 0.2.2

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 (95) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +17 -6
  3. package/dist/runtime/app/components/cms/BrandMark.vue +2 -3
  4. package/dist/runtime/app/components/cms/ConfirmModal.vue +14 -1
  5. package/dist/runtime/app/components/cms/Drawer.vue +5 -9
  6. package/dist/runtime/app/components/cms/EntryDrawer.vue +11 -3
  7. package/dist/runtime/app/components/cms/EntryForm.vue +0 -1
  8. package/dist/runtime/app/components/cms/Input.d.vue.ts +0 -1
  9. package/dist/runtime/app/components/cms/Input.vue +7 -4
  10. package/dist/runtime/app/components/cms/Input.vue.d.ts +0 -1
  11. package/dist/runtime/app/components/cms/LocaleSwitch.vue +1 -1
  12. package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +0 -1
  13. package/dist/runtime/app/components/cms/MediaFolderPicker.vue +1 -3
  14. package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +0 -1
  15. package/dist/runtime/app/components/cms/MediaGallery.vue +89 -11
  16. package/dist/runtime/app/components/cms/Modal.vue +5 -11
  17. package/dist/runtime/app/components/cms/PasswordInput.vue +9 -5
  18. package/dist/runtime/app/components/cms/RelationField.vue +7 -2
  19. package/dist/runtime/app/components/cms/SelectMenu.d.vue.ts +0 -1
  20. package/dist/runtime/app/components/cms/SelectMenu.vue +1 -6
  21. package/dist/runtime/app/components/cms/SelectMenu.vue.d.ts +0 -1
  22. package/dist/runtime/app/components/cms/SettingsEntry.vue +1 -8
  23. package/dist/runtime/app/components/cms/SettingsModal.vue +5 -10
  24. package/dist/runtime/app/components/cms/SettingsUsers.vue +8 -4
  25. package/dist/runtime/app/components/cms/Table.d.vue.ts +0 -7
  26. package/dist/runtime/app/components/cms/Table.vue +7 -49
  27. package/dist/runtime/app/components/cms/Table.vue.d.ts +0 -7
  28. package/dist/runtime/app/components/cms/Textarea.d.vue.ts +0 -1
  29. package/dist/runtime/app/components/cms/Textarea.vue +1 -2
  30. package/dist/runtime/app/components/cms/Textarea.vue.d.ts +0 -1
  31. package/dist/runtime/app/composables/cms-confirm.d.ts +16 -7
  32. package/dist/runtime/app/composables/cms-confirm.js +1 -0
  33. package/dist/runtime/app/composables/cms-entry-disabled.js +1 -1
  34. package/dist/runtime/app/composables/cms-media-keys.d.ts +1 -0
  35. package/dist/runtime/app/composables/cms-media-keys.js +5 -0
  36. package/dist/runtime/app/composables/cms-media-library.d.ts +1 -2
  37. package/dist/runtime/app/composables/cms-media-library.js +15 -19
  38. package/dist/runtime/app/composables/cms-media-uploader.d.ts +0 -1
  39. package/dist/runtime/app/composables/cms-media-uploader.js +1 -1
  40. package/dist/runtime/app/composables/cms-overlay.d.ts +4 -0
  41. package/dist/runtime/app/composables/cms-overlay.js +23 -0
  42. package/dist/runtime/app/composables/cms-settings.d.ts +0 -1
  43. package/dist/runtime/app/composables/cms-settings.js +1 -1
  44. package/dist/runtime/app/pages/admin-entry.vue +1 -0
  45. package/dist/runtime/app/utils/ui.d.ts +0 -2
  46. package/dist/runtime/assets/main.css +1 -1
  47. package/dist/runtime/generate.js +1 -1
  48. package/dist/runtime/seed.js +11 -1
  49. package/dist/runtime/server/api/collection.get.js +1 -1
  50. package/dist/runtime/server/api/collection.post.js +20 -14
  51. package/dist/runtime/server/api/collection.put.js +12 -12
  52. package/dist/runtime/server/api/item.put.js +14 -13
  53. package/dist/runtime/server/api/media-folder.patch.js +10 -16
  54. package/dist/runtime/server/api/media-move.post.js +12 -8
  55. package/dist/runtime/server/api/media.delete.js +3 -2
  56. package/dist/runtime/server/api/media.post.js +4 -2
  57. package/dist/runtime/server/api/media.put.js +4 -2
  58. package/dist/runtime/server/api/users.post.js +13 -9
  59. package/dist/runtime/server/plugins/migrate-sqlite.js +2 -2
  60. package/dist/runtime/server/routes/auth/login.post.js +30 -24
  61. package/dist/runtime/server/utils/db-d1.d.ts +0 -1
  62. package/dist/runtime/server/utils/db-d1.js +3 -4
  63. package/dist/runtime/server/utils/db-libsql.d.ts +0 -1
  64. package/dist/runtime/server/utils/db-libsql.js +3 -4
  65. package/dist/runtime/server/utils/db-postgres.d.ts +0 -1
  66. package/dist/runtime/server/utils/db-postgres.js +1 -4
  67. package/dist/runtime/server/utils/db-sqlite.d.ts +0 -1
  68. package/dist/runtime/server/utils/db-sqlite.js +4 -10
  69. package/dist/runtime/server/utils/graphql-depth.js +12 -5
  70. package/dist/runtime/server/utils/graphql.js +4 -3
  71. package/dist/runtime/server/utils/libsql-migrations.d.ts +1 -0
  72. package/dist/runtime/server/utils/libsql-migrations.js +1 -1
  73. package/dist/runtime/server/utils/media-folders.js +23 -24
  74. package/dist/runtime/server/utils/media.d.ts +1 -0
  75. package/dist/runtime/server/utils/media.js +6 -1
  76. package/dist/runtime/server/utils/migrate.d.ts +1 -0
  77. package/dist/runtime/server/utils/migrate.js +33 -1
  78. package/dist/runtime/server/utils/registry.d.ts +3 -0
  79. package/dist/runtime/server/utils/registry.js +10 -1
  80. package/dist/runtime/server/utils/relations.d.ts +1 -1
  81. package/dist/runtime/server/utils/relations.js +6 -9
  82. package/dist/runtime/server/utils/require-admin.d.ts +0 -2
  83. package/dist/runtime/server/utils/require-admin.js +0 -1
  84. package/dist/runtime/server/utils/timestamps.d.ts +12 -0
  85. package/dist/runtime/server/utils/timestamps.js +48 -0
  86. package/dist/runtime/server/utils/users.js +3 -2
  87. package/dist/runtime/server/utils/write-statements.d.ts +33 -0
  88. package/dist/runtime/server/utils/write-statements.js +75 -0
  89. package/dist/runtime/shared/index.d.ts +14 -0
  90. package/dist/runtime/shared/index.js +13 -0
  91. package/dist/runtime/shared/layout.d.ts +0 -1
  92. package/dist/runtime/shared/layout.js +0 -3
  93. package/dist/runtime/shared/timestamps.d.ts +4 -0
  94. package/dist/runtime/shared/timestamps.js +24 -0
  95. package/package.json +1 -1
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.2.0",
4
+ "version": "0.2.2",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -380,7 +380,7 @@ function validateConfig(config, i18n) {
380
380
  errors.push(`${fat}: relation target '${field.to}' must be a collection`);
381
381
  if (!entry.table && target.table)
382
382
  errors.push(
383
- `${fat}: relation target '${field.to}' uses a custom table \u2014 derived relations can only target derived entries`
383
+ `${fat}: relation target '${field.to}' uses a custom table: derived relations can only target derived entries`
384
384
  );
385
385
  }
386
386
  if (field.cardinality && !["many-to-one", "one-to-one", "many-to-many"].includes(field.cardinality)) {
@@ -835,7 +835,12 @@ ${lines.join(
835
835
  function pageTypesTs(name, entry) {
836
836
  const auto = `${typeName(name)}Auto`;
837
837
  const lines = pageRoutes(entry).map((route) => {
838
- const keys = ["id", "path", "updatedAt", ...Object.keys(pageFields(entry, route.path))];
838
+ const keys = [
839
+ "id",
840
+ "path",
841
+ "updatedAt",
842
+ ...Object.entries(pageFields(entry, route.path)).filter(([, field]) => !isPrivateField(field)).map(([key]) => key)
843
+ ];
839
844
  return ` ${JSON.stringify(route.path)}: Pick<${auto}, ${quotedKeys(keys)}>`;
840
845
  });
841
846
  return [
@@ -1027,7 +1032,7 @@ async function loadCmsConfig(nuxt, resolver, configPathOption, i18n, logger) {
1027
1032
  cmsConfig = expandMobileMedia(await jiti.import(configPath, { default: true }));
1028
1033
  } else {
1029
1034
  logger.warn(
1030
- `[nuxt-cms] Config file not found: ${configPath}. Using an empty registry \u2014 create a ${configPathOption}.ts with defineCmsConfig().`
1035
+ `[nuxt-cms] Config file not found: ${configPath}. Using an empty registry. Create a ${configPathOption}.ts with defineCmsConfig().`
1031
1036
  );
1032
1037
  nuxt.options.alias["#cms-config"] = resolver.resolve("./runtime/shared/empty-config");
1033
1038
  }
@@ -1121,7 +1126,7 @@ function addCmsTypeTemplates(nuxt, cmsConfig) {
1121
1126
  ``,
1122
1127
  `declare const graphql: CmsGraphql`,
1123
1128
  ``,
1124
- `// @ts-ignore: gql.tada's cache overload rejects this instantiation, but the parse overload still resolves`,
1129
+ `// @ts-ignore`,
1125
1130
  `export type CmsDocument<Query extends string> = ReturnType<typeof graphql<Query, []>>`,
1126
1131
  ``,
1127
1132
  `export type CmsResult<Query extends string> = string extends Query`,
@@ -1288,7 +1293,13 @@ const module$1 = defineNuxtModule({
1288
1293
  ` dialect: '${dialect}',`,
1289
1294
  ` schema: '${toPosix(schemaTemplate.dst)}',`,
1290
1295
  ` out: '${toPosix(relativeMigrationsDir)}',`,
1291
- driver === "postgres" ? ` dbCredentials: { url: process.env.NUXT_CMS_DATABASE_URL ?? '${databaseUrl}' },` : driver === "libsql" ? ` dbCredentials: { url: process.env.NUXT_CMS_DATABASE_URL ?? '${databaseUrl || `file:${toPosix(resolvedDbPath)}`}', authToken: (process.env.NUXT_CMS_DATABASE_AUTH_TOKEN ?? '${databaseAuthToken}') || undefined },` : driver === "d1" ? `` : ` dbCredentials: { url: '${toPosix(resolvedDbPath)}' },`,
1296
+ driver === "postgres" ? ` dbCredentials: { url: process.env.NUXT_CMS_DATABASE_URL ?? ${JSON.stringify(
1297
+ databaseUrl
1298
+ )} },` : driver === "libsql" ? ` dbCredentials: { url: process.env.NUXT_CMS_DATABASE_URL ?? ${JSON.stringify(
1299
+ databaseUrl || `file:${toPosix(resolvedDbPath)}`
1300
+ )}, authToken: (process.env.NUXT_CMS_DATABASE_AUTH_TOKEN ?? ${JSON.stringify(
1301
+ databaseAuthToken
1302
+ )}) || undefined },` : driver === "d1" ? `` : ` dbCredentials: { url: ${JSON.stringify(toPosix(resolvedDbPath))} },`,
1292
1303
  `}`,
1293
1304
  ``
1294
1305
  ].join("\n")
@@ -1305,7 +1316,7 @@ const module$1 = defineNuxtModule({
1305
1316
  const migrations = await collectMigrations(migrationsDir);
1306
1317
  if (!migrations && !nuxt.options.dev && Object.keys(cmsConfig).length) {
1307
1318
  logger.warn(
1308
- `[nuxt-cms] No migrations found at ${migrationsDir}. The CMS tables will not be created \u2014 run the dev server once to generate them and commit ${toPosix(
1319
+ `[nuxt-cms] No migrations found at ${migrationsDir}. The CMS tables will not be created. Run the dev server once to generate them and commit ${toPosix(
1309
1320
  relativeMigrationsDir
1310
1321
  )}.`
1311
1322
  );
@@ -2,12 +2,11 @@
2
2
  <span class="cms-brand-mark" :class="{ 'is-lg': size === 'lg' }" aria-hidden="true">
3
3
  <svg viewBox="0 0 16 16" fill="none">
4
4
  <path
5
- d="M4.25 12.25V7.5a3.75 3.75 0 0 1 7.5 0v4.75"
5
+ d="M4.5 5.5h7M4.5 8h7M4.5 10.5h4"
6
6
  stroke="currentColor"
7
- stroke-width="2"
7
+ stroke-width="1.5"
8
8
  stroke-linecap="round"
9
9
  />
10
- <circle cx="8" cy="12.25" r="1.25" fill="currentColor" />
11
10
  </svg>
12
11
  </span>
13
12
  </template>
@@ -2,12 +2,25 @@
2
2
  <CmsModal
3
3
  v-model:open="state.open"
4
4
  :title="state.title ?? 'Are you sure?'"
5
- size="sm"
5
+ :size="state.groups?.length ? void 0 : 'sm'"
6
6
  @after:leave="finish(false)"
7
7
  >
8
8
  <template #body>
9
9
  <div class="cms-form">
10
10
  <p class="text-sm">{{ state.message }}</p>
11
+ <div v-if="state.groups?.length" class="cms-confirm-groups">
12
+ <section v-for="group in state.groups" :key="group.title" class="cms-confirm-group">
13
+ <h3 class="cms-confirm-group-title" :title="group.title">{{ group.title }}</h3>
14
+ <ul class="cms-media-usage-list">
15
+ <li v-for="link in group.links" :key="link.to + link.meta">
16
+ <NuxtLink :to="link.to" class="cms-media-usage-link" @click="finish(false)">
17
+ <span class="cms-media-usage-title">{{ link.title }}</span>
18
+ <span class="cms-media-usage-meta">{{ link.meta }}</span>
19
+ </NuxtLink>
20
+ </li>
21
+ </ul>
22
+ </section>
23
+ </div>
11
24
  <div class="cms-actions is-end">
12
25
  <CmsButton label="Cancel" variant="soft" color="neutral" @click="finish(false)" />
13
26
  <CmsButton
@@ -35,6 +35,7 @@
35
35
 
36
36
  <script setup>
37
37
  import { onBeforeUnmount, watch } from "#imports";
38
+ import { useCmsOverlay } from "../../composables/cms-overlay";
38
39
  defineProps({
39
40
  title: { type: String, required: false }
40
41
  });
@@ -43,23 +44,18 @@ const emit = defineEmits(["close"]);
43
44
  function close() {
44
45
  emit("close");
45
46
  }
46
- function onKeydown(event) {
47
- if (event.key === "Escape") close();
48
- }
47
+ const overlay = useCmsOverlay(close);
49
48
  watch(open, (value) => {
50
49
  if (import.meta.server) return;
51
50
  if (value) {
52
- document.addEventListener("keydown", onKeydown);
51
+ overlay.activate();
53
52
  document.body.style.overflow = "hidden";
54
53
  } else {
55
- document.removeEventListener("keydown", onKeydown);
54
+ overlay.deactivate();
56
55
  document.body.style.overflow = "";
57
56
  }
58
57
  });
59
58
  onBeforeUnmount(() => {
60
- if (import.meta.client) {
61
- document.removeEventListener("keydown", onKeydown);
62
- document.body.style.overflow = "";
63
- }
59
+ if (import.meta.client) document.body.style.overflow = "";
64
60
  });
65
61
  </script>
@@ -121,16 +121,23 @@ function revertStatus() {
121
121
  if (!drafts.value) return;
122
122
  formState.value.status = JSON.parse(snapshot.value).status ?? null;
123
123
  }
124
+ let loadRequest = 0;
124
125
  async function load() {
125
- if (props.entryId === null) {
126
+ const request = ++loadRequest;
127
+ const id = props.entryId;
128
+ if (id === null) {
129
+ loading.value = false;
126
130
  setState(emptyState());
127
131
  return;
128
132
  }
133
+ const isStale = () => request !== loadRequest || props.entryId !== id || !open.value;
129
134
  loading.value = true;
130
135
  try {
131
- const entry = await cmsApi(`${endpoint.value}/${props.entryId}`);
136
+ const entry = await cmsApi(`${endpoint.value}/${id}`);
137
+ if (isStale()) return;
132
138
  setState(pickFields(entry));
133
139
  } catch (error) {
140
+ if (isStale()) return;
134
141
  toast.add({
135
142
  title: "Could not load entry",
136
143
  description: errorMessage(error),
@@ -138,7 +145,7 @@ async function load() {
138
145
  });
139
146
  open.value = false;
140
147
  } finally {
141
- loading.value = false;
148
+ if (request === loadRequest) loading.value = false;
142
149
  }
143
150
  }
144
151
  watch(
@@ -165,6 +172,7 @@ async function save() {
165
172
  emit("saved");
166
173
  open.value = false;
167
174
  } catch (error) {
175
+ revertStatus();
168
176
  toast.add({
169
177
  title: "Save failed",
170
178
  description: errorMessage(error),
@@ -125,7 +125,6 @@ watch(tabList, (tabs) => {
125
125
  if (!tabs.some((tab) => tab.id === activeTab.value)) activeTab.value = tabs[0]?.id ?? "";
126
126
  });
127
127
  const settings = useCmsSettingsState();
128
- const fields = computed(() => props.fields);
129
128
  const layout = computed(
130
129
  () => props.entryName ? entryFormLayout(props.entryName, settings.value, props.fields) : resolveFormLayout(props.fields, void 0)
131
130
  );
@@ -2,7 +2,6 @@ type __VLS_Props = {
2
2
  type?: string;
3
3
  size?: 'sm' | 'md' | 'lg';
4
4
  icon?: string;
5
- color?: 'error';
6
5
  };
7
6
  type __VLS_ModelProps = {
8
7
  modelValue?: string | number | null;
@@ -1,5 +1,9 @@
1
1
  <template>
2
- <div class="cms-input-wrap" :class="[$attrs.class, { 'has-lead': icon }]">
2
+ <div
3
+ class="cms-input-wrap"
4
+ :class="[$attrs.class, { 'has-lead': icon }]"
5
+ :style="$attrs.style"
6
+ >
3
7
  <span v-if="icon" class="cms-input-lead">
4
8
  <CmsIcon :name="icon" class="size-4" />
5
9
  </span>
@@ -7,7 +11,7 @@
7
11
  v-bind="inputAttrs"
8
12
  :type="type ?? 'text'"
9
13
  :value="model ?? ''"
10
- :class="['cms-field', `cms-field-${size ?? 'md'}`, { 'is-error': color === 'error' }]"
14
+ :class="['cms-field', `cms-field-${size ?? 'md'}`]"
11
15
  @input="onInput"
12
16
  />
13
17
  <span v-if="$slots.trailing" class="cms-input-trail">
@@ -22,8 +26,7 @@ defineOptions({ inheritAttrs: false });
22
26
  defineProps({
23
27
  type: { type: String, required: false },
24
28
  size: { type: String, required: false },
25
- icon: { type: String, required: false },
26
- color: { type: String, required: false }
29
+ icon: { type: String, required: false }
27
30
  });
28
31
  const [model, modifiers] = defineModel({ type: [String, Number, null] });
29
32
  const attrs = useAttrs();
@@ -2,7 +2,6 @@ type __VLS_Props = {
2
2
  type?: string;
3
3
  size?: 'sm' | 'md' | 'lg';
4
4
  icon?: string;
5
- color?: 'error';
6
5
  };
7
6
  type __VLS_ModelProps = {
8
7
  modelValue?: string | number | null;
@@ -6,7 +6,7 @@
6
6
  type="button"
7
7
  role="tab"
8
8
  :aria-selected="model === locale"
9
- :title="filled(locale) ? locale : `${locale} \u2014 empty`"
9
+ :title="filled(locale) ? locale : `${locale}: empty`"
10
10
  class="cms-locale-option"
11
11
  :class="{ 'is-active': model === locale, 'is-empty': !filled(locale) }"
12
12
  @click="model = locale"
@@ -1,7 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  folders: string[];
3
3
  exclude?: string | null;
4
- disabled?: (string | null)[];
5
4
  };
6
5
  type __VLS_ModelProps = {
7
6
  modelValue?: string | null;
@@ -50,8 +50,7 @@ import { computed, ref } from "#imports";
50
50
  import { isWithinMediaFolder, mediaFolderDepth, mediaFolderName } from "#nuxt-cms";
51
51
  const props = defineProps({
52
52
  folders: { type: Array, required: true },
53
- exclude: { type: [String, null], required: false },
54
- disabled: { type: Array, required: false }
53
+ exclude: { type: [String, null], required: false }
55
54
  });
56
55
  const model = defineModel({ type: [String, null], ...{ default: null } });
57
56
  const query = ref("");
@@ -60,7 +59,6 @@ const visible = computed(() => {
60
59
  return term ? props.folders.filter((folder) => folder.includes(term)) : props.folders;
61
60
  });
62
61
  function isDisabled(folder) {
63
- if (props.disabled?.includes(folder)) return true;
64
62
  return !!props.exclude && !!folder && isWithinMediaFolder(folder, props.exclude);
65
63
  }
66
64
  </script>
@@ -1,7 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  folders: string[];
3
3
  exclude?: string | null;
4
- disabled?: (string | null)[];
5
4
  };
6
5
  type __VLS_ModelProps = {
7
6
  modelValue?: string | null;
@@ -336,6 +336,19 @@
336
336
  <div v-if="query && item.folder" class="cms-media-folder">
337
337
  <CmsIcon name="folder" class="size-3" />{{ item.folder }}
338
338
  </div>
339
+ <span
340
+ v-if="!selectable"
341
+ class="cms-media-usage-badge"
342
+ :class="{ 'is-unused': usageCount(item.key) === 0 }"
343
+ >
344
+ <template v-if="usageCount(item.key) !== null">
345
+ <CmsIcon
346
+ :name="usageCount(item.key) ? 'link' : 'link-slash'"
347
+ class="size-3"
348
+ />{{ usageLabel(item.key) }}
349
+ </template>
350
+ <template v-else>&nbsp;</template>
351
+ </span>
339
352
  </div>
340
353
  </div>
341
354
  </div>
@@ -378,6 +391,18 @@
378
391
  <CmsIcon name="folder" class="size-3" />{{ item.folder }}
379
392
  </span>
380
393
  </span>
394
+ <span v-if="!selectable" class="cms-media-row-cell is-usage">
395
+ <span
396
+ v-if="usageCount(item.key) !== null"
397
+ class="cms-media-usage-badge"
398
+ :class="{ 'is-unused': usageCount(item.key) === 0 }"
399
+ >
400
+ <CmsIcon
401
+ :name="usageCount(item.key) ? 'link' : 'link-slash'"
402
+ class="size-3"
403
+ />{{ usageLabel(item.key) }}
404
+ </span>
405
+ </span>
381
406
  <span class="cms-media-row-cell is-mono">{{ extension(item.key) }}</span>
382
407
  <span class="cms-media-row-cell">{{
383
408
  item.size != null ? formatFileSize(item.size) : ""
@@ -556,13 +581,15 @@ import {
556
581
  mediaFolderName,
557
582
  mediaFolderParent,
558
583
  mediaIconFor,
584
+ groupMediaUsage,
585
+ mediaUsageGroups,
559
586
  mediaTypeAccept,
560
587
  mediaTypeFilter,
561
588
  rebaseMediaFolder,
562
589
  slugify
563
590
  } from "#nuxt-cms";
564
591
  import { useCmsConfirm } from "../../composables/cms-confirm";
565
- import { useCmsMediaLibrary, usageSummary } from "../../composables/cms-media-library";
592
+ import { useCmsMediaLibrary, usageLink } from "../../composables/cms-media-library";
566
593
  import {
567
594
  uploadTreeFromDrop,
568
595
  uploadTreeFromFiles,
@@ -707,6 +734,35 @@ watch(visibleFiles, (list) => {
707
734
  const paged = computed(
708
735
  () => visibleFiles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
709
736
  );
737
+ const usageByKey = ref({});
738
+ let usageRequest = 0;
739
+ watch(
740
+ () => [props.selectable, library.loading.value, paged.value],
741
+ async ([selectable, loading, items]) => {
742
+ const request = ++usageRequest;
743
+ if (selectable || loading || !items.length) return;
744
+ const usage = await library.usage(items.map((item) => item.key));
745
+ if (request !== usageRequest) return;
746
+ usageByKey.value = usage ?? {};
747
+ },
748
+ { immediate: true }
749
+ );
750
+ const usageCounts = computed(
751
+ () => new Map(
752
+ Object.entries(usageByKey.value).map(([key, list]) => [key, groupMediaUsage(list).length])
753
+ )
754
+ );
755
+ function usageCount(key) {
756
+ return usageCounts.value.get(key) ?? null;
757
+ }
758
+ function entriesLabel(count) {
759
+ return `${count} entr${count === 1 ? "y" : "ies"}`;
760
+ }
761
+ function usageLabel(key) {
762
+ const count = usageCount(key);
763
+ if (count === null) return "";
764
+ return count ? `Used in ${entriesLabel(count)}` : "Unused";
765
+ }
710
766
  const emptyState = computed(() => {
711
767
  if (query.value) {
712
768
  return {
@@ -890,6 +946,31 @@ async function renameFolder() {
890
946
  renameOpen.value = false;
891
947
  openFolder(to);
892
948
  }
949
+ async function confirmDelete(keys, question, title) {
950
+ const usage = await library.usage(keys);
951
+ const groups = usage ? mediaUsageGroups(usage, keys) : [];
952
+ if (!groups.length) {
953
+ const warning = usage ? "" : " Could not check where the files are used.";
954
+ return confirmAction(`${question}${warning}`, { title, confirmLabel: "Delete" });
955
+ }
956
+ const single = keys.length === 1;
957
+ const usedIn = single ? `This file is used in ${entriesLabel(groups[0].entries.length)}.` : `${groups.length} of ${keys.length} files are used in your content.`;
958
+ return confirmAction(
959
+ `${question} ${usedIn} Deleting ${single ? "it" : "them"} leaves broken links there.`,
960
+ {
961
+ title: single ? "File in use" : "Files in use",
962
+ confirmLabel: "Delete anyway",
963
+ groups: groups.map((group) => ({
964
+ title: group.key,
965
+ links: group.entries.map((entry) => ({
966
+ title: entry.title ?? entry.label,
967
+ meta: `${entry.label} \xB7 ${entry.fields.join(", ")}`,
968
+ to: usageLink(entry)
969
+ }))
970
+ }))
971
+ }
972
+ );
973
+ }
893
974
  async function removeFolder(path) {
894
975
  const files = library.itemsWithin(path);
895
976
  const subfolders = library.folders.value.filter(
@@ -898,10 +979,12 @@ async function removeFolder(path) {
898
979
  const contents = [];
899
980
  if (subfolders) contents.push(`${subfolders} subfolder${subfolders > 1 ? "s" : ""}`);
900
981
  if (files.length) contents.push(`${files.length} file${files.length > 1 ? "s" : ""}`);
901
- const keys = files.map((item) => item.key);
902
- const summary = usageSummary(await library.usage(keys), keys);
903
- const message = contents.length ? `Delete "${mediaFolderName(path)}" with ${contents.join(" and ")}? ${summary}` : `Delete the empty folder "${mediaFolderName(path)}"?`;
904
- if (!await confirmAction(message.trim(), { title: "Delete folder", confirmLabel: "Delete" }))
982
+ const question = contents.length ? `Delete "${mediaFolderName(path)}" with ${contents.join(" and ")}?` : `Delete the empty folder "${mediaFolderName(path)}"?`;
983
+ if (!await confirmDelete(
984
+ files.map((item) => item.key),
985
+ question,
986
+ "Delete folder"
987
+ ))
905
988
  return;
906
989
  const ok = await run(() => library.deleteFolder(path));
907
990
  if (ok && folder.value && isWithinMediaFolder(folder.value, path)) {
@@ -911,13 +994,8 @@ async function removeFolder(path) {
911
994
  async function removeItems(items) {
912
995
  if (!items.length) return;
913
996
  const keys = items.map((item) => item.key);
914
- const summary = usageSummary(await library.usage(keys), keys);
915
997
  const question = keys.length === 1 ? `Delete "${mediaFilename(keys[0])}"?` : `Delete ${keys.length} files?`;
916
- if (!await confirmAction(`${question} ${summary}`.trim(), {
917
- title: "Delete",
918
- confirmLabel: "Delete"
919
- }))
920
- return;
998
+ if (!await confirmDelete(keys, question, "Delete")) return;
921
999
  const ok = await run(() => library.deleteItems(keys));
922
1000
  if (!ok) return;
923
1001
  if (detailKey.value && keys.includes(detailKey.value)) detailKey.value = null;
@@ -1,7 +1,5 @@
1
1
  <template>
2
2
  <Teleport to="body">
3
- <!-- Wrapped in .cms-scope so the vendored Tailwind utilities still apply
4
- once teleported outside the admin layout root. -->
5
3
  <Transition name="cms-modal">
6
4
  <div v-if="open" class="cms-scope cms-overlay" @click.self="close">
7
5
  <div
@@ -27,7 +25,8 @@
27
25
  </template>
28
26
 
29
27
  <script setup>
30
- import { onBeforeUnmount, watch } from "#imports";
28
+ import { watch } from "#imports";
29
+ import { useCmsOverlay } from "../../composables/cms-overlay";
31
30
  defineProps({
32
31
  title: { type: String, required: false },
33
32
  size: { type: String, required: false }
@@ -37,19 +36,14 @@ const emit = defineEmits(["after:leave"]);
37
36
  function close() {
38
37
  open.value = false;
39
38
  }
40
- function onKeydown(event) {
41
- if (event.key === "Escape") close();
42
- }
39
+ const overlay = useCmsOverlay(close);
43
40
  watch(open, (value, previous) => {
44
41
  if (import.meta.server) return;
45
42
  if (value) {
46
- document.addEventListener("keydown", onKeydown);
43
+ overlay.activate();
47
44
  } else {
48
- document.removeEventListener("keydown", onKeydown);
45
+ overlay.deactivate();
49
46
  if (previous) emit("after:leave");
50
47
  }
51
48
  });
52
- onBeforeUnmount(() => {
53
- if (import.meta.client) document.removeEventListener("keydown", onKeydown);
54
- });
55
49
  </script>
@@ -67,11 +67,15 @@ function regenerate() {
67
67
  }
68
68
  async function copyPassword() {
69
69
  if (!model.value) return;
70
- await navigator.clipboard.writeText(model.value);
71
- copied.value = true;
72
- clearTimeout(copiedTimer);
73
- copiedTimer = setTimeout(() => {
70
+ try {
71
+ await navigator.clipboard.writeText(model.value);
72
+ copied.value = true;
73
+ clearTimeout(copiedTimer);
74
+ copiedTimer = setTimeout(() => {
75
+ copied.value = false;
76
+ }, 1600);
77
+ } catch {
74
78
  copied.value = false;
75
- }, 1600);
79
+ }
76
80
  }
77
81
  </script>
@@ -22,7 +22,7 @@
22
22
 
23
23
  <script setup>
24
24
  import { isTranslatableField } from "#nuxt-cms";
25
- import { computed, onMounted, ref, watch } from "#imports";
25
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "#imports";
26
26
  import cmsConfig from "#cms-config";
27
27
  import { useCmsRuntime } from "../../composables/cms-runtime";
28
28
  import { useCmsToast } from "../../composables/cms-toast";
@@ -40,22 +40,26 @@ const fetched = ref([]);
40
40
  const selectedRows = ref(/* @__PURE__ */ new Map());
41
41
  const loading = ref(true);
42
42
  const searchTerm = ref("");
43
+ let fetchRequest = 0;
43
44
  async function fetchOptions() {
45
+ const request = ++fetchRequest;
44
46
  loading.value = true;
45
47
  try {
46
48
  const term = searchTerm.value.trim();
47
49
  const page = await $fetch(endpoint, {
48
50
  query: { light: "true", limit: 25, ...term ? { search: term } : {} }
49
51
  });
52
+ if (request !== fetchRequest) return;
50
53
  fetched.value = page.items;
51
54
  } catch (err) {
55
+ if (request !== fetchRequest) return;
52
56
  toast.add({
53
57
  title: "Loading failed",
54
58
  description: errorMessage(err),
55
59
  color: "error"
56
60
  });
57
61
  } finally {
58
- loading.value = false;
62
+ if (request === fetchRequest) loading.value = false;
59
63
  }
60
64
  }
61
65
  let searchTimer;
@@ -63,6 +67,7 @@ watch(searchTerm, () => {
63
67
  clearTimeout(searchTimer);
64
68
  searchTimer = setTimeout(() => void fetchOptions(), 300);
65
69
  });
70
+ onBeforeUnmount(() => clearTimeout(searchTimer));
66
71
  const selectedIds = computed(() => {
67
72
  if (props.multiple) return model.value ?? [];
68
73
  return model.value ? [model.value] : [];
@@ -7,7 +7,6 @@ type __VLS_Props = {
7
7
  multiple?: boolean;
8
8
  ignoreFilter?: boolean;
9
9
  loading?: boolean;
10
- size?: string;
11
10
  placeholder?: string;
12
11
  };
13
12
  type __VLS_ModelProps = {
@@ -1,10 +1,6 @@
1
1
  <template>
2
2
  <div ref="root" class="relative w-full">
3
- <div
4
- class="cms-field flex flex-wrap items-center gap-1"
5
- :class="`cms-field-${size ?? 'md'}`"
6
- @click="focusInput"
7
- >
3
+ <div class="cms-field cms-field-md flex flex-wrap items-center gap-1" @click="focusInput">
8
4
  <template v-if="multiple">
9
5
  <span v-for="value in modelArray" :key="value" class="cms-tag">
10
6
  {{ labelFor(value) }}
@@ -70,7 +66,6 @@ const props = defineProps({
70
66
  multiple: { type: Boolean, required: false },
71
67
  ignoreFilter: { type: Boolean, required: false },
72
68
  loading: { type: Boolean, required: false },
73
- size: { type: String, required: false },
74
69
  placeholder: { type: String, required: false }
75
70
  });
76
71
  const model = defineModel({ type: [String, Array, null] });
@@ -7,7 +7,6 @@ type __VLS_Props = {
7
7
  multiple?: boolean;
8
8
  ignoreFilter?: boolean;
9
9
  loading?: boolean;
10
- size?: string;
11
10
  placeholder?: string;
12
11
  };
13
12
  type __VLS_ModelProps = {
@@ -55,7 +55,7 @@
55
55
  </template>
56
56
 
57
57
  <script setup>
58
- import { computed, ref, watch } from "#imports";
58
+ import { computed, ref } from "#imports";
59
59
  import cmsConfig from "#cms-config";
60
60
  import {
61
61
  entryFields,
@@ -89,13 +89,6 @@ const parts = computed(() => [
89
89
  { id: "layout", label: "Form layout" }
90
90
  ]);
91
91
  const activePart = ref(parts.value[0].id);
92
- watch(
93
- () => props.name,
94
- () => {
95
- activePart.value = parts.value[0].id;
96
- status.value = "idle";
97
- }
98
- );
99
92
  const columns = computed(() => entryListColumns(props.name, settings.value));
100
93
  const layout = computed(() => entryFormLayout(props.name, settings.value, fields.value));
101
94
  const isCustom = computed(() => {
@@ -132,10 +132,11 @@
132
132
  </template>
133
133
 
134
134
  <script setup>
135
- import { computed, onBeforeUnmount, ref, watch } from "#imports";
135
+ import { computed, ref, watch } from "#imports";
136
136
  import cmsConfig from "#cms-config";
137
137
  import { CMS_THEMES, setCmsTheme } from "../../composables/cms-theme";
138
138
  import { useCmsAccount } from "../../composables/cms-account";
139
+ import { useCmsOverlay } from "../../composables/cms-overlay";
139
140
  const { isSuperAdmin } = useCmsAccount();
140
141
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
141
142
  const theme = defineModel("theme", { type: String, ...{ required: true } });
@@ -165,15 +166,9 @@ function previewHalves(value) {
165
166
  function close() {
166
167
  open.value = false;
167
168
  }
168
- function onKeydown(event) {
169
- if (event.key === "Escape") close();
170
- }
169
+ const overlay = useCmsOverlay(close);
171
170
  watch(open, (value) => {
172
- if (import.meta.server) return;
173
- if (value) document.addEventListener("keydown", onKeydown);
174
- else document.removeEventListener("keydown", onKeydown);
175
- });
176
- onBeforeUnmount(() => {
177
- if (import.meta.client) document.removeEventListener("keydown", onKeydown);
171
+ if (value) overlay.activate();
172
+ else overlay.deactivate();
178
173
  });
179
174
  </script>