@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.
- package/README.md +61 -1
- package/dist/module.d.mts +3 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +18 -7
- package/dist/runtime/app/components/cms/BlocksField.vue +59 -16
- package/dist/runtime/app/components/cms/ConfirmModal.d.vue.ts +20 -0
- package/dist/runtime/app/components/cms/ConfirmModal.vue +43 -0
- package/dist/runtime/app/components/cms/ConfirmModal.vue.d.ts +20 -0
- package/dist/runtime/app/components/cms/MediaGallery.vue +9 -2
- package/dist/runtime/app/components/cms/RelationField.vue +56 -10
- package/dist/runtime/app/components/cms/RichTextField.vue +63 -2
- package/dist/runtime/app/composables/cms-confirm.d.ts +4 -0
- package/dist/runtime/app/composables/cms-confirm.js +7 -0
- package/dist/runtime/app/i18n/en.json +32 -2
- package/dist/runtime/app/i18n/it.json +32 -2
- package/dist/runtime/app/pages/admin-collection.vue +89 -14
- package/dist/runtime/app/pages/admin-entry.vue +12 -5
- package/dist/runtime/server/api/collection.get.d.ts +1 -3
- package/dist/runtime/server/api/collection.get.js +34 -8
- package/dist/runtime/server/api/collection.post.js +11 -7
- package/dist/runtime/server/api/collection.put.js +11 -7
- package/dist/runtime/server/api/item.get.js +3 -2
- package/dist/runtime/server/api/item.put.js +12 -8
- package/dist/runtime/server/api/media-presign.post.js +2 -1
- package/dist/runtime/server/api/media.post.js +2 -1
- package/dist/runtime/server/plugins/migrate-libsql.d.ts +2 -0
- package/dist/runtime/server/plugins/migrate-libsql.js +17 -0
- package/dist/runtime/server/plugins/migrate-postgres.js +9 -1
- package/dist/runtime/server/plugins/migrate-sqlite.js +9 -1
- package/dist/runtime/server/routes/auth/login.post.js +1 -1
- package/dist/runtime/server/utils/db-libsql.d.ts +7 -0
- package/dist/runtime/server/utils/db-libsql.js +18 -0
- package/dist/runtime/server/utils/db-postgres.d.ts +4 -0
- package/dist/runtime/server/utils/db-postgres.js +3 -0
- package/dist/runtime/server/utils/db-sqlite.d.ts +2 -0
- package/dist/runtime/server/utils/db-sqlite.js +19 -0
- package/dist/runtime/server/utils/graphql.js +10 -4
- package/dist/runtime/server/utils/media.d.ts +1 -0
- package/dist/runtime/server/utils/media.js +27 -0
- package/dist/runtime/server/utils/relations.d.ts +3 -2
- package/dist/runtime/server/utils/relations.js +2 -4
- package/dist/runtime/server/utils/require-admin.js +15 -1
- package/dist/runtime/shared/index.d.ts +69 -1
- 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
|
|
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?:
|
|
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
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(
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
46
|
+
const searchTerm = ref("");
|
|
47
|
+
async function fetchOptions() {
|
|
48
|
+
loading.value = true;
|
|
41
49
|
try {
|
|
42
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
}
|