@xleddyl/nuxt-cms 0.1.30 → 0.1.32
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 +3 -3
- package/dist/module.json +1 -1
- package/dist/module.mjs +23 -8
- package/dist/runtime/app/components/cms/MediaGallery.vue +27 -10
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/media.get.d.ts +2 -1
- package/dist/runtime/server/api/media.get.js +13 -2
- package/dist/runtime/server/api/media.put.js +19 -3
- package/dist/runtime/server/utils/graphql.js +24 -1
- package/dist/runtime/server/utils/media-index.d.ts +8 -0
- package/dist/runtime/server/utils/media-index.js +49 -0
- package/dist/runtime/server/utils/media-sync.d.ts +0 -11
- package/dist/runtime/server/utils/media-sync.js +0 -20
- package/dist/runtime/shared/graphql-sdl.js +5 -3
- package/dist/runtime/shared/index.d.ts +12 -3
- package/dist/runtime/shared/index.js +3 -0
- package/package.json +1 -1
- package/dist/runtime/server/plugins/media-sync-local.d.ts +0 -2
- package/dist/runtime/server/plugins/media-sync-local.js +0 -58
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight
|
|
|
4
4
|
|
|
5
5
|
- **Zero extra infrastructure**: the CMS runs inside your app's Nitro server; you deploy one thing.
|
|
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
|
-
- **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, or a
|
|
8
|
-
- **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination.
|
|
7
|
+
- **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, or a local mode backed directly by your `public/` folder), single-admin auth from env credentials.
|
|
8
|
+
- **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination; fields marked `private` stay out of it.
|
|
9
9
|
- **SQLite, Postgres or libSQL/Turso**: a local file database by default, one config line to switch (including remote SQLite over the network).
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
@@ -79,7 +79,7 @@ Full documentation lives in [`docs/`](docs/README.md):
|
|
|
79
79
|
- [Schema](docs/schema.md) — `defineCmsConfig`, entries, field types, relations, blocks, i18n.
|
|
80
80
|
- [Querying content](docs/querying.md) — GraphQL API, `useCms` / `$cmsQuery`, filters, sorting, pagination.
|
|
81
81
|
- [Admin panel & security](docs/admin.md) — pages, authentication, sessions, admin REST API.
|
|
82
|
-
- [Media](docs/media.md) — S3-compatible storage or
|
|
82
|
+
- [Media](docs/media.md) — S3-compatible storage or local mode backed by your `public/` folder, upload flow, allowed file types.
|
|
83
83
|
|
|
84
84
|
## LLM guide
|
|
85
85
|
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -10,17 +10,19 @@ 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, isTranslatableMediaField, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
|
|
13
|
+
import { isMultiSelect, isTranslatableField, isTranslatableMediaField, isPrivateField, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
|
|
14
14
|
import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
|
|
15
15
|
|
|
16
16
|
async function collectMediaManifest(root) {
|
|
17
17
|
const files = await scanMediaDirectory(root);
|
|
18
18
|
return Promise.all(files.map((file) => readMediaFileMeta(root, file)));
|
|
19
19
|
}
|
|
20
|
-
function renderMediaManifestFile(files) {
|
|
20
|
+
function renderMediaManifestFile(files, builtAt) {
|
|
21
21
|
return [
|
|
22
22
|
`export const generated = ${files !== null}`,
|
|
23
23
|
``,
|
|
24
|
+
`export const builtAt = ${JSON.stringify(builtAt)}`,
|
|
25
|
+
``,
|
|
24
26
|
`export const files = ${JSON.stringify(files ?? [], null, 3)}`,
|
|
25
27
|
``
|
|
26
28
|
].join("\n");
|
|
@@ -31,6 +33,8 @@ function renderMediaManifestTypes(typesPath) {
|
|
|
31
33
|
``,
|
|
32
34
|
`export declare const generated: boolean`,
|
|
33
35
|
``,
|
|
36
|
+
`export declare const builtAt: string | null`,
|
|
37
|
+
``,
|
|
34
38
|
`export declare const files: MediaFileMeta[]`,
|
|
35
39
|
``
|
|
36
40
|
].join("\n");
|
|
@@ -165,6 +169,8 @@ function validateConfig(config, i18n) {
|
|
|
165
169
|
}
|
|
166
170
|
if (blockField.translatable)
|
|
167
171
|
errors.push(`${bfat}: translatable fields are not supported inside blocks`);
|
|
172
|
+
if (blockField.private)
|
|
173
|
+
errors.push(`${bfat}: private fields are not supported inside blocks`);
|
|
168
174
|
if (blockField.type === "select" && !blockField.options?.length) {
|
|
169
175
|
errors.push(`${bfat}: select requires a non-empty options array`);
|
|
170
176
|
}
|
|
@@ -412,6 +418,7 @@ ${lines.join("\n")}
|
|
|
412
418
|
function entryTs(config, name, entry) {
|
|
413
419
|
const lines = [" id: string"];
|
|
414
420
|
for (const [key, field] of Object.entries(entry.fields)) {
|
|
421
|
+
if (isPrivateField(field)) continue;
|
|
415
422
|
lines.push(` ${key}: ${fieldTsType(config, name, key, field)}`);
|
|
416
423
|
}
|
|
417
424
|
if (entry.kind === "collection") lines.push(" createdAt: string");
|
|
@@ -436,7 +443,8 @@ function renderTypesFile(config) {
|
|
|
436
443
|
];
|
|
437
444
|
for (const [name, entry] of Object.entries(config)) {
|
|
438
445
|
for (const [key, field] of Object.entries(entry.fields)) {
|
|
439
|
-
if (field.type === "blocks"
|
|
446
|
+
if (field.type === "blocks" && !isPrivateField(field))
|
|
447
|
+
parts.push(...blockTypesTs(name, key, field));
|
|
440
448
|
}
|
|
441
449
|
parts.push(entryTs(config, name, entry));
|
|
442
450
|
}
|
|
@@ -723,6 +731,11 @@ const module$1 = defineNuxtModule({
|
|
|
723
731
|
});
|
|
724
732
|
const publicDir = resolve(nuxt.options.rootDir, nuxt.options.dir?.public ?? "public");
|
|
725
733
|
const mediaLocalRoot = resolved.media.storage === "local" && resolved.media.publicBaseUrl.startsWith("/") ? join(publicDir, ...resolved.media.publicBaseUrl.split("/").filter(Boolean)) : "";
|
|
734
|
+
if (resolved.media.storage === "local" && !mediaLocalRoot) {
|
|
735
|
+
logger.warn(
|
|
736
|
+
`[nuxt-cms] media.storage is 'local' but media.publicBaseUrl (${resolved.media.publicBaseUrl || "empty"}) is not a root-relative path, so no folder can be read: the media library will be empty. Set it to something like '/images'.`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
726
739
|
addTemplate({
|
|
727
740
|
filename: "cms/media-manifest.d.ts",
|
|
728
741
|
write: true,
|
|
@@ -734,14 +747,17 @@ const module$1 = defineNuxtModule({
|
|
|
734
747
|
filename: "cms/media-manifest.js",
|
|
735
748
|
write: true,
|
|
736
749
|
getContents: async () => {
|
|
737
|
-
if (!mediaLocalRoot || nuxt.options.dev) return renderMediaManifestFile(null);
|
|
750
|
+
if (!mediaLocalRoot || nuxt.options.dev) return renderMediaManifestFile(null, null);
|
|
738
751
|
if (!existsSync(mediaLocalRoot)) {
|
|
739
752
|
logger.warn(
|
|
740
|
-
`[nuxt-cms] Local media folder not found at build time: ${mediaLocalRoot}. The media library will
|
|
753
|
+
`[nuxt-cms] Local media folder not found at build time: ${mediaLocalRoot}. The media library will be empty at runtime.`
|
|
741
754
|
);
|
|
742
|
-
return renderMediaManifestFile(null);
|
|
755
|
+
return renderMediaManifestFile(null, null);
|
|
743
756
|
}
|
|
744
|
-
return renderMediaManifestFile(
|
|
757
|
+
return renderMediaManifestFile(
|
|
758
|
+
await collectMediaManifest(mediaLocalRoot),
|
|
759
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
760
|
+
);
|
|
745
761
|
}
|
|
746
762
|
});
|
|
747
763
|
nuxt.options.alias["#cms-media-manifest"] = mediaManifestTemplate.dst;
|
|
@@ -750,7 +766,6 @@ const module$1 = defineNuxtModule({
|
|
|
750
766
|
driver === "postgres" ? "./runtime/server/plugins/migrate-postgres" : driver === "libsql" ? "./runtime/server/plugins/migrate-libsql" : "./runtime/server/plugins/migrate-sqlite"
|
|
751
767
|
)
|
|
752
768
|
);
|
|
753
|
-
addServerPlugin(resolver.resolve("./runtime/server/plugins/media-sync-local"));
|
|
754
769
|
if (!nuxt.options.dev) {
|
|
755
770
|
addServerPlugin(resolver.resolve("./runtime/server/plugins/session-check"));
|
|
756
771
|
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
<div v-else class="cms-media-gallery">
|
|
10
10
|
<CmsInput v-model="search" icon="magnifying-glass" placeholder="Search media…" />
|
|
11
11
|
|
|
12
|
+
<p v-if="sourceHint" class="cms-media-source">{{ sourceHint }}</p>
|
|
13
|
+
|
|
12
14
|
<div v-if="showTypeFilters || folderChips.length || !readOnly" class="cms-media-filters">
|
|
13
15
|
<template v-if="showTypeFilters">
|
|
14
16
|
<button
|
|
@@ -69,12 +71,12 @@
|
|
|
69
71
|
v-for="item in paged"
|
|
70
72
|
:key="item.key"
|
|
71
73
|
:type="selectable ? 'button' : void 0"
|
|
72
|
-
:role="!selectable &&
|
|
73
|
-
:tabindex="!selectable &&
|
|
74
|
+
:role="!selectable && canEditAlt ? 'button' : void 0"
|
|
75
|
+
:tabindex="!selectable && canEditAlt ? 0 : void 0"
|
|
74
76
|
class="cms-card cms-media-tile"
|
|
75
|
-
:class="{ 'is-selectable': selectable ||
|
|
77
|
+
:class="{ 'is-selectable': selectable || canEditAlt }"
|
|
76
78
|
@click="onTileClick(item)"
|
|
77
|
-
@keydown.enter="!selectable &&
|
|
79
|
+
@keydown.enter="!selectable && canEditAlt && openEdit(item)"
|
|
78
80
|
>
|
|
79
81
|
<div class="cms-media-preview">
|
|
80
82
|
<img
|
|
@@ -203,7 +205,7 @@
|
|
|
203
205
|
</CmsModal>
|
|
204
206
|
|
|
205
207
|
<CmsModal
|
|
206
|
-
v-if="
|
|
208
|
+
v-if="canEditAlt"
|
|
207
209
|
:open="!!editing"
|
|
208
210
|
:title="editing ? mediaFilename(editing.key) : ''"
|
|
209
211
|
@update:open="
|
|
@@ -217,7 +219,7 @@
|
|
|
217
219
|
<CmsFormField label="Alt text">
|
|
218
220
|
<CmsInput v-model="editAlt" />
|
|
219
221
|
</CmsFormField>
|
|
220
|
-
<CmsFormField label="Folder">
|
|
222
|
+
<CmsFormField v-if="canUpload" label="Folder">
|
|
221
223
|
<CmsMediaFolderPicker
|
|
222
224
|
v-model="editFolder"
|
|
223
225
|
:folders="folderNames"
|
|
@@ -260,10 +262,14 @@ const props = defineProps({
|
|
|
260
262
|
const emit = defineEmits(["select"]);
|
|
261
263
|
const toast = useCmsToast();
|
|
262
264
|
const runtime = useCmsRuntime();
|
|
263
|
-
const
|
|
265
|
+
const isLocal = computed(() => runtime.mediaStorage === "local");
|
|
266
|
+
const canUpload = computed(() => !isLocal.value);
|
|
267
|
+
const canEditAlt = computed(() => true);
|
|
268
|
+
const readOnly = computed(() => !canUpload.value);
|
|
264
269
|
const endpoint = "/api/cms/admin/media";
|
|
265
270
|
const DRAFT_FOLDERS_KEY = "nuxt-cms:media-folders";
|
|
266
271
|
const items = ref([]);
|
|
272
|
+
const source = ref(null);
|
|
267
273
|
const loading = ref(true);
|
|
268
274
|
const errorCode = ref(null);
|
|
269
275
|
async function reload() {
|
|
@@ -272,12 +278,23 @@ async function reload() {
|
|
|
272
278
|
try {
|
|
273
279
|
const result = await $fetch(endpoint);
|
|
274
280
|
items.value = result.items;
|
|
281
|
+
source.value = result.source;
|
|
275
282
|
} catch (err) {
|
|
276
283
|
errorCode.value = err.statusCode ?? 500;
|
|
277
284
|
} finally {
|
|
278
285
|
loading.value = false;
|
|
279
286
|
}
|
|
280
287
|
}
|
|
288
|
+
const sourceHint = computed(() => {
|
|
289
|
+
if (!isLocal.value || !source.value) return null;
|
|
290
|
+
const { kind, root, builtAt } = source.value;
|
|
291
|
+
if (kind === "filesystem") return `read live from ${root}`;
|
|
292
|
+
if (kind === "manifest") {
|
|
293
|
+
const when = builtAt ? new Date(builtAt).toLocaleString() : "the last build";
|
|
294
|
+
return `from the build of ${when} \u2014 add files to ${root} and redeploy to update`;
|
|
295
|
+
}
|
|
296
|
+
return `no media folder was found at build time \u2014 check cms.media.publicBaseUrl`;
|
|
297
|
+
});
|
|
281
298
|
onMounted(reload);
|
|
282
299
|
const notConfigured = computed(() => errorCode.value === 501);
|
|
283
300
|
const loadError = computed(() => errorCode.value !== null && errorCode.value !== 501);
|
|
@@ -392,7 +409,7 @@ function onTileClick(item) {
|
|
|
392
409
|
emit("select", { key: item.key, url: item.url });
|
|
393
410
|
return;
|
|
394
411
|
}
|
|
395
|
-
if (
|
|
412
|
+
if (canEditAlt.value) openEdit(item);
|
|
396
413
|
}
|
|
397
414
|
function openEdit(item) {
|
|
398
415
|
editing.value = item;
|
|
@@ -406,9 +423,9 @@ async function saveEdit() {
|
|
|
406
423
|
await $fetch(endpoint, {
|
|
407
424
|
method: "PUT",
|
|
408
425
|
body: {
|
|
409
|
-
|
|
426
|
+
key: editing.value.key,
|
|
410
427
|
alt: editAlt.value.trim() || null,
|
|
411
|
-
folder: editFolder.value
|
|
428
|
+
...canUpload.value ? { folder: editFolder.value } : {}
|
|
412
429
|
}
|
|
413
430
|
});
|
|
414
431
|
editing.value = null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
@layer theme, base, components, utilities;@import "tailwindcss/theme.css" layer(theme) theme(inline);.cms-scope{@import "tailwindcss/preflight.css" layer(base);@import "tailwindcss/utilities.css" layer(utilities) source(none)}@source "../app";.cms-scope,body:has(.cms-scope){--cms-canvas:#1b1b1e;--cms-surface:#222226;--cms-field:#2a2a2f;--cms-elevated:#2f2f35;--cms-line:#35353b;--cms-line-strong:#46464e;--cms-text:#ededef;--cms-text-toned:#c2c2c8;--cms-text-muted:#9a9aa3;--cms-text-dimmed:#6e6e78;--cms-fern:#8fae8b;--cms-error:#f0857d;--cms-success:#7fb083;--cms-warning:#d4ad76;--cms-font-ui:"Hanken Grotesk Variable","Hanken Grotesk",ui-sans-serif,system-ui,sans-serif;--cms-font-mono:"Fragment Mono",ui-monospace,SFMono-Regular,Menlo,monospace;--cms-radius-sm:6px;--cms-radius-md:8px;--cms-radius-lg:12px;--cms-shadow-overlay:0 8px 24px rgba(0,0,0,.35);--cms-scrim:rgba(0,0,0,.5);--cms-duration:120ms;--cms-duration-lg:250ms;--cms-ease:cubic-bezier(0.2,0,0,1);color-scheme:dark;--ui-text-dimmed:var(--cms-text-dimmed);--ui-text-muted:var(--cms-text-muted);--ui-text-toned:var(--cms-text-toned);--ui-text:var(--cms-text-toned);--ui-text-highlighted:var(--cms-text);--ui-text-inverted:var(--cms-canvas);--ui-bg:var(--cms-surface);--ui-bg-muted:var(--cms-canvas);--ui-bg-elevated:var(--cms-field);--ui-bg-accented:var(--cms-elevated);--ui-bg-inverted:var(--cms-text);--ui-border:var(--cms-line);--ui-border-muted:var(--cms-line);--ui-border-accented:var(--cms-line-strong);--ui-border-inverted:var(--cms-text);--ui-primary:var(--cms-text);--ui-accent:var(--cms-fern);--ui-error:var(--cms-error);--ui-success:var(--cms-success);--ui-warning:var(--cms-warning);--ui-radius:var(--cms-radius-md);color:var(--ui-text);font-family:var(--cms-font-ui);margin:0;padding:0;-webkit-font-smoothing:antialiased}body:has(.cms-scope) ::-moz-selection{background:color-mix(in oklab,var(--cms-fern) 35%,transparent);color:var(--ui-text-highlighted)}body:has(.cms-scope) ::selection{background:color-mix(in oklab,var(--cms-fern) 35%,transparent);color:var(--ui-text-highlighted)}body:has(.cms-scope) :is(.cms-scope,.cms-scope *){scrollbar-color:var(--cms-line-strong) transparent;scrollbar-width:thin}body:has(.cms-scope) :is(.cms-scope,.cms-scope *)::-webkit-scrollbar{height:10px;width:10px}body:has(.cms-scope) :is(.cms-scope,.cms-scope *)::-webkit-scrollbar-track{background:transparent}body:has(.cms-scope) :is(.cms-scope,.cms-scope *)::-webkit-scrollbar-thumb{background:var(--cms-line-strong);background-clip:content-box;border:3px solid transparent;border-radius:9999px}body:has(.cms-scope) :is(.cms-scope,.cms-scope *)::-webkit-scrollbar-thumb:hover{background:color-mix(in oklab,var(--cms-line-strong) 70%,var(--cms-text));background-clip:content-box}.cms-display,.cms-title{font-family:var(--cms-font-ui);font-weight:650;letter-spacing:-.01em}.cms-title{color:var(--ui-text-highlighted)}.cms-title-sm{font-size:1.125rem;line-height:1.4}.cms-title-md{font-size:1.5rem;line-height:1.25}.cms-title-lg{font-size:1.875rem;line-height:1.2}.cms-subtitle{color:var(--ui-text-muted);font-size:.875rem}.cms-accent{color:var(--cms-fern)}.cms-label{color:var(--ui-text-muted);font-size:.75rem;font-weight:500;line-height:1.4}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]){@apply cursor-pointer select-none}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option]):focus-visible{outline:2px solid var(--cms-fern);outline-offset:2px}.cms-canvas{background-color:var(--cms-canvas)}.cms-card{background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg)}.cms-panel{padding:1.75rem}body:has(.cms-scope),html:has(.cms-scope){overscroll-behavior-y:none}.cms-shell{display:flex;min-height:100vh}.cms-sidebar{background:var(--cms-surface);border-right:1px solid var(--cms-line);display:flex;flex-direction:column;flex-shrink:0;gap:1.75rem;height:100vh;padding:1.75rem 1rem;position:sticky;top:0;width:16rem}.cms-sidebar-brand{color:var(--ui-text-highlighted);font-family:var(--cms-font-ui);font-size:1.25rem;font-weight:650;letter-spacing:-.01em;padding-inline:.75rem}.cms-sidebar-nav{display:flex;flex:1;flex-direction:column;gap:1.5rem;overflow-y:auto;overscroll-behavior-y:none}.cms-sidebar-group{display:flex;flex-direction:column;gap:.25rem}.cms-sidebar-group>.cms-label{margin-bottom:.5rem;padding-inline:.75rem}.cms-sidebar-footer{border-top:1px solid var(--cms-line);display:flex;flex-direction:column;gap:.75rem;padding-top:1rem}.cms-sidebar-footer .cms-btn.is-block{color:var(--ui-text-muted);justify-content:space-between;padding-inline:.75rem}.cms-sidebar-footer .cms-btn.is-block:hover{background:var(--cms-field);color:var(--ui-text)}.cms-main{flex:1;height:100vh;min-width:0;overflow-y:auto;overscroll-behavior-y:none;padding:2.5rem}.cms-main-inner{margin-inline:auto;max-width:64rem;min-height:100%;width:100%}.cms-main-inner,.cms-page{display:flex;flex-direction:column}.cms-page{gap:1.75rem}.cms-page.is-fill{flex:1;min-height:0}.cms-page-header{align-items:flex-end;display:flex;gap:1rem;justify-content:space-between}.cms-page-heading{display:flex;flex-direction:column;gap:.5rem}.cms-toolbar{align-items:center;display:flex;gap:1.5rem}.cms-toolbar-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-actions{align-items:center;display:flex;gap:.75rem}.cms-actions.is-end{justify-content:flex-end}.cms-spinner{color:var(--ui-text-dimmed);flex:1;justify-content:center;padding-block:2.5rem}.cms-empty,.cms-spinner{align-items:center;display:flex}.cms-empty{background:color-mix(in oklab,var(--cms-surface) 55%,transparent);border:1px dashed var(--cms-line-strong);border-radius:var(--cms-radius-lg);flex-direction:column;gap:.5rem;padding:3.5rem;text-align:center}.cms-empty.is-fill{flex:1;justify-content:center}.cms-empty-icon{color:var(--cms-fern);margin-bottom:.25rem}.cms-empty>.cms-btn{margin-top:.75rem}.cms-auth{align-items:center;display:flex;justify-content:center;min-height:100vh;padding:1.5rem}.cms-auth-panel{gap:2rem;max-width:24rem;width:100%}.cms-auth-header,.cms-auth-panel{display:flex;flex-direction:column}.cms-auth-header{align-items:center;gap:.75rem;text-align:center}.cms-auth-card{padding:1.75rem}.cms-navlink{align-items:center;border-radius:var(--cms-radius-md);color:var(--ui-text-toned);display:flex;font-size:.875rem;font-weight:500;gap:.625rem;padding:.5rem .75rem;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-navlink:hover{background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-navlink.is-active{background:var(--cms-field);color:var(--cms-fern)}.cms-navlink-icon{color:var(--ui-text-dimmed)}.cms-navlink.is-active .cms-navlink-icon{color:var(--cms-fern)}.cms-badge{align-items:center;border:1px solid transparent;border-radius:var(--cms-radius-md);display:inline-flex;font-family:var(--cms-font-mono);font-size:.6875rem;letter-spacing:.08em;line-height:1.4;padding:.1875rem .5rem;pointer-events:none;text-transform:uppercase;transition:background-color var(--cms-duration) var(--cms-ease),border-color var(--cms-duration) var(--cms-ease);white-space:nowrap}.cms-badge.is-published{background:color-mix(in oklab,var(--cms-success) 18%,transparent);border-color:color-mix(in oklab,var(--cms-success) 32%,transparent);color:var(--cms-success)}.cms-badge.is-muted{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-muted)}.cms-badge.is-draft{background:transparent}.cms-badge.is-draft,.cms-dropzone{border:1px dashed var(--cms-line-strong);color:var(--ui-text-muted)}.cms-dropzone{align-items:center;background:color-mix(in oklab,var(--cms-surface) 55%,transparent);border-radius:var(--cms-radius-lg);cursor:pointer;display:flex;flex-direction:column;gap:.5rem;justify-content:center;padding:2rem 1.5rem;transition:border-color var(--cms-duration) var(--cms-ease),background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease);width:100%}.cms-dropzone.is-over,.cms-dropzone:hover{background:var(--cms-field);border-color:var(--cms-fern);color:var(--ui-text-highlighted)}.cms-dropzone.is-dense{flex-direction:row;gap:.625rem;padding:.875rem 1.25rem}.cms-dropzone.is-busy{opacity:.7;pointer-events:none}.cms-pill{align-items:center;background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:9999px;color:var(--ui-text-muted);cursor:pointer;display:inline-flex;flex:none;font-size:.8125rem;gap:.35rem;line-height:1.4;padding:.3rem .8rem;transition:background-color var(--cms-duration) var(--cms-ease),border-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease);white-space:nowrap}.cms-pill:hover{background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-pill.is-active{background:var(--cms-field);border-color:color-mix(in oklab,var(--cms-fern) 45%,transparent);color:var(--cms-fern)}.cms-pill.is-empty{border-style:dashed}.cms-pill-group{align-items:center;display:inline-flex;gap:.125rem}.cms-pill-group .cms-pill{padding-right:.6rem}.cms-pill-discard{align-items:center;border-radius:9999px;color:var(--ui-text-dimmed);cursor:pointer;display:inline-flex;height:1.25rem;justify-content:center;transition:color var(--cms-duration) var(--cms-ease);width:1.25rem}.cms-pill-discard:hover{color:var(--ui-error)}.cms-folder-picker{align-items:center;display:flex;flex-wrap:wrap;gap:.375rem}.cms-folder-picker-input{width:11rem}.cms-form-hint{color:var(--ui-text-dimmed);font-size:.8125rem}.cms-dropzone-hint{align-items:center;color:var(--ui-text-dimmed);display:inline-flex;font-size:.75rem;gap:.3rem}.cms-block-label{color:var(--ui-text-toned);font-size:.875rem;font-weight:550;line-height:1.4;transition:color var(--cms-duration) var(--cms-ease)}.cms-block-label:hover{color:var(--ui-text-highlighted)}@keyframes cms-rise{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}.cms-rise{animation:cms-rise .3s var(--cms-ease) both}.cms-rise.is-delayed{animation-delay:90ms}@media (prefers-reduced-motion:reduce){.cms-rise{animation:none}}.cms-richtext{background:var(--cms-field);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);overflow:hidden}.cms-richtext-toolbar{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;flex-wrap:wrap;gap:2px;padding:4px}.cms-richtext-body{color:var(--ui-text-highlighted);font-size:.875rem;min-height:9rem;outline:none;padding:.75rem 1rem}.cms-richtext-body h2{font-size:1.25rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body h3{font-size:1.1rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body p{margin:.35em 0}.cms-richtext-body ul{list-style:disc;padding-left:1.25rem}.cms-richtext-body ol{list-style:decimal;padding-left:1.25rem}.cms-richtext-body blockquote{border-left:3px solid var(--cms-line-strong);color:var(--ui-text-muted);padding-left:.75rem}.cms-btn{align-items:center;border:1px solid transparent;border-radius:var(--cms-radius-md);display:inline-flex;font-weight:500;gap:.375rem;justify-content:center;line-height:1;min-height:var(--cms-btn-size);transition:background-color var(--cms-duration) var(--cms-ease),border-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease),opacity var(--cms-duration) var(--cms-ease);white-space:nowrap}.cms-btn:active:not(:disabled,[aria-disabled=true]){scale:.99}.cms-btn:disabled,.cms-btn[aria-disabled=true]{opacity:.5;pointer-events:none}.cms-btn.is-block{width:100%}.cms-btn.is-icon{justify-content:center;padding-inline:0;width:var(--cms-btn-size)}.cms-btn-xs{--cms-btn-size:1.75rem;font-size:.875rem;padding:.25rem .625rem}.cms-btn-sm{--cms-btn-size:2rem;font-size:.875rem;padding:.25rem .75rem}.cms-btn-md{--cms-btn-size:2.25rem;font-size:.875rem;padding:.25rem .875rem}.cms-btn-lg{--cms-btn-size:2.625rem;font-size:.9375rem;padding:.375rem 1.125rem}.cms-btn-primary.cms-btn-solid{background:var(--ui-primary);color:var(--ui-text-inverted)}.cms-btn-primary.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-primary) 82%,#fff)}.cms-btn-primary.cms-btn-solid:active{background:color-mix(in oklab,var(--ui-primary) 88%,#000)}.cms-btn-primary.cms-btn-soft{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-highlighted)}.cms-btn-primary.cms-btn-soft:hover{background:var(--cms-elevated);border-color:var(--cms-line-strong)}.cms-btn-primary.cms-btn-soft:active{background:var(--cms-field)}.cms-btn-primary.cms-btn-ghost{background:transparent;color:var(--ui-text-toned)}.cms-btn-primary.cms-btn-ghost:hover{background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-btn-primary.cms-btn-ghost:active{background:var(--cms-elevated)}.cms-btn-neutral.cms-btn-solid{background:var(--cms-line-strong);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-solid:hover{background:color-mix(in oklab,var(--cms-line-strong) 80%,var(--cms-text))}.cms-btn-neutral.cms-btn-solid:active{background:color-mix(in oklab,var(--cms-line-strong) 90%,#000)}.cms-btn-neutral.cms-btn-soft{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-soft:hover{background:var(--cms-elevated);border-color:var(--cms-line-strong)}.cms-btn-neutral.cms-btn-soft:active{background:var(--cms-field)}.cms-btn-neutral.cms-btn-ghost{background:transparent;color:var(--ui-text-toned)}.cms-btn-neutral.cms-btn-ghost:hover{background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-ghost:active{background:var(--cms-elevated)}.cms-btn-error.cms-btn-solid{background:var(--ui-error);color:var(--cms-canvas)}.cms-btn-error.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-error) 85%,#fff)}.cms-btn-error.cms-btn-solid:active{background:color-mix(in oklab,var(--ui-error) 88%,#000)}.cms-btn-error.cms-btn-soft{background:color-mix(in oklab,var(--ui-error) 16%,transparent);border-color:color-mix(in oklab,var(--ui-error) 28%,transparent);color:var(--ui-error)}.cms-btn-error.cms-btn-soft:hover{background:color-mix(in oklab,var(--ui-error) 24%,transparent);border-color:color-mix(in oklab,var(--ui-error) 40%,transparent)}.cms-btn-error.cms-btn-soft:active{background:color-mix(in oklab,var(--ui-error) 16%,transparent)}.cms-btn-error.cms-btn-ghost{background:transparent;color:var(--ui-error)}.cms-btn-error.cms-btn-ghost:hover{background:color-mix(in oklab,var(--ui-error) 14%,transparent)}.cms-btn-error.cms-btn-ghost:active{background:color-mix(in oklab,var(--ui-error) 22%,transparent)}.cms-btn-success.cms-btn-solid{background:var(--ui-success);color:var(--cms-canvas)}.cms-btn-success.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-success) 85%,#fff)}.cms-btn-success.cms-btn-solid:active{background:color-mix(in oklab,var(--ui-success) 88%,#000)}.cms-btn-success.cms-btn-soft{background:color-mix(in oklab,var(--ui-success) 16%,transparent);border-color:color-mix(in oklab,var(--ui-success) 28%,transparent);color:var(--ui-success)}.cms-btn-success.cms-btn-soft:hover{background:color-mix(in oklab,var(--ui-success) 24%,transparent);border-color:color-mix(in oklab,var(--ui-success) 40%,transparent)}.cms-btn-success.cms-btn-soft:active{background:color-mix(in oklab,var(--ui-success) 16%,transparent)}.cms-btn-success.cms-btn-ghost{background:transparent;color:var(--ui-success)}.cms-btn-success.cms-btn-ghost:hover{background:color-mix(in oklab,var(--ui-success) 14%,transparent)}.cms-btn-success.cms-btn-ghost:active{background:color-mix(in oklab,var(--ui-success) 22%,transparent)}.cms-form{gap:1.25rem}.cms-form,.cms-form-field{display:flex;flex-direction:column}.cms-form-field{gap:.375rem}.cms-form-label-row{align-items:center;display:flex;gap:.75rem;justify-content:space-between;min-height:1.75rem}.cms-form-label{color:var(--ui-text-toned);font-size:.875rem;font-weight:500}.cms-form-error,.cms-form-required{color:var(--ui-error)}.cms-form-error{font-size:.875rem}.cms-form-actions{align-items:center;display:flex;gap:.75rem;justify-content:flex-end;padding-top:.25rem}.cms-field{background:var(--cms-field);border:1px solid var(--cms-line);border-radius:var(--ui-radius);color:var(--ui-text-highlighted);line-height:1.25;transition:border-color var(--cms-duration) var(--cms-ease),box-shadow var(--cms-duration) var(--cms-ease);width:100%}textarea.cms-field{line-height:1.5;min-height:0;padding-bottom:.625rem;padding-top:.625rem;resize:vertical}.cms-field::-moz-placeholder{color:var(--ui-text-dimmed)}.cms-field::placeholder{color:var(--ui-text-dimmed)}.cms-field:hover:not(:focus,:focus-within){border-color:var(--cms-line-strong)}.cms-field:focus,.cms-field:focus-within{border-color:var(--cms-fern);box-shadow:0 0 0 3px color-mix(in oklab,var(--cms-fern) 25%,transparent);outline:none}.cms-field.is-error{border-color:var(--ui-error)}.cms-field.is-error:focus,.cms-field.is-error:focus-within{border-color:var(--ui-error);box-shadow:0 0 0 3px color-mix(in oklab,var(--ui-error) 25%,transparent)}.cms-field-sm{font-size:.875rem;min-height:2rem;padding:.25rem .625rem}.cms-field-md{font-size:.875rem;min-height:2.25rem;padding:.25rem .75rem}.cms-field-lg{font-size:.9375rem;min-height:2.625rem;padding:.375rem .875rem}.cms-select-chevron{align-items:center;cursor:pointer;display:inline-flex;transition:transform var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-select-chevron:hover{color:var(--ui-text-muted)}.cms-select-chevron.is-open{transform:rotate(180deg)}.cms-locale-switch{align-items:center;background:var(--cms-field);border:1px solid var(--cms-line);border-radius:var(--cms-radius-md);display:inline-flex;gap:2px;padding:2px}.cms-locale-option{border-radius:var(--cms-radius-sm);color:var(--ui-text-muted);cursor:pointer;font-family:var(--cms-font-mono);font-size:.6875rem;letter-spacing:.08em;line-height:1.4;padding:.125rem .5rem;text-transform:uppercase;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-locale-option:hover:not(.is-active){color:var(--ui-text-highlighted)}.cms-locale-option.is-empty{color:var(--ui-text-dimmed)}.cms-locale-option.is-active{background:var(--cms-elevated);color:var(--cms-fern)}.cms-input-wrap{align-items:center;display:flex;position:relative;width:100%}.cms-input-wrap .cms-field{width:100%}.cms-input-lead,.cms-input-trail{align-items:center;color:var(--ui-text-dimmed);display:flex;position:absolute}.cms-input-lead{left:.625rem}.cms-input-trail{right:.375rem}.cms-input-wrap.has-lead .cms-field{padding-left:2rem}.cms-switch{align-items:center;background:var(--cms-line-strong);border-radius:9999px;display:inline-flex;flex-shrink:0;height:1.25rem;position:relative;transition:background-color var(--cms-duration) var(--cms-ease);width:2.25rem}.cms-switch.is-on{background:var(--cms-fern)}.cms-switch:after{background:var(--cms-text);border-radius:9999px;content:"";height:.875rem;left:.1875rem;position:absolute;transition:transform var(--cms-duration) var(--cms-ease);width:.875rem}.cms-switch.is-on:after{background:var(--cms-canvas);transform:translateX(1rem)}.cms-overlay{align-items:center;background:var(--cms-scrim);display:flex;inset:0;justify-content:center;overflow-y:auto;padding:2rem 1rem;position:fixed;z-index:60}.cms-modal{background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);display:flex;flex-direction:column;max-height:calc(100dvh - 4rem);max-width:32rem;width:100%}.cms-modal.is-sm{max-width:28rem}.cms-modal.is-lg{max-width:48rem}.cms-modal-header{padding:1.25rem 1.5rem 0}.cms-modal-body{flex:1;min-height:0;overflow-y:auto;padding:1.25rem 1.5rem 1.5rem}.cms-modal-enter-active,.cms-modal-leave-active{transition:opacity var(--cms-duration-lg) var(--cms-ease)}.cms-modal-enter-active .cms-modal,.cms-modal-leave-active .cms-modal{transition:transform var(--cms-duration-lg) var(--cms-ease),opacity var(--cms-duration-lg) var(--cms-ease);will-change:transform}.cms-modal-enter-from,.cms-modal-leave-to{opacity:0}.cms-modal-enter-from .cms-modal,.cms-modal-leave-to .cms-modal{opacity:0;transform:translateY(8px)}.cms-overlay-drawer{align-items:stretch;justify-content:flex-end;overflow:hidden;padding:0;z-index:55}.cms-drawer{background:var(--cms-surface);border-left:1px solid var(--cms-line);border-radius:var(--cms-radius-lg) 0 0 var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);display:flex;flex-direction:column;height:100%;max-width:46rem;width:100%}.cms-drawer-header{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-body{flex:1;min-height:0;overflow-y:auto;padding:1.75rem}.cms-drawer-footer{align-items:center;border-top:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-enter-active,.cms-drawer-leave-active{transition:opacity var(--cms-duration-lg) var(--cms-ease)}.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active .cms-drawer{transition:transform var(--cms-duration-lg) var(--cms-ease);will-change:transform}.cms-drawer-enter-from,.cms-drawer-leave-to{opacity:0}.cms-drawer-enter-from .cms-drawer,.cms-drawer-leave-to .cms-drawer{transform:translateX(100%)}@media (prefers-reduced-motion:reduce){.cms-drawer-enter-active,.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active,.cms-drawer-leave-active .cms-drawer,.cms-modal-enter-active,.cms-modal-enter-active .cms-modal,.cms-modal-leave-active,.cms-modal-leave-active .cms-modal{transition-duration:.01ms}}.cms-menu{background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);margin-top:.375rem;min-width:12rem;padding:.25rem;position:absolute;z-index:50}.cms-menu.align-end{right:0}.cms-menu-item{align-items:center;border-radius:var(--cms-radius-sm);color:var(--ui-text);cursor:pointer;display:flex;font-size:.875rem;gap:.5rem;padding:.4rem .6rem;text-align:left;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease);width:100%}.cms-menu-item:hover{background:color-mix(in oklab,var(--cms-text) 8%,transparent);color:var(--ui-text-highlighted)}.cms-menu-check{color:var(--cms-fern);margin-left:auto}.cms-popover{background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);margin-top:.375rem;position:absolute;z-index:50}.cms-selectmenu-input{background:transparent;color:var(--ui-text-highlighted);flex:1;min-width:4rem;outline:none}.cms-selectmenu-affix{color:var(--ui-text-dimmed);flex-shrink:0}.cms-selectmenu-panel{background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);left:0;margin-top:.375rem;max-height:15rem;overflow-y:auto;padding:.25rem;position:absolute;right:0;z-index:50}.cms-tag{align-items:center;background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:9999px;color:var(--ui-text-highlighted);display:inline-flex;font-size:.8125rem;gap:.25rem;padding:.125rem .5rem}.cms-media-gallery{display:flex;flex-direction:column;gap:1.25rem}.cms-media-filters{align-items:center;display:flex;flex-wrap:wrap;gap:.25rem}.cms-media-filters-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-media-filter-separator{background:var(--cms-line);flex:none;height:1rem;margin-inline:.25rem;width:1px}.cms-media-grid{display:grid;gap:1rem;grid-template-columns:repeat(2,minmax(0,1fr))}@media (min-width:640px){.cms-media-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.cms-media-grid:not(.is-compact){grid-template-columns:repeat(4,minmax(0,1fr))}}.cms-media-tile{overflow:hidden;text-align:left;transition:border-color var(--cms-duration) var(--cms-ease)}.cms-media-tile.is-selectable{cursor:pointer}.cms-media-tile.is-selectable:hover{border-color:var(--cms-line-strong)}.cms-media-preview{align-items:center;aspect-ratio:1;background:var(--cms-field);color:var(--ui-text-dimmed);display:flex;justify-content:center;position:relative}.cms-media-preview>img,.cms-media-preview>video{height:100%;inset:0;-o-object-fit:cover;object-fit:cover;position:absolute;width:100%}.cms-media-actions{display:flex;gap:.25rem;opacity:0;position:absolute;right:.5rem;top:.5rem;transition:opacity var(--cms-duration) var(--cms-ease)}.cms-media-actions:focus-within,.cms-media-tile:hover .cms-media-actions{opacity:1}.cms-media-info{background:linear-gradient(0deg,rgba(0,0,0,.75),transparent);bottom:0;color:var(--cms-text);font-family:var(--cms-font-mono);font-size:.75rem;left:0;letter-spacing:.08em;line-height:1.4;opacity:0;padding:1.5rem .75rem .5rem;pointer-events:none;position:absolute;right:0;text-transform:uppercase;transition:opacity var(--cms-duration) var(--cms-ease)}.cms-media-tile:focus-visible .cms-media-info,.cms-media-tile:hover .cms-media-info{opacity:1}.cms-media-meta{display:flex;flex-direction:column;gap:.25rem;padding:.625rem .875rem}.cms-media-name{color:var(--ui-text-highlighted);font-size:.875rem;font-weight:500}.cms-media-folder,.cms-media-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-folder{align-items:center;color:var(--ui-text-dimmed);display:inline-flex;font-size:.75rem;gap:.25rem}.cms-media-field{overflow:hidden}.cms-media-field-preview{align-items:center;background-color:var(--cms-canvas);background-image:repeating-conic-gradient(from 0deg,hsla(0,0%,100%,.045) 0 25%,hsla(0,0%,100%,0) 0 50%);background-position:50%;background-size:16px 16px;color:var(--ui-text-dimmed);display:flex;justify-content:center;max-height:16rem;min-height:8rem;padding:.75rem}.cms-media-field-preview>img,.cms-media-field-preview>video{max-height:14.5rem;max-width:100%;-o-object-fit:contain;object-fit:contain}.cms-media-field-bar{align-items:center;background:var(--cms-surface);border-top:1px solid var(--cms-line);display:flex;gap:.5rem;padding:.625rem .75rem}.cms-media-bar-icon{color:var(--ui-text-dimmed);flex-shrink:0}.cms-media-bar-name{color:var(--ui-text-highlighted);flex:1;font-size:.875rem;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-thumb{background:var(--cms-field);border-radius:var(--cms-radius-sm);height:2.25rem;-o-object-fit:cover;object-fit:cover;width:2.25rem}.cms-media-thumb.is-fallback{align-items:center;color:var(--ui-text-dimmed);display:flex;justify-content:center}.cms-media-thumb-empty{color:var(--ui-text-dimmed)}.cms-table-shell{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.cms-table-scroll{flex:1;min-height:0;overflow:auto}.cms-table{border-collapse:collapse;min-width:-moz-max-content;min-width:max-content;text-align:left;width:100%}.cms-table td,.cms-table th{white-space:nowrap}.cms-table thead{background:var(--cms-surface);position:sticky;top:0;z-index:10}.cms-table th{background:var(--cms-surface);box-shadow:inset 0 -1px 0 var(--cms-line);color:var(--ui-text-muted);font-family:var(--cms-font-mono);font-size:.6875rem;font-weight:400;letter-spacing:.08em;line-height:1.4;padding:.75rem 1.25rem;text-transform:uppercase;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-table td{color:var(--ui-text);font-size:.875rem;padding:.875rem 1.25rem}.cms-table tbody tr{border-bottom:1px solid var(--cms-line);transition:background-color var(--cms-duration) var(--cms-ease)}.cms-table tbody tr:last-child{border-bottom:0}.cms-table tbody tr:hover{background:var(--cms-field)}.cms-table-cell-actions{display:flex;justify-content:flex-end}.cms-table th[draggable=true]{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.cms-table th[draggable=true]:active{cursor:grabbing}.cms-table th.is-dragging{opacity:.4}.cms-table th.is-drop-target{background:var(--cms-field);box-shadow:inset 0 -1px 0 var(--cms-line),inset 0 0 0 2px color-mix(in oklab,var(--cms-fern) 55%,transparent)}.cms-th-label{align-items:center;display:inline-flex;gap:.35rem}.cms-table th.is-sortable{cursor:pointer}.cms-table th.is-sortable:hover,.cms-table th.is-sorted{color:var(--ui-text-highlighted)}.cms-table th.is-sorted{background:var(--cms-field)}.cms-th-sort{flex:none;opacity:0;transition:opacity var(--cms-duration) var(--cms-ease)}.cms-table th.is-sortable:hover .cms-th-sort,.cms-table th.is-sorted .cms-th-sort{opacity:1}.cms-alert{border:1px solid transparent;border-radius:var(--cms-radius-md);display:flex;flex-direction:column;font-size:.875rem;gap:.125rem;padding:.75rem 1rem}.cms-alert-title{font-weight:550}.cms-alert-error{background:color-mix(in oklab,var(--ui-error) 14%,transparent);border-color:color-mix(in oklab,var(--ui-error) 26%,transparent);color:var(--ui-error)}.cms-alert-success{background:color-mix(in oklab,var(--ui-success) 14%,transparent);border-color:color-mix(in oklab,var(--ui-success) 26%,transparent);color:var(--ui-success)}.cms-alert-warning{background:color-mix(in oklab,var(--ui-warning) 14%,transparent);border-color:color-mix(in oklab,var(--ui-warning) 26%,transparent);color:var(--ui-warning)}.cms-alert-neutral{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-toned)}.cms-alert-neutral .cms-alert-title{color:var(--ui-text-highlighted)}.cms-toaster{bottom:1rem;display:flex;flex-direction:column;gap:.5rem;max-width:calc(100vw - 2rem);position:fixed;right:1rem;width:20rem;z-index:80}.cms-toast{background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);padding:.75rem .875rem}.cms-toast-row{align-items:flex-start;display:flex;gap:.625rem}.cms-toast-icon{color:var(--ui-text-muted);flex-shrink:0;margin-top:.0625rem}.cms-toast.is-success .cms-toast-icon{color:var(--ui-success)}.cms-toast.is-error .cms-toast-icon{color:var(--ui-error)}.cms-toast-title{color:var(--ui-text-highlighted);font-size:.875rem;font-weight:500}.cms-toast-description{color:var(--ui-text-muted);font-size:.875rem;margin-top:.125rem}.cms-toast-dismiss{color:var(--ui-text-dimmed);flex-shrink:0;transition:color var(--cms-duration) var(--cms-ease)}.cms-toast-dismiss:hover{color:var(--ui-text-highlighted)}.cms-pagination{align-items:center;display:flex;gap:.25rem;justify-content:center}.cms-page-btn{border-radius:var(--cms-radius-sm);color:var(--ui-text-toned);font-size:.875rem;height:2rem;min-width:2rem;padding:0 .5rem;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-page-btn:hover:not(:disabled){background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-page-btn.is-active{background:var(--cms-field);color:var(--cms-fern);font-weight:600}.cms-page-btn:disabled{opacity:.4}
|
|
1
|
+
@layer theme, base, components, utilities;@import "tailwindcss/theme.css" layer(theme) theme(inline);.cms-scope{@import "tailwindcss/preflight.css" layer(base);@import "tailwindcss/utilities.css" layer(utilities) source(none)}@source "../app";.cms-scope,body:has(.cms-scope){--cms-canvas:#1b1b1e;--cms-surface:#222226;--cms-field:#2a2a2f;--cms-elevated:#2f2f35;--cms-line:#35353b;--cms-line-strong:#46464e;--cms-text:#ededef;--cms-text-toned:#c2c2c8;--cms-text-muted:#9a9aa3;--cms-text-dimmed:#6e6e78;--cms-fern:#8fae8b;--cms-error:#f0857d;--cms-success:#7fb083;--cms-warning:#d4ad76;--cms-font-ui:"Hanken Grotesk Variable","Hanken Grotesk",ui-sans-serif,system-ui,sans-serif;--cms-font-mono:"Fragment Mono",ui-monospace,SFMono-Regular,Menlo,monospace;--cms-radius-sm:6px;--cms-radius-md:8px;--cms-radius-lg:12px;--cms-shadow-overlay:0 8px 24px rgba(0,0,0,.35);--cms-scrim:rgba(0,0,0,.5);--cms-duration:120ms;--cms-duration-lg:250ms;--cms-ease:cubic-bezier(0.2,0,0,1);color-scheme:dark;--ui-text-dimmed:var(--cms-text-dimmed);--ui-text-muted:var(--cms-text-muted);--ui-text-toned:var(--cms-text-toned);--ui-text:var(--cms-text-toned);--ui-text-highlighted:var(--cms-text);--ui-text-inverted:var(--cms-canvas);--ui-bg:var(--cms-surface);--ui-bg-muted:var(--cms-canvas);--ui-bg-elevated:var(--cms-field);--ui-bg-accented:var(--cms-elevated);--ui-bg-inverted:var(--cms-text);--ui-border:var(--cms-line);--ui-border-muted:var(--cms-line);--ui-border-accented:var(--cms-line-strong);--ui-border-inverted:var(--cms-text);--ui-primary:var(--cms-text);--ui-accent:var(--cms-fern);--ui-error:var(--cms-error);--ui-success:var(--cms-success);--ui-warning:var(--cms-warning);--ui-radius:var(--cms-radius-md);color:var(--ui-text);font-family:var(--cms-font-ui);margin:0;padding:0;-webkit-font-smoothing:antialiased}body:has(.cms-scope) ::-moz-selection{background:color-mix(in oklab,var(--cms-fern) 35%,transparent);color:var(--ui-text-highlighted)}body:has(.cms-scope) ::selection{background:color-mix(in oklab,var(--cms-fern) 35%,transparent);color:var(--ui-text-highlighted)}body:has(.cms-scope) :is(.cms-scope,.cms-scope *){scrollbar-color:var(--cms-line-strong) transparent;scrollbar-width:thin}body:has(.cms-scope) :is(.cms-scope,.cms-scope *)::-webkit-scrollbar{height:10px;width:10px}body:has(.cms-scope) :is(.cms-scope,.cms-scope *)::-webkit-scrollbar-track{background:transparent}body:has(.cms-scope) :is(.cms-scope,.cms-scope *)::-webkit-scrollbar-thumb{background:var(--cms-line-strong);background-clip:content-box;border:3px solid transparent;border-radius:9999px}body:has(.cms-scope) :is(.cms-scope,.cms-scope *)::-webkit-scrollbar-thumb:hover{background:color-mix(in oklab,var(--cms-line-strong) 70%,var(--cms-text));background-clip:content-box}.cms-display,.cms-title{font-family:var(--cms-font-ui);font-weight:650;letter-spacing:-.01em}.cms-title{color:var(--ui-text-highlighted)}.cms-title-sm{font-size:1.125rem;line-height:1.4}.cms-title-md{font-size:1.5rem;line-height:1.25}.cms-title-lg{font-size:1.875rem;line-height:1.2}.cms-subtitle{color:var(--ui-text-muted);font-size:.875rem}.cms-accent{color:var(--cms-fern)}.cms-label{color:var(--ui-text-muted);font-size:.75rem;font-weight:500;line-height:1.4}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option],.__clickable):not(:disabled,[aria-disabled=true]){@apply cursor-pointer select-none}body:has(.cms-scope) :is(button,a,[role=button],[role=menuitem],[role=option]):focus-visible{outline:2px solid var(--cms-fern);outline-offset:2px}.cms-canvas{background-color:var(--cms-canvas)}.cms-card{background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg)}.cms-panel{padding:1.75rem}body:has(.cms-scope),html:has(.cms-scope){overscroll-behavior-y:none}.cms-shell{display:flex;min-height:100vh}.cms-sidebar{background:var(--cms-surface);border-right:1px solid var(--cms-line);display:flex;flex-direction:column;flex-shrink:0;gap:1.75rem;height:100vh;padding:1.75rem 1rem;position:sticky;top:0;width:16rem}.cms-sidebar-brand{color:var(--ui-text-highlighted);font-family:var(--cms-font-ui);font-size:1.25rem;font-weight:650;letter-spacing:-.01em;padding-inline:.75rem}.cms-sidebar-nav{display:flex;flex:1;flex-direction:column;gap:1.5rem;overflow-y:auto;overscroll-behavior-y:none}.cms-sidebar-group{display:flex;flex-direction:column;gap:.25rem}.cms-sidebar-group>.cms-label{margin-bottom:.5rem;padding-inline:.75rem}.cms-sidebar-footer{border-top:1px solid var(--cms-line);display:flex;flex-direction:column;gap:.75rem;padding-top:1rem}.cms-sidebar-footer .cms-btn.is-block{color:var(--ui-text-muted);justify-content:space-between;padding-inline:.75rem}.cms-sidebar-footer .cms-btn.is-block:hover{background:var(--cms-field);color:var(--ui-text)}.cms-main{flex:1;height:100vh;min-width:0;overflow-y:auto;overscroll-behavior-y:none;padding:2.5rem}.cms-main-inner{margin-inline:auto;max-width:64rem;min-height:100%;width:100%}.cms-main-inner,.cms-page{display:flex;flex-direction:column}.cms-page{gap:1.75rem}.cms-page.is-fill{flex:1;min-height:0}.cms-page-header{align-items:flex-end;display:flex;gap:1rem;justify-content:space-between}.cms-page-heading{display:flex;flex-direction:column;gap:.5rem}.cms-toolbar{align-items:center;display:flex;gap:1.5rem}.cms-toolbar-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-actions{align-items:center;display:flex;gap:.75rem}.cms-actions.is-end{justify-content:flex-end}.cms-spinner{color:var(--ui-text-dimmed);flex:1;justify-content:center;padding-block:2.5rem}.cms-empty,.cms-spinner{align-items:center;display:flex}.cms-empty{background:color-mix(in oklab,var(--cms-surface) 55%,transparent);border:1px dashed var(--cms-line-strong);border-radius:var(--cms-radius-lg);flex-direction:column;gap:.5rem;padding:3.5rem;text-align:center}.cms-empty.is-fill{flex:1;justify-content:center}.cms-empty-icon{color:var(--cms-fern);margin-bottom:.25rem}.cms-empty>.cms-btn{margin-top:.75rem}.cms-auth{align-items:center;display:flex;justify-content:center;min-height:100vh;padding:1.5rem}.cms-auth-panel{gap:2rem;max-width:24rem;width:100%}.cms-auth-header,.cms-auth-panel{display:flex;flex-direction:column}.cms-auth-header{align-items:center;gap:.75rem;text-align:center}.cms-auth-card{padding:1.75rem}.cms-navlink{align-items:center;border-radius:var(--cms-radius-md);color:var(--ui-text-toned);display:flex;font-size:.875rem;font-weight:500;gap:.625rem;padding:.5rem .75rem;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-navlink:hover{background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-navlink.is-active{background:var(--cms-field);color:var(--cms-fern)}.cms-navlink-icon{color:var(--ui-text-dimmed)}.cms-navlink.is-active .cms-navlink-icon{color:var(--cms-fern)}.cms-badge{align-items:center;border:1px solid transparent;border-radius:var(--cms-radius-md);display:inline-flex;font-family:var(--cms-font-mono);font-size:.6875rem;letter-spacing:.08em;line-height:1.4;padding:.1875rem .5rem;pointer-events:none;text-transform:uppercase;transition:background-color var(--cms-duration) var(--cms-ease),border-color var(--cms-duration) var(--cms-ease);white-space:nowrap}.cms-badge.is-published{background:color-mix(in oklab,var(--cms-success) 18%,transparent);border-color:color-mix(in oklab,var(--cms-success) 32%,transparent);color:var(--cms-success)}.cms-badge.is-muted{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-muted)}.cms-badge.is-draft{background:transparent}.cms-badge.is-draft,.cms-dropzone{border:1px dashed var(--cms-line-strong);color:var(--ui-text-muted)}.cms-dropzone{align-items:center;background:color-mix(in oklab,var(--cms-surface) 55%,transparent);border-radius:var(--cms-radius-lg);cursor:pointer;display:flex;flex-direction:column;gap:.5rem;justify-content:center;padding:2rem 1.5rem;transition:border-color var(--cms-duration) var(--cms-ease),background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease);width:100%}.cms-dropzone.is-over,.cms-dropzone:hover{background:var(--cms-field);border-color:var(--cms-fern);color:var(--ui-text-highlighted)}.cms-dropzone.is-dense{flex-direction:row;gap:.625rem;padding:.875rem 1.25rem}.cms-dropzone.is-busy{opacity:.7;pointer-events:none}.cms-pill{align-items:center;background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:9999px;color:var(--ui-text-muted);cursor:pointer;display:inline-flex;flex:none;font-size:.8125rem;gap:.35rem;line-height:1.4;padding:.3rem .8rem;transition:background-color var(--cms-duration) var(--cms-ease),border-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease);white-space:nowrap}.cms-pill:hover{background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-pill.is-active{background:var(--cms-field);border-color:color-mix(in oklab,var(--cms-fern) 45%,transparent);color:var(--cms-fern)}.cms-pill.is-empty{border-style:dashed}.cms-pill-group{align-items:center;display:inline-flex;gap:.125rem}.cms-pill-group .cms-pill{padding-right:.6rem}.cms-pill-discard{align-items:center;border-radius:9999px;color:var(--ui-text-dimmed);cursor:pointer;display:inline-flex;height:1.25rem;justify-content:center;transition:color var(--cms-duration) var(--cms-ease);width:1.25rem}.cms-pill-discard:hover{color:var(--ui-error)}.cms-folder-picker{align-items:center;display:flex;flex-wrap:wrap;gap:.375rem}.cms-folder-picker-input{width:11rem}.cms-form-hint{color:var(--ui-text-dimmed);font-size:.8125rem}.cms-dropzone-hint{align-items:center;color:var(--ui-text-dimmed);display:inline-flex;font-size:.75rem;gap:.3rem}.cms-block-label{color:var(--ui-text-toned);font-size:.875rem;font-weight:550;line-height:1.4;transition:color var(--cms-duration) var(--cms-ease)}.cms-block-label:hover{color:var(--ui-text-highlighted)}@keyframes cms-rise{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}.cms-rise{animation:cms-rise .3s var(--cms-ease) both}.cms-rise.is-delayed{animation-delay:90ms}@media (prefers-reduced-motion:reduce){.cms-rise{animation:none}}.cms-richtext{background:var(--cms-field);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);overflow:hidden}.cms-richtext-toolbar{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;flex-wrap:wrap;gap:2px;padding:4px}.cms-richtext-body{color:var(--ui-text-highlighted);font-size:.875rem;min-height:9rem;outline:none;padding:.75rem 1rem}.cms-richtext-body h2{font-size:1.25rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body h3{font-size:1.1rem;font-weight:600;margin:.75em 0 .35em}.cms-richtext-body p{margin:.35em 0}.cms-richtext-body ul{list-style:disc;padding-left:1.25rem}.cms-richtext-body ol{list-style:decimal;padding-left:1.25rem}.cms-richtext-body blockquote{border-left:3px solid var(--cms-line-strong);color:var(--ui-text-muted);padding-left:.75rem}.cms-btn{align-items:center;border:1px solid transparent;border-radius:var(--cms-radius-md);display:inline-flex;font-weight:500;gap:.375rem;justify-content:center;line-height:1;min-height:var(--cms-btn-size);transition:background-color var(--cms-duration) var(--cms-ease),border-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease),opacity var(--cms-duration) var(--cms-ease);white-space:nowrap}.cms-btn:active:not(:disabled,[aria-disabled=true]){scale:.99}.cms-btn:disabled,.cms-btn[aria-disabled=true]{opacity:.5;pointer-events:none}.cms-btn.is-block{width:100%}.cms-btn.is-icon{justify-content:center;padding-inline:0;width:var(--cms-btn-size)}.cms-btn-xs{--cms-btn-size:1.75rem;font-size:.875rem;padding:.25rem .625rem}.cms-btn-sm{--cms-btn-size:2rem;font-size:.875rem;padding:.25rem .75rem}.cms-btn-md{--cms-btn-size:2.25rem;font-size:.875rem;padding:.25rem .875rem}.cms-btn-lg{--cms-btn-size:2.625rem;font-size:.9375rem;padding:.375rem 1.125rem}.cms-btn-primary.cms-btn-solid{background:var(--ui-primary);color:var(--ui-text-inverted)}.cms-btn-primary.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-primary) 82%,#fff)}.cms-btn-primary.cms-btn-solid:active{background:color-mix(in oklab,var(--ui-primary) 88%,#000)}.cms-btn-primary.cms-btn-soft{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-highlighted)}.cms-btn-primary.cms-btn-soft:hover{background:var(--cms-elevated);border-color:var(--cms-line-strong)}.cms-btn-primary.cms-btn-soft:active{background:var(--cms-field)}.cms-btn-primary.cms-btn-ghost{background:transparent;color:var(--ui-text-toned)}.cms-btn-primary.cms-btn-ghost:hover{background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-btn-primary.cms-btn-ghost:active{background:var(--cms-elevated)}.cms-btn-neutral.cms-btn-solid{background:var(--cms-line-strong);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-solid:hover{background:color-mix(in oklab,var(--cms-line-strong) 80%,var(--cms-text))}.cms-btn-neutral.cms-btn-solid:active{background:color-mix(in oklab,var(--cms-line-strong) 90%,#000)}.cms-btn-neutral.cms-btn-soft{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-soft:hover{background:var(--cms-elevated);border-color:var(--cms-line-strong)}.cms-btn-neutral.cms-btn-soft:active{background:var(--cms-field)}.cms-btn-neutral.cms-btn-ghost{background:transparent;color:var(--ui-text-toned)}.cms-btn-neutral.cms-btn-ghost:hover{background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-btn-neutral.cms-btn-ghost:active{background:var(--cms-elevated)}.cms-btn-error.cms-btn-solid{background:var(--ui-error);color:var(--cms-canvas)}.cms-btn-error.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-error) 85%,#fff)}.cms-btn-error.cms-btn-solid:active{background:color-mix(in oklab,var(--ui-error) 88%,#000)}.cms-btn-error.cms-btn-soft{background:color-mix(in oklab,var(--ui-error) 16%,transparent);border-color:color-mix(in oklab,var(--ui-error) 28%,transparent);color:var(--ui-error)}.cms-btn-error.cms-btn-soft:hover{background:color-mix(in oklab,var(--ui-error) 24%,transparent);border-color:color-mix(in oklab,var(--ui-error) 40%,transparent)}.cms-btn-error.cms-btn-soft:active{background:color-mix(in oklab,var(--ui-error) 16%,transparent)}.cms-btn-error.cms-btn-ghost{background:transparent;color:var(--ui-error)}.cms-btn-error.cms-btn-ghost:hover{background:color-mix(in oklab,var(--ui-error) 14%,transparent)}.cms-btn-error.cms-btn-ghost:active{background:color-mix(in oklab,var(--ui-error) 22%,transparent)}.cms-btn-success.cms-btn-solid{background:var(--ui-success);color:var(--cms-canvas)}.cms-btn-success.cms-btn-solid:hover{background:color-mix(in oklab,var(--ui-success) 85%,#fff)}.cms-btn-success.cms-btn-solid:active{background:color-mix(in oklab,var(--ui-success) 88%,#000)}.cms-btn-success.cms-btn-soft{background:color-mix(in oklab,var(--ui-success) 16%,transparent);border-color:color-mix(in oklab,var(--ui-success) 28%,transparent);color:var(--ui-success)}.cms-btn-success.cms-btn-soft:hover{background:color-mix(in oklab,var(--ui-success) 24%,transparent);border-color:color-mix(in oklab,var(--ui-success) 40%,transparent)}.cms-btn-success.cms-btn-soft:active{background:color-mix(in oklab,var(--ui-success) 16%,transparent)}.cms-btn-success.cms-btn-ghost{background:transparent;color:var(--ui-success)}.cms-btn-success.cms-btn-ghost:hover{background:color-mix(in oklab,var(--ui-success) 14%,transparent)}.cms-btn-success.cms-btn-ghost:active{background:color-mix(in oklab,var(--ui-success) 22%,transparent)}.cms-form{gap:1.25rem}.cms-form,.cms-form-field{display:flex;flex-direction:column}.cms-form-field{gap:.375rem}.cms-form-label-row{align-items:center;display:flex;gap:.75rem;justify-content:space-between;min-height:1.75rem}.cms-form-label{color:var(--ui-text-toned);font-size:.875rem;font-weight:500}.cms-form-error,.cms-form-required{color:var(--ui-error)}.cms-form-error{font-size:.875rem}.cms-form-actions{align-items:center;display:flex;gap:.75rem;justify-content:flex-end;padding-top:.25rem}.cms-field{background:var(--cms-field);border:1px solid var(--cms-line);border-radius:var(--ui-radius);color:var(--ui-text-highlighted);line-height:1.25;transition:border-color var(--cms-duration) var(--cms-ease),box-shadow var(--cms-duration) var(--cms-ease);width:100%}textarea.cms-field{line-height:1.5;min-height:0;padding-bottom:.625rem;padding-top:.625rem;resize:vertical}.cms-field::-moz-placeholder{color:var(--ui-text-dimmed)}.cms-field::placeholder{color:var(--ui-text-dimmed)}.cms-field:hover:not(:focus,:focus-within){border-color:var(--cms-line-strong)}.cms-field:focus,.cms-field:focus-within{border-color:var(--cms-fern);box-shadow:0 0 0 3px color-mix(in oklab,var(--cms-fern) 25%,transparent);outline:none}.cms-field.is-error{border-color:var(--ui-error)}.cms-field.is-error:focus,.cms-field.is-error:focus-within{border-color:var(--ui-error);box-shadow:0 0 0 3px color-mix(in oklab,var(--ui-error) 25%,transparent)}.cms-field-sm{font-size:.875rem;min-height:2rem;padding:.25rem .625rem}.cms-field-md{font-size:.875rem;min-height:2.25rem;padding:.25rem .75rem}.cms-field-lg{font-size:.9375rem;min-height:2.625rem;padding:.375rem .875rem}.cms-select-chevron{align-items:center;cursor:pointer;display:inline-flex;transition:transform var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-select-chevron:hover{color:var(--ui-text-muted)}.cms-select-chevron.is-open{transform:rotate(180deg)}.cms-locale-switch{align-items:center;background:var(--cms-field);border:1px solid var(--cms-line);border-radius:var(--cms-radius-md);display:inline-flex;gap:2px;padding:2px}.cms-locale-option{border-radius:var(--cms-radius-sm);color:var(--ui-text-muted);cursor:pointer;font-family:var(--cms-font-mono);font-size:.6875rem;letter-spacing:.08em;line-height:1.4;padding:.125rem .5rem;text-transform:uppercase;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-locale-option:hover:not(.is-active){color:var(--ui-text-highlighted)}.cms-locale-option.is-empty{color:var(--ui-text-dimmed)}.cms-locale-option.is-active{background:var(--cms-elevated);color:var(--cms-fern)}.cms-input-wrap{align-items:center;display:flex;position:relative;width:100%}.cms-input-wrap .cms-field{width:100%}.cms-input-lead,.cms-input-trail{align-items:center;color:var(--ui-text-dimmed);display:flex;position:absolute}.cms-input-lead{left:.625rem}.cms-input-trail{right:.375rem}.cms-input-wrap.has-lead .cms-field{padding-left:2rem}.cms-switch{align-items:center;background:var(--cms-line-strong);border-radius:9999px;display:inline-flex;flex-shrink:0;height:1.25rem;position:relative;transition:background-color var(--cms-duration) var(--cms-ease);width:2.25rem}.cms-switch.is-on{background:var(--cms-fern)}.cms-switch:after{background:var(--cms-text);border-radius:9999px;content:"";height:.875rem;left:.1875rem;position:absolute;transition:transform var(--cms-duration) var(--cms-ease);width:.875rem}.cms-switch.is-on:after{background:var(--cms-canvas);transform:translateX(1rem)}.cms-overlay{align-items:center;background:var(--cms-scrim);display:flex;inset:0;justify-content:center;overflow-y:auto;padding:2rem 1rem;position:fixed;z-index:60}.cms-modal{background:var(--cms-surface);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);display:flex;flex-direction:column;max-height:calc(100dvh - 4rem);max-width:32rem;width:100%}.cms-modal.is-sm{max-width:28rem}.cms-modal.is-lg{max-width:48rem}.cms-modal-header{padding:1.25rem 1.5rem 0}.cms-modal-body{flex:1;min-height:0;overflow-y:auto;padding:1.25rem 1.5rem 1.5rem}.cms-modal-enter-active,.cms-modal-leave-active{transition:opacity var(--cms-duration-lg) var(--cms-ease)}.cms-modal-enter-active .cms-modal,.cms-modal-leave-active .cms-modal{transition:transform var(--cms-duration-lg) var(--cms-ease),opacity var(--cms-duration-lg) var(--cms-ease);will-change:transform}.cms-modal-enter-from,.cms-modal-leave-to{opacity:0}.cms-modal-enter-from .cms-modal,.cms-modal-leave-to .cms-modal{opacity:0;transform:translateY(8px)}.cms-overlay-drawer{align-items:stretch;justify-content:flex-end;overflow:hidden;padding:0;z-index:55}.cms-drawer{background:var(--cms-surface);border-left:1px solid var(--cms-line);border-radius:var(--cms-radius-lg) 0 0 var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);display:flex;flex-direction:column;height:100%;max-width:46rem;width:100%}.cms-drawer-header{align-items:center;border-bottom:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-body{flex:1;min-height:0;overflow-y:auto;padding:1.75rem}.cms-drawer-footer{align-items:center;border-top:1px solid var(--cms-line);display:flex;gap:1rem;justify-content:space-between;padding:1.25rem 1.75rem}.cms-drawer-enter-active,.cms-drawer-leave-active{transition:opacity var(--cms-duration-lg) var(--cms-ease)}.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active .cms-drawer{transition:transform var(--cms-duration-lg) var(--cms-ease);will-change:transform}.cms-drawer-enter-from,.cms-drawer-leave-to{opacity:0}.cms-drawer-enter-from .cms-drawer,.cms-drawer-leave-to .cms-drawer{transform:translateX(100%)}@media (prefers-reduced-motion:reduce){.cms-drawer-enter-active,.cms-drawer-enter-active .cms-drawer,.cms-drawer-leave-active,.cms-drawer-leave-active .cms-drawer,.cms-modal-enter-active,.cms-modal-enter-active .cms-modal,.cms-modal-leave-active,.cms-modal-leave-active .cms-modal{transition-duration:.01ms}}.cms-menu{background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);margin-top:.375rem;min-width:12rem;padding:.25rem;position:absolute;z-index:50}.cms-menu.align-end{right:0}.cms-menu-item{align-items:center;border-radius:var(--cms-radius-sm);color:var(--ui-text);cursor:pointer;display:flex;font-size:.875rem;gap:.5rem;padding:.4rem .6rem;text-align:left;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease);width:100%}.cms-menu-item:hover{background:color-mix(in oklab,var(--cms-text) 8%,transparent);color:var(--ui-text-highlighted)}.cms-menu-check{color:var(--cms-fern);margin-left:auto}.cms-popover{background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);margin-top:.375rem;position:absolute;z-index:50}.cms-selectmenu-input{background:transparent;color:var(--ui-text-highlighted);flex:1;min-width:4rem;outline:none}.cms-selectmenu-affix{color:var(--ui-text-dimmed);flex-shrink:0}.cms-selectmenu-panel{background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);left:0;margin-top:.375rem;max-height:15rem;overflow-y:auto;padding:.25rem;position:absolute;right:0;z-index:50}.cms-tag{align-items:center;background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:9999px;color:var(--ui-text-highlighted);display:inline-flex;font-size:.8125rem;gap:.25rem;padding:.125rem .5rem}.cms-media-gallery{display:flex;flex-direction:column;gap:1.25rem}.cms-media-source{color:var(--ui-text-muted);font-size:.8125rem;margin-top:-.75rem}.cms-media-filters{align-items:center;display:flex;flex-wrap:wrap;gap:.25rem}.cms-media-filters-actions{align-items:center;display:flex;flex-shrink:0;gap:.5rem;margin-left:auto}.cms-media-filter-separator{background:var(--cms-line);flex:none;height:1rem;margin-inline:.25rem;width:1px}.cms-media-grid{display:grid;gap:1rem;grid-template-columns:repeat(2,minmax(0,1fr))}@media (min-width:640px){.cms-media-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.cms-media-grid:not(.is-compact){grid-template-columns:repeat(4,minmax(0,1fr))}}.cms-media-tile{overflow:hidden;text-align:left;transition:border-color var(--cms-duration) var(--cms-ease)}.cms-media-tile.is-selectable{cursor:pointer}.cms-media-tile.is-selectable:hover{border-color:var(--cms-line-strong)}.cms-media-preview{align-items:center;aspect-ratio:1;background:var(--cms-field);color:var(--ui-text-dimmed);display:flex;justify-content:center;position:relative}.cms-media-preview>img,.cms-media-preview>video{height:100%;inset:0;-o-object-fit:cover;object-fit:cover;position:absolute;width:100%}.cms-media-actions{display:flex;gap:.25rem;opacity:0;position:absolute;right:.5rem;top:.5rem;transition:opacity var(--cms-duration) var(--cms-ease)}.cms-media-actions:focus-within,.cms-media-tile:hover .cms-media-actions{opacity:1}.cms-media-info{background:linear-gradient(0deg,rgba(0,0,0,.75),transparent);bottom:0;color:var(--cms-text);font-family:var(--cms-font-mono);font-size:.75rem;left:0;letter-spacing:.08em;line-height:1.4;opacity:0;padding:1.5rem .75rem .5rem;pointer-events:none;position:absolute;right:0;text-transform:uppercase;transition:opacity var(--cms-duration) var(--cms-ease)}.cms-media-tile:focus-visible .cms-media-info,.cms-media-tile:hover .cms-media-info{opacity:1}.cms-media-meta{display:flex;flex-direction:column;gap:.25rem;padding:.625rem .875rem}.cms-media-name{color:var(--ui-text-highlighted);font-size:.875rem;font-weight:500}.cms-media-folder,.cms-media-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-folder{align-items:center;color:var(--ui-text-dimmed);display:inline-flex;font-size:.75rem;gap:.25rem}.cms-media-field{overflow:hidden}.cms-media-field-preview{align-items:center;background-color:var(--cms-canvas);background-image:repeating-conic-gradient(from 0deg,hsla(0,0%,100%,.045) 0 25%,hsla(0,0%,100%,0) 0 50%);background-position:50%;background-size:16px 16px;color:var(--ui-text-dimmed);display:flex;justify-content:center;max-height:16rem;min-height:8rem;padding:.75rem}.cms-media-field-preview>img,.cms-media-field-preview>video{max-height:14.5rem;max-width:100%;-o-object-fit:contain;object-fit:contain}.cms-media-field-bar{align-items:center;background:var(--cms-surface);border-top:1px solid var(--cms-line);display:flex;gap:.5rem;padding:.625rem .75rem}.cms-media-bar-icon{color:var(--ui-text-dimmed);flex-shrink:0}.cms-media-bar-name{color:var(--ui-text-highlighted);flex:1;font-size:.875rem;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-media-thumb{background:var(--cms-field);border-radius:var(--cms-radius-sm);height:2.25rem;-o-object-fit:cover;object-fit:cover;width:2.25rem}.cms-media-thumb.is-fallback{align-items:center;color:var(--ui-text-dimmed);display:flex;justify-content:center}.cms-media-thumb-empty{color:var(--ui-text-dimmed)}.cms-table-shell{display:flex;flex:1;flex-direction:column;min-height:0;overflow:hidden}.cms-table-scroll{flex:1;min-height:0;overflow:auto}.cms-table{border-collapse:collapse;min-width:-moz-max-content;min-width:max-content;text-align:left;width:100%}.cms-table td,.cms-table th{white-space:nowrap}.cms-table thead{background:var(--cms-surface);position:sticky;top:0;z-index:10}.cms-table th{background:var(--cms-surface);box-shadow:inset 0 -1px 0 var(--cms-line);color:var(--ui-text-muted);font-family:var(--cms-font-mono);font-size:.6875rem;font-weight:400;letter-spacing:.08em;line-height:1.4;padding:.75rem 1.25rem;text-transform:uppercase;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-table td{color:var(--ui-text);font-size:.875rem;padding:.875rem 1.25rem}.cms-table tbody tr{border-bottom:1px solid var(--cms-line);transition:background-color var(--cms-duration) var(--cms-ease)}.cms-table tbody tr:last-child{border-bottom:0}.cms-table tbody tr:hover{background:var(--cms-field)}.cms-table-cell-actions{display:flex;justify-content:flex-end}.cms-table th[draggable=true]{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.cms-table th[draggable=true]:active{cursor:grabbing}.cms-table th.is-dragging{opacity:.4}.cms-table th.is-drop-target{background:var(--cms-field);box-shadow:inset 0 -1px 0 var(--cms-line),inset 0 0 0 2px color-mix(in oklab,var(--cms-fern) 55%,transparent)}.cms-th-label{align-items:center;display:inline-flex;gap:.35rem}.cms-table th.is-sortable{cursor:pointer}.cms-table th.is-sortable:hover,.cms-table th.is-sorted{color:var(--ui-text-highlighted)}.cms-table th.is-sorted{background:var(--cms-field)}.cms-th-sort{flex:none;opacity:0;transition:opacity var(--cms-duration) var(--cms-ease)}.cms-table th.is-sortable:hover .cms-th-sort,.cms-table th.is-sorted .cms-th-sort{opacity:1}.cms-alert{border:1px solid transparent;border-radius:var(--cms-radius-md);display:flex;flex-direction:column;font-size:.875rem;gap:.125rem;padding:.75rem 1rem}.cms-alert-title{font-weight:550}.cms-alert-error{background:color-mix(in oklab,var(--ui-error) 14%,transparent);border-color:color-mix(in oklab,var(--ui-error) 26%,transparent);color:var(--ui-error)}.cms-alert-success{background:color-mix(in oklab,var(--ui-success) 14%,transparent);border-color:color-mix(in oklab,var(--ui-success) 26%,transparent);color:var(--ui-success)}.cms-alert-warning{background:color-mix(in oklab,var(--ui-warning) 14%,transparent);border-color:color-mix(in oklab,var(--ui-warning) 26%,transparent);color:var(--ui-warning)}.cms-alert-neutral{background:var(--cms-field);border-color:var(--cms-line);color:var(--ui-text-toned)}.cms-alert-neutral .cms-alert-title{color:var(--ui-text-highlighted)}.cms-toaster{bottom:1rem;display:flex;flex-direction:column;gap:.5rem;max-width:calc(100vw - 2rem);position:fixed;right:1rem;width:20rem;z-index:80}.cms-toast{background:var(--cms-elevated);border:1px solid var(--cms-line);border-radius:var(--cms-radius-lg);box-shadow:var(--cms-shadow-overlay);padding:.75rem .875rem}.cms-toast-row{align-items:flex-start;display:flex;gap:.625rem}.cms-toast-icon{color:var(--ui-text-muted);flex-shrink:0;margin-top:.0625rem}.cms-toast.is-success .cms-toast-icon{color:var(--ui-success)}.cms-toast.is-error .cms-toast-icon{color:var(--ui-error)}.cms-toast-title{color:var(--ui-text-highlighted);font-size:.875rem;font-weight:500}.cms-toast-description{color:var(--ui-text-muted);font-size:.875rem;margin-top:.125rem}.cms-toast-dismiss{color:var(--ui-text-dimmed);flex-shrink:0;transition:color var(--cms-duration) var(--cms-ease)}.cms-toast-dismiss:hover{color:var(--ui-text-highlighted)}.cms-pagination{align-items:center;display:flex;gap:.25rem;justify-content:center}.cms-page-btn{border-radius:var(--cms-radius-sm);color:var(--ui-text-toned);font-size:.875rem;height:2rem;min-width:2rem;padding:0 .5rem;transition:background-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-page-btn:hover:not(:disabled){background:var(--cms-field);color:var(--ui-text-highlighted)}.cms-page-btn.is-active{background:var(--cms-field);color:var(--cms-fern);font-weight:600}.cms-page-btn:disabled{opacity:.4}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { MediaItem } from '../../shared/index.js';
|
|
1
|
+
import type { MediaItem, MediaSourceInfo } from '../../shared/index.js';
|
|
2
2
|
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
|
|
3
3
|
items: MediaItem[];
|
|
4
|
+
source: MediaSourceInfo;
|
|
4
5
|
}>>;
|
|
5
6
|
export default _default;
|
|
@@ -2,12 +2,23 @@ import { desc } from "drizzle-orm";
|
|
|
2
2
|
import { defineEventHandler } from "h3";
|
|
3
3
|
import { useDb } from "#cms-db";
|
|
4
4
|
import { cms_media } from "#cms-tables";
|
|
5
|
+
import { useMediaIndex } from "../utils/media-index.js";
|
|
5
6
|
import { toMediaItem, useMediaConfig } from "../utils/media.js";
|
|
6
7
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
7
8
|
export default defineEventHandler(async (event) => {
|
|
8
9
|
await requireAdmin(event);
|
|
9
|
-
const { publicUrl } = useMediaConfig(event);
|
|
10
|
+
const { media, publicUrl } = useMediaConfig(event);
|
|
11
|
+
if (media.storage === "local") {
|
|
12
|
+
const index = await useMediaIndex();
|
|
13
|
+
const rows2 = await useDb().select({ key: cms_media.key, alt: cms_media.alt }).from(cms_media);
|
|
14
|
+
const altByKey = new Map(rows2.map((row) => [row.key, row.alt]));
|
|
15
|
+
const items2 = index.files.map(
|
|
16
|
+
(file) => toMediaItem({ ...file, alt: altByKey.get(file.key) ?? null, createdAt: null }, publicUrl)
|
|
17
|
+
);
|
|
18
|
+
return { items: items2, source: index.source };
|
|
19
|
+
}
|
|
10
20
|
const rows = await useDb().select().from(cms_media).orderBy(desc(cms_media.createdAt), desc(cms_media.id));
|
|
11
21
|
const items = rows.map((row) => toMediaItem(row, publicUrl));
|
|
12
|
-
|
|
22
|
+
const source = { kind: "database", root: "", builtAt: null };
|
|
23
|
+
return { items, source };
|
|
13
24
|
});
|
|
@@ -4,25 +4,41 @@ import { z } from "zod";
|
|
|
4
4
|
import { useDb } from "#cms-db";
|
|
5
5
|
import { cms_media } from "#cms-tables";
|
|
6
6
|
import { normalizeMediaFolder } from "../../shared/index.js";
|
|
7
|
+
import { useMediaIndex } from "../utils/media-index.js";
|
|
7
8
|
import { assertMediaWritable, toMediaItem, useMediaConfig } from "../utils/media.js";
|
|
8
9
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
9
10
|
const bodySchema = z.object({
|
|
10
|
-
|
|
11
|
+
key: z.string().min(1).max(1024),
|
|
11
12
|
alt: z.string().max(1024).nullish(),
|
|
12
13
|
folder: z.string().max(255).nullish()
|
|
13
14
|
});
|
|
14
15
|
export default defineEventHandler(async (event) => {
|
|
15
16
|
await requireAdmin(event);
|
|
16
17
|
const { media, publicUrl } = useMediaConfig(event);
|
|
17
|
-
assertMediaWritable(media);
|
|
18
18
|
const body = await readValidatedBody(event, bodySchema.parse);
|
|
19
|
+
if (media.storage === "local") {
|
|
20
|
+
if (body.folder !== void 0) {
|
|
21
|
+
throw createError({
|
|
22
|
+
statusCode: 501,
|
|
23
|
+
statusMessage: "Media storage is local; the folder follows the file on disk"
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const file = (await useMediaIndex()).get(body.key);
|
|
27
|
+
if (!file) {
|
|
28
|
+
throw createError({ statusCode: 404, statusMessage: "Media not found" });
|
|
29
|
+
}
|
|
30
|
+
const alt = body.alt ?? null;
|
|
31
|
+
await useDb().insert(cms_media).values({ key: body.key, alt }).onConflictDoUpdate({ target: cms_media.key, set: { alt } });
|
|
32
|
+
return toMediaItem({ ...file, alt, createdAt: null }, publicUrl);
|
|
33
|
+
}
|
|
34
|
+
assertMediaWritable(media);
|
|
19
35
|
const updates = {};
|
|
20
36
|
if (body.alt !== void 0) updates.alt = body.alt;
|
|
21
37
|
if (body.folder !== void 0) updates.folder = normalizeMediaFolder(body.folder);
|
|
22
38
|
if (Object.keys(updates).length === 0) {
|
|
23
39
|
throw createError({ statusCode: 400, statusMessage: "No fields to update" });
|
|
24
40
|
}
|
|
25
|
-
const [row] = await useDb().update(cms_media).set(updates).where(eq(cms_media.
|
|
41
|
+
const [row] = await useDb().update(cms_media).set(updates).where(eq(cms_media.key, body.key)).returning();
|
|
26
42
|
if (!row) {
|
|
27
43
|
throw createError({ statusCode: 404, statusMessage: "Media not found" });
|
|
28
44
|
}
|
|
@@ -21,6 +21,7 @@ import * as cmsTables from "#cms-tables";
|
|
|
21
21
|
import { useRuntimeConfig } from "#imports";
|
|
22
22
|
import {
|
|
23
23
|
decodeTranslatableMedia,
|
|
24
|
+
isPrivateField,
|
|
24
25
|
isTranslatableMediaField,
|
|
25
26
|
mediaPublicUrl,
|
|
26
27
|
mediaTypeFor,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
translatableFieldKeys
|
|
29
30
|
} from "../../shared/index.js";
|
|
30
31
|
import { blockTypeName, blockUnionName, renderGraphqlSdl, typeName } from "../../shared/graphql-sdl.js";
|
|
32
|
+
import { useMediaIndex } from "./media-index.js";
|
|
31
33
|
import { getContentI18n, resolveTable, tableColumns } from "./registry.js";
|
|
32
34
|
const MAX_LIMIT = 100;
|
|
33
35
|
const DEFAULT_LIMIT = 50;
|
|
@@ -159,12 +161,23 @@ function sortOrder(table, sort) {
|
|
|
159
161
|
return order;
|
|
160
162
|
}
|
|
161
163
|
let mediaBase;
|
|
164
|
+
let mediaStorage;
|
|
162
165
|
function getMediaBase() {
|
|
163
166
|
mediaBase ??= useRuntimeConfig().public.cms.mediaBaseUrl;
|
|
164
167
|
return mediaBase;
|
|
165
168
|
}
|
|
169
|
+
function isLocalMedia() {
|
|
170
|
+
mediaStorage ??= useRuntimeConfig().cms.media.storage;
|
|
171
|
+
return mediaStorage === "local";
|
|
172
|
+
}
|
|
166
173
|
async function resolveMedia(ctx, key) {
|
|
167
174
|
if (!key) return null;
|
|
175
|
+
if (isLocalMedia()) {
|
|
176
|
+
const file = (await useMediaIndex()).get(key);
|
|
177
|
+
if (!file) return null;
|
|
178
|
+
const alt = await loadMediaAlt(ctx).load(key);
|
|
179
|
+
return mediaObject(key, { ...file, alt: alt ?? null });
|
|
180
|
+
}
|
|
168
181
|
const row = await loadMedia(ctx).load(key);
|
|
169
182
|
return row ? mediaObject(key, row) : null;
|
|
170
183
|
}
|
|
@@ -221,6 +234,14 @@ function loadMedia(ctx) {
|
|
|
221
234
|
);
|
|
222
235
|
});
|
|
223
236
|
}
|
|
237
|
+
function loadMediaAlt(ctx) {
|
|
238
|
+
return getLoader(ctx, "mediaAlt", async (keys) => {
|
|
239
|
+
const media = cmsTables.cms_media;
|
|
240
|
+
const columns = tableColumns(media);
|
|
241
|
+
const rows = await useDb().select({ key: columns.key, alt: columns.alt }).from(media).where(inArray(columns.key, keys));
|
|
242
|
+
return new Map(rows.map((row) => [row.key, row.alt ?? null]));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
224
245
|
function mediaObject(key, row) {
|
|
225
246
|
return {
|
|
226
247
|
key,
|
|
@@ -237,6 +258,7 @@ function mediaObject(key, row) {
|
|
|
237
258
|
function entryResolvers(config, name, entry) {
|
|
238
259
|
const resolvers = {};
|
|
239
260
|
for (const [key, field] of Object.entries(entry.fields)) {
|
|
261
|
+
if (isPrivateField(field)) continue;
|
|
240
262
|
if (field.type === "relation" && field.cardinality === "many-to-many") {
|
|
241
263
|
resolvers[key] = async (parent, _args, ctx) => {
|
|
242
264
|
const rows = await loadManyToMany(ctx, name, key, field).load(parent.id);
|
|
@@ -281,7 +303,8 @@ export function buildCmsSchema() {
|
|
|
281
303
|
const fieldLevel = entryResolvers(config, name, entry);
|
|
282
304
|
if (Object.keys(fieldLevel).length) typeResolvers[gqlType] = fieldLevel;
|
|
283
305
|
for (const [key, field] of Object.entries(entry.fields)) {
|
|
284
|
-
if (field.type === "blocks"
|
|
306
|
+
if (field.type === "blocks" && !isPrivateField(field))
|
|
307
|
+
Object.assign(typeResolvers, blockResolvers(name, key, field));
|
|
285
308
|
}
|
|
286
309
|
if (entry.kind === "single") {
|
|
287
310
|
queryResolvers[name] = async (_, args) => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MediaSourceInfo } from '../../shared/index.js';
|
|
2
|
+
import type { MediaFileMeta } from './media-sync.js';
|
|
3
|
+
export interface MediaIndex {
|
|
4
|
+
files: MediaFileMeta[];
|
|
5
|
+
get: (key: string) => MediaFileMeta | undefined;
|
|
6
|
+
source: MediaSourceInfo;
|
|
7
|
+
}
|
|
8
|
+
export declare function useMediaIndex(): Promise<MediaIndex>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
builtAt as manifestBuiltAt,
|
|
4
|
+
files as manifestFiles,
|
|
5
|
+
generated as manifestGenerated
|
|
6
|
+
} from "#cms-media-manifest";
|
|
7
|
+
import { useRuntimeConfig } from "#imports";
|
|
8
|
+
import { readMediaFileMeta, scanMediaDirectory } from "./media-sync.js";
|
|
9
|
+
const FILESYSTEM_TTL = 1e3;
|
|
10
|
+
let cached = null;
|
|
11
|
+
const metaByKeyAndSize = /* @__PURE__ */ new Map();
|
|
12
|
+
function makeIndex(files, source) {
|
|
13
|
+
const byKey = new Map(files.map((file) => [file.key, file]));
|
|
14
|
+
return { files, get: (key) => byKey.get(key), source };
|
|
15
|
+
}
|
|
16
|
+
async function readMeta(root, file) {
|
|
17
|
+
const cacheKey = `${file.key}:${file.size}`;
|
|
18
|
+
const hit = metaByKeyAndSize.get(cacheKey);
|
|
19
|
+
if (hit) return hit;
|
|
20
|
+
const meta = await readMediaFileMeta(root, file);
|
|
21
|
+
metaByKeyAndSize.set(cacheKey, meta);
|
|
22
|
+
return meta;
|
|
23
|
+
}
|
|
24
|
+
function localMediaRoot() {
|
|
25
|
+
const { media } = useRuntimeConfig().cms;
|
|
26
|
+
if (media?.storage !== "local") return "";
|
|
27
|
+
return media.localRoot ?? "";
|
|
28
|
+
}
|
|
29
|
+
async function buildIndex() {
|
|
30
|
+
const root = localMediaRoot();
|
|
31
|
+
if (root && existsSync(root)) {
|
|
32
|
+
const scanned = await scanMediaDirectory(root);
|
|
33
|
+
const files = await Promise.all(scanned.map((file) => readMeta(root, file)));
|
|
34
|
+
return makeIndex(files, { kind: "filesystem", root, builtAt: null });
|
|
35
|
+
}
|
|
36
|
+
if (manifestGenerated) {
|
|
37
|
+
return makeIndex(manifestFiles, { kind: "manifest", root, builtAt: manifestBuiltAt });
|
|
38
|
+
}
|
|
39
|
+
return makeIndex([], { kind: "none", root, builtAt: null });
|
|
40
|
+
}
|
|
41
|
+
export async function useMediaIndex() {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
if (cached && (cached.index.source.kind !== "filesystem" || now - cached.at < FILESYSTEM_TTL)) {
|
|
44
|
+
return cached.index;
|
|
45
|
+
}
|
|
46
|
+
const index = await buildIndex();
|
|
47
|
+
cached = { index, at: now };
|
|
48
|
+
return index;
|
|
49
|
+
}
|
|
@@ -2,15 +2,6 @@ export interface ScannedMediaFile {
|
|
|
2
2
|
key: string;
|
|
3
3
|
size: number;
|
|
4
4
|
}
|
|
5
|
-
export interface MediaSyncRow {
|
|
6
|
-
key: string;
|
|
7
|
-
size: number | null;
|
|
8
|
-
}
|
|
9
|
-
export interface MediaSyncPlan {
|
|
10
|
-
insert: ScannedMediaFile[];
|
|
11
|
-
update: ScannedMediaFile[];
|
|
12
|
-
remove: string[];
|
|
13
|
-
}
|
|
14
5
|
export interface MediaFileMeta {
|
|
15
6
|
key: string;
|
|
16
7
|
folder: string | null;
|
|
@@ -34,6 +25,4 @@ export declare function webpImageSize(bytes: Uint8Array): ImageSize | null;
|
|
|
34
25
|
export declare function gifImageSize(bytes: Uint8Array): ImageSize | null;
|
|
35
26
|
export declare function imageSizeFromBuffer(bytes: Uint8Array): ImageSize | null;
|
|
36
27
|
export declare function scanMediaDirectory(root: string): Promise<ScannedMediaFile[]>;
|
|
37
|
-
export declare function planMediaSync(files: ScannedMediaFile[], rows: MediaSyncRow[]): MediaSyncPlan;
|
|
38
28
|
export declare function readMediaFileMeta(root: string, file: ScannedMediaFile): Promise<MediaFileMeta>;
|
|
39
|
-
export declare function chunked<T>(items: T[], size: number): T[][];
|
|
@@ -189,19 +189,6 @@ export async function scanMediaDirectory(root) {
|
|
|
189
189
|
await walk(root, "");
|
|
190
190
|
return files.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
191
191
|
}
|
|
192
|
-
export function planMediaSync(files, rows) {
|
|
193
|
-
const rowByKey = new Map(rows.map((row) => [row.key, row]));
|
|
194
|
-
const insert = [];
|
|
195
|
-
const update = [];
|
|
196
|
-
for (const file of files) {
|
|
197
|
-
const row = rowByKey.get(file.key);
|
|
198
|
-
if (!row) insert.push(file);
|
|
199
|
-
else if (row.size !== file.size) update.push(file);
|
|
200
|
-
}
|
|
201
|
-
const scanned = new Set(files.map((file) => file.key));
|
|
202
|
-
const remove = rows.filter((row) => !scanned.has(row.key)).map((row) => row.key);
|
|
203
|
-
return { insert, update, remove };
|
|
204
|
-
}
|
|
205
192
|
export async function readMediaFileMeta(root, file) {
|
|
206
193
|
const mime = mediaMimeForKey(file.key);
|
|
207
194
|
let size = null;
|
|
@@ -218,10 +205,3 @@ export async function readMediaFileMeta(root, file) {
|
|
|
218
205
|
height: size?.height ?? null
|
|
219
206
|
};
|
|
220
207
|
}
|
|
221
|
-
export function chunked(items, size) {
|
|
222
|
-
const chunks = [];
|
|
223
|
-
for (let index = 0; index < items.length; index += size) {
|
|
224
|
-
chunks.push(items.slice(index, index + size));
|
|
225
|
-
}
|
|
226
|
-
return chunks;
|
|
227
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isTranslatableField } from "./index.js";
|
|
1
|
+
import { isPrivateField, isTranslatableField } from "./index.js";
|
|
2
2
|
export function typeName(name) {
|
|
3
3
|
return name.replace(/(?:^|_)([a-z0-9])/gi, (_, c) => c.toUpperCase());
|
|
4
4
|
}
|
|
@@ -21,7 +21,7 @@ function scalarFor(field) {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
function filterScalarFor(field) {
|
|
24
|
-
if (isTranslatableField(field)) return null;
|
|
24
|
+
if (isPrivateField(field) || isTranslatableField(field)) return null;
|
|
25
25
|
switch (field.type) {
|
|
26
26
|
case "json":
|
|
27
27
|
return null;
|
|
@@ -64,6 +64,7 @@ function fieldSdl(config, entryName, key, field) {
|
|
|
64
64
|
function entrySdl(config, name, entry) {
|
|
65
65
|
const lines = [" id: ID!"];
|
|
66
66
|
for (const [key, field] of Object.entries(entry.fields)) {
|
|
67
|
+
if (isPrivateField(field)) continue;
|
|
67
68
|
lines.push(fieldSdl(config, name, key, field));
|
|
68
69
|
}
|
|
69
70
|
if (entry.kind === "collection") lines.push(" createdAt: String!");
|
|
@@ -127,7 +128,8 @@ export function renderGraphqlSdl(config) {
|
|
|
127
128
|
const gqlType = typeName(name);
|
|
128
129
|
types.push(entrySdl(config, name, entry));
|
|
129
130
|
for (const [key, field] of Object.entries(entry.fields)) {
|
|
130
|
-
if (field.type === "blocks"
|
|
131
|
+
if (field.type === "blocks" && !isPrivateField(field))
|
|
132
|
+
types.push(...blocksSdl(name, key, field));
|
|
131
133
|
}
|
|
132
134
|
if (entry.kind === "single") {
|
|
133
135
|
queryLines.push(` ${name}(locale: String): ${gqlType}`);
|
|
@@ -20,7 +20,7 @@ export declare function slugify(value: string): string;
|
|
|
20
20
|
export declare const MEDIA_FOLDER_MAX_DEPTH = 4;
|
|
21
21
|
export declare function normalizeMediaFolder(value: string | null | undefined): string | null;
|
|
22
22
|
export interface MediaItem {
|
|
23
|
-
id
|
|
23
|
+
id?: number;
|
|
24
24
|
key: string;
|
|
25
25
|
alt: string | null;
|
|
26
26
|
folder: string | null;
|
|
@@ -28,10 +28,16 @@ export interface MediaItem {
|
|
|
28
28
|
size: number | null;
|
|
29
29
|
width: number | null;
|
|
30
30
|
height: number | null;
|
|
31
|
-
createdAt: string;
|
|
31
|
+
createdAt: string | null;
|
|
32
32
|
type: MediaType;
|
|
33
33
|
url: string | null;
|
|
34
34
|
}
|
|
35
|
+
export type MediaSourceKind = 'database' | 'filesystem' | 'manifest' | 'none';
|
|
36
|
+
export interface MediaSourceInfo {
|
|
37
|
+
kind: MediaSourceKind;
|
|
38
|
+
root: string;
|
|
39
|
+
builtAt: string | null;
|
|
40
|
+
}
|
|
35
41
|
export interface BlockConfig {
|
|
36
42
|
label: string;
|
|
37
43
|
fields: Record<string, FieldConfig>;
|
|
@@ -40,6 +46,7 @@ export interface FieldConfig {
|
|
|
40
46
|
label: string;
|
|
41
47
|
type: FieldType;
|
|
42
48
|
required?: boolean;
|
|
49
|
+
private?: boolean;
|
|
43
50
|
textarea?: boolean;
|
|
44
51
|
integer?: boolean;
|
|
45
52
|
translatable?: boolean;
|
|
@@ -53,6 +60,7 @@ export interface FieldConfig {
|
|
|
53
60
|
cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
|
|
54
61
|
onDelete?: 'set null' | 'cascade' | 'restrict';
|
|
55
62
|
}
|
|
63
|
+
export declare function isPrivateField(field: FieldConfig): boolean;
|
|
56
64
|
export declare function isTranslatableField(field: FieldConfig): boolean;
|
|
57
65
|
export declare function isTranslatableMediaField(field: FieldConfig): boolean;
|
|
58
66
|
export declare function decodeTranslatableMedia(value: unknown, defaultLocale: string): Record<string, string> | null;
|
|
@@ -76,6 +84,7 @@ export type CmsConfig = Record<string, CmsEntry>;
|
|
|
76
84
|
interface FieldInputBase {
|
|
77
85
|
label: string;
|
|
78
86
|
required?: boolean;
|
|
87
|
+
private?: boolean;
|
|
79
88
|
}
|
|
80
89
|
export interface TextFieldInput extends FieldInputBase {
|
|
81
90
|
type: 'text';
|
|
@@ -123,7 +132,7 @@ export interface RelationFieldInput extends FieldInputBase {
|
|
|
123
132
|
cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
|
|
124
133
|
onDelete?: 'set null' | 'cascade' | 'restrict';
|
|
125
134
|
}
|
|
126
|
-
export type BlockFieldInput = Omit<TextFieldInput, 'translatable'> | Omit<RichtextFieldInput, 'translatable'> | NumberFieldInput | BooleanFieldInput | DateFieldInput | EmailFieldInput | SelectFieldInput | JsonFieldInput | Omit<MediaFieldInput, 'translatable'>;
|
|
135
|
+
export type BlockFieldInput = Omit<TextFieldInput, 'translatable' | 'private'> | Omit<RichtextFieldInput, 'translatable' | 'private'> | Omit<NumberFieldInput, 'private'> | Omit<BooleanFieldInput, 'private'> | Omit<DateFieldInput, 'private'> | Omit<EmailFieldInput, 'private'> | Omit<SelectFieldInput, 'private'> | Omit<JsonFieldInput, 'private'> | Omit<MediaFieldInput, 'translatable' | 'private'>;
|
|
127
136
|
export interface BlockInput {
|
|
128
137
|
label: string;
|
|
129
138
|
fields: Record<string, BlockFieldInput>;
|
|
@@ -61,6 +61,9 @@ export function normalizeMediaFolder(value) {
|
|
|
61
61
|
const segments = value.split("/").map(slugify).filter(Boolean).slice(0, MEDIA_FOLDER_MAX_DEPTH);
|
|
62
62
|
return segments.length ? segments.join("/") : null;
|
|
63
63
|
}
|
|
64
|
+
export function isPrivateField(field) {
|
|
65
|
+
return !!field.private;
|
|
66
|
+
}
|
|
64
67
|
export function isTranslatableField(field) {
|
|
65
68
|
return !!field.translatable && (field.type === "text" || field.type === "richtext" || field.type === "media");
|
|
66
69
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
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)",
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { eq, inArray } from "drizzle-orm";
|
|
3
|
-
import { useDb } from "#cms-db";
|
|
4
|
-
import { files as manifestFiles, generated as manifestGenerated } from "#cms-media-manifest";
|
|
5
|
-
import { cms_media } from "#cms-tables";
|
|
6
|
-
import { useRuntimeConfig } from "#imports";
|
|
7
|
-
import { chunked, planMediaSync, readMediaFileMeta, scanMediaDirectory } from "../utils/media-sync.js";
|
|
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
|
-
}
|
|
25
|
-
export default async () => {
|
|
26
|
-
try {
|
|
27
|
-
const { media } = useRuntimeConfig().cms;
|
|
28
|
-
if (media?.storage !== "local") return;
|
|
29
|
-
const root = media.localRoot;
|
|
30
|
-
if (!root) return;
|
|
31
|
-
const source = await resolveMediaSource(root);
|
|
32
|
-
if (!source) {
|
|
33
|
-
console.info(
|
|
34
|
-
`[nuxt-cms] Local media folder not found: ${root}, and no build manifest was generated. Skipping media library sync.`
|
|
35
|
-
);
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
const db = useDb();
|
|
39
|
-
const rows = await db.select({ key: cms_media.key, size: cms_media.size }).from(cms_media);
|
|
40
|
-
const { insert, update, remove } = planMediaSync(source.files, rows);
|
|
41
|
-
for (const chunk of chunked(insert, CHUNK_SIZE)) {
|
|
42
|
-
const values = await Promise.all(chunk.map((file) => source.meta(file)));
|
|
43
|
-
await db.insert(cms_media).values(values.map((value) => ({ ...value, alt: null }))).onConflictDoNothing();
|
|
44
|
-
}
|
|
45
|
-
for (const file of update) {
|
|
46
|
-
const { mime, size, width, height } = await source.meta(file);
|
|
47
|
-
await db.update(cms_media).set({ mime, size, width, height }).where(eq(cms_media.key, file.key));
|
|
48
|
-
}
|
|
49
|
-
for (const chunk of chunked(remove, CHUNK_SIZE)) {
|
|
50
|
-
await db.delete(cms_media).where(inArray(cms_media.key, chunk));
|
|
51
|
-
}
|
|
52
|
-
console.info(
|
|
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})`
|
|
54
|
-
);
|
|
55
|
-
} catch (error) {
|
|
56
|
-
console.warn("[nuxt-cms] Local media sync failed:", error);
|
|
57
|
-
}
|
|
58
|
-
};
|