@xleddyl/nuxt-cms 0.1.55 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +57 -5
  3. package/dist/runtime/app/components/cms/BlocksField.vue +19 -1
  4. package/dist/runtime/app/components/cms/MediaField.vue +14 -1
  5. package/dist/runtime/app/composables/cms-media-keys.d.ts +4 -0
  6. package/dist/runtime/app/composables/cms-media-keys.js +21 -0
  7. package/dist/runtime/app/composables/cms-query-disabled.d.ts +3 -6
  8. package/dist/runtime/app/composables/cms-query.js +57 -4
  9. package/dist/runtime/assets/main.css +1 -1
  10. package/dist/runtime/server/api/collection.get.js +2 -1
  11. package/dist/runtime/server/api/item.get.js +6 -4
  12. package/dist/runtime/server/api/item.put.js +5 -4
  13. package/dist/runtime/server/routes/graphql.js +2 -0
  14. package/dist/runtime/server/utils/db-d1.d.ts +2 -0
  15. package/dist/runtime/server/utils/db-d1.js +5 -0
  16. package/dist/runtime/server/utils/db-libsql.d.ts +2 -0
  17. package/dist/runtime/server/utils/db-libsql.js +5 -0
  18. package/dist/runtime/server/utils/db-postgres.d.ts +2 -0
  19. package/dist/runtime/server/utils/db-postgres.js +8 -0
  20. package/dist/runtime/server/utils/db-sqlite.d.ts +2 -0
  21. package/dist/runtime/server/utils/db-sqlite.js +8 -0
  22. package/dist/runtime/server/utils/graphql.js +3 -4
  23. package/dist/runtime/server/utils/media-check.d.ts +2 -0
  24. package/dist/runtime/server/utils/media-check.js +20 -0
  25. package/dist/runtime/server/utils/media-references.d.ts +10 -0
  26. package/dist/runtime/server/utils/media-references.js +50 -0
  27. package/dist/runtime/server/utils/page-rows.d.ts +51 -0
  28. package/dist/runtime/server/utils/page-rows.js +223 -0
  29. package/dist/runtime/server/utils/page-storage.d.ts +11 -0
  30. package/dist/runtime/server/utils/page-storage.js +42 -0
  31. package/dist/runtime/shared/index.d.ts +7 -0
  32. package/dist/runtime/shared/index.js +21 -0
  33. package/package.json +1 -1
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.1.55",
4
+ "version": "0.1.57",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -11,7 +11,7 @@ import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal'
11
11
  import { createJiti } from 'jiti';
12
12
  import { typeName, blockTypeName, blocksFieldTypeName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
13
13
  import { migrationsDirFor } from '../dist/runtime/shared/migrations-dir.js';
14
- import { isTranslatableField, isTranslatableMediaField, isMultiSelect, pageAllFields, PAGE_PATH_FIELD, pageRoutes, fieldConditions, isRequiredField, pageFields, isPrivateField, entryFieldsFor, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
14
+ import { pageColumnFields, isTranslatableField, isTranslatableMediaField, isMultiSelect, pageAllFields, PAGE_PATH_FIELD, pageRoutes, fieldConditions, pageFieldsTableName, pageMediaTableName, isRequiredField, pageFields, isPrivateField, entryFieldsFor, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
15
15
  import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { readFile } from 'node:fs/promises';
@@ -181,9 +181,11 @@ function validateConfig(config, i18n) {
181
181
  declared.set(key, field.type);
182
182
  }
183
183
  }
184
+ errors.push(...pageColumnsErrors(at, entry));
184
185
  } else if (entry.overrides || entry.routes || entry.include || entry.exclude) {
185
186
  errors.push(`${at}: routes, include, exclude and overrides need kind 'page'`);
186
187
  }
188
+ if (entry.kind !== "page" && entry.columns) errors.push(`${at}: columns need kind 'page'`);
187
189
  if (entry.drafts && entry.kind !== "collection")
188
190
  errors.push(`${at}: drafts are only supported on collections`);
189
191
  const tabIds = /* @__PURE__ */ new Set();
@@ -386,6 +388,10 @@ function validateConfig(config, i18n) {
386
388
  }
387
389
  };
388
390
  registerTable(name, `entry '${name}'`);
391
+ if (entry.kind === "page") {
392
+ registerTable(pageFieldsTableName(name), `the field rows table of '${name}'`);
393
+ registerTable(pageMediaTableName(name), `the media rows table of '${name}'`);
394
+ }
389
395
  for (const [key, field] of Object.entries(entry.fields ?? {})) {
390
396
  if (isManyToMany(field))
391
397
  registerTable(`${name}_${snakeCase(key)}`, `the join table of '${name}.${key}'`);
@@ -393,6 +399,35 @@ function validateConfig(config, i18n) {
393
399
  }
394
400
  return errors;
395
401
  }
402
+ function pageColumnsErrors(at, entry) {
403
+ const errors = [];
404
+ if (entry.table) errors.push(`${at}: page entries cannot use a custom table`);
405
+ const columns = /* @__PURE__ */ new Set();
406
+ for (const key of entry.columns ?? []) {
407
+ const cat = `${at}, column '${key}'`;
408
+ if (columns.has(key)) errors.push(`${cat}: listed more than once`);
409
+ columns.add(key);
410
+ if (!Object.hasOwn(entry.fields ?? {}, key)) {
411
+ errors.push(`${cat}: not a field declared for every page in fields`);
412
+ continue;
413
+ }
414
+ for (const [path, override] of Object.entries(entry.overrides ?? {})) {
415
+ if (Object.hasOwn(override, key) && !override[key])
416
+ errors.push(`${cat}: removed from '${path}', a column must exist on every page`);
417
+ }
418
+ }
419
+ for (const [key, field] of Object.entries(pageAllFields(entry))) {
420
+ const fat = `${at}, field '${key}'`;
421
+ if (isManyToMany(field)) {
422
+ errors.push(`${fat}: many-to-many relations are not supported on pages`);
423
+ continue;
424
+ }
425
+ if (columns.has(key)) continue;
426
+ if (field.type === "relation" || field.type === "slug")
427
+ errors.push(`${fat}: ${field.type} fields on pages need to be listed in columns`);
428
+ }
429
+ return errors;
430
+ }
396
431
  function snakeCase(key) {
397
432
  return key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
398
433
  }
@@ -455,7 +490,7 @@ function tableExpr(name, entry, dialect) {
455
490
  lines.push(` id: text('id').primaryKey(),`);
456
491
  if (entry.kind === "page") lines.push(` path: text('path').notNull().unique(),`);
457
492
  for (const [key, field] of Object.entries(
458
- entry.kind === "page" ? pageAllFields(entry) : entry.fields
493
+ entry.kind === "page" ? pageColumnFields(entry) : entry.fields
459
494
  )) {
460
495
  if (isManyToMany(field)) continue;
461
496
  lines.push(columnExpr(key, field, dialect));
@@ -478,6 +513,19 @@ function joinTableExpr(name, key, field, dialect) {
478
513
  position: integer('position').notNull().default(0),
479
514
  }, table => [primaryKey({ columns: [table.sourceId, table.targetId] })])`;
480
515
  }
516
+ function pageRowsTableExprs(name, dialect) {
517
+ const tableFn = dialect === "postgres" ? "pgTable" : "sqliteTable";
518
+ const rowsTable = (table, valueLine) => `export const ${table} = ${tableFn}('${table}', {
519
+ pageId: text('page_id').notNull().references(() => ${name}.id, { onDelete: 'cascade' }),
520
+ key: text('key').notNull(),
521
+ position: integer('position').notNull().default(0),
522
+ ${valueLine},
523
+ }, table => [primaryKey({ columns: [table.pageId, table.key, table.position] })])`;
524
+ return [
525
+ rowsTable(pageFieldsTableName(name), `value: text('value').notNull()`),
526
+ rowsTable(pageMediaTableName(name), `mediaKey: text('media_key').notNull()`)
527
+ ];
528
+ }
481
529
  function mediaTableExpr(dialect) {
482
530
  const pg = dialect === "postgres";
483
531
  const tableFn = pg ? "pgTable" : "sqliteTable";
@@ -495,7 +543,10 @@ function mediaTableExpr(dialect) {
495
543
  }
496
544
  function renderSchemaFile(config, dialect, resolveImport = (s) => s) {
497
545
  const derived = Object.entries(config).filter(([, entry]) => !entry.table);
498
- const fields = derived.flatMap(([, entry]) => Object.values(entry.fields));
546
+ const fields = derived.flatMap(
547
+ ([, entry]) => Object.values(entry.kind === "page" ? pageColumnFields(entry) : entry.fields)
548
+ );
549
+ const rowsEntries = derived.filter(([, entry]) => entry.kind === "page");
499
550
  const pg = dialect === "postgres";
500
551
  const core = /* @__PURE__ */ new Set(["integer", "text", pg ? "pgTable" : "sqliteTable"]);
501
552
  if (fields.some((f) => f.type === "number" && !f.integer))
@@ -507,7 +558,7 @@ function renderSchemaFile(config, dialect, resolveImport = (s) => s) {
507
558
  ))
508
559
  core.add("jsonb");
509
560
  if (pg) core.add("timestamp");
510
- if (fields.some(isManyToMany)) core.add("primaryKey");
561
+ if (fields.some(isManyToMany) || rowsEntries.length) core.add("primaryKey");
511
562
  const imports = [
512
563
  ...pg ? [] : [`import { sql } from '${resolveImport("drizzle-orm")}'`],
513
564
  `import { ${[...core].sort().join(", ")} } from '${resolveImport(
@@ -518,9 +569,10 @@ function renderSchemaFile(config, dialect, resolveImport = (s) => s) {
518
569
  const joins = derived.flatMap(
519
570
  ([name, entry]) => Object.entries(entry.kind === "page" ? pageAllFields(entry) : entry.fields).filter(([, field]) => isManyToMany(field)).map(([key, field]) => joinTableExpr(name, key, field, dialect))
520
571
  );
572
+ const rows = rowsEntries.flatMap(([name]) => pageRowsTableExprs(name, dialect));
521
573
  return `${imports.join("\n")}
522
574
 
523
- ${[mediaTableExpr(dialect), ...tables, ...joins].join(
575
+ ${[mediaTableExpr(dialect), ...tables, ...joins, ...rows].join(
524
576
  "\n\n"
525
577
  )}
526
578
  `;
@@ -8,7 +8,8 @@
8
8
  :class="{
9
9
  'is-muted': isHidden(item),
10
10
  'is-dragging': dragIndex === index,
11
- 'is-drop-target': dropIndex === index && dragIndex !== index
11
+ 'is-drop-target': dropIndex === index && dragIndex !== index,
12
+ 'is-missing': hasMissingMedia(item)
12
13
  }"
13
14
  draggable="true"
14
15
  @dragstart="onDragStart(index, $event)"
@@ -55,6 +56,13 @@
55
56
  <CmsIcon name="bars-2" class="size-4" />
56
57
  </button>
57
58
  <span class="cms-block-bar-name" :title="barTitle(item)">{{ barLabel(item) }}</span>
59
+ <span
60
+ v-if="hasMissingMedia(item)"
61
+ class="cms-media-missing-icon"
62
+ title="Not found in the media library"
63
+ >
64
+ <CmsIcon name="exclamation-triangle" class="size-4" />
65
+ </span>
58
66
  <div class="cms-block-bar-actions">
59
67
  <CmsButton
60
68
  icon="trash"
@@ -150,6 +158,7 @@ import {
150
158
  pickTranslatedMedia
151
159
  } from "#nuxt-cms";
152
160
  import { computed, ref } from "#imports";
161
+ import { useCmsMediaKeys } from "../../composables/cms-media-keys";
153
162
  import { useCmsRuntime } from "../../composables/cms-runtime";
154
163
  const props = defineProps({
155
164
  field: { type: Object, required: true },
@@ -157,6 +166,7 @@ const props = defineProps({
157
166
  });
158
167
  const model = defineModel({ type: [Array, null], ...{ required: true } });
159
168
  const { mediaBaseUrl, i18n } = useCmsRuntime();
169
+ const mediaKeys = useCmsMediaKeys();
160
170
  const blocks = computed(() => props.field.blocks ?? {});
161
171
  const items = computed(() => Array.isArray(model.value) ? model.value : []);
162
172
  let uidCounter = 0;
@@ -192,6 +202,14 @@ function mediaKeyOf(item) {
192
202
  }
193
203
  return null;
194
204
  }
205
+ function hasMissingMedia(item) {
206
+ return Object.entries(blockOf(item)?.fields ?? {}).some(([key, field]) => {
207
+ if (field.type !== "media") return false;
208
+ const raw = item[key];
209
+ const values = raw && typeof raw === "object" ? Object.values(raw) : [raw];
210
+ return values.some((value) => mediaKeys.isMissing(value));
211
+ });
212
+ }
195
213
  function previewOf(item) {
196
214
  const key = mediaKeyOf(item);
197
215
  if (!key) return null;
@@ -1,5 +1,9 @@
1
1
  <template>
2
- <div v-if="model" class="cms-card cms-block-tile cms-media-single">
2
+ <div
3
+ v-if="model"
4
+ class="cms-card cms-block-tile cms-media-single"
5
+ :class="{ 'is-missing': missing }"
6
+ >
3
7
  <button
4
8
  type="button"
5
9
  class="cms-block-preview"
@@ -44,6 +48,11 @@
44
48
  <span>Choose</span>
45
49
  </button>
46
50
 
51
+ <p v-if="missing" class="cms-media-missing">
52
+ <CmsIcon name="exclamation-triangle" class="size-4" />
53
+ <span>Not found in the media library</span>
54
+ </p>
55
+
47
56
  <CmsModal v-model:open="galleryOpen" title="Media" size="lg">
48
57
  <template #body>
49
58
  <CmsMediaGallery selectable :media-type="mediaType" :accept="accept" @select="onSelect" />
@@ -54,6 +63,7 @@
54
63
  <script setup>
55
64
  import { computed, ref } from "#imports";
56
65
  import { mediaFilename, mediaIconFor, mediaPublicUrl, mediaTypeForKey } from "#nuxt-cms";
66
+ import { useCmsMediaKeys } from "../../composables/cms-media-keys";
57
67
  import { useCmsRuntime } from "../../composables/cms-runtime";
58
68
  defineProps({
59
69
  mediaType: { type: null, required: false },
@@ -61,8 +71,10 @@ defineProps({
61
71
  });
62
72
  const model = defineModel({ type: [String, null], ...{ required: true } });
63
73
  const { mediaBaseUrl } = useCmsRuntime();
74
+ const mediaKeys = useCmsMediaKeys();
64
75
  const galleryOpen = ref(false);
65
76
  function onSelect(item) {
77
+ mediaKeys.remember(item.key);
66
78
  model.value = item.key;
67
79
  galleryOpen.value = false;
68
80
  }
@@ -72,6 +84,7 @@ function openGallery() {
72
84
  function clear() {
73
85
  model.value = null;
74
86
  }
87
+ const missing = computed(() => mediaKeys.isMissing(model.value));
75
88
  const url = computed(() => model.value ? mediaPublicUrl(mediaBaseUrl, model.value) : null);
76
89
  const kind = computed(() => model.value ? mediaTypeForKey(model.value) : "file");
77
90
  const icon = computed(() => mediaIconFor(kind.value));
@@ -0,0 +1,4 @@
1
+ export declare function useCmsMediaKeys(): {
2
+ isMissing: (key: unknown) => boolean;
3
+ remember: (key: string) => void;
4
+ };
@@ -0,0 +1,21 @@
1
+ import { ref } from "#imports";
2
+ const known = ref(null);
3
+ let loading = null;
4
+ function load() {
5
+ loading ??= $fetch("/api/cms/admin/media").then((result) => {
6
+ known.value = result.source.kind === "none" ? null : new Set(result.items.map((item) => item.key));
7
+ }).catch(() => {
8
+ known.value = null;
9
+ });
10
+ return loading;
11
+ }
12
+ export function useCmsMediaKeys() {
13
+ if (import.meta.client) void load();
14
+ function isMissing(key) {
15
+ return typeof key === "string" && key !== "" && !!known.value && !known.value.has(key);
16
+ }
17
+ function remember(key) {
18
+ if (known.value) known.value = /* @__PURE__ */ new Set([...known.value, key]);
19
+ }
20
+ return { isMissing, remember };
21
+ }
@@ -1,11 +1,8 @@
1
- import type { AsyncData, AsyncDataOptions } from 'nuxt/app';
1
+ import type { AsyncData } from 'nuxt/app';
2
+ import type { CmsAsyncDataOptions } from './cms-query.js';
2
3
  type CmsDisabledResult = Record<string, any>;
3
4
  type CmsDisabledVariables = Record<string, any>;
4
- export interface CmsAsyncDataOptions<ResT, DefaultT> extends Pick<AsyncDataOptions<ResT>, 'server' | 'lazy' | 'immediate' | 'deep' | 'dedupe' | 'watch'> {
5
- key?: string;
6
- default?: () => DefaultT;
7
- }
5
+ export type { CmsAsyncDataOptions };
8
6
  export declare function cmsQueryKey(query: string, variables?: unknown): string;
9
7
  export declare function $cmsQuery<const Q extends string>(query: Q, variables?: CmsDisabledVariables): Promise<CmsDisabledResult>;
10
8
  export declare function useCms<const Q extends string, DefaultT = undefined>(query: Q, variables?: CmsDisabledVariables, options?: CmsAsyncDataOptions<CmsDisabledResult, DefaultT>): AsyncData<CmsDisabledResult | DefaultT | undefined, Error | undefined>;
11
- export {};
@@ -1,13 +1,66 @@
1
1
  import { useAsyncData } from "#imports";
2
+ import { CMS_GRAPHQL_BATCH_LIMIT } from "../../shared/index.js";
2
3
  const ENDPOINT = "/api/cms/graphql";
4
+ const inFlight = /* @__PURE__ */ new Map();
5
+ let queue = [];
6
+ let scheduled = false;
7
+ function responseError(response) {
8
+ return new Error(
9
+ response?.errors?.map((e) => e.message).join("; ") || "Invalid batched GraphQL response"
10
+ );
11
+ }
12
+ async function sendBatch(batch) {
13
+ try {
14
+ if (batch.length === 1) {
15
+ batch[0].resolve(
16
+ await $fetch(ENDPOINT, {
17
+ method: "POST",
18
+ body: batch[0].body
19
+ })
20
+ );
21
+ return;
22
+ }
23
+ const responses = await $fetch(
24
+ ENDPOINT,
25
+ { method: "POST", body: batch.map((pending) => pending.body) }
26
+ );
27
+ if (!Array.isArray(responses)) throw responseError(responses);
28
+ batch.forEach((pending, index) => {
29
+ const response = responses[index];
30
+ if (response) pending.resolve(response);
31
+ else pending.reject(responseError(response));
32
+ });
33
+ } catch (error) {
34
+ for (const pending of batch) pending.reject(error);
35
+ }
36
+ }
37
+ function flushQueue() {
38
+ const pending = queue;
39
+ queue = [];
40
+ scheduled = false;
41
+ for (let index = 0; index < pending.length; index += CMS_GRAPHQL_BATCH_LIMIT) {
42
+ void sendBatch(pending.slice(index, index + CMS_GRAPHQL_BATCH_LIMIT));
43
+ }
44
+ }
45
+ function requestGraphql(body) {
46
+ const key = JSON.stringify(body);
47
+ const existing = inFlight.get(key);
48
+ if (existing) return existing;
49
+ const request = new Promise((resolve, reject) => {
50
+ queue.push({ body, resolve, reject });
51
+ if (!scheduled) {
52
+ scheduled = true;
53
+ setTimeout(flushQueue, 0);
54
+ }
55
+ }).finally(() => inFlight.delete(key));
56
+ inFlight.set(key, request);
57
+ return request;
58
+ }
3
59
  export function cmsQueryKey(query, variables) {
4
60
  return `cms-gql:${query}:${JSON.stringify(variables ?? {})}`;
5
61
  }
6
62
  export async function $cmsQuery(query, variables) {
7
- const res = await $fetch(ENDPOINT, {
8
- method: "POST",
9
- body: { query, variables }
10
- });
63
+ const res = await requestGraphql({ query, variables });
11
64
  if (res.errors?.length) {
12
65
  throw new Error(res.errors.map((e) => e.message).join("; "));
13
66
  }
@@ -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-breadcrumb{align-items:center;color:var(--ui-text-muted);display:flex;font-size:.75rem;font-weight:500;gap:.375rem;line-height:1.4}.cms-breadcrumb-link{border-radius:.25rem;padding:.0625rem .125rem;transition:color .15s ease}.cms-breadcrumb-link:hover{color:var(--ui-text-highlighted)}.cms-breadcrumb-separator{color:var(--ui-text-dimmed)}.cms-tree-row{align-items:center;display:flex;gap:.75rem;padding:.625rem 1rem;transition:background-color .15s ease}.cms-tree-row:hover{background-color:var(--ui-bg-elevated)}.cms-tree-branch{color:var(--ui-text-dimmed);flex-shrink:0;transform:scaleY(-1) rotate(90deg);width:1rem}.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{display:flex;flex-direction:column;gap:1.25rem}.cms-tabs{align-items:center;border-bottom:1px solid var(--ui-border);display:flex;gap:.25rem;margin-bottom:.25rem;overflow-x:auto}.cms-tab{border-bottom:2px solid transparent;color:var(--ui-text-toned);cursor:pointer;font-size:.875rem;font-weight:500;margin-bottom:-1px;padding:.5rem .875rem;transition:color var(--cms-duration) var(--cms-ease),border-color var(--cms-duration) var(--cms-ease);white-space:nowrap}.cms-tab:hover{color:var(--ui-text-highlighted)}.cms-tab.is-active{border-bottom-color:var(--cms-fern);color:var(--cms-fern)}.cms-form-field{display:flex;flex-direction:column;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-pill-count{color:var(--ui-text-dimmed);font-family:var(--cms-font-mono);font-size:.6875rem;margin-left:.25rem}.cms-blocks-grid{display:grid;gap:.75rem;grid-template-columns:repeat(2,minmax(0,1fr))}@media (min-width:640px){.cms-blocks-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.cms-blocks-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.cms-block-tile{overflow:hidden;transition:opacity var(--cms-duration) var(--cms-ease)}.cms-block-tile.is-muted{opacity:.45}.cms-block-preview{align-items:center;aspect-ratio:4/3;background:var(--cms-field);color:var(--ui-text-dimmed);display:flex;justify-content:center;position:relative;width:100%}.cms-block-preview>img,.cms-block-preview>video{height:100%;inset:0;-o-object-fit:cover;object-fit:cover;position:absolute;width:100%}.cms-block-summary{color:var(--ui-text-muted);font-size:.75rem;line-height:1.5;padding:.75rem;text-align:left}.cms-block-hidden{color:var(--cms-text);filter:drop-shadow(0 1px 2px rgb(0 0 0/.6));left:.5rem;position:absolute;top:.5rem}.cms-block-tile.is-dragging{opacity:.4}.cms-block-tile.is-drop-target{outline:2px solid var(--cms-fern);outline-offset:2px}.cms-block-bar{gap:.25rem;padding:.375rem}.cms-block-bar,.cms-block-grip{align-items:center;display:flex}.cms-block-grip{color:var(--ui-text-dimmed);cursor:grab;justify-content:center;padding:.25rem;transition:color var(--cms-duration) var(--cms-ease)}.cms-block-grip:hover{color:var(--ui-text-muted)}.cms-block-tile.is-dragging .cms-block-grip{cursor:grabbing}.cms-block-bar-name{color:var(--ui-text-muted);flex:1;font-size:.75rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-block-bar-actions{align-items:center;display:flex;gap:.125rem}.cms-block-add{align-items:center;border:1px dashed var(--cms-line-strong);border-radius:var(--cms-radius-md);color:var(--ui-text-muted);display:flex;flex-direction:column;font-size:.8125rem;font-weight:500;gap:.375rem;justify-content:center;min-height:6rem;transition:border-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-block-add:hover{border-color:var(--cms-fern);color:var(--ui-text-highlighted)}.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-single{max-width:15rem;width:100%}.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-breadcrumb{align-items:center;color:var(--ui-text-muted);display:flex;font-size:.75rem;font-weight:500;gap:.375rem;line-height:1.4}.cms-breadcrumb-link{border-radius:.25rem;padding:.0625rem .125rem;transition:color .15s ease}.cms-breadcrumb-link:hover{color:var(--ui-text-highlighted)}.cms-breadcrumb-separator{color:var(--ui-text-dimmed)}.cms-tree-row{align-items:center;display:flex;gap:.75rem;padding:.625rem 1rem;transition:background-color .15s ease}.cms-tree-row:hover{background-color:var(--ui-bg-elevated)}.cms-tree-branch{color:var(--ui-text-dimmed);flex-shrink:0;transform:scaleY(-1) rotate(90deg);width:1rem}.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{display:flex;flex-direction:column;gap:1.25rem}.cms-tabs{align-items:center;border-bottom:1px solid var(--ui-border);display:flex;gap:.25rem;margin-bottom:.25rem;overflow-x:auto}.cms-tab{border-bottom:2px solid transparent;color:var(--ui-text-toned);cursor:pointer;font-size:.875rem;font-weight:500;margin-bottom:-1px;padding:.5rem .875rem;transition:color var(--cms-duration) var(--cms-ease),border-color var(--cms-duration) var(--cms-ease);white-space:nowrap}.cms-tab:hover{color:var(--ui-text-highlighted)}.cms-tab.is-active{border-bottom-color:var(--cms-fern);color:var(--cms-fern)}.cms-form-field{display:flex;flex-direction:column;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-pill-count{color:var(--ui-text-dimmed);font-family:var(--cms-font-mono);font-size:.6875rem;margin-left:.25rem}.cms-blocks-grid{display:grid;gap:.75rem;grid-template-columns:repeat(2,minmax(0,1fr))}@media (min-width:640px){.cms-blocks-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.cms-blocks-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.cms-block-tile{overflow:hidden;transition:opacity var(--cms-duration) var(--cms-ease)}.cms-block-tile.is-muted{opacity:.45}.cms-block-preview{align-items:center;aspect-ratio:4/3;background:var(--cms-field);color:var(--ui-text-dimmed);display:flex;justify-content:center;position:relative;width:100%}.cms-block-preview>img,.cms-block-preview>video{height:100%;inset:0;-o-object-fit:cover;object-fit:cover;position:absolute;width:100%}.cms-block-summary{color:var(--ui-text-muted);font-size:.75rem;line-height:1.5;padding:.75rem;text-align:left}.cms-block-hidden{color:var(--cms-text);filter:drop-shadow(0 1px 2px rgb(0 0 0/.6));left:.5rem;position:absolute;top:.5rem}.cms-block-tile.is-dragging{opacity:.4}.cms-block-tile.is-drop-target{outline:2px solid var(--cms-fern);outline-offset:2px}.cms-block-bar{gap:.25rem;padding:.375rem}.cms-block-bar,.cms-block-grip{align-items:center;display:flex}.cms-block-grip{color:var(--ui-text-dimmed);cursor:grab;justify-content:center;padding:.25rem;transition:color var(--cms-duration) var(--cms-ease)}.cms-block-grip:hover{color:var(--ui-text-muted)}.cms-block-tile.is-dragging .cms-block-grip{cursor:grabbing}.cms-block-bar-name{color:var(--ui-text-muted);flex:1;font-size:.75rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cms-block-bar-actions{align-items:center;display:flex;gap:.125rem}.cms-block-add{align-items:center;border:1px dashed var(--cms-line-strong);border-radius:var(--cms-radius-md);color:var(--ui-text-muted);display:flex;flex-direction:column;font-size:.8125rem;font-weight:500;gap:.375rem;justify-content:center;min-height:6rem;transition:border-color var(--cms-duration) var(--cms-ease),color var(--cms-duration) var(--cms-ease)}.cms-block-add:hover{border-color:var(--cms-fern);color:var(--ui-text-highlighted)}.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-single{max-width:15rem;width:100%}.cms-block-tile.is-missing{border-color:color-mix(in oklab,var(--ui-warning) 55%,transparent)}.cms-media-missing{align-items:center;font-size:.75rem;gap:.375rem}.cms-media-missing,.cms-media-missing-icon{color:var(--ui-warning);display:flex}.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}
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { useDb } from "#cms-db";
5
5
  import { pageRoutes } from "../../shared/index.js";
6
6
  import { decodeRows, getRegistryEntry, idColumn, tableColumns } from "../utils/registry.js";
7
+ import { readPages } from "../utils/page-storage.js";
7
8
  import { attachManyToMany, relationTitles } from "../utils/relations.js";
8
9
  import { requireAdmin } from "../utils/require-admin.js";
9
10
  const querySchema = z.object({
@@ -31,7 +32,7 @@ export default defineEventHandler(async (event) => {
31
32
  querySchema.parse
32
33
  );
33
34
  if (entry.kind === "page") {
34
- const rows = await db.select().from(table);
35
+ const rows = await readPages(name, entry, table);
35
36
  decodeRows(entry, rows);
36
37
  const saved = new Map(rows.map((row) => [row.id, row]));
37
38
  const items2 = pageRoutes(entry).map((route) => {
@@ -8,6 +8,7 @@ import {
8
8
  parseId,
9
9
  requirePageRoute
10
10
  } from "../utils/registry.js";
11
+ import { readPages } from "../utils/page-storage.js";
11
12
  import { attachManyToMany } from "../utils/relations.js";
12
13
  import { requireAdmin } from "../utils/require-admin.js";
13
14
  export default defineEventHandler(async (event) => {
@@ -17,13 +18,14 @@ export default defineEventHandler(async (event) => {
17
18
  throw createError({ statusCode: 404, statusMessage: "Single objects have no items" });
18
19
  }
19
20
  const id = parseId(event);
20
- const db = useDb();
21
- const rows = await db.select().from(table).where(eq(idColumn(table), id)).limit(1);
22
21
  if (entry.kind === "page") {
23
22
  const route = requirePageRoute(entry, id);
24
- if (!rows[0]) return { id: route.key, path: route.path };
25
- return decodeRows(entry, [rows[0]])[0];
23
+ const [page] = await readPages(name, entry, table, { id });
24
+ if (!page) return { id: route.key, path: route.path };
25
+ return decodeRows(entry, [page])[0];
26
26
  }
27
+ const db = useDb();
28
+ const rows = await db.select().from(table).where(eq(idColumn(table), id)).limit(1);
27
29
  if (!rows[0]) throw createError({ statusCode: 404, statusMessage: "Row not found" });
28
30
  const [attached] = await attachManyToMany(db, name, entry, [rows[0]]);
29
31
  return decodeRows(entry, [attached])[0];
@@ -11,6 +11,9 @@ import {
11
11
  requirePageRoute,
12
12
  withUpdatedAt
13
13
  } from "../utils/registry.js";
14
+ import { pageFields } from "../../shared/index.js";
15
+ import { assertMediaExists } from "../utils/media-check.js";
16
+ import { writePage } from "../utils/page-storage.js";
14
17
  import {
15
18
  assertRelationTargets,
16
19
  attachManyToMany,
@@ -32,13 +35,11 @@ export default defineEventHandler(async (event) => {
32
35
  if (entry.kind === "page") {
33
36
  const route = requirePageRoute(entry, id);
34
37
  const body2 = await readValidatedBody(event, buildValidator(entry, route.path).parse);
38
+ await assertMediaExists(pageFields(entry, route.path), body2);
35
39
  const values2 = encodeColumnValues(entry, body2);
36
40
  const set2 = withUpdatedAt(table, values2);
37
41
  return mapConstraintErrors(
38
- () => withTransaction(async (db) => {
39
- const [row] = await db.insert(table).values({ id: route.key, path: route.path, ...set2 }).onConflictDoUpdate({ target: idColumn(table), set: set2 }).returning();
40
- return decodeRows(entry, [row])[0];
41
- })
42
+ async () => decodeRows(entry, [await writePage(name, entry, table, route, set2)])[0]
42
43
  );
43
44
  }
44
45
  const body = await readValidatedBody(event, buildValidator(entry).parse);
@@ -3,6 +3,7 @@ import { defineEventHandler } from "h3";
3
3
  import { useRuntimeConfig } from "#imports";
4
4
  import { buildCmsSchema, createGraphqlContext } from "../utils/graphql.js";
5
5
  import { createBreadthRule, createDepthRule, createIntrospectionRule } from "../utils/graphql-depth.js";
6
+ import { CMS_GRAPHQL_BATCH_LIMIT } from "../../shared/index.js";
6
7
  function buildYoga(options) {
7
8
  return createYoga({
8
9
  schema: buildCmsSchema(),
@@ -10,6 +11,7 @@ function buildYoga(options) {
10
11
  landingPage: false,
11
12
  graphiql: options.graphiql,
12
13
  cors: false,
14
+ batching: { limit: CMS_GRAPHQL_BATCH_LIMIT },
13
15
  context: () => createGraphqlContext(),
14
16
  plugins: [
15
17
  {
@@ -7,5 +7,7 @@ export declare function useDb(): import("drizzle-orm/d1").DrizzleD1Database<Reco
7
7
  };
8
8
  type Db = ReturnType<typeof useDb>;
9
9
  export type CmsDb = Db;
10
+ export declare const cmsDialect: 'sqlite' | 'postgres';
10
11
  export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
12
+ export declare function runBatch(build: (db: CmsDb) => PromiseLike<unknown>[]): Promise<unknown[]>;
11
13
  export {};
@@ -21,6 +21,11 @@ export function useDb() {
21
21
  if (!_db) _db = drizzle(useD1Binding());
22
22
  return _db;
23
23
  }
24
+ export const cmsDialect = "sqlite";
24
25
  export function withTransaction(fn) {
25
26
  return fn(useDb());
26
27
  }
28
+ export function runBatch(build) {
29
+ const db = useDb();
30
+ return db.batch(build(db));
31
+ }
@@ -3,5 +3,7 @@ export declare function useDb(): import("drizzle-orm/libsql").LibSQLDatabase<Rec
3
3
  };
4
4
  type Db = ReturnType<typeof useDb>;
5
5
  export type CmsDb = Db | Parameters<Parameters<Db['transaction']>[0]>[0];
6
+ export declare const cmsDialect: 'sqlite' | 'postgres';
6
7
  export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
8
+ export declare function runBatch(build: (db: CmsDb) => PromiseLike<unknown>[]): Promise<unknown[]>;
7
9
  export {};
@@ -20,6 +20,11 @@ export function useDb() {
20
20
  }
21
21
  return _db;
22
22
  }
23
+ export const cmsDialect = "sqlite";
23
24
  export function withTransaction(fn) {
24
25
  return useDb().transaction((tx) => fn(tx));
25
26
  }
27
+ export function runBatch(build) {
28
+ const db = useDb();
29
+ return db.batch(build(db));
30
+ }
@@ -3,5 +3,7 @@ export declare function useDb(): import("drizzle-orm/node-postgres").NodePgDatab
3
3
  };
4
4
  type Db = ReturnType<typeof useDb>;
5
5
  export type CmsDb = Db | Parameters<Parameters<Db['transaction']>[0]>[0];
6
+ export declare const cmsDialect: 'sqlite' | 'postgres';
6
7
  export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
8
+ export declare function runBatch(build: (db: CmsDb) => PromiseLike<unknown>[]): Promise<unknown[]>;
7
9
  export {};
@@ -16,6 +16,14 @@ export function useDb() {
16
16
  }
17
17
  return _db;
18
18
  }
19
+ export const cmsDialect = "postgres";
19
20
  export function withTransaction(fn) {
20
21
  return useDb().transaction((tx) => fn(tx));
21
22
  }
23
+ export function runBatch(build) {
24
+ return withTransaction(async (db) => {
25
+ const results = [];
26
+ for (const statement of build(db)) results.push(await statement);
27
+ return results;
28
+ });
29
+ }
@@ -3,4 +3,6 @@ export declare function useDb(): import("drizzle-orm/better-sqlite3").BetterSQLi
3
3
  $client: Database.Database;
4
4
  };
5
5
  export type CmsDb = ReturnType<typeof useDb>;
6
+ export declare const cmsDialect: 'sqlite' | 'postgres';
6
7
  export declare function withTransaction<T>(fn: (db: CmsDb) => Promise<T>): Promise<T>;
8
+ export declare function runBatch(build: (db: CmsDb) => PromiseLike<unknown>[]): Promise<unknown[]>;
@@ -16,6 +16,7 @@ export function useDb() {
16
16
  }
17
17
  return _db;
18
18
  }
19
+ export const cmsDialect = "sqlite";
19
20
  let txQueue = Promise.resolve();
20
21
  export function withTransaction(fn) {
21
22
  const db = useDb();
@@ -34,3 +35,10 @@ export function withTransaction(fn) {
34
35
  });
35
36
  return run;
36
37
  }
38
+ export function runBatch(build) {
39
+ return withTransaction(async (db) => {
40
+ const results = [];
41
+ for (const statement of build(db)) results.push(await statement);
42
+ return results;
43
+ });
44
+ }
@@ -38,6 +38,7 @@ import {
38
38
  typeName
39
39
  } from "../../shared/graphql-sdl.js";
40
40
  import { useMediaIndex } from "./media-index.js";
41
+ import { readPages } from "./page-storage.js";
41
42
  import { getContentI18n, resolveTable, tableColumns } from "./registry.js";
42
43
  const MAX_LIMIT = 100;
43
44
  const DEFAULT_LIMIT = 50;
@@ -322,14 +323,12 @@ export function buildCmsSchema() {
322
323
  if (entry.kind === "page") {
323
324
  queryResolvers[name] = async (_, args) => {
324
325
  const locale = resolveLocaleArg(args.locale);
325
- const table = tableFor(name);
326
- const rows = await useDb().select().from(table).orderBy(asc(tableColumns(table).path));
326
+ const rows = await readPages(name, entry, tableFor(name), { orderByPath: true });
327
327
  return rows.map((row) => localizeRow(entry, row, locale));
328
328
  };
329
329
  queryResolvers[`${name}ByPath`] = async (_, args) => {
330
330
  const locale = resolveLocaleArg(args.locale);
331
- const table = tableFor(name);
332
- const [row] = await useDb().select().from(table).where(eq(tableColumns(table).path, args.path)).limit(1);
331
+ const [row] = await readPages(name, entry, tableFor(name), { path: args.path });
333
332
  return row ? localizeRow(entry, row, locale) : null;
334
333
  };
335
334
  continue;
@@ -0,0 +1,2 @@
1
+ import type { FieldConfig } from '../../shared/index.js';
2
+ export declare function assertMediaExists(fields: Record<string, FieldConfig>, values: Record<string, unknown>): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { useDb } from "#cms-db";
2
+ import { cms_media } from "#cms-tables";
3
+ import { useRuntimeConfig } from "#imports";
4
+ import { useMediaIndex } from "./media-index.js";
5
+ import { assertKnownMedia, mediaKeysInTable, mediaReferences } from "./media-references.js";
6
+ async function mediaLookup() {
7
+ const { media } = useRuntimeConfig().cms;
8
+ if (media.storage !== "local") {
9
+ return (keys) => mediaKeysInTable(useDb(), cms_media, keys);
10
+ }
11
+ const index = await useMediaIndex();
12
+ if (index.source.kind === "none") return null;
13
+ return async (keys) => new Set(keys.filter((key) => index.get(key)));
14
+ }
15
+ export async function assertMediaExists(fields, values) {
16
+ const references = mediaReferences(fields, values);
17
+ if (!references.length) return;
18
+ const lookup = await mediaLookup();
19
+ if (lookup) await assertKnownMedia(references, lookup);
20
+ }
@@ -0,0 +1,10 @@
1
+ import type { BaseSQLiteDatabase, SQLiteTable } from 'drizzle-orm/sqlite-core';
2
+ import type { FieldConfig } from '../../shared/index.js';
3
+ export interface MediaReference {
4
+ field: string;
5
+ key: string;
6
+ }
7
+ export type MediaLookup = (keys: string[]) => Promise<Set<string>>;
8
+ export declare function mediaReferences(fields: Record<string, FieldConfig>, values: Record<string, unknown>): MediaReference[];
9
+ export declare function assertKnownMedia(references: MediaReference[], lookup: MediaLookup): Promise<void>;
10
+ export declare function mediaKeysInTable(db: Pick<BaseSQLiteDatabase<'sync' | 'async', unknown>, 'select'>, table: SQLiteTable, keys: string[]): Promise<Set<string>>;
@@ -0,0 +1,50 @@
1
+ import { inArray } from "drizzle-orm";
2
+ import { createError } from "h3";
3
+ const LOOKUP_CHUNK = 90;
4
+ function mediaKeysOf(value) {
5
+ if (typeof value === "string") return value ? [value] : [];
6
+ if (!value || typeof value !== "object") return [];
7
+ return Object.values(value).filter(
8
+ (key) => typeof key === "string" && key !== ""
9
+ );
10
+ }
11
+ export function mediaReferences(fields, values) {
12
+ const references = [];
13
+ for (const [key, field] of Object.entries(fields)) {
14
+ const value = values[key];
15
+ if (field.type === "media") {
16
+ for (const mediaKey of mediaKeysOf(value)) references.push({ field: key, key: mediaKey });
17
+ continue;
18
+ }
19
+ if (field.type !== "blocks" || !Array.isArray(value)) continue;
20
+ value.forEach((item, index) => {
21
+ const block = item ? field.blocks?.[String(item.type)] : void 0;
22
+ for (const [blockFieldKey, blockField] of Object.entries(block?.fields ?? {})) {
23
+ if (blockField.type !== "media") continue;
24
+ for (const mediaKey of mediaKeysOf(item?.[blockFieldKey])) {
25
+ references.push({ field: `${key}[${index}].${blockFieldKey}`, key: mediaKey });
26
+ }
27
+ }
28
+ });
29
+ }
30
+ return references;
31
+ }
32
+ export async function assertKnownMedia(references, lookup) {
33
+ if (!references.length) return;
34
+ const known = await lookup([...new Set(references.map((reference) => reference.key))]);
35
+ const missing = references.filter((reference) => !known.has(reference.key));
36
+ if (!missing.length) return;
37
+ throw createError({
38
+ statusCode: 400,
39
+ statusMessage: `Media not found in the library: ${missing.map((reference) => `'${reference.key}' (${reference.field})`).join(", ")}`
40
+ });
41
+ }
42
+ export async function mediaKeysInTable(db, table, keys) {
43
+ const column = table.key;
44
+ const found = /* @__PURE__ */ new Set();
45
+ for (let index = 0; index < keys.length; index += LOOKUP_CHUNK) {
46
+ const rows = await db.select({ key: column }).from(table).where(inArray(column, keys.slice(index, index + LOOKUP_CHUNK)));
47
+ for (const row of rows) found.add(row.key);
48
+ }
49
+ return found;
50
+ }
@@ -0,0 +1,51 @@
1
+ import type { SQL } from 'drizzle-orm';
2
+ import type { BaseSQLiteDatabase, SQLiteTable } from 'drizzle-orm/sqlite-core';
3
+ import type { CmsEntry, CmsPageRoute } from '../../shared/index.js';
4
+ export type PageDialect = 'sqlite' | 'postgres';
5
+ export type PageDb = Pick<BaseSQLiteDatabase<'sync' | 'async', unknown>, 'select' | 'insert' | 'delete'>;
6
+ export interface PageTables {
7
+ page: SQLiteTable;
8
+ fields: SQLiteTable;
9
+ media: SQLiteTable;
10
+ }
11
+ export interface PageFieldRow {
12
+ pageId: string;
13
+ key: string;
14
+ position: number;
15
+ value: string;
16
+ }
17
+ export interface PageMediaRow {
18
+ pageId: string;
19
+ key: string;
20
+ position: number;
21
+ mediaKey: string;
22
+ }
23
+ export interface EncodedPage {
24
+ columns: Record<string, unknown>;
25
+ fields: PageFieldRow[];
26
+ media: PageMediaRow[];
27
+ fieldKeys: string[];
28
+ mediaKeys: string[];
29
+ }
30
+ type Row = Record<string, unknown>;
31
+ export declare const PAGE_BLOCK_TYPE_KEY = "_type";
32
+ export declare function pageBlockKey(fieldKey: string, blockFieldKey: string): string;
33
+ export declare function encodePageRows(entry: CmsEntry, pageId: string, values: Row): EncodedPage;
34
+ export declare function decodePageRow(entry: CmsEntry, row: Row): Row;
35
+ export declare function decodePageRows(entry: CmsEntry, rows: Row[]): Row[];
36
+ export declare function pageSelection(dialect: PageDialect, tables: PageTables): {
37
+ $fields: SQL<unknown>;
38
+ $media: SQL<unknown>;
39
+ };
40
+ export declare function selectPages(db: PageDb, dialect: PageDialect, tables: PageTables): import("drizzle-orm/sqlite-core").SQLiteSelectBase<string, "async" | "sync", unknown, {
41
+ $fields: SQL<unknown>;
42
+ $media: SQL<unknown>;
43
+ }, "partial", Record<string, "not-null">, true, never, {
44
+ $fields: unknown;
45
+ $media: unknown;
46
+ }[], {
47
+ $fields: import("drizzle-orm").DrizzleTypeError<"You cannot reference this field without assigning it an alias first - use `.as(<alias>)`">;
48
+ $media: import("drizzle-orm").DrizzleTypeError<"You cannot reference this field without assigning it an alias first - use `.as(<alias>)`">;
49
+ }>;
50
+ export declare function pageWriteStatements(db: PageDb, dialect: PageDialect, tables: PageTables, entry: CmsEntry, route: Pick<CmsPageRoute, 'key' | 'path'>, values: Row): PromiseLike<unknown>[];
51
+ export {};
@@ -0,0 +1,223 @@
1
+ import { and, eq, getTableColumns, getTableName, inArray, sql } from "drizzle-orm";
2
+ import {
3
+ PAGE_PATH_FIELD,
4
+ isMultiSelect,
5
+ isTranslatableField,
6
+ isTranslatableMediaField,
7
+ pageAllFields,
8
+ pageRowFields
9
+ } from "../../shared/index.js";
10
+ export const PAGE_BLOCK_TYPE_KEY = "_type";
11
+ const FIELDS_SELECTION = "$fields";
12
+ const MEDIA_SELECTION = "$media";
13
+ const INSERT_CHUNK = 20;
14
+ const KEY_CHUNK = 90;
15
+ const PLAIN_STRING_TYPES = /* @__PURE__ */ new Set([
16
+ "text",
17
+ "richtext",
18
+ "email",
19
+ "slug",
20
+ "date",
21
+ "select",
22
+ "relation"
23
+ ]);
24
+ function columnsOf(table) {
25
+ return table;
26
+ }
27
+ function isPlainString(field) {
28
+ return !!field && PLAIN_STRING_TYPES.has(field.type) && !isTranslatableField(field) && !isMultiSelect(field);
29
+ }
30
+ function parseJson(raw) {
31
+ try {
32
+ return JSON.parse(raw);
33
+ } catch {
34
+ return raw;
35
+ }
36
+ }
37
+ function encodeValue(field, value) {
38
+ return isPlainString(field) ? String(value) : JSON.stringify(value);
39
+ }
40
+ function decodeValue(field, raw) {
41
+ return isPlainString(field) ? raw : parseJson(raw);
42
+ }
43
+ function encodeMedia(value) {
44
+ return typeof value === "string" ? value : JSON.stringify(value);
45
+ }
46
+ function decodeBlockMedia(field, raw) {
47
+ return isTranslatableMediaField(field) && raw.startsWith("{") ? parseJson(raw) : raw;
48
+ }
49
+ export function pageBlockKey(fieldKey, blockFieldKey) {
50
+ return `${fieldKey}.${blockFieldKey}`;
51
+ }
52
+ function blockOwnedKeys(key, field) {
53
+ const fields = /* @__PURE__ */ new Set([pageBlockKey(key, PAGE_BLOCK_TYPE_KEY)]);
54
+ const media = /* @__PURE__ */ new Set();
55
+ for (const block of Object.values(field.blocks ?? {})) {
56
+ for (const [blockFieldKey, blockField] of Object.entries(block.fields)) {
57
+ const blockKey = pageBlockKey(key, blockFieldKey);
58
+ if (blockField.type === "media") media.add(blockKey);
59
+ else fields.add(blockKey);
60
+ }
61
+ }
62
+ return { fields: [...fields], media: [...media] };
63
+ }
64
+ export function encodePageRows(entry, pageId, values) {
65
+ const rowFields = pageRowFields(entry);
66
+ const encoded = { columns: {}, fields: [], media: [], fieldKeys: [], mediaKeys: [] };
67
+ const pushField = (key, position, value) => encoded.fields.push({ pageId, key, position, value });
68
+ const pushMedia = (key, position, value) => encoded.media.push({ pageId, key, position, mediaKey: encodeMedia(value) });
69
+ for (const [key, value] of Object.entries(values)) {
70
+ const field = rowFields[key];
71
+ if (!field) {
72
+ encoded.columns[key] = value;
73
+ continue;
74
+ }
75
+ if (field.type === "blocks") {
76
+ const owned = blockOwnedKeys(key, field);
77
+ encoded.fieldKeys.push(...owned.fields);
78
+ encoded.mediaKeys.push(...owned.media);
79
+ if (!Array.isArray(value)) continue;
80
+ value.forEach((item, position) => {
81
+ const type = String(item.type);
82
+ const block = field.blocks?.[type];
83
+ pushField(pageBlockKey(key, PAGE_BLOCK_TYPE_KEY), position, type);
84
+ for (const [blockFieldKey, blockValue] of Object.entries(item)) {
85
+ if (blockFieldKey === "type" || blockValue == null) continue;
86
+ const blockField = block?.fields[blockFieldKey];
87
+ const blockKey = pageBlockKey(key, blockFieldKey);
88
+ if (blockField?.type === "media") pushMedia(blockKey, position, blockValue);
89
+ else pushField(blockKey, position, encodeValue(blockField, blockValue));
90
+ }
91
+ });
92
+ continue;
93
+ }
94
+ if (field.type === "media") {
95
+ encoded.mediaKeys.push(key);
96
+ if (value != null) pushMedia(key, 0, value);
97
+ continue;
98
+ }
99
+ encoded.fieldKeys.push(key);
100
+ if (value != null) pushField(key, 0, encodeValue(field, value));
101
+ }
102
+ return encoded;
103
+ }
104
+ function parseChildren(value) {
105
+ if (value == null) return [];
106
+ const rows = typeof value === "string" ? parseJson(value) : value;
107
+ return Array.isArray(rows) ? rows : [];
108
+ }
109
+ function indexChildren(rows) {
110
+ const index = /* @__PURE__ */ new Map();
111
+ for (const [key, position, value] of rows) {
112
+ if (key == null || value == null) continue;
113
+ let positions = index.get(key);
114
+ if (!positions) {
115
+ positions = /* @__PURE__ */ new Map();
116
+ index.set(key, positions);
117
+ }
118
+ positions.set(Number(position), String(value));
119
+ }
120
+ return index;
121
+ }
122
+ function decodeBlocks(key, field, fields, media) {
123
+ const types = fields.get(pageBlockKey(key, PAGE_BLOCK_TYPE_KEY));
124
+ if (!types?.size) return null;
125
+ return [...types.entries()].sort(([a], [b]) => a - b).map(([position, type]) => {
126
+ const item = { type };
127
+ for (const [blockFieldKey, blockField] of Object.entries(
128
+ field.blocks?.[type]?.fields ?? {}
129
+ )) {
130
+ const blockKey = pageBlockKey(key, blockFieldKey);
131
+ if (blockField.type === "media") {
132
+ const raw2 = media.get(blockKey)?.get(position);
133
+ item[blockFieldKey] = raw2 == null ? null : decodeBlockMedia(blockField, raw2);
134
+ continue;
135
+ }
136
+ const raw = fields.get(blockKey)?.get(position);
137
+ item[blockFieldKey] = raw == null ? null : decodeValue(blockField, raw);
138
+ }
139
+ return item;
140
+ });
141
+ }
142
+ function decodeRowField(key, field, fields, media) {
143
+ if (field.type === "blocks") return decodeBlocks(key, field, fields, media);
144
+ if (field.type === "media") return media.get(key)?.get(0) ?? null;
145
+ const raw = fields.get(key)?.get(0);
146
+ return raw == null ? null : decodeValue(field, raw);
147
+ }
148
+ export function decodePageRow(entry, row) {
149
+ const rowFields = pageRowFields(entry);
150
+ const fields = indexChildren(parseChildren(row[FIELDS_SELECTION]));
151
+ const media = indexChildren(parseChildren(row[MEDIA_SELECTION]));
152
+ const result = { id: row.id, [PAGE_PATH_FIELD]: row[PAGE_PATH_FIELD] };
153
+ for (const [key, field] of Object.entries(pageAllFields(entry))) {
154
+ result[key] = Object.hasOwn(rowFields, key) ? decodeRowField(key, field, fields, media) : row[key] ?? null;
155
+ }
156
+ for (const [key, value] of Object.entries(row)) {
157
+ if (key === FIELDS_SELECTION || key === MEDIA_SELECTION || Object.hasOwn(result, key))
158
+ continue;
159
+ result[key] = value;
160
+ }
161
+ return result;
162
+ }
163
+ export function decodePageRows(entry, rows) {
164
+ return rows.map((row) => decodePageRow(entry, row));
165
+ }
166
+ function qualified(table, column) {
167
+ return sql`${sql.identifier(getTableName(table))}.${sql.identifier(column.name)}`;
168
+ }
169
+ function childAggregate(dialect, page, table, valueKey) {
170
+ const columns = columnsOf(table);
171
+ const owner = qualified(table, columns.pageId);
172
+ const key = qualified(table, columns.key);
173
+ const position = qualified(table, columns.position);
174
+ const value = qualified(table, columns[valueKey]);
175
+ const pageId = qualified(page, columnsOf(page).id);
176
+ const from = sql.identifier(getTableName(table));
177
+ return dialect === "postgres" ? sql`(select coalesce(json_agg(json_build_array(${key}, ${position}, ${value})), '[]'::json) from ${from} where ${owner} = ${pageId})` : sql`(select json_group_array(json_array(${key}, ${position}, ${value})) from ${from} where ${owner} = ${pageId})`;
178
+ }
179
+ export function pageSelection(dialect, tables) {
180
+ return {
181
+ ...getTableColumns(tables.page),
182
+ [FIELDS_SELECTION]: childAggregate(dialect, tables.page, tables.fields, "value"),
183
+ [MEDIA_SELECTION]: childAggregate(dialect, tables.page, tables.media, "mediaKey")
184
+ };
185
+ }
186
+ export function selectPages(db, dialect, tables) {
187
+ return db.select(pageSelection(dialect, tables)).from(tables.page).$dynamic();
188
+ }
189
+ function chunks(items, size) {
190
+ const out = [];
191
+ for (let index = 0; index < items.length; index += size) {
192
+ out.push(items.slice(index, index + size));
193
+ }
194
+ return out;
195
+ }
196
+ export function pageWriteStatements(db, dialect, tables, entry, route, values) {
197
+ const encoded = encodePageRows(entry, route.key, values);
198
+ const pageId = columnsOf(tables.page).id;
199
+ const fieldColumns = columnsOf(tables.fields);
200
+ const mediaColumns = columnsOf(tables.media);
201
+ const set = encoded.columns;
202
+ const statements = [
203
+ db.insert(tables.page).values({ ...set, id: route.key, [PAGE_PATH_FIELD]: route.path }).onConflictDoUpdate({ target: pageId, set })
204
+ ];
205
+ for (const keys of chunks(encoded.fieldKeys, KEY_CHUNK)) {
206
+ statements.push(
207
+ db.delete(tables.fields).where(and(eq(fieldColumns.pageId, route.key), inArray(fieldColumns.key, keys)))
208
+ );
209
+ }
210
+ for (const keys of chunks(encoded.mediaKeys, KEY_CHUNK)) {
211
+ statements.push(
212
+ db.delete(tables.media).where(and(eq(mediaColumns.pageId, route.key), inArray(mediaColumns.key, keys)))
213
+ );
214
+ }
215
+ for (const rows of chunks(encoded.fields, INSERT_CHUNK)) {
216
+ statements.push(db.insert(tables.fields).values(rows));
217
+ }
218
+ for (const rows of chunks(encoded.media, INSERT_CHUNK)) {
219
+ statements.push(db.insert(tables.media).values(rows));
220
+ }
221
+ statements.push(selectPages(db, dialect, tables).where(eq(pageId, route.key)).limit(1));
222
+ return statements;
223
+ }
@@ -0,0 +1,11 @@
1
+ import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
2
+ import type { CmsEntry, CmsPageRoute } from '../../shared/index.js';
3
+ type Row = Record<string, unknown>;
4
+ export interface PageReadOptions {
5
+ id?: string;
6
+ path?: string;
7
+ orderByPath?: boolean;
8
+ }
9
+ export declare function readPages(name: string, entry: CmsEntry, table: SQLiteTable, options?: PageReadOptions): Promise<Row[]>;
10
+ export declare function writePage(name: string, entry: CmsEntry, table: SQLiteTable, route: Pick<CmsPageRoute, 'key' | 'path'>, set: Row): Promise<Row>;
11
+ export {};
@@ -0,0 +1,42 @@
1
+ import { asc, eq } from "drizzle-orm";
2
+ import { createError } from "h3";
3
+ import { cmsDialect, runBatch, useDb } from "#cms-db";
4
+ import * as cmsTables from "#cms-tables";
5
+ import { PAGE_PATH_FIELD, pageFieldsTableName, pageMediaTableName } from "../../shared/index.js";
6
+ import { decodePageRows, pageWriteStatements, selectPages } from "./page-rows.js";
7
+ import { idColumn, tableColumns } from "./registry.js";
8
+ function childTable(name) {
9
+ const table = cmsTables[name];
10
+ if (!table) throw createError({ statusCode: 500, statusMessage: `No table: ${name}` });
11
+ return table;
12
+ }
13
+ function pageTables(name, table) {
14
+ return {
15
+ page: table,
16
+ fields: childTable(pageFieldsTableName(name)),
17
+ media: childTable(pageMediaTableName(name))
18
+ };
19
+ }
20
+ function pageFilter(table, options) {
21
+ if (options.id !== void 0) return eq(idColumn(table), options.id);
22
+ if (options.path !== void 0) return eq(tableColumns(table)[PAGE_PATH_FIELD], options.path);
23
+ return void 0;
24
+ }
25
+ export async function readPages(name, entry, table, options = {}) {
26
+ const db = useDb();
27
+ const where = pageFilter(table, options);
28
+ const single = options.id !== void 0 || options.path !== void 0;
29
+ const rows = selectPages(db, cmsDialect, pageTables(name, table));
30
+ if (where) rows.where(where);
31
+ if (options.orderByPath) rows.orderBy(asc(tableColumns(table)[PAGE_PATH_FIELD]));
32
+ if (single) rows.limit(1);
33
+ const result = await rows;
34
+ return decodePageRows(entry, result);
35
+ }
36
+ export async function writePage(name, entry, table, route, set) {
37
+ const tables = pageTables(name, table);
38
+ const results = await runBatch(
39
+ (db) => pageWriteStatements(db, cmsDialect, tables, entry, route, set)
40
+ );
41
+ return decodePageRows(entry, results.at(-1))[0];
42
+ }
@@ -96,6 +96,7 @@ export declare function translatableBlockFieldKeys(block: BlockConfig): string[]
96
96
  export declare function hasTranslatableBlockFields(field: FieldConfig): boolean;
97
97
  export declare function localizeBlock(field: FieldConfig, item: unknown, locale: string, defaultLocale: string): unknown;
98
98
  export declare function localizeBlocks(field: FieldConfig, value: unknown, locale: string, defaultLocale: string): unknown;
99
+ export declare const CMS_GRAPHQL_BATCH_LIMIT = 20;
99
100
  export type CmsEntryKind = 'collection' | 'single' | 'page';
100
101
  export interface CmsPageRoute {
101
102
  path: string;
@@ -116,6 +117,7 @@ export interface CmsEntry {
116
117
  labels?: Record<string, string>;
117
118
  overrides?: Record<string, Record<string, FieldConfig | null>>;
118
119
  pages?: CmsPageRoute[];
120
+ columns?: string[];
119
121
  tabs?: CmsTab[];
120
122
  table?: CmsTable;
121
123
  }
@@ -134,6 +136,10 @@ export declare function pageRouteOf(entry: CmsEntry, key: string): CmsPageRoute
134
136
  export declare function pageOverrideFields(entry: CmsEntry, path: string): Record<string, FieldConfig | null>;
135
137
  export declare function pageFields(entry: CmsEntry, path: string): Record<string, FieldConfig>;
136
138
  export declare function pageAllFields(entry: CmsEntry): Record<string, FieldConfig>;
139
+ export declare function pageColumnFields(entry: CmsEntry): Record<string, FieldConfig>;
140
+ export declare function pageRowFields(entry: CmsEntry): Record<string, FieldConfig>;
141
+ export declare function pageFieldsTableName(name: string): string;
142
+ export declare function pageMediaTableName(name: string): string;
137
143
  type EntryLike = Pick<CmsEntry, 'fields'> & Partial<Pick<CmsEntry, 'kind' | 'overrides'>>;
138
144
  export declare function entryFieldsFor(entry: EntryLike, path?: string): Record<string, FieldConfig>;
139
145
  export declare function typeName(name: string): string;
@@ -228,6 +234,7 @@ export interface CmsPageInput extends CmsEntryInputBase {
228
234
  order?: string[];
229
235
  labels?: Record<string, string>;
230
236
  overrides?: Record<string, Record<string, CmsFieldInput | null>>;
237
+ columns?: string[];
231
238
  }
232
239
  export type CmsEntryInput = CmsCollectionInput | CmsSingleInput | CmsPageInput;
233
240
  export type CmsConfigInput = Record<string, CmsEntryInput>;
@@ -191,6 +191,7 @@ export function localizeBlocks(field, value, locale, defaultLocale) {
191
191
  if (!Array.isArray(value)) return value;
192
192
  return value.map((item) => localizeBlock(field, item, locale, defaultLocale));
193
193
  }
194
+ export const CMS_GRAPHQL_BATCH_LIMIT = 20;
194
195
  export function entryTabs(entry) {
195
196
  return entry.tabs?.length ? entry.tabs : [];
196
197
  }
@@ -258,6 +259,26 @@ export function pageAllFields(entry) {
258
259
  }
259
260
  return fields;
260
261
  }
262
+ export function pageColumnFields(entry) {
263
+ const fields = {};
264
+ for (const key of entry.columns ?? []) {
265
+ const field = entry.fields[key];
266
+ if (field) fields[key] = field;
267
+ }
268
+ return fields;
269
+ }
270
+ export function pageRowFields(entry) {
271
+ const columns = new Set(entry.columns ?? []);
272
+ return Object.fromEntries(
273
+ Object.entries(pageAllFields(entry)).filter(([key]) => !columns.has(key))
274
+ );
275
+ }
276
+ export function pageFieldsTableName(name) {
277
+ return `${name}_fields`;
278
+ }
279
+ export function pageMediaTableName(name) {
280
+ return `${name}_media`;
281
+ }
261
282
  export function entryFieldsFor(entry, path) {
262
283
  if (entry.kind !== "page") return entry.fields;
263
284
  return path ? pageFields(entry, path) : pageAllFields(entry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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)",