@xleddyl/nuxt-cms 0.1.28 → 0.1.30
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 +5 -4
- package/dist/module.d.mts +37 -30
- package/dist/module.json +1 -1
- package/dist/module.mjs +165 -35
- package/dist/runtime/app/components/cms/MediaUpload.vue +9 -4
- package/dist/runtime/app/components/cms/TranslatableField.vue +19 -8
- package/dist/runtime/app/composables/cms-runtime.d.ts +1 -0
- package/dist/runtime/app/pages/admin-collection.vue +8 -2
- package/dist/runtime/server/api/collection.get.d.ts +1 -3
- package/dist/runtime/server/api/collection.get.js +3 -2
- package/dist/runtime/server/api/collection.post.js +3 -3
- package/dist/runtime/server/api/collection.put.js +13 -3
- package/dist/runtime/server/api/item.get.js +2 -2
- package/dist/runtime/server/api/item.put.js +7 -2
- package/dist/runtime/server/api/media-presign.post.js +3 -9
- package/dist/runtime/server/plugins/media-sync-local.js +24 -7
- package/dist/runtime/server/utils/graphql.js +13 -1
- package/dist/runtime/server/utils/media.d.ts +2 -0
- package/dist/runtime/server/utils/media.js +9 -1
- package/dist/runtime/server/utils/registry.d.ts +2 -0
- package/dist/runtime/server/utils/registry.js +7 -0
- package/dist/runtime/shared/index.d.ts +11 -1
- package/dist/runtime/shared/index.js +70 -1
- package/dist/runtime/shared/validation.js +1 -1
- package/dist/types.d.mts +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,10 +20,6 @@ Register the module and configure it under the `cms` key in `nuxt.config.ts`:
|
|
|
20
20
|
export default defineNuxtConfig({
|
|
21
21
|
modules: ['@xleddyl/nuxt-cms'],
|
|
22
22
|
cms: {
|
|
23
|
-
database: {
|
|
24
|
-
driver: 'sqlite', // 'sqlite' | 'postgres' (with `url`) | 'libsql' (Turso/remote, with `url` + `authToken`)
|
|
25
|
-
path: 'data/cms.db',
|
|
26
|
-
},
|
|
27
23
|
i18n: {
|
|
28
24
|
locales: ['en', 'it'],
|
|
29
25
|
defaultLocale: 'en',
|
|
@@ -32,6 +28,11 @@ export default defineNuxtConfig({
|
|
|
32
28
|
})
|
|
33
29
|
```
|
|
34
30
|
|
|
31
|
+
`database` defaults to SQLite (`{ driver: 'sqlite', path: 'data/cms.db' }`) and can be omitted
|
|
32
|
+
entirely. Switch it to `{ driver: 'postgres', url: '...' }` or
|
|
33
|
+
`{ driver: 'libsql', url: '...', authToken: '...' }` (Turso/remote); see
|
|
34
|
+
[Configuration](docs/configuration.md) for every option.
|
|
35
|
+
|
|
35
36
|
Then declare your content types in a `cms.config.ts` at the project root with `defineCmsConfig()`.
|
|
36
37
|
|
|
37
38
|
### Disabling the CMS
|
package/dist/module.d.mts
CHANGED
|
@@ -1,41 +1,48 @@
|
|
|
1
1
|
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
-
import { MediaStorageMode } from '../dist/runtime/shared/index.js';
|
|
3
|
-
|
|
4
|
-
type Dialect = 'sqlite' | 'postgres';
|
|
5
|
-
type Driver = Dialect | 'libsql';
|
|
6
2
|
|
|
3
|
+
type ModuleOptionsDatabase = {
|
|
4
|
+
driver?: 'sqlite';
|
|
5
|
+
path?: string;
|
|
6
|
+
} | {
|
|
7
|
+
driver: 'postgres';
|
|
8
|
+
url?: string;
|
|
9
|
+
} | {
|
|
10
|
+
driver: 'libsql';
|
|
11
|
+
url?: string;
|
|
12
|
+
authToken?: string;
|
|
13
|
+
};
|
|
14
|
+
type ModuleOptionsMedia = {
|
|
15
|
+
storage?: 's3';
|
|
16
|
+
endpoint?: string;
|
|
17
|
+
region?: string;
|
|
18
|
+
bucket?: string;
|
|
19
|
+
accessKeyId?: string;
|
|
20
|
+
secretAccessKey?: string;
|
|
21
|
+
publicBaseUrl?: string;
|
|
22
|
+
presignExpiry?: number;
|
|
23
|
+
maxFileSize?: number;
|
|
24
|
+
} | {
|
|
25
|
+
storage: 'local';
|
|
26
|
+
publicBaseUrl: string;
|
|
27
|
+
};
|
|
7
28
|
interface ModuleOptions {
|
|
8
29
|
enabled?: boolean;
|
|
9
|
-
configPath
|
|
10
|
-
admin
|
|
11
|
-
email
|
|
12
|
-
password
|
|
13
|
-
};
|
|
14
|
-
database: {
|
|
15
|
-
driver?: Driver;
|
|
16
|
-
path?: string;
|
|
17
|
-
url?: string;
|
|
18
|
-
authToken?: string;
|
|
19
|
-
};
|
|
20
|
-
media: {
|
|
21
|
-
storage: MediaStorageMode;
|
|
22
|
-
endpoint: string;
|
|
23
|
-
region: string;
|
|
24
|
-
bucket: string;
|
|
25
|
-
publicBaseUrl: string;
|
|
26
|
-
presignExpiry: number;
|
|
27
|
-
accessKeyId: string;
|
|
28
|
-
secretAccessKey: string;
|
|
30
|
+
configPath?: string;
|
|
31
|
+
admin?: {
|
|
32
|
+
email?: string;
|
|
33
|
+
password?: string;
|
|
29
34
|
};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
database?: ModuleOptionsDatabase;
|
|
36
|
+
media?: ModuleOptionsMedia;
|
|
37
|
+
i18n?: {
|
|
38
|
+
locales?: string[];
|
|
39
|
+
defaultLocale?: string;
|
|
33
40
|
};
|
|
34
|
-
graphql
|
|
35
|
-
maxDepth
|
|
41
|
+
graphql?: {
|
|
42
|
+
maxDepth?: number;
|
|
36
43
|
};
|
|
37
44
|
}
|
|
38
45
|
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
39
46
|
|
|
40
47
|
export { _default as default };
|
|
41
|
-
export type { ModuleOptions };
|
|
48
|
+
export type { ModuleOptions, ModuleOptionsDatabase, ModuleOptionsMedia };
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -10,7 +10,31 @@ import { introspectionFromSchema, buildSchema } from 'graphql';
|
|
|
10
10
|
import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal';
|
|
11
11
|
import { createJiti } from 'jiti';
|
|
12
12
|
import { typeName, blockTypeName, blockUnionName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
|
|
13
|
-
import { isMultiSelect, isTranslatableField } from '../dist/runtime/shared/index.js';
|
|
13
|
+
import { isMultiSelect, isTranslatableField, isTranslatableMediaField, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
|
|
14
|
+
import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
|
|
15
|
+
|
|
16
|
+
async function collectMediaManifest(root) {
|
|
17
|
+
const files = await scanMediaDirectory(root);
|
|
18
|
+
return Promise.all(files.map((file) => readMediaFileMeta(root, file)));
|
|
19
|
+
}
|
|
20
|
+
function renderMediaManifestFile(files) {
|
|
21
|
+
return [
|
|
22
|
+
`export const generated = ${files !== null}`,
|
|
23
|
+
``,
|
|
24
|
+
`export const files = ${JSON.stringify(files ?? [], null, 3)}`,
|
|
25
|
+
``
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
function renderMediaManifestTypes(typesPath) {
|
|
29
|
+
return [
|
|
30
|
+
`import type { MediaFileMeta } from '${typesPath}'`,
|
|
31
|
+
``,
|
|
32
|
+
`export declare const generated: boolean`,
|
|
33
|
+
``,
|
|
34
|
+
`export declare const files: MediaFileMeta[]`,
|
|
35
|
+
``
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
14
38
|
|
|
15
39
|
const IDENTIFIER = /^[a-z_]\w*$/i;
|
|
16
40
|
const RESERVED_ENTRY_KEYS = ["admin", "auth", "login", "media", "graphql", "cms_media"];
|
|
@@ -96,8 +120,10 @@ function validateConfig(config, i18n) {
|
|
|
96
120
|
columnNames.add(column);
|
|
97
121
|
}
|
|
98
122
|
if (field.translatable) {
|
|
99
|
-
if (field.type !== "text" && field.type !== "richtext")
|
|
100
|
-
errors.push(
|
|
123
|
+
if (field.type !== "text" && field.type !== "richtext" && field.type !== "media")
|
|
124
|
+
errors.push(
|
|
125
|
+
`${fat}: translatable is only supported on text, richtext and media fields`
|
|
126
|
+
);
|
|
101
127
|
if (!locales.length)
|
|
102
128
|
errors.push(`${fat}: translatable requires cms.i18n.locales in nuxt.config`);
|
|
103
129
|
}
|
|
@@ -207,7 +233,7 @@ function columnExpr(key, field, dialect) {
|
|
|
207
233
|
const col = snakeCase(key);
|
|
208
234
|
let expr;
|
|
209
235
|
if (isTranslatableField(field)) {
|
|
210
|
-
expr = jsonExpr(col, dialect);
|
|
236
|
+
expr = isTranslatableMediaField(field) ? `text('${col}')` : jsonExpr(col, dialect);
|
|
211
237
|
if (field.required) expr += ".notNull()";
|
|
212
238
|
return ` ${key}: ${expr},`;
|
|
213
239
|
}
|
|
@@ -304,7 +330,7 @@ function renderSchemaFile(config, dialect, resolveImport = (s) => s) {
|
|
|
304
330
|
if (pg && fields.some((f) => f.type === "date")) core.add("date");
|
|
305
331
|
if (pg && fields.some((f) => f.type === "boolean")) core.add("boolean");
|
|
306
332
|
if (pg && fields.some(
|
|
307
|
-
(f) => f.type === "json" || f.type === "blocks" || isTranslatableField(f) || isMultiSelect(f)
|
|
333
|
+
(f) => f.type === "json" || f.type === "blocks" || isTranslatableField(f) && !isTranslatableMediaField(f) || isMultiSelect(f)
|
|
308
334
|
))
|
|
309
335
|
core.add("jsonb");
|
|
310
336
|
if (pg) core.add("timestamp");
|
|
@@ -428,6 +454,64 @@ function resolveCmsEnabled(explicit, envValue) {
|
|
|
428
454
|
return true;
|
|
429
455
|
}
|
|
430
456
|
|
|
457
|
+
function resolveDatabaseOptions(database) {
|
|
458
|
+
if (database?.driver === "postgres") {
|
|
459
|
+
return { driver: "postgres", path: "data/cms.db", url: database.url ?? "", authToken: "" };
|
|
460
|
+
}
|
|
461
|
+
if (database?.driver === "libsql") {
|
|
462
|
+
return {
|
|
463
|
+
driver: "libsql",
|
|
464
|
+
path: "data/cms.db",
|
|
465
|
+
url: database.url ?? "",
|
|
466
|
+
authToken: database.authToken ?? ""
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
return { driver: "sqlite", path: database?.path ?? "data/cms.db", url: "", authToken: "" };
|
|
470
|
+
}
|
|
471
|
+
function resolveMediaOptions(media) {
|
|
472
|
+
if (media?.storage === "local") {
|
|
473
|
+
return {
|
|
474
|
+
storage: "local",
|
|
475
|
+
endpoint: "",
|
|
476
|
+
region: "auto",
|
|
477
|
+
bucket: "",
|
|
478
|
+
accessKeyId: "",
|
|
479
|
+
secretAccessKey: "",
|
|
480
|
+
presignExpiry: 600,
|
|
481
|
+
maxFileSize: DEFAULT_MEDIA_MAX_FILE_SIZE,
|
|
482
|
+
publicBaseUrl: media.publicBaseUrl
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
storage: "s3",
|
|
487
|
+
endpoint: media?.endpoint ?? "",
|
|
488
|
+
region: media?.region ?? "auto",
|
|
489
|
+
bucket: media?.bucket ?? "",
|
|
490
|
+
accessKeyId: media?.accessKeyId ?? "",
|
|
491
|
+
secretAccessKey: media?.secretAccessKey ?? "",
|
|
492
|
+
presignExpiry: media?.presignExpiry ?? 600,
|
|
493
|
+
maxFileSize: media?.maxFileSize ?? DEFAULT_MEDIA_MAX_FILE_SIZE,
|
|
494
|
+
publicBaseUrl: media?.publicBaseUrl ?? ""
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function resolveModuleOptions(options) {
|
|
498
|
+
return {
|
|
499
|
+
configPath: options.configPath ?? "cms.config",
|
|
500
|
+
admin: {
|
|
501
|
+
email: options.admin?.email ?? "",
|
|
502
|
+
password: options.admin?.password ?? ""
|
|
503
|
+
},
|
|
504
|
+
database: resolveDatabaseOptions(options.database),
|
|
505
|
+
media: resolveMediaOptions(options.media),
|
|
506
|
+
i18n: {
|
|
507
|
+
locales: options.i18n?.locales ?? [],
|
|
508
|
+
defaultLocale: options.i18n?.defaultLocale ?? "en"
|
|
509
|
+
},
|
|
510
|
+
graphql: {
|
|
511
|
+
maxDepth: options.graphql?.maxDepth ?? 8
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
}
|
|
431
515
|
const module$1 = defineNuxtModule({
|
|
432
516
|
meta: {
|
|
433
517
|
name: "@xleddyl/nuxt-cms",
|
|
@@ -445,9 +529,7 @@ const module$1 = defineNuxtModule({
|
|
|
445
529
|
},
|
|
446
530
|
database: {
|
|
447
531
|
driver: "sqlite",
|
|
448
|
-
path: "data/cms.db"
|
|
449
|
-
url: "",
|
|
450
|
-
authToken: ""
|
|
532
|
+
path: "data/cms.db"
|
|
451
533
|
},
|
|
452
534
|
media: {
|
|
453
535
|
storage: "s3",
|
|
@@ -456,6 +538,7 @@ const module$1 = defineNuxtModule({
|
|
|
456
538
|
bucket: "",
|
|
457
539
|
publicBaseUrl: "",
|
|
458
540
|
presignExpiry: 600,
|
|
541
|
+
maxFileSize: DEFAULT_MEDIA_MAX_FILE_SIZE,
|
|
459
542
|
accessKeyId: "",
|
|
460
543
|
secretAccessKey: ""
|
|
461
544
|
},
|
|
@@ -470,6 +553,7 @@ const module$1 = defineNuxtModule({
|
|
|
470
553
|
async setup(options, nuxt) {
|
|
471
554
|
const resolver = createResolver(import.meta.url);
|
|
472
555
|
const logger = useLogger("nuxt-cms");
|
|
556
|
+
const resolved = resolveModuleOptions(options);
|
|
473
557
|
if (!resolveCmsEnabled(options.enabled, process.env[CMS_ENABLED_ENV])) {
|
|
474
558
|
const stub = resolver.resolve("./runtime/app/composables/cms-query-disabled");
|
|
475
559
|
addImports([
|
|
@@ -477,16 +561,38 @@ const module$1 = defineNuxtModule({
|
|
|
477
561
|
{ name: "$cmsQuery", from: stub }
|
|
478
562
|
]);
|
|
479
563
|
nuxt.options.runtimeConfig.public.cms = {
|
|
480
|
-
mediaBaseUrl:
|
|
481
|
-
mediaStorage:
|
|
482
|
-
|
|
564
|
+
mediaBaseUrl: resolved.media.publicBaseUrl,
|
|
565
|
+
mediaStorage: resolved.media.storage,
|
|
566
|
+
mediaMaxFileSize: resolved.media.maxFileSize,
|
|
567
|
+
i18n: resolved.i18n
|
|
483
568
|
};
|
|
484
569
|
logger.info(
|
|
485
570
|
"[nuxt-cms] disabled: registering no-op useCms/$cmsQuery stubs, skipping admin, server and database setup"
|
|
486
571
|
);
|
|
487
572
|
return;
|
|
488
573
|
}
|
|
489
|
-
|
|
574
|
+
if ((resolved.database.driver === "postgres" || resolved.database.driver === "libsql") && !resolved.database.url && !process.env.NUXT_CMS_DATABASE_URL) {
|
|
575
|
+
logger.warn(
|
|
576
|
+
`[nuxt-cms] database.driver is '${resolved.database.driver}' but no url is configured (database.url or NUXT_CMS_DATABASE_URL); the app will fail to connect unless one is provided before the server starts.`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
if (!Number.isInteger(resolved.media.maxFileSize) || resolved.media.maxFileSize <= 0) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`[nuxt-cms] media.maxFileSize must be a positive integer number of bytes, got ${resolved.media.maxFileSize}`
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
const s3KeysConfigured = [
|
|
585
|
+
resolved.media.endpoint,
|
|
586
|
+
resolved.media.bucket,
|
|
587
|
+
resolved.media.accessKeyId,
|
|
588
|
+
resolved.media.secretAccessKey
|
|
589
|
+
].some(Boolean);
|
|
590
|
+
if (resolved.media.storage === "s3" && s3KeysConfigured && !resolved.media.publicBaseUrl && !process.env.NUXT_PUBLIC_CMS_MEDIA_BASE_URL) {
|
|
591
|
+
logger.warn(
|
|
592
|
+
"[nuxt-cms] media.storage is 's3' with credentials configured but no publicBaseUrl (media.publicBaseUrl or NUXT_PUBLIC_CMS_MEDIA_BASE_URL); uploaded media URLs will be null."
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
const configPath = await resolvePath(resolved.configPath, { cwd: nuxt.options.rootDir });
|
|
490
596
|
nuxt.options.alias["#nuxt-cms"] = resolver.resolve("./runtime/shared/index");
|
|
491
597
|
nuxt.options.watch.push(configPath);
|
|
492
598
|
let cmsConfig = {};
|
|
@@ -499,11 +605,11 @@ const module$1 = defineNuxtModule({
|
|
|
499
605
|
cmsConfig = await jiti.import(configPath, { default: true });
|
|
500
606
|
} else {
|
|
501
607
|
logger.warn(
|
|
502
|
-
`[nuxt-cms] Config file not found: ${configPath}. Using an empty registry \u2014 create a ${
|
|
608
|
+
`[nuxt-cms] Config file not found: ${configPath}. Using an empty registry \u2014 create a ${resolved.configPath}.ts with defineCmsConfig().`
|
|
503
609
|
);
|
|
504
610
|
nuxt.options.alias["#cms-config"] = resolver.resolve("./runtime/shared/empty-config");
|
|
505
611
|
}
|
|
506
|
-
const configErrors = validateConfig(cmsConfig,
|
|
612
|
+
const configErrors = validateConfig(cmsConfig, resolved.i18n);
|
|
507
613
|
if (configErrors.length) {
|
|
508
614
|
for (const error of configErrors) logger.error(error);
|
|
509
615
|
throw new Error(
|
|
@@ -523,7 +629,7 @@ const module$1 = defineNuxtModule({
|
|
|
523
629
|
write: true,
|
|
524
630
|
getContents: () => renderSchemaFile(
|
|
525
631
|
cmsConfig,
|
|
526
|
-
|
|
632
|
+
resolved.database.driver === "postgres" ? "postgres" : "sqlite",
|
|
527
633
|
resolveImport
|
|
528
634
|
)
|
|
529
635
|
});
|
|
@@ -588,11 +694,11 @@ const module$1 = defineNuxtModule({
|
|
|
588
694
|
{ name: "$cmsQuery", from: resolver.resolve("./runtime/app/composables/cms-query") }
|
|
589
695
|
]);
|
|
590
696
|
const {
|
|
591
|
-
driver
|
|
592
|
-
path: dbPath
|
|
593
|
-
url: databaseUrl
|
|
594
|
-
authToken: databaseAuthToken
|
|
595
|
-
} =
|
|
697
|
+
driver,
|
|
698
|
+
path: dbPath,
|
|
699
|
+
url: databaseUrl,
|
|
700
|
+
authToken: databaseAuthToken
|
|
701
|
+
} = resolved.database;
|
|
596
702
|
nuxt.options.alias["#cms-db"] = resolver.resolve(
|
|
597
703
|
driver === "postgres" ? "./runtime/server/utils/db-postgres" : driver === "libsql" ? "./runtime/server/utils/db-libsql" : "./runtime/server/utils/db-sqlite"
|
|
598
704
|
);
|
|
@@ -615,6 +721,30 @@ const module$1 = defineNuxtModule({
|
|
|
615
721
|
``
|
|
616
722
|
].join("\n")
|
|
617
723
|
});
|
|
724
|
+
const publicDir = resolve(nuxt.options.rootDir, nuxt.options.dir?.public ?? "public");
|
|
725
|
+
const mediaLocalRoot = resolved.media.storage === "local" && resolved.media.publicBaseUrl.startsWith("/") ? join(publicDir, ...resolved.media.publicBaseUrl.split("/").filter(Boolean)) : "";
|
|
726
|
+
addTemplate({
|
|
727
|
+
filename: "cms/media-manifest.d.ts",
|
|
728
|
+
write: true,
|
|
729
|
+
getContents: () => renderMediaManifestTypes(
|
|
730
|
+
toPosix(resolver.resolve("./runtime/server/utils/media-sync"))
|
|
731
|
+
)
|
|
732
|
+
});
|
|
733
|
+
const mediaManifestTemplate = addTemplate({
|
|
734
|
+
filename: "cms/media-manifest.js",
|
|
735
|
+
write: true,
|
|
736
|
+
getContents: async () => {
|
|
737
|
+
if (!mediaLocalRoot || nuxt.options.dev) return renderMediaManifestFile(null);
|
|
738
|
+
if (!existsSync(mediaLocalRoot)) {
|
|
739
|
+
logger.warn(
|
|
740
|
+
`[nuxt-cms] Local media folder not found at build time: ${mediaLocalRoot}. The media library will not be synced at runtime.`
|
|
741
|
+
);
|
|
742
|
+
return renderMediaManifestFile(null);
|
|
743
|
+
}
|
|
744
|
+
return renderMediaManifestFile(await collectMediaManifest(mediaLocalRoot));
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
nuxt.options.alias["#cms-media-manifest"] = mediaManifestTemplate.dst;
|
|
618
748
|
addServerPlugin(
|
|
619
749
|
resolver.resolve(
|
|
620
750
|
driver === "postgres" ? "./runtime/server/plugins/migrate-postgres" : driver === "libsql" ? "./runtime/server/plugins/migrate-libsql" : "./runtime/server/plugins/migrate-sqlite"
|
|
@@ -644,12 +774,10 @@ const module$1 = defineNuxtModule({
|
|
|
644
774
|
logger.error(`[nuxt-cms] drizzle-kit generate failed (exit code ${code})`);
|
|
645
775
|
});
|
|
646
776
|
}
|
|
647
|
-
const publicDir = resolve(nuxt.options.rootDir, nuxt.options.dir?.public ?? "public");
|
|
648
|
-
const mediaLocalRoot = options.media.storage === "local" && options.media.publicBaseUrl.startsWith("/") ? join(publicDir, ...options.media.publicBaseUrl.split("/").filter(Boolean)) : "";
|
|
649
777
|
const existingConfig = nuxt.options.runtimeConfig.cms ?? {};
|
|
650
778
|
nuxt.options.runtimeConfig.cms = {
|
|
651
|
-
adminEmail:
|
|
652
|
-
adminPassword:
|
|
779
|
+
adminEmail: resolved.admin.email,
|
|
780
|
+
adminPassword: resolved.admin.password,
|
|
653
781
|
databaseUrl,
|
|
654
782
|
databaseAuthToken,
|
|
655
783
|
dbPath: resolvedDbPath,
|
|
@@ -657,25 +785,27 @@ const module$1 = defineNuxtModule({
|
|
|
657
785
|
...existingConfig,
|
|
658
786
|
graphql: {
|
|
659
787
|
graphiql: nuxt.options.dev,
|
|
660
|
-
maxDepth:
|
|
788
|
+
maxDepth: resolved.graphql.maxDepth,
|
|
661
789
|
...existingConfig.graphql ?? {}
|
|
662
790
|
},
|
|
663
791
|
media: {
|
|
664
|
-
storage:
|
|
665
|
-
endpoint:
|
|
666
|
-
region:
|
|
667
|
-
bucket:
|
|
668
|
-
presignExpiry:
|
|
669
|
-
|
|
670
|
-
|
|
792
|
+
storage: resolved.media.storage,
|
|
793
|
+
endpoint: resolved.media.endpoint,
|
|
794
|
+
region: resolved.media.region,
|
|
795
|
+
bucket: resolved.media.bucket,
|
|
796
|
+
presignExpiry: resolved.media.presignExpiry,
|
|
797
|
+
maxFileSize: resolved.media.maxFileSize,
|
|
798
|
+
accessKeyId: resolved.media.accessKeyId,
|
|
799
|
+
secretAccessKey: resolved.media.secretAccessKey,
|
|
671
800
|
localRoot: mediaLocalRoot,
|
|
672
801
|
...existingConfig.media ?? {}
|
|
673
802
|
}
|
|
674
803
|
};
|
|
675
804
|
nuxt.options.runtimeConfig.public.cms = {
|
|
676
|
-
mediaBaseUrl:
|
|
677
|
-
mediaStorage:
|
|
678
|
-
|
|
805
|
+
mediaBaseUrl: resolved.media.publicBaseUrl,
|
|
806
|
+
mediaStorage: resolved.media.storage,
|
|
807
|
+
mediaMaxFileSize: resolved.media.maxFileSize,
|
|
808
|
+
i18n: resolved.i18n
|
|
679
809
|
};
|
|
680
810
|
addTypeTemplate(
|
|
681
811
|
{
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
</template>
|
|
37
37
|
|
|
38
38
|
<script setup>
|
|
39
|
+
import { formatFileSize } from "#nuxt-cms";
|
|
39
40
|
import { computed, ref } from "#imports";
|
|
41
|
+
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
40
42
|
import { useCmsToast } from "../../composables/cms-toast";
|
|
41
43
|
const props = defineProps({
|
|
42
44
|
multiple: { type: Boolean, required: false },
|
|
@@ -46,6 +48,7 @@ const props = defineProps({
|
|
|
46
48
|
});
|
|
47
49
|
const emit = defineEmits(["uploaded"]);
|
|
48
50
|
const toast = useCmsToast();
|
|
51
|
+
const { mediaMaxFileSize } = useCmsRuntime();
|
|
49
52
|
async function imageDimensions(file) {
|
|
50
53
|
if (!file.type.startsWith("image/")) return {};
|
|
51
54
|
try {
|
|
@@ -74,7 +77,6 @@ function matchesAccept(file) {
|
|
|
74
77
|
return file.type.toLowerCase() === pattern;
|
|
75
78
|
});
|
|
76
79
|
}
|
|
77
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
78
80
|
async function uploadOne(file) {
|
|
79
81
|
const presign = await $fetch("/api/cms/admin/media/presign", {
|
|
80
82
|
method: "POST",
|
|
@@ -106,11 +108,14 @@ async function uploadOne(file) {
|
|
|
106
108
|
}
|
|
107
109
|
async function handleFiles(list) {
|
|
108
110
|
let files = Array.from(list).filter(matchesAccept);
|
|
109
|
-
const tooLarge = files.filter((file) => file.size >
|
|
111
|
+
const tooLarge = files.filter((file) => file.size > mediaMaxFileSize);
|
|
110
112
|
for (const file of tooLarge) {
|
|
111
|
-
toast.add({
|
|
113
|
+
toast.add({
|
|
114
|
+
title: `File too large (max ${formatFileSize(mediaMaxFileSize)}): ${file.name}`,
|
|
115
|
+
color: "error"
|
|
116
|
+
});
|
|
112
117
|
}
|
|
113
|
-
files = files.filter((file) => file.size <=
|
|
118
|
+
files = files.filter((file) => file.size <= mediaMaxFileSize);
|
|
114
119
|
if (!props.multiple) files = files.slice(0, 1);
|
|
115
120
|
if (!files.length || uploading.value) return;
|
|
116
121
|
uploading.value = true;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<CmsMediaField
|
|
3
|
+
v-if="props.field.type === 'media'"
|
|
4
|
+
v-model="currentMedia"
|
|
5
|
+
:media-type="props.field.mediaType"
|
|
6
|
+
:accept="props.field.accept"
|
|
7
|
+
/>
|
|
8
|
+
<CmsRichTextField v-else-if="props.field.type === 'richtext'" v-model="current" />
|
|
3
9
|
<CmsTextarea v-else-if="props.field.textarea" v-model="current" :rows="8" />
|
|
4
10
|
<CmsInput v-else v-model="current" />
|
|
5
11
|
</template>
|
|
@@ -14,14 +20,19 @@ const props = defineProps({
|
|
|
14
20
|
const model = defineModel({ type: [Object, null], ...{ required: true } });
|
|
15
21
|
const { i18n } = useCmsRuntime();
|
|
16
22
|
const active = computed(() => props.locale ?? i18n.defaultLocale);
|
|
23
|
+
function write(value) {
|
|
24
|
+
const next = Object.fromEntries(
|
|
25
|
+
Object.entries(model.value ?? {}).filter(([locale]) => locale !== active.value)
|
|
26
|
+
);
|
|
27
|
+
if (value != null && value !== "") next[active.value] = value;
|
|
28
|
+
model.value = Object.keys(next).length ? next : null;
|
|
29
|
+
}
|
|
17
30
|
const current = computed({
|
|
18
31
|
get: () => model.value?.[active.value] ?? "",
|
|
19
|
-
set:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
model.value = Object.keys(next).length ? next : null;
|
|
25
|
-
}
|
|
32
|
+
set: write
|
|
33
|
+
});
|
|
34
|
+
const currentMedia = computed({
|
|
35
|
+
get: () => model.value?.[active.value] ?? null,
|
|
36
|
+
set: write
|
|
26
37
|
});
|
|
27
38
|
</script>
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
@reorder="reorderColumns"
|
|
58
58
|
>
|
|
59
59
|
<template v-for="key in mediaKeys" #[`${key}-cell`]="{ row }" :key="key">
|
|
60
|
-
<CmsMediaThumb :value="row.original[key]" />
|
|
60
|
+
<CmsMediaThumb :value="mediaThumbValue(key, row.original[key])" />
|
|
61
61
|
</template>
|
|
62
62
|
<template v-if="drafts" #status-cell="{ row }">
|
|
63
63
|
<CmsStatusBadge :published="row.original.status === 'published'" />
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
</template>
|
|
110
110
|
|
|
111
111
|
<script setup>
|
|
112
|
-
import { isTranslatableField } from "#nuxt-cms";
|
|
112
|
+
import { isTranslatableField, isTranslatableMediaField, pickTranslatedMedia } from "#nuxt-cms";
|
|
113
113
|
import {
|
|
114
114
|
computed,
|
|
115
115
|
createError,
|
|
@@ -226,6 +226,12 @@ function localized(value) {
|
|
|
226
226
|
const record = value;
|
|
227
227
|
return record[contentI18n.defaultLocale] ?? Object.values(record)[0] ?? "";
|
|
228
228
|
}
|
|
229
|
+
function mediaThumbValue(key, value) {
|
|
230
|
+
const field = config?.fields[key];
|
|
231
|
+
if (!field || !isTranslatableMediaField(field)) return value ?? null;
|
|
232
|
+
const locale = contentI18n.defaultLocale;
|
|
233
|
+
return pickTranslatedMedia(value, locale, locale);
|
|
234
|
+
}
|
|
229
235
|
function relationLabel(field, key, id) {
|
|
230
236
|
const title = relations.value[key]?.[String(id)];
|
|
231
237
|
if (title == null || title === "") return `#${id}`;
|
|
@@ -1,4 +1,2 @@
|
|
|
1
|
-
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<
|
|
2
|
-
[x: string]: any;
|
|
3
|
-
} | null>>;
|
|
1
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<Record<string, unknown> | null>>;
|
|
4
2
|
export default _default;
|
|
@@ -2,7 +2,7 @@ import { asc, count, desc, sql } from "drizzle-orm";
|
|
|
2
2
|
import { defineEventHandler, getValidatedQuery } from "h3";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { useDb } from "#cms-db";
|
|
5
|
-
import { getRegistryEntry, idColumn, tableColumns } from "../utils/registry.js";
|
|
5
|
+
import { decodeRows, getRegistryEntry, idColumn, tableColumns } from "../utils/registry.js";
|
|
6
6
|
import { attachManyToMany, relationTitles } from "../utils/relations.js";
|
|
7
7
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
8
8
|
const querySchema = z.object({
|
|
@@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => {
|
|
|
23
23
|
if (entry.kind === "single") {
|
|
24
24
|
const rows = await db.select().from(table).limit(1);
|
|
25
25
|
await attachManyToMany(db, name, entry, rows);
|
|
26
|
-
return rows[0] ?? null;
|
|
26
|
+
return decodeRows(entry, rows)[0] ?? null;
|
|
27
27
|
}
|
|
28
28
|
const { limit, offset, search, sort, order, light } = await getValidatedQuery(
|
|
29
29
|
event,
|
|
@@ -52,6 +52,7 @@ export default defineEventHandler(async (event) => {
|
|
|
52
52
|
const [counted] = await (where ? db.select({ total: count() }).from(table).where(where) : db.select({ total: count() }).from(table));
|
|
53
53
|
if (light) return { items, total: counted?.total ?? 0, relations: {} };
|
|
54
54
|
await attachManyToMany(db, name, entry, items);
|
|
55
|
+
decodeRows(entry, items);
|
|
55
56
|
return {
|
|
56
57
|
items,
|
|
57
58
|
total: counted?.total ?? 0,
|
|
@@ -2,7 +2,7 @@ import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
|
2
2
|
import { withTransaction } from "#cms-db";
|
|
3
3
|
import { customId } from "../utils/custom-id.js";
|
|
4
4
|
import { mapConstraintErrors } from "../utils/db-errors.js";
|
|
5
|
-
import { buildValidator, getRegistryEntry } from "../utils/registry.js";
|
|
5
|
+
import { buildValidator, decodeRows, encodeColumnValues, getRegistryEntry } from "../utils/registry.js";
|
|
6
6
|
import {
|
|
7
7
|
assertRelationTargets,
|
|
8
8
|
attachManyToMany,
|
|
@@ -17,7 +17,7 @@ export default defineEventHandler(async (event) => {
|
|
|
17
17
|
throw createError({ statusCode: 405, statusMessage: "Single objects are updated with PUT" });
|
|
18
18
|
}
|
|
19
19
|
const body = await readValidatedBody(event, buildValidator(entry).parse);
|
|
20
|
-
const { values, lists } = splitRelationValues(entry, body);
|
|
20
|
+
const { values, lists } = splitRelationValues(entry, encodeColumnValues(entry, body));
|
|
21
21
|
await assertRelationTargets(entry, lists);
|
|
22
22
|
if (entry.drafts) values.status ??= "draft";
|
|
23
23
|
values.id = customId(entry.id);
|
|
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
|
|
28
28
|
const [attached] = await attachManyToMany(db, name, entry, [
|
|
29
29
|
row
|
|
30
30
|
]);
|
|
31
|
-
return attached;
|
|
31
|
+
return decodeRows(entry, [attached])[0];
|
|
32
32
|
})
|
|
33
33
|
);
|
|
34
34
|
});
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
2
2
|
import { withTransaction } from "#cms-db";
|
|
3
3
|
import { mapConstraintErrors } from "../utils/db-errors.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildValidator,
|
|
6
|
+
decodeRows,
|
|
7
|
+
encodeColumnValues,
|
|
8
|
+
getRegistryEntry,
|
|
9
|
+
idColumn,
|
|
10
|
+
withUpdatedAt
|
|
11
|
+
} from "../utils/registry.js";
|
|
5
12
|
import {
|
|
6
13
|
assertRelationTargets,
|
|
7
14
|
attachManyToMany,
|
|
@@ -16,7 +23,10 @@ export default defineEventHandler(async (event) => {
|
|
|
16
23
|
throw createError({ statusCode: 405, statusMessage: "Collections are updated with PUT /:id" });
|
|
17
24
|
}
|
|
18
25
|
const body = await readValidatedBody(event, buildValidator(entry).parse);
|
|
19
|
-
const { values, lists } = splitRelationValues(
|
|
26
|
+
const { values, lists } = splitRelationValues(
|
|
27
|
+
entry,
|
|
28
|
+
encodeColumnValues(entry, body)
|
|
29
|
+
);
|
|
20
30
|
await assertRelationTargets(entry, lists);
|
|
21
31
|
const set = withUpdatedAt(table, values);
|
|
22
32
|
return mapConstraintErrors(
|
|
@@ -26,7 +36,7 @@ export default defineEventHandler(async (event) => {
|
|
|
26
36
|
const [attached] = await attachManyToMany(db, name, entry, [
|
|
27
37
|
row
|
|
28
38
|
]);
|
|
29
|
-
return attached;
|
|
39
|
+
return decodeRows(entry, [attached])[0];
|
|
30
40
|
})
|
|
31
41
|
);
|
|
32
42
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { eq } from "drizzle-orm";
|
|
2
2
|
import { createError, defineEventHandler } from "h3";
|
|
3
3
|
import { useDb } from "#cms-db";
|
|
4
|
-
import { getRegistryEntry, idColumn, parseId } from "../utils/registry.js";
|
|
4
|
+
import { decodeRows, getRegistryEntry, idColumn, parseId } from "../utils/registry.js";
|
|
5
5
|
import { attachManyToMany } from "../utils/relations.js";
|
|
6
6
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
7
7
|
export default defineEventHandler(async (event) => {
|
|
@@ -15,5 +15,5 @@ export default defineEventHandler(async (event) => {
|
|
|
15
15
|
const rows = await db.select().from(table).where(eq(idColumn(table), id)).limit(1);
|
|
16
16
|
if (!rows[0]) throw createError({ statusCode: 404, statusMessage: "Row not found" });
|
|
17
17
|
const [attached] = await attachManyToMany(db, name, entry, [rows[0]]);
|
|
18
|
-
return attached;
|
|
18
|
+
return decodeRows(entry, [attached])[0];
|
|
19
19
|
});
|
|
@@ -3,6 +3,8 @@ import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
|
3
3
|
import { withTransaction } from "#cms-db";
|
|
4
4
|
import {
|
|
5
5
|
buildValidator,
|
|
6
|
+
decodeRows,
|
|
7
|
+
encodeColumnValues,
|
|
6
8
|
getRegistryEntry,
|
|
7
9
|
idColumn,
|
|
8
10
|
parseId,
|
|
@@ -27,7 +29,10 @@ export default defineEventHandler(async (event) => {
|
|
|
27
29
|
}
|
|
28
30
|
const id = parseId(event);
|
|
29
31
|
const body = await readValidatedBody(event, buildValidator(entry).parse);
|
|
30
|
-
const { values, lists } = splitRelationValues(
|
|
32
|
+
const { values, lists } = splitRelationValues(
|
|
33
|
+
entry,
|
|
34
|
+
encodeColumnValues(entry, body)
|
|
35
|
+
);
|
|
31
36
|
await assertRelationTargets(entry, lists);
|
|
32
37
|
const set = withUpdatedAt(table, values);
|
|
33
38
|
return mapConstraintErrors(
|
|
@@ -38,7 +43,7 @@ export default defineEventHandler(async (event) => {
|
|
|
38
43
|
const [attached] = await attachManyToMany(db, name, entry, [
|
|
39
44
|
row
|
|
40
45
|
]);
|
|
41
|
-
return attached;
|
|
46
|
+
return decodeRows(entry, [attached])[0];
|
|
42
47
|
})
|
|
43
48
|
);
|
|
44
49
|
});
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { defineEventHandler, readValidatedBody } from "h3";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { normalizeMediaFolder, slugify } from "../../shared/index.js";
|
|
5
|
-
import { assertUploadContentType, useMediaStorage } from "../utils/media.js";
|
|
5
|
+
import { assertUploadContentType, assertUploadSize, useMediaStorage } from "../utils/media.js";
|
|
6
6
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
7
7
|
const MAX_BASE_LENGTH = 80;
|
|
8
8
|
const MAX_EXT_LENGTH = 10;
|
|
9
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
10
9
|
const bodySchema = z.object({
|
|
11
10
|
filename: z.string().trim().min(1).max(255),
|
|
12
11
|
contentType: z.string().regex(/^[-\w.+]+\/[-\w.+]+$/, "Invalid content type"),
|
|
@@ -24,12 +23,7 @@ export default defineEventHandler(async (event) => {
|
|
|
24
23
|
const { media, client, bucketUrl, publicUrl } = useMediaStorage(event);
|
|
25
24
|
const { filename, contentType, size, folder } = await readValidatedBody(event, bodySchema.parse);
|
|
26
25
|
assertUploadContentType(contentType);
|
|
27
|
-
|
|
28
|
-
throw createError({
|
|
29
|
-
statusCode: 413,
|
|
30
|
-
statusMessage: "File exceeds the maximum size of 10 MB"
|
|
31
|
-
});
|
|
32
|
-
}
|
|
26
|
+
assertUploadSize(size, media.maxFileSize);
|
|
33
27
|
const now = /* @__PURE__ */ new Date();
|
|
34
28
|
const normalizedFolder = normalizeMediaFolder(folder);
|
|
35
29
|
const prefix = normalizedFolder ?? `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
@@ -1,39 +1,56 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { eq, inArray } from "drizzle-orm";
|
|
3
3
|
import { useDb } from "#cms-db";
|
|
4
|
+
import { files as manifestFiles, generated as manifestGenerated } from "#cms-media-manifest";
|
|
4
5
|
import { cms_media } from "#cms-tables";
|
|
5
6
|
import { useRuntimeConfig } from "#imports";
|
|
6
7
|
import { chunked, planMediaSync, readMediaFileMeta, scanMediaDirectory } from "../utils/media-sync.js";
|
|
7
8
|
const CHUNK_SIZE = 100;
|
|
9
|
+
async function resolveMediaSource(root) {
|
|
10
|
+
if (existsSync(root)) {
|
|
11
|
+
return {
|
|
12
|
+
origin: root,
|
|
13
|
+
files: await scanMediaDirectory(root),
|
|
14
|
+
meta: (file) => readMediaFileMeta(root, file)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (!manifestGenerated) return null;
|
|
18
|
+
const byKey = new Map(manifestFiles.map((file) => [file.key, file]));
|
|
19
|
+
return {
|
|
20
|
+
origin: `build manifest of ${root}`,
|
|
21
|
+
files: manifestFiles.map(({ key, size }) => ({ key, size })),
|
|
22
|
+
meta: async (file) => byKey.get(file.key)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
8
25
|
export default async () => {
|
|
9
26
|
try {
|
|
10
27
|
const { media } = useRuntimeConfig().cms;
|
|
11
28
|
if (media?.storage !== "local") return;
|
|
12
29
|
const root = media.localRoot;
|
|
13
30
|
if (!root) return;
|
|
14
|
-
|
|
31
|
+
const source = await resolveMediaSource(root);
|
|
32
|
+
if (!source) {
|
|
15
33
|
console.info(
|
|
16
|
-
`[nuxt-cms] Local media folder not found: ${root}. Skipping media library sync.`
|
|
34
|
+
`[nuxt-cms] Local media folder not found: ${root}, and no build manifest was generated. Skipping media library sync.`
|
|
17
35
|
);
|
|
18
36
|
return;
|
|
19
37
|
}
|
|
20
|
-
const files = await scanMediaDirectory(root);
|
|
21
38
|
const db = useDb();
|
|
22
39
|
const rows = await db.select({ key: cms_media.key, size: cms_media.size }).from(cms_media);
|
|
23
|
-
const { insert, update, remove } = planMediaSync(files, rows);
|
|
40
|
+
const { insert, update, remove } = planMediaSync(source.files, rows);
|
|
24
41
|
for (const chunk of chunked(insert, CHUNK_SIZE)) {
|
|
25
|
-
const values = await Promise.all(chunk.map((file) =>
|
|
42
|
+
const values = await Promise.all(chunk.map((file) => source.meta(file)));
|
|
26
43
|
await db.insert(cms_media).values(values.map((value) => ({ ...value, alt: null }))).onConflictDoNothing();
|
|
27
44
|
}
|
|
28
45
|
for (const file of update) {
|
|
29
|
-
const { mime, size, width, height } = await
|
|
46
|
+
const { mime, size, width, height } = await source.meta(file);
|
|
30
47
|
await db.update(cms_media).set({ mime, size, width, height }).where(eq(cms_media.key, file.key));
|
|
31
48
|
}
|
|
32
49
|
for (const chunk of chunked(remove, CHUNK_SIZE)) {
|
|
33
50
|
await db.delete(cms_media).where(inArray(cms_media.key, chunk));
|
|
34
51
|
}
|
|
35
52
|
console.info(
|
|
36
|
-
`[nuxt-cms] Local media sync: ${insert.length} added, ${remove.length} removed, ${update.length} updated (${files.length} file${files.length === 1 ? "" : "s"} in ${
|
|
53
|
+
`[nuxt-cms] Local media sync: ${insert.length} added, ${remove.length} removed, ${update.length} updated (${source.files.length} file${source.files.length === 1 ? "" : "s"} in ${source.origin})`
|
|
37
54
|
);
|
|
38
55
|
} catch (error) {
|
|
39
56
|
console.warn("[nuxt-cms] Local media sync failed:", error);
|
|
@@ -19,7 +19,14 @@ import cmsConfig from "#cms-config";
|
|
|
19
19
|
import { useDb } from "#cms-db";
|
|
20
20
|
import * as cmsTables from "#cms-tables";
|
|
21
21
|
import { useRuntimeConfig } from "#imports";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
decodeTranslatableMedia,
|
|
24
|
+
isTranslatableMediaField,
|
|
25
|
+
mediaPublicUrl,
|
|
26
|
+
mediaTypeFor,
|
|
27
|
+
pickTranslatedMedia,
|
|
28
|
+
translatableFieldKeys
|
|
29
|
+
} from "../../shared/index.js";
|
|
23
30
|
import { blockTypeName, blockUnionName, renderGraphqlSdl, typeName } from "../../shared/graphql-sdl.js";
|
|
24
31
|
import { getContentI18n, resolveTable, tableColumns } from "./registry.js";
|
|
25
32
|
const MAX_LIMIT = 100;
|
|
@@ -87,6 +94,11 @@ function localizeRow(entry, row, locale) {
|
|
|
87
94
|
const { defaultLocale } = getContentI18n();
|
|
88
95
|
const result = { ...row, [LOCALE]: locale };
|
|
89
96
|
for (const key of translatableFieldKeys(entry)) {
|
|
97
|
+
if (isTranslatableMediaField(entry.fields[key])) {
|
|
98
|
+
const media = decodeTranslatableMedia(row[key], defaultLocale);
|
|
99
|
+
result[key] = pickTranslatedMedia(media, locale, defaultLocale);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
90
102
|
const value = row[key];
|
|
91
103
|
result[key] = value?.[locale] ?? value?.[defaultLocale] ?? null;
|
|
92
104
|
}
|
|
@@ -7,10 +7,12 @@ interface MediaConfig {
|
|
|
7
7
|
region: string;
|
|
8
8
|
bucket: string;
|
|
9
9
|
presignExpiry: number;
|
|
10
|
+
maxFileSize: number;
|
|
10
11
|
accessKeyId: string;
|
|
11
12
|
secretAccessKey: string;
|
|
12
13
|
}
|
|
13
14
|
export declare function assertUploadContentType(contentType: string): void;
|
|
15
|
+
export declare function assertUploadSize(size: number, maxFileSize: number): void;
|
|
14
16
|
export declare function encodeKey(key: string): string;
|
|
15
17
|
export declare function assertMediaConfigured(media: MediaConfig): void;
|
|
16
18
|
export declare function assertMediaWritable(media: MediaConfig): void;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AwsClient } from "aws4fetch";
|
|
2
2
|
import { createError } from "h3";
|
|
3
3
|
import { useRuntimeConfig } from "#imports";
|
|
4
|
-
import { mediaPublicUrl, mediaTypeFor } from "../../shared/index.js";
|
|
4
|
+
import { formatFileSize, mediaPublicUrl, mediaTypeFor } from "../../shared/index.js";
|
|
5
5
|
const UPLOAD_TYPE_PREFIXES = ["image/", "video/", "audio/", "font/"];
|
|
6
6
|
const UPLOAD_TYPE_BLOCKLIST = /* @__PURE__ */ new Set(["image/svg+xml"]);
|
|
7
7
|
const UPLOAD_TYPES = /* @__PURE__ */ new Set([
|
|
@@ -29,6 +29,14 @@ export function assertUploadContentType(contentType) {
|
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
export function assertUploadSize(size, maxFileSize) {
|
|
33
|
+
if (size > maxFileSize) {
|
|
34
|
+
throw createError({
|
|
35
|
+
statusCode: 413,
|
|
36
|
+
statusMessage: `File exceeds the maximum size of ${formatFileSize(maxFileSize)}`
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
32
40
|
export function encodeKey(key) {
|
|
33
41
|
return key.split("/").map(encodeURIComponent).join("/");
|
|
34
42
|
}
|
|
@@ -17,4 +17,6 @@ export declare function getRegistryEntry(event: H3Event): {
|
|
|
17
17
|
export declare function buildValidator(entry: CmsEntry): z.ZodObject<{
|
|
18
18
|
[x: string]: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
|
|
19
19
|
}, z.core.$strip>;
|
|
20
|
+
export declare function encodeColumnValues(entry: CmsEntry, values: Record<string, unknown>): Record<string, unknown>;
|
|
21
|
+
export declare function decodeRows<T extends Record<string, unknown>>(entry: CmsEntry, rows: T[]): T[];
|
|
20
22
|
export declare function parseId(event: H3Event): string;
|
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import cmsConfig from "#cms-config";
|
|
4
4
|
import * as cmsTables from "#cms-tables";
|
|
5
5
|
import { useRuntimeConfig } from "#imports";
|
|
6
|
+
import { decodeEntryTranslatableMedia, encodeEntryTranslatableMedia } from "../../shared/index.js";
|
|
6
7
|
import { buildEntrySchema } from "../../shared/validation.js";
|
|
7
8
|
let contentI18n;
|
|
8
9
|
export function getContentI18n() {
|
|
@@ -42,6 +43,12 @@ export function getRegistryEntry(event) {
|
|
|
42
43
|
export function buildValidator(entry) {
|
|
43
44
|
return buildEntrySchema(entry, getContentI18n());
|
|
44
45
|
}
|
|
46
|
+
export function encodeColumnValues(entry, values) {
|
|
47
|
+
return encodeEntryTranslatableMedia(entry, values);
|
|
48
|
+
}
|
|
49
|
+
export function decodeRows(entry, rows) {
|
|
50
|
+
return decodeEntryTranslatableMedia(entry, rows, getContentI18n().defaultLocale);
|
|
51
|
+
}
|
|
45
52
|
export function parseId(event) {
|
|
46
53
|
return z.string().min(1).parse(getRouterParam(event, "id"));
|
|
47
54
|
}
|
|
@@ -14,6 +14,8 @@ export declare function mediaFilename(key: string): string;
|
|
|
14
14
|
export declare function mediaTypeFor(mime: string | null | undefined, key: string): MediaType;
|
|
15
15
|
export declare function mediaIconFor(type: MediaType): string;
|
|
16
16
|
export declare function mediaPublicUrl(baseUrl: string | null | undefined, key: string): string | null;
|
|
17
|
+
export declare const DEFAULT_MEDIA_MAX_FILE_SIZE: number;
|
|
18
|
+
export declare function formatFileSize(bytes: number): string;
|
|
17
19
|
export declare function slugify(value: string): string;
|
|
18
20
|
export declare const MEDIA_FOLDER_MAX_DEPTH = 4;
|
|
19
21
|
export declare function normalizeMediaFolder(value: string | null | undefined): string | null;
|
|
@@ -52,6 +54,13 @@ export interface FieldConfig {
|
|
|
52
54
|
onDelete?: 'set null' | 'cascade' | 'restrict';
|
|
53
55
|
}
|
|
54
56
|
export declare function isTranslatableField(field: FieldConfig): boolean;
|
|
57
|
+
export declare function isTranslatableMediaField(field: FieldConfig): boolean;
|
|
58
|
+
export declare function decodeTranslatableMedia(value: unknown, defaultLocale: string): Record<string, string> | null;
|
|
59
|
+
export declare function encodeTranslatableMedia(value: unknown): string | null;
|
|
60
|
+
export declare function pickTranslatedMedia(values: Record<string, string> | null | undefined, locale: string, defaultLocale: string): string | null;
|
|
61
|
+
export declare function translatableMediaKeys(entry: Pick<CmsEntry, 'fields'>): string[];
|
|
62
|
+
export declare function encodeEntryTranslatableMedia(entry: Pick<CmsEntry, 'fields'>, values: Record<string, unknown>): Record<string, unknown>;
|
|
63
|
+
export declare function decodeEntryTranslatableMedia<T extends Record<string, unknown>>(entry: Pick<CmsEntry, 'fields'>, rows: T[], defaultLocale: string): T[];
|
|
55
64
|
export declare function isMultiSelect(field: FieldConfig): boolean;
|
|
56
65
|
export declare function translatableFieldKeys(entry: CmsEntry): string[];
|
|
57
66
|
export interface CmsEntry {
|
|
@@ -106,6 +115,7 @@ export interface MediaFieldInput extends FieldInputBase {
|
|
|
106
115
|
type: 'media';
|
|
107
116
|
mediaType?: MediaType;
|
|
108
117
|
accept?: string[];
|
|
118
|
+
translatable?: boolean;
|
|
109
119
|
}
|
|
110
120
|
export interface RelationFieldInput extends FieldInputBase {
|
|
111
121
|
type: 'relation';
|
|
@@ -113,7 +123,7 @@ export interface RelationFieldInput extends FieldInputBase {
|
|
|
113
123
|
cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
|
|
114
124
|
onDelete?: 'set null' | 'cascade' | 'restrict';
|
|
115
125
|
}
|
|
116
|
-
export type BlockFieldInput = Omit<TextFieldInput, 'translatable'> | Omit<RichtextFieldInput, 'translatable'> | NumberFieldInput | BooleanFieldInput | DateFieldInput | EmailFieldInput | SelectFieldInput | JsonFieldInput | MediaFieldInput
|
|
126
|
+
export type BlockFieldInput = Omit<TextFieldInput, 'translatable'> | Omit<RichtextFieldInput, 'translatable'> | NumberFieldInput | BooleanFieldInput | DateFieldInput | EmailFieldInput | SelectFieldInput | JsonFieldInput | Omit<MediaFieldInput, 'translatable'>;
|
|
117
127
|
export interface BlockInput {
|
|
118
128
|
label: string;
|
|
119
129
|
fields: Record<string, BlockFieldInput>;
|
|
@@ -41,6 +41,17 @@ export function mediaIconFor(type) {
|
|
|
41
41
|
export function mediaPublicUrl(baseUrl, key) {
|
|
42
42
|
return baseUrl ? `${baseUrl.replace(/\/+$/, "")}/${key}` : null;
|
|
43
43
|
}
|
|
44
|
+
export const DEFAULT_MEDIA_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
45
|
+
const FILE_SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
46
|
+
export function formatFileSize(bytes) {
|
|
47
|
+
let value = Math.max(0, bytes);
|
|
48
|
+
let unit = 0;
|
|
49
|
+
while (value >= 1024 && unit < FILE_SIZE_UNITS.length - 1) {
|
|
50
|
+
value /= 1024;
|
|
51
|
+
unit++;
|
|
52
|
+
}
|
|
53
|
+
return `${Math.round(value * 10) / 10} ${FILE_SIZE_UNITS[unit]}`;
|
|
54
|
+
}
|
|
44
55
|
export function slugify(value) {
|
|
45
56
|
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036F]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
46
57
|
}
|
|
@@ -51,7 +62,65 @@ export function normalizeMediaFolder(value) {
|
|
|
51
62
|
return segments.length ? segments.join("/") : null;
|
|
52
63
|
}
|
|
53
64
|
export function isTranslatableField(field) {
|
|
54
|
-
return !!field.translatable && (field.type === "text" || field.type === "richtext");
|
|
65
|
+
return !!field.translatable && (field.type === "text" || field.type === "richtext" || field.type === "media");
|
|
66
|
+
}
|
|
67
|
+
export function isTranslatableMediaField(field) {
|
|
68
|
+
return !!field.translatable && field.type === "media";
|
|
69
|
+
}
|
|
70
|
+
function parseJsonObject(raw) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
export function decodeTranslatableMedia(value, defaultLocale) {
|
|
82
|
+
if (value == null) return null;
|
|
83
|
+
if (typeof value === "object") return value;
|
|
84
|
+
const raw = String(value).trim();
|
|
85
|
+
if (!raw) return null;
|
|
86
|
+
return (raw.startsWith("{") ? parseJsonObject(raw) : null) ?? { [defaultLocale]: raw };
|
|
87
|
+
}
|
|
88
|
+
export function encodeTranslatableMedia(value) {
|
|
89
|
+
if (value == null) return null;
|
|
90
|
+
if (typeof value === "string") return value.trim() || null;
|
|
91
|
+
if (typeof value !== "object") return null;
|
|
92
|
+
const entries = Object.entries(value).filter(
|
|
93
|
+
(pair) => typeof pair[1] === "string" && pair[1].trim() !== ""
|
|
94
|
+
);
|
|
95
|
+
return entries.length ? JSON.stringify(Object.fromEntries(entries)) : null;
|
|
96
|
+
}
|
|
97
|
+
export function pickTranslatedMedia(values, locale, defaultLocale) {
|
|
98
|
+
if (!values) return null;
|
|
99
|
+
return values[locale] || values[defaultLocale] || Object.values(values).find(Boolean) || null;
|
|
100
|
+
}
|
|
101
|
+
export function translatableMediaKeys(entry) {
|
|
102
|
+
return Object.entries(entry.fields).filter(([, field]) => isTranslatableMediaField(field)).map(([key]) => key);
|
|
103
|
+
}
|
|
104
|
+
export function encodeEntryTranslatableMedia(entry, values) {
|
|
105
|
+
const keys = translatableMediaKeys(entry);
|
|
106
|
+
if (!keys.length) return values;
|
|
107
|
+
const encoded = { ...values };
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
if (Object.hasOwn(encoded, key)) encoded[key] = encodeTranslatableMedia(encoded[key]);
|
|
110
|
+
}
|
|
111
|
+
return encoded;
|
|
112
|
+
}
|
|
113
|
+
export function decodeEntryTranslatableMedia(entry, rows, defaultLocale) {
|
|
114
|
+
const keys = translatableMediaKeys(entry);
|
|
115
|
+
if (!keys.length) return rows;
|
|
116
|
+
for (const row of rows) {
|
|
117
|
+
for (const key of keys) {
|
|
118
|
+
if (Object.hasOwn(row, key)) {
|
|
119
|
+
row[key] = decodeTranslatableMedia(row[key], defaultLocale);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return rows;
|
|
55
124
|
}
|
|
56
125
|
export function isMultiSelect(field) {
|
|
57
126
|
return field.type === "select" && !!field.multiple;
|
|
@@ -69,7 +69,7 @@ export function buildEntrySchema(entry, i18n, messages) {
|
|
|
69
69
|
continue;
|
|
70
70
|
}
|
|
71
71
|
if (isTranslatableField(field) && locales.length) {
|
|
72
|
-
const record = z.record(z.string(), z.string()).refine((v) => Object.keys(v).every((k) => locales.includes(k)), m.unknownLocale);
|
|
72
|
+
const record = z.record(z.string(), field.type === "media" ? objectKeySchema : z.string()).refine((v) => Object.keys(v).every((k) => locales.includes(k)), m.unknownLocale);
|
|
73
73
|
shape[key] = field.required ? record.refine((v) => !!v[defaultLocale]?.trim(), m.requiredLocale(defaultLocale)) : record.nullish().transform((v) => v ?? null);
|
|
74
74
|
continue;
|
|
75
75
|
}
|
package/dist/types.d.mts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"description": "Lightweight CMS that ships with your Nuxt app: runs on the Nitro server, content types defined in code, /cms admin panel, GraphQL API, SQLite or Postgres. No external CMS needed!",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Edoardo Alberti (https://github.com/xleddyl)",
|
|
@@ -105,6 +105,6 @@
|
|
|
105
105
|
"test": "vitest run",
|
|
106
106
|
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit",
|
|
107
107
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md,html,json,sql,vue}\" --log-level error",
|
|
108
|
-
"release": "changelogen --release && git push origin main && git push origin v$(node -p \"require('./package.json').version\")"
|
|
108
|
+
"release": "changelogen --release --no-github && git push origin main && git push origin v$(node -p \"require('./package.json').version\")"
|
|
109
109
|
}
|
|
110
110
|
}
|