@xleddyl/nuxt-cms 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +61 -1
  2. package/dist/module.d.mts +3 -1
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +18 -7
  5. package/dist/runtime/app/components/cms/BlocksField.vue +59 -16
  6. package/dist/runtime/app/components/cms/ConfirmModal.d.vue.ts +20 -0
  7. package/dist/runtime/app/components/cms/ConfirmModal.vue +43 -0
  8. package/dist/runtime/app/components/cms/ConfirmModal.vue.d.ts +20 -0
  9. package/dist/runtime/app/components/cms/MediaGallery.vue +9 -2
  10. package/dist/runtime/app/components/cms/RelationField.vue +56 -10
  11. package/dist/runtime/app/components/cms/RichTextField.vue +63 -2
  12. package/dist/runtime/app/composables/cms-confirm.d.ts +4 -0
  13. package/dist/runtime/app/composables/cms-confirm.js +7 -0
  14. package/dist/runtime/app/i18n/en.json +32 -2
  15. package/dist/runtime/app/i18n/it.json +32 -2
  16. package/dist/runtime/app/pages/admin-collection.vue +89 -14
  17. package/dist/runtime/app/pages/admin-entry.vue +12 -5
  18. package/dist/runtime/server/api/collection.get.d.ts +1 -3
  19. package/dist/runtime/server/api/collection.get.js +34 -8
  20. package/dist/runtime/server/api/collection.post.js +11 -7
  21. package/dist/runtime/server/api/collection.put.js +11 -7
  22. package/dist/runtime/server/api/item.get.js +3 -2
  23. package/dist/runtime/server/api/item.put.js +12 -8
  24. package/dist/runtime/server/api/media-presign.post.js +2 -1
  25. package/dist/runtime/server/api/media.post.js +2 -1
  26. package/dist/runtime/server/plugins/migrate-libsql.d.ts +2 -0
  27. package/dist/runtime/server/plugins/migrate-libsql.js +17 -0
  28. package/dist/runtime/server/plugins/migrate-postgres.js +9 -1
  29. package/dist/runtime/server/plugins/migrate-sqlite.js +9 -1
  30. package/dist/runtime/server/routes/auth/login.post.js +1 -1
  31. package/dist/runtime/server/utils/db-libsql.d.ts +7 -0
  32. package/dist/runtime/server/utils/db-libsql.js +18 -0
  33. package/dist/runtime/server/utils/db-postgres.d.ts +4 -0
  34. package/dist/runtime/server/utils/db-postgres.js +3 -0
  35. package/dist/runtime/server/utils/db-sqlite.d.ts +2 -0
  36. package/dist/runtime/server/utils/db-sqlite.js +19 -0
  37. package/dist/runtime/server/utils/graphql.js +10 -4
  38. package/dist/runtime/server/utils/media.d.ts +1 -0
  39. package/dist/runtime/server/utils/media.js +27 -0
  40. package/dist/runtime/server/utils/relations.d.ts +3 -2
  41. package/dist/runtime/server/utils/relations.js +2 -4
  42. package/dist/runtime/server/utils/require-admin.js +15 -1
  43. package/dist/runtime/shared/index.d.ts +69 -1
  44. package/package.json +7 -1
package/README.md CHANGED
@@ -6,7 +6,67 @@ nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight
6
6
  - **Content types in code**: a `cms.config.ts` with `defineCmsConfig()` declares collections, single documents, relations, blocks and translatable fields; database schema, migrations and TypeScript types are generated from it.
7
7
  - **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage), single-admin auth from env credentials.
8
8
  - **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination.
9
- - **SQLite or Postgres**: a local file database by default, one config line to switch.
9
+ - **SQLite, Postgres or libSQL/Turso**: a local file database by default, one config line to switch (including remote SQLite over the network).
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @xleddyl/nuxt-cms
15
+ ```
16
+
17
+ Register the module and configure it under the `cms` key in `nuxt.config.ts`:
18
+
19
+ ```ts
20
+ export default defineNuxtConfig({
21
+ modules: ['@xleddyl/nuxt-cms'],
22
+ cms: {
23
+ database: {
24
+ driver: 'sqlite', // 'sqlite' | 'postgres' (with `url`) | 'libsql' (Turso/remote, with `url` + `authToken`)
25
+ path: 'data/cms.db',
26
+ },
27
+ i18n: {
28
+ locales: ['en', 'it'],
29
+ defaultLocale: 'en',
30
+ },
31
+ },
32
+ })
33
+ ```
34
+
35
+ Then declare your content types in a `cms.config.ts` at the project root with `defineCmsConfig()`.
36
+
37
+ ### Environment variables
38
+
39
+ Every secret maps to runtime config, so it can be set as an env var instead of in `nuxt.config.ts`:
40
+
41
+ | Variable | Required | Purpose |
42
+ | --- | --- | --- |
43
+ | `NUXT_CMS_ADMIN_EMAIL` | yes | admin login email |
44
+ | `NUXT_CMS_ADMIN_PASSWORD` | yes | admin login password |
45
+ | `NUXT_SESSION_PASSWORD` | in production | session encryption key (32+ chars) |
46
+ | `NUXT_CMS_DATABASE_URL` | with `postgres` / remote `libsql` | Postgres connection string or libSQL URL |
47
+ | `NUXT_CMS_DATABASE_AUTH_TOKEN` | with remote `libsql` | libSQL/Turso auth token |
48
+ | `NUXT_CMS_MEDIA_ENDPOINT` | for media | S3-compatible endpoint |
49
+ | `NUXT_CMS_MEDIA_REGION` | for media | S3 region (default `auto`) |
50
+ | `NUXT_CMS_MEDIA_BUCKET` | for media | bucket name |
51
+ | `NUXT_CMS_MEDIA_ACCESS_KEY_ID` | for media | S3 access key id |
52
+ | `NUXT_CMS_MEDIA_SECRET_ACCESS_KEY` | for media | S3 secret access key |
53
+ | `NUXT_PUBLIC_CMS_MEDIA_BASE_URL` | for media | public base URL for uploaded files |
54
+
55
+ ## Documentation
56
+
57
+ Full documentation lives in [`docs/`](docs/README.md):
58
+
59
+ - [Getting started](docs/getting-started.md) — install, configure, first content type, run.
60
+ - [Configuration](docs/configuration.md) — every `cms.*` option and the `NUXT_CMS_*` env vars.
61
+ - [Database](docs/database.md) — SQLite, Postgres and libSQL/Turso drivers, migrations, studio.
62
+ - [Schema](docs/schema.md) — `defineCmsConfig`, entries, field types, relations, blocks, i18n.
63
+ - [Querying content](docs/querying.md) — GraphQL API, `useCms` / `$cmsQuery`, filters, sorting, pagination.
64
+ - [Admin panel & security](docs/admin.md) — pages, authentication, sessions, admin REST API.
65
+ - [Media](docs/media.md) — S3-compatible storage, upload flow, allowed file types.
66
+
67
+ ## LLM guide
68
+
69
+ [llm.txt](llm.txt) is a compact reference meant to be fed to an LLM: schema definition (`cms.config.ts`, field types, relations, blocks, i18n), the `useCms` / `$cmsQuery` composables and the generated GraphQL API (filters, sorting, pagination).
10
70
 
11
71
  ## Development
12
72
 
package/dist/module.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
2
 
3
3
  type Dialect = 'sqlite' | 'postgres';
4
+ type Driver = Dialect | 'libsql';
4
5
 
5
6
  interface ModuleOptions {
6
7
  configPath: string;
@@ -9,9 +10,10 @@ interface ModuleOptions {
9
10
  password: string;
10
11
  };
11
12
  database: {
12
- driver?: Dialect;
13
+ driver?: Driver;
13
14
  path?: string;
14
15
  url?: string;
16
+ authToken?: string;
15
17
  };
16
18
  media: {
17
19
  endpoint: string;
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.9",
4
+ "version": "0.1.11",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -424,7 +424,8 @@ const module$1 = defineNuxtModule({
424
424
  database: {
425
425
  driver: "sqlite",
426
426
  path: "data/cms.db",
427
- url: ""
427
+ url: "",
428
+ authToken: ""
428
429
  },
429
430
  media: {
430
431
  endpoint: "",
@@ -481,7 +482,11 @@ const module$1 = defineNuxtModule({
481
482
  const schemaTemplate = addTemplate({
482
483
  filename: "cms/schema.ts",
483
484
  write: true,
484
- getContents: () => renderSchemaFile(cmsConfig, options.database.driver ?? "sqlite", resolveImport)
485
+ getContents: () => renderSchemaFile(
486
+ cmsConfig,
487
+ options.database.driver === "postgres" ? "postgres" : "sqlite",
488
+ resolveImport
489
+ )
485
490
  });
486
491
  nuxt.options.alias["#cms-tables"] = schemaTemplate.dst;
487
492
  addTemplate({
@@ -552,11 +557,16 @@ const module$1 = defineNuxtModule({
552
557
  ]
553
558
  });
554
559
  });
555
- const { driver = "sqlite", path: dbPath = "data/cms.db", url: databaseUrl = "" } = options.database;
560
+ const {
561
+ driver = "sqlite",
562
+ path: dbPath = "data/cms.db",
563
+ url: databaseUrl = "",
564
+ authToken: databaseAuthToken = ""
565
+ } = options.database;
556
566
  nuxt.options.alias["#cms-db"] = resolver.resolve(
557
- driver === "postgres" ? "./runtime/server/utils/db-postgres" : "./runtime/server/utils/db-sqlite"
567
+ driver === "postgres" ? "./runtime/server/utils/db-postgres" : driver === "libsql" ? "./runtime/server/utils/db-libsql" : "./runtime/server/utils/db-sqlite"
558
568
  );
559
- const dialect = driver === "postgres" ? "postgresql" : "sqlite";
569
+ const dialect = driver === "postgres" ? "postgresql" : driver === "libsql" ? "turso" : "sqlite";
560
570
  const resolvedDbPath = isAbsolute(dbPath) ? dbPath : resolve(nuxt.options.rootDir, dbPath);
561
571
  const migrationsDir = resolve(nuxt.options.rootDir, `server/db/migrations/${driver}`);
562
572
  const relativeSchemaPath = relative(nuxt.options.rootDir, schemaTemplate.dst);
@@ -570,14 +580,14 @@ const module$1 = defineNuxtModule({
570
580
  ` dialect: '${dialect}',`,
571
581
  ` schema: '${toPosix(schemaTemplate.dst)}',`,
572
582
  ` out: '${toPosix(migrationsDir)}',`,
573
- driver === "postgres" ? ` dbCredentials: { url: process.env.NUXT_CMS_DATABASE_URL ?? '${databaseUrl}' },` : ` dbCredentials: { url: '${toPosix(resolvedDbPath)}' },`,
583
+ 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 },` : ` dbCredentials: { url: '${toPosix(resolvedDbPath)}' },`,
574
584
  `}`,
575
585
  ``
576
586
  ].join("\n")
577
587
  });
578
588
  addServerPlugin(
579
589
  resolver.resolve(
580
- driver === "postgres" ? "./runtime/server/plugins/migrate-postgres" : "./runtime/server/plugins/migrate-sqlite"
590
+ driver === "postgres" ? "./runtime/server/plugins/migrate-postgres" : driver === "libsql" ? "./runtime/server/plugins/migrate-libsql" : "./runtime/server/plugins/migrate-sqlite"
581
591
  )
582
592
  );
583
593
  if (!nuxt.options.dev) {
@@ -608,6 +618,7 @@ const module$1 = defineNuxtModule({
608
618
  adminEmail: options.admin.email,
609
619
  adminPassword: options.admin.password,
610
620
  databaseUrl,
621
+ databaseAuthToken,
611
622
  dbPath: resolvedDbPath,
612
623
  migrationsDir,
613
624
  ...existingConfig,
@@ -6,14 +6,26 @@
6
6
  class="cms-card flex flex-col gap-4 p-4"
7
7
  >
8
8
  <div class="flex items-center gap-1">
9
- <span class="cms-kicker grow">
9
+ <button
10
+ type="button"
11
+ class="cms-kicker flex grow items-center gap-1.5 text-left"
12
+ :aria-label="
13
+ isCollapsed(item) ? t('cms.form.expandBlock') : t('cms.form.collapseBlock')
14
+ "
15
+ @click="toggleCollapsed(item)"
16
+ >
17
+ <UIcon
18
+ :name="isCollapsed(item) ? 'i-lucide-chevron-right' : 'i-lucide-chevron-down'"
19
+ class="size-3.5"
20
+ />
10
21
  {{ blocks[item.type]?.label ?? item.type }}
11
- </span>
22
+ </button>
12
23
  <UButton
13
24
  icon="i-lucide-chevron-up"
14
25
  size="xs"
15
26
  variant="ghost"
16
27
  color="neutral"
28
+ :aria-label="t('cms.form.moveUp')"
17
29
  :disabled="index === 0"
18
30
  @click="move(index, -1)"
19
31
  />
@@ -22,30 +34,42 @@
22
34
  size="xs"
23
35
  variant="ghost"
24
36
  color="neutral"
37
+ :aria-label="t('cms.form.moveDown')"
25
38
  :disabled="index === items.length - 1"
26
39
  @click="move(index, 1)"
27
40
  />
41
+ <UButton
42
+ icon="i-lucide-copy"
43
+ size="xs"
44
+ variant="ghost"
45
+ color="neutral"
46
+ :aria-label="t('cms.form.duplicateBlock')"
47
+ @click="duplicate(index)"
48
+ />
28
49
  <UButton
29
50
  icon="i-lucide-trash-2"
30
51
  size="xs"
31
52
  variant="ghost"
32
53
  color="error"
54
+ :aria-label="t('cms.form.removeBlock')"
33
55
  @click="remove(index)"
34
56
  />
35
57
  </div>
36
- <UFormField
37
- v-for="(blockField, blockKey) in blocks[item.type]?.fields"
38
- :key="blockKey"
39
- :label="blockField.label"
40
- :required="blockField.required"
41
- :ui="CMS_FIELD_UI"
42
- >
43
- <CmsFieldInput
44
- :model-value="item[blockKey]"
45
- :field="blockField"
46
- @update:model-value="(value) => updateField(index, blockKey, value)"
47
- />
48
- </UFormField>
58
+ <template v-if="!isCollapsed(item)">
59
+ <UFormField
60
+ v-for="(blockField, blockKey) in blocks[item.type]?.fields"
61
+ :key="blockKey"
62
+ :label="blockField.label"
63
+ :required="blockField.required"
64
+ :ui="CMS_FIELD_UI"
65
+ >
66
+ <CmsFieldInput
67
+ :model-value="item[blockKey]"
68
+ :field="blockField"
69
+ @update:model-value="(value) => updateField(index, blockKey, value)"
70
+ />
71
+ </UFormField>
72
+ </template>
49
73
  </div>
50
74
  <UDropdownMenu :items="addItems">
51
75
  <UButton
@@ -59,7 +83,7 @@
59
83
  </template>
60
84
 
61
85
  <script setup>
62
- import { computed, useI18n } from "#imports";
86
+ import { computed, ref, useI18n } from "#imports";
63
87
  import { CMS_FIELD_UI } from "../../utils/ui";
64
88
  const props = defineProps({
65
89
  field: { type: Object, required: true }
@@ -95,6 +119,25 @@ function remove(index) {
95
119
  const next = items.value.filter((_, i) => i !== index);
96
120
  model.value = next.length ? next : null;
97
121
  }
122
+ function duplicate(index) {
123
+ const current = items.value[index];
124
+ if (!current) return;
125
+ const copy = JSON.parse(JSON.stringify(current));
126
+ const next = [...items.value];
127
+ next.splice(index + 1, 0, copy);
128
+ model.value = next;
129
+ }
130
+ const collapsedUids = ref(/* @__PURE__ */ new Set());
131
+ function isCollapsed(item) {
132
+ return collapsedUids.value.has(uidFor(item));
133
+ }
134
+ function toggleCollapsed(item) {
135
+ const uid = uidFor(item);
136
+ const next = new Set(collapsedUids.value);
137
+ if (next.has(uid)) next.delete(uid);
138
+ else next.add(uid);
139
+ collapsedUids.value = next;
140
+ }
98
141
  function move(index, delta) {
99
142
  const next = [...items.value];
100
143
  const [item] = next.splice(index, 1);
@@ -0,0 +1,20 @@
1
+ type __VLS_Props = {
2
+ message: string;
3
+ title?: string;
4
+ confirmLabel?: string;
5
+ };
6
+ type __VLS_ModelProps = {
7
+ 'open'?: boolean;
8
+ };
9
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
10
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
11
+ close: (value: boolean) => any;
12
+ "after:leave": () => any;
13
+ "update:open": (value: boolean) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
15
+ onClose?: ((value: boolean) => any) | undefined;
16
+ "onAfter:leave"?: (() => any) | undefined;
17
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
18
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
19
+ declare const _default: typeof __VLS_export;
20
+ export default _default;
@@ -0,0 +1,43 @@
1
+ <template>
2
+ <UModal
3
+ v-model:open="open"
4
+ :title="title ?? t('cms.confirm.title')"
5
+ :ui="ui"
6
+ @after:leave="emit('after:leave')"
7
+ >
8
+ <template #body>
9
+ <div class="flex flex-col gap-5">
10
+ <p class="text-(--ui-text) text-sm">{{ message }}</p>
11
+ <div class="flex justify-end gap-2">
12
+ <UButton
13
+ :label="t('cms.confirm.cancel')"
14
+ variant="subtle"
15
+ color="neutral"
16
+ class="rounded-full px-4"
17
+ @click="emit('close', false)"
18
+ />
19
+ <UButton
20
+ :label="confirmLabel ?? t('cms.confirm.confirm')"
21
+ color="error"
22
+ class="rounded-full px-4"
23
+ @click="emit('close', true)"
24
+ />
25
+ </div>
26
+ </div>
27
+ </template>
28
+ </UModal>
29
+ </template>
30
+
31
+ <script setup>
32
+ import { useI18n } from "#imports";
33
+ import { CMS_MODAL_UI } from "../../utils/ui";
34
+ defineProps({
35
+ message: { type: String, required: true },
36
+ title: { type: String, required: false },
37
+ confirmLabel: { type: String, required: false }
38
+ });
39
+ const open = defineModel("open", { type: Boolean, ...{ default: true } });
40
+ const emit = defineEmits(["close", "after:leave"]);
41
+ const { t } = useI18n();
42
+ const ui = { ...CMS_MODAL_UI, content: "rounded-2xl shadow-xl sm:max-w-md" };
43
+ </script>
@@ -0,0 +1,20 @@
1
+ type __VLS_Props = {
2
+ message: string;
3
+ title?: string;
4
+ confirmLabel?: string;
5
+ };
6
+ type __VLS_ModelProps = {
7
+ 'open'?: boolean;
8
+ };
9
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
10
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
11
+ close: (value: boolean) => any;
12
+ "after:leave": () => any;
13
+ "update:open": (value: boolean) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
15
+ onClose?: ((value: boolean) => any) | undefined;
16
+ "onAfter:leave"?: (() => any) | undefined;
17
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
18
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
19
+ declare const _default: typeof __VLS_export;
20
+ export default _default;
@@ -86,7 +86,7 @@
86
86
  />
87
87
  <div
88
88
  v-if="!selectable"
89
- class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
89
+ class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
90
90
  >
91
91
  <UButton
92
92
  icon="i-lucide-pencil"
@@ -187,6 +187,7 @@
187
187
  <script setup>
188
188
  import { computed, onMounted, ref, useI18n, useToast } from "#imports";
189
189
  import { MEDIA_TYPES, mediaFilename, mediaIconFor } from "#nuxt-cms";
190
+ import { useCmsConfirm } from "../../composables/cms-confirm";
190
191
  import { CMS_MODAL_UI, errorMessage } from "../../utils/ui";
191
192
  const props = defineProps({
192
193
  selectable: { type: Boolean, required: false },
@@ -293,8 +294,12 @@ async function copy(item) {
293
294
  toast.add({ title: t("cms.media.copyError"), color: "error" });
294
295
  }
295
296
  }
297
+ const confirmAction = useCmsConfirm();
298
+ const removing = ref(false);
296
299
  async function remove(item) {
297
- if (!confirm(t("cms.media.deleteConfirm"))) return;
300
+ if (removing.value) return;
301
+ if (!await confirmAction(t("cms.media.deleteConfirm"))) return;
302
+ removing.value = true;
298
303
  try {
299
304
  await $fetch(endpoint, { method: "DELETE", query: { key: item.key } });
300
305
  await reload();
@@ -304,6 +309,8 @@ async function remove(item) {
304
309
  description: errorMessage(err),
305
310
  color: "error"
306
311
  });
312
+ } finally {
313
+ removing.value = false;
307
314
  }
308
315
  }
309
316
  </script>
@@ -2,7 +2,9 @@
2
2
  <USelectMenu
3
3
  v-if="multiple"
4
4
  v-model="many"
5
+ v-model:search-term="searchTerm"
5
6
  multiple
7
+ ignore-filter
6
8
  :items="options"
7
9
  value-key="value"
8
10
  :loading="loading"
@@ -12,6 +14,8 @@
12
14
  <USelectMenu
13
15
  v-else
14
16
  v-model="single"
17
+ v-model:search-term="searchTerm"
18
+ ignore-filter
15
19
  :items="singleItems"
16
20
  value-key="value"
17
21
  :loading="loading"
@@ -22,7 +26,7 @@
22
26
 
23
27
  <script setup>
24
28
  import { isTranslatableField } from "#nuxt-cms";
25
- import { computed, onMounted, ref, useI18n, useToast } from "#imports";
29
+ import { computed, onMounted, ref, useI18n, useToast, watch } from "#imports";
26
30
  import cmsConfig from "#cms-config";
27
31
  import { useCmsRuntime } from "../../composables/cms-runtime";
28
32
  import { errorMessage } from "../../utils/ui";
@@ -35,11 +39,19 @@ const model = defineModel({ type: [String, Array, null], ...{ required: true } }
35
39
  const { t } = useI18n();
36
40
  const toast = useToast();
37
41
  const target = cmsConfig[props.to];
38
- const rows = ref([]);
42
+ const endpoint = `/api/cms/admin/${props.to}`;
43
+ const fetched = ref([]);
44
+ const selectedRows = ref(/* @__PURE__ */ new Map());
39
45
  const loading = ref(true);
40
- onMounted(async () => {
46
+ const searchTerm = ref("");
47
+ async function fetchOptions() {
48
+ loading.value = true;
41
49
  try {
42
- rows.value = await $fetch(`/api/cms/admin/${props.to}`);
50
+ const term = searchTerm.value.trim();
51
+ const page = await $fetch(endpoint, {
52
+ query: { light: "true", limit: 25, ...term ? { search: term } : {} }
53
+ });
54
+ fetched.value = page.items;
43
55
  } catch (err) {
44
56
  toast.add({
45
57
  title: t("cms.toast.loadFailed"),
@@ -49,6 +61,31 @@ onMounted(async () => {
49
61
  } finally {
50
62
  loading.value = false;
51
63
  }
64
+ }
65
+ let searchTimer;
66
+ watch(searchTerm, () => {
67
+ clearTimeout(searchTimer);
68
+ searchTimer = setTimeout(() => void fetchOptions(), 300);
69
+ });
70
+ const selectedIds = computed(() => {
71
+ if (props.multiple) return model.value ?? [];
72
+ return model.value ? [model.value] : [];
73
+ });
74
+ async function resolveSelected() {
75
+ const missing = selectedIds.value.filter((id) => !selectedRows.value.has(id));
76
+ await Promise.all(
77
+ missing.map(async (id) => {
78
+ try {
79
+ const row = await $fetch(`${endpoint}/${id}`);
80
+ selectedRows.value.set(id, row);
81
+ } catch {
82
+ }
83
+ })
84
+ );
85
+ }
86
+ onMounted(() => {
87
+ void fetchOptions();
88
+ void resolveSelected();
52
89
  });
53
90
  const { i18n: contentI18n } = useCmsRuntime();
54
91
  const titleField = target?.titleField ?? "id";
@@ -62,12 +99,21 @@ function optionLabel(row) {
62
99
  }
63
100
  return String(value ?? `#${row.id}`);
64
101
  }
65
- const options = computed(
66
- () => rows.value.map((row) => ({
67
- label: optionLabel(row),
68
- value: row.id
69
- }))
70
- );
102
+ const options = computed(() => {
103
+ const seen = /* @__PURE__ */ new Set();
104
+ const list = [];
105
+ for (const row of fetched.value) {
106
+ const id = row.id;
107
+ list.push({ label: optionLabel(row), value: id });
108
+ seen.add(id);
109
+ }
110
+ for (const id of selectedIds.value) {
111
+ if (seen.has(id)) continue;
112
+ const row = selectedRows.value.get(id);
113
+ list.push({ label: row ? optionLabel(row) : `#${id}`, value: id });
114
+ }
115
+ return list;
116
+ });
71
117
  const singleItems = computed(() => [
72
118
  ...props.required ? [] : [{ label: "\u2014", value: null }],
73
119
  ...options.value
@@ -8,14 +8,48 @@
8
8
  size="xs"
9
9
  :variant="editor?.isActive(action.mark, action.attrs) ? 'soft' : 'ghost'"
10
10
  color="neutral"
11
+ :aria-label="t(action.label)"
12
+ :title="t(action.label)"
11
13
  @click="action.run"
12
14
  />
15
+ <UPopover v-model:open="linkOpen">
16
+ <UButton
17
+ icon="i-lucide-link"
18
+ size="xs"
19
+ :variant="editor?.isActive('link') ? 'soft' : 'ghost'"
20
+ color="neutral"
21
+ :aria-label="t('cms.richtext.link')"
22
+ :title="t('cms.richtext.link')"
23
+ />
24
+ <template #content>
25
+ <div class="flex items-center gap-2 p-2">
26
+ <UInput
27
+ v-model="linkUrl"
28
+ size="sm"
29
+ placeholder="https://"
30
+ class="w-64"
31
+ @keydown.enter.prevent="applyLink"
32
+ />
33
+ <UButton size="xs" :label="t('cms.richtext.setLink')" @click="applyLink" />
34
+ <UButton
35
+ v-if="editor?.isActive('link')"
36
+ size="xs"
37
+ variant="subtle"
38
+ color="error"
39
+ :label="t('cms.richtext.unsetLink')"
40
+ @click="removeLink"
41
+ />
42
+ </div>
43
+ </template>
44
+ </UPopover>
13
45
  <span class="grow" />
14
46
  <UButton
15
47
  icon="i-lucide-undo-2"
16
48
  size="xs"
17
49
  variant="ghost"
18
50
  color="neutral"
51
+ :aria-label="t('cms.richtext.undo')"
52
+ :title="t('cms.richtext.undo')"
19
53
  :disabled="!editor?.can().undo()"
20
54
  @click="undo"
21
55
  />
@@ -24,6 +58,8 @@
24
58
  size="xs"
25
59
  variant="ghost"
26
60
  color="neutral"
61
+ :aria-label="t('cms.richtext.redo')"
62
+ :title="t('cms.richtext.redo')"
27
63
  :disabled="!editor?.can().redo()"
28
64
  @click="redo"
29
65
  />
@@ -35,11 +71,12 @@
35
71
  <script setup>
36
72
  import StarterKit from "@tiptap/starter-kit";
37
73
  import { EditorContent, useEditor } from "@tiptap/vue-3";
38
- import { watch } from "#imports";
74
+ import { ref, useI18n, watch } from "#imports";
39
75
  const model = defineModel({ type: [String, null], ...{ required: true } });
76
+ const { t } = useI18n();
40
77
  const editor = useEditor({
41
78
  content: model.value ?? "",
42
- extensions: [StarterKit],
79
+ extensions: [StarterKit.configure({ link: { openOnClick: false } })],
43
80
  editorProps: {
44
81
  attributes: { class: "cms-richtext-body" }
45
82
  },
@@ -58,6 +95,7 @@ const actions = [
58
95
  {
59
96
  icon: "i-lucide-bold",
60
97
  mark: "bold",
98
+ label: "cms.richtext.bold",
61
99
  run: () => {
62
100
  editor.value?.chain().focus().toggleBold().run();
63
101
  }
@@ -65,6 +103,7 @@ const actions = [
65
103
  {
66
104
  icon: "i-lucide-italic",
67
105
  mark: "italic",
106
+ label: "cms.richtext.italic",
68
107
  run: () => {
69
108
  editor.value?.chain().focus().toggleItalic().run();
70
109
  }
@@ -72,6 +111,7 @@ const actions = [
72
111
  {
73
112
  icon: "i-lucide-strikethrough",
74
113
  mark: "strike",
114
+ label: "cms.richtext.strike",
75
115
  run: () => {
76
116
  editor.value?.chain().focus().toggleStrike().run();
77
117
  }
@@ -80,6 +120,7 @@ const actions = [
80
120
  icon: "i-lucide-heading-2",
81
121
  mark: "heading",
82
122
  attrs: { level: 2 },
123
+ label: "cms.richtext.h2",
83
124
  run: () => {
84
125
  editor.value?.chain().focus().toggleHeading({ level: 2 }).run();
85
126
  }
@@ -88,6 +129,7 @@ const actions = [
88
129
  icon: "i-lucide-heading-3",
89
130
  mark: "heading",
90
131
  attrs: { level: 3 },
132
+ label: "cms.richtext.h3",
91
133
  run: () => {
92
134
  editor.value?.chain().focus().toggleHeading({ level: 3 }).run();
93
135
  }
@@ -95,6 +137,7 @@ const actions = [
95
137
  {
96
138
  icon: "i-lucide-list",
97
139
  mark: "bulletList",
140
+ label: "cms.richtext.bulletList",
98
141
  run: () => {
99
142
  editor.value?.chain().focus().toggleBulletList().run();
100
143
  }
@@ -102,6 +145,7 @@ const actions = [
102
145
  {
103
146
  icon: "i-lucide-list-ordered",
104
147
  mark: "orderedList",
148
+ label: "cms.richtext.orderedList",
105
149
  run: () => {
106
150
  editor.value?.chain().focus().toggleOrderedList().run();
107
151
  }
@@ -109,11 +153,28 @@ const actions = [
109
153
  {
110
154
  icon: "i-lucide-text-quote",
111
155
  mark: "blockquote",
156
+ label: "cms.richtext.blockquote",
112
157
  run: () => {
113
158
  editor.value?.chain().focus().toggleBlockquote().run();
114
159
  }
115
160
  }
116
161
  ];
162
+ const linkOpen = ref(false);
163
+ const linkUrl = ref("");
164
+ watch(linkOpen, (open) => {
165
+ if (open) linkUrl.value = editor.value?.getAttributes("link").href ?? "";
166
+ });
167
+ function applyLink() {
168
+ const url = linkUrl.value.trim();
169
+ const chain = editor.value?.chain().focus().extendMarkRange("link");
170
+ if (url) chain?.setLink({ href: url }).run();
171
+ else chain?.unsetLink().run();
172
+ linkOpen.value = false;
173
+ }
174
+ function removeLink() {
175
+ editor.value?.chain().focus().extendMarkRange("link").unsetLink().run();
176
+ linkOpen.value = false;
177
+ }
117
178
  function undo() {
118
179
  editor.value?.chain().focus().undo().run();
119
180
  }