@xleddyl/nuxt-cms 0.1.30 → 0.1.31
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 +2 -2
- package/dist/module.json +1 -1
- package/dist/module.mjs +17 -6
- 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 +20 -0
- 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/index.d.ts +8 -2
- 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,7 +4,7 @@ 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
|
|
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
8
|
- **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination.
|
|
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
|
|
|
@@ -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
|
@@ -17,10 +17,12 @@ 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");
|
|
@@ -723,6 +727,11 @@ const module$1 = defineNuxtModule({
|
|
|
723
727
|
});
|
|
724
728
|
const publicDir = resolve(nuxt.options.rootDir, nuxt.options.dir?.public ?? "public");
|
|
725
729
|
const mediaLocalRoot = resolved.media.storage === "local" && resolved.media.publicBaseUrl.startsWith("/") ? join(publicDir, ...resolved.media.publicBaseUrl.split("/").filter(Boolean)) : "";
|
|
730
|
+
if (resolved.media.storage === "local" && !mediaLocalRoot) {
|
|
731
|
+
logger.warn(
|
|
732
|
+
`[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'.`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
726
735
|
addTemplate({
|
|
727
736
|
filename: "cms/media-manifest.d.ts",
|
|
728
737
|
write: true,
|
|
@@ -734,14 +743,17 @@ const module$1 = defineNuxtModule({
|
|
|
734
743
|
filename: "cms/media-manifest.js",
|
|
735
744
|
write: true,
|
|
736
745
|
getContents: async () => {
|
|
737
|
-
if (!mediaLocalRoot || nuxt.options.dev) return renderMediaManifestFile(null);
|
|
746
|
+
if (!mediaLocalRoot || nuxt.options.dev) return renderMediaManifestFile(null, null);
|
|
738
747
|
if (!existsSync(mediaLocalRoot)) {
|
|
739
748
|
logger.warn(
|
|
740
|
-
`[nuxt-cms] Local media folder not found at build time: ${mediaLocalRoot}. The media library will
|
|
749
|
+
`[nuxt-cms] Local media folder not found at build time: ${mediaLocalRoot}. The media library will be empty at runtime.`
|
|
741
750
|
);
|
|
742
|
-
return renderMediaManifestFile(null);
|
|
751
|
+
return renderMediaManifestFile(null, null);
|
|
743
752
|
}
|
|
744
|
-
return renderMediaManifestFile(
|
|
753
|
+
return renderMediaManifestFile(
|
|
754
|
+
await collectMediaManifest(mediaLocalRoot),
|
|
755
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
756
|
+
);
|
|
745
757
|
}
|
|
746
758
|
});
|
|
747
759
|
nuxt.options.alias["#cms-media-manifest"] = mediaManifestTemplate.dst;
|
|
@@ -750,7 +762,6 @@ const module$1 = defineNuxtModule({
|
|
|
750
762
|
driver === "postgres" ? "./runtime/server/plugins/migrate-postgres" : driver === "libsql" ? "./runtime/server/plugins/migrate-libsql" : "./runtime/server/plugins/migrate-sqlite"
|
|
751
763
|
)
|
|
752
764
|
);
|
|
753
|
-
addServerPlugin(resolver.resolve("./runtime/server/plugins/media-sync-local"));
|
|
754
765
|
if (!nuxt.options.dev) {
|
|
755
766
|
addServerPlugin(resolver.resolve("./runtime/server/plugins/session-check"));
|
|
756
767
|
}
|
|
@@ -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
|
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
translatableFieldKeys
|
|
29
29
|
} from "../../shared/index.js";
|
|
30
30
|
import { blockTypeName, blockUnionName, renderGraphqlSdl, typeName } from "../../shared/graphql-sdl.js";
|
|
31
|
+
import { useMediaIndex } from "./media-index.js";
|
|
31
32
|
import { getContentI18n, resolveTable, tableColumns } from "./registry.js";
|
|
32
33
|
const MAX_LIMIT = 100;
|
|
33
34
|
const DEFAULT_LIMIT = 50;
|
|
@@ -159,12 +160,23 @@ function sortOrder(table, sort) {
|
|
|
159
160
|
return order;
|
|
160
161
|
}
|
|
161
162
|
let mediaBase;
|
|
163
|
+
let mediaStorage;
|
|
162
164
|
function getMediaBase() {
|
|
163
165
|
mediaBase ??= useRuntimeConfig().public.cms.mediaBaseUrl;
|
|
164
166
|
return mediaBase;
|
|
165
167
|
}
|
|
168
|
+
function isLocalMedia() {
|
|
169
|
+
mediaStorage ??= useRuntimeConfig().cms.media.storage;
|
|
170
|
+
return mediaStorage === "local";
|
|
171
|
+
}
|
|
166
172
|
async function resolveMedia(ctx, key) {
|
|
167
173
|
if (!key) return null;
|
|
174
|
+
if (isLocalMedia()) {
|
|
175
|
+
const file = (await useMediaIndex()).get(key);
|
|
176
|
+
if (!file) return null;
|
|
177
|
+
const alt = await loadMediaAlt(ctx).load(key);
|
|
178
|
+
return mediaObject(key, { ...file, alt: alt ?? null });
|
|
179
|
+
}
|
|
168
180
|
const row = await loadMedia(ctx).load(key);
|
|
169
181
|
return row ? mediaObject(key, row) : null;
|
|
170
182
|
}
|
|
@@ -221,6 +233,14 @@ function loadMedia(ctx) {
|
|
|
221
233
|
);
|
|
222
234
|
});
|
|
223
235
|
}
|
|
236
|
+
function loadMediaAlt(ctx) {
|
|
237
|
+
return getLoader(ctx, "mediaAlt", async (keys) => {
|
|
238
|
+
const media = cmsTables.cms_media;
|
|
239
|
+
const columns = tableColumns(media);
|
|
240
|
+
const rows = await useDb().select({ key: columns.key, alt: columns.alt }).from(media).where(inArray(columns.key, keys));
|
|
241
|
+
return new Map(rows.map((row) => [row.key, row.alt ?? null]));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
224
244
|
function mediaObject(key, row) {
|
|
225
245
|
return {
|
|
226
246
|
key,
|
|
@@ -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
|
-
}
|
|
@@ -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>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
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
|
-
};
|