@jxsuite/studio 0.10.2 → 0.11.0

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.
@@ -35,6 +35,9 @@ import {
35
35
  import { isCustomElementDoc, collectCssParts } from "./signals-panel.js";
36
36
  import { mediaDisplayName } from "./shared.js";
37
37
  import { getCssInitialMap } from "./style-utils.js";
38
+ import { renderMediaPicker } from "../ui/media-picker.js";
39
+ import { getEffectiveLayoutPath, invalidateLayoutCache } from "../site-context.js";
40
+ import { getPlatform } from "../platform.js";
38
41
  import htmlMeta from "../../data/html-meta.json";
39
42
 
40
43
  /**
@@ -245,9 +248,12 @@ function renderFrontmatterOnlyPanel() {
245
248
  return html`<div class="empty-state">No frontmatter. Select an element to inspect.</div>`;
246
249
  }
247
250
 
251
+ const pageT = renderPageSection(S.document || {});
252
+
248
253
  return html`
249
254
  <div class="style-sidebar">
250
255
  <sp-accordion allow-multiple size="s">
256
+ ${pageT}
251
257
  <sp-accordion-item label=${col ? `Frontmatter (${col.name})` : "Frontmatter"} open>
252
258
  <div class="style-section-body">
253
259
  ${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
@@ -336,6 +342,72 @@ function renderFmFieldRow(
336
342
  });
337
343
  }
338
344
 
345
+ if (entry.format === "image") {
346
+ return renderFieldRow({
347
+ prop: field,
348
+ label: displayLabel,
349
+ hasValue: hasVal,
350
+ onClear,
351
+ widget: renderMediaPicker(field, value, (v) =>
352
+ update(updateFrontmatter(getState(), field, v || undefined)),
353
+ ),
354
+ });
355
+ }
356
+
357
+ if (entry.type === "array" && entry.items?.format === "image") {
358
+ const images = Array.isArray(value) ? value : [];
359
+ return renderFieldRow({
360
+ prop: field,
361
+ label: displayLabel,
362
+ hasValue: hasVal,
363
+ onClear,
364
+ widget: html`
365
+ <div class="gallery-picker">
366
+ <div class="gallery-picker-strip">
367
+ ${images.map(
368
+ (img, i) => html`
369
+ <div class="gallery-picker-item">
370
+ <img src=${img} alt="" class="gallery-picker-thumb" />
371
+ <sp-action-button
372
+ size="xs"
373
+ quiet
374
+ title="Remove"
375
+ @click=${() => {
376
+ const next = images.filter((_, idx) => idx !== i);
377
+ update(updateFrontmatter(getState(), field, next.length ? next : undefined));
378
+ }}
379
+ >
380
+ <sp-icon-close slot="icon"></sp-icon-close>
381
+ </sp-action-button>
382
+ </div>
383
+ `,
384
+ )}
385
+ </div>
386
+ ${renderMediaPicker(`${field}:add`, "", (v) => {
387
+ if (!v) return;
388
+ const next = [...images, v];
389
+ update(updateFrontmatter(getState(), field, next));
390
+ })}
391
+ </div>
392
+ `,
393
+ });
394
+ }
395
+
396
+ if (entry.$ref) {
397
+ const targetName = entry.$ref.replace("#/contentTypes/", "");
398
+ const targetDef = projectState?.projectConfig?.contentTypes?.[targetName];
399
+ const picker = renderReferencePicker(field, value, targetName, targetDef, (v) =>
400
+ update(updateFrontmatter(getState(), field, v || undefined)),
401
+ );
402
+ return renderFieldRow({
403
+ prop: field,
404
+ label: displayLabel,
405
+ hasValue: hasVal,
406
+ onClear,
407
+ widget: picker,
408
+ });
409
+ }
410
+
339
411
  if (entry.type === "number") {
340
412
  return renderFieldRow({
341
413
  prop: field,
@@ -374,6 +446,78 @@ function renderFmFieldRow(
374
446
  });
375
447
  }
376
448
 
449
+ // ─── Reference picker ────────────────────────────────────────────────────────
450
+
451
+ /** @type {Map<string, { slug: string; title: string }[]>} */
452
+ const refEntriesCache = new Map();
453
+
454
+ /**
455
+ * Render a reference field as a picker of entries from the target content type.
456
+ *
457
+ * @param {string} field
458
+ * @param {any} value
459
+ * @param {string} targetName
460
+ * @param {any} targetDef
461
+ * @param {(val: any) => void} onCommit
462
+ */
463
+ function renderReferencePicker(field, value, targetName, targetDef, onCommit) {
464
+ if (!targetDef?.source) {
465
+ return html`<sp-textfield
466
+ size="s"
467
+ placeholder="slug"
468
+ .value=${live(value || "")}
469
+ @input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) =>
470
+ onCommit(e.target.value),
471
+ )}
472
+ ></sp-textfield>`;
473
+ }
474
+
475
+ const cacheKey = targetName;
476
+ if (!refEntriesCache.has(cacheKey)) {
477
+ loadRefEntries(targetName, targetDef);
478
+ }
479
+ const entries = refEntriesCache.get(cacheKey) || [];
480
+
481
+ return html`
482
+ <sp-picker
483
+ size="s"
484
+ label=${targetName}
485
+ .value=${live(value || "")}
486
+ @change=${(/** @type {any} */ e) => onCommit(e.target.value || undefined)}
487
+ >
488
+ <sp-menu-item value="">— none —</sp-menu-item>
489
+ ${entries.map(
490
+ (ent) => html`<sp-menu-item value=${ent.slug}>${ent.title || ent.slug}</sp-menu-item>`,
491
+ )}
492
+ </sp-picker>
493
+ `;
494
+ }
495
+
496
+ async function loadRefEntries(/** @type {string} */ targetName, /** @type {any} */ targetDef) {
497
+ const platform = getPlatform();
498
+ const sourceDir = targetDef.source.replace(/^\.\//, "").split("/**")[0].split("/*")[0];
499
+ try {
500
+ const listing = await platform.listDirectory(sourceDir);
501
+ const entries = [];
502
+ for (const item of listing) {
503
+ if (item.type === "directory") continue;
504
+ const slug = item.name.replace(/\.[^.]+$/, "");
505
+ let title = slug;
506
+ try {
507
+ const content = await platform.readFile(item.path);
508
+ const match = content.match(/^---[\s\S]*?title:\s*(.+?)[\r\n]/m);
509
+ if (match) title = match[1].trim().replace(/^["']|["']$/g, "");
510
+ } catch {}
511
+ entries.push({ slug, title });
512
+ }
513
+ refEntriesCache.set(targetName, entries);
514
+ } catch {}
515
+ }
516
+
517
+ export function invalidateRefCache() {
518
+ refEntriesCache.clear();
519
+ }
520
+
377
521
  // ─── Sub-templates ──────────────────────────────────────────────────────────
378
522
 
379
523
  /** Repeater fields template */
@@ -840,6 +984,149 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
840
984
  `;
841
985
  }
842
986
 
987
+ // ─── Layout picker ──────────────────────────────────────────────────────────
988
+
989
+ /** @type {{ name: string; path: string }[] | null} */
990
+ let layoutEntries = null;
991
+
992
+ async function loadLayoutEntries() {
993
+ try {
994
+ const platform = getPlatform();
995
+ const listing = await platform.listDirectory("layouts");
996
+ layoutEntries = listing
997
+ .filter((/** @type {any} */ f) => f.type === "file" && f.name.endsWith(".json"))
998
+ .map((/** @type {any} */ f) => ({
999
+ name: f.name.replace(/\.json$/, ""),
1000
+ path: `./layouts/${f.name}`,
1001
+ }));
1002
+ } catch {
1003
+ layoutEntries = [];
1004
+ }
1005
+ renderOnly("rightPanel");
1006
+ }
1007
+
1008
+ export function invalidateLayoutPickerCache() {
1009
+ layoutEntries = null;
1010
+ }
1011
+
1012
+ function isPageDocument(/** @type {any} */ documentPath) {
1013
+ if (!documentPath || !projectState?.isSiteProject) return false;
1014
+ return documentPath.startsWith("pages/") || documentPath.startsWith("./pages/");
1015
+ }
1016
+
1017
+ function renderPageSection(/** @type {any} */ node) {
1018
+ const S = getState();
1019
+ if (!isPageDocument(S.documentPath)) return nothing;
1020
+
1021
+ if (layoutEntries === null) {
1022
+ loadLayoutEntries();
1023
+ return nothing;
1024
+ }
1025
+
1026
+ const currentLayout = node.$layout;
1027
+ const defaultLayout = projectState?.projectConfig?.defaults?.layout;
1028
+ const effectivePath = getEffectiveLayoutPath(currentLayout);
1029
+ const displayValue =
1030
+ currentLayout === false ? "__none__" : currentLayout ? currentLayout : "__default__";
1031
+
1032
+ return html`
1033
+ <sp-accordion-item label="Page" open>
1034
+ <div class="style-section-body">
1035
+ <div class="style-row" data-prop="$layout">
1036
+ <div class="style-row-label">
1037
+ ${currentLayout !== undefined
1038
+ ? html`<span
1039
+ class="set-dot"
1040
+ title="Reset to default"
1041
+ @click=${(/** @type {any} */ e) => {
1042
+ e.stopPropagation();
1043
+ update(updateProperty(getState(), [], "$layout", undefined));
1044
+ }}
1045
+ ></span>`
1046
+ : nothing}
1047
+ <sp-field-label size="s">Layout</sp-field-label>
1048
+ </div>
1049
+ <sp-picker
1050
+ size="s"
1051
+ value=${displayValue}
1052
+ @change=${(/** @type {any} */ e) => {
1053
+ const val = e.target.value;
1054
+ if (val === "__default__") {
1055
+ update(updateProperty(getState(), [], "$layout", undefined));
1056
+ } else if (val === "__none__") {
1057
+ update(updateProperty(getState(), [], "$layout", false));
1058
+ } else {
1059
+ update(updateProperty(getState(), [], "$layout", val));
1060
+ }
1061
+ invalidateLayoutCache();
1062
+ }}
1063
+ >
1064
+ <sp-menu-item value="__default__"
1065
+ >Default${defaultLayout
1066
+ ? ` (${defaultLayout.replace(/^\.\/layouts\//, "").replace(/\.json$/, "")})`
1067
+ : ""}</sp-menu-item
1068
+ >
1069
+ <sp-menu-item value="__none__">None</sp-menu-item>
1070
+ <sp-menu-divider></sp-menu-divider>
1071
+ ${layoutEntries.map(
1072
+ (/** @type {any} */ l) =>
1073
+ html`<sp-menu-item value=${l.path}>${l.name}</sp-menu-item>`,
1074
+ )}
1075
+ </sp-picker>
1076
+ </div>
1077
+ ${effectivePath
1078
+ ? html`<div style="font-size:10px;color:var(--fg-dim);padding:2px 0;font-style:italic">
1079
+ Wraps page content via &lt;slot&gt; distribution
1080
+ </div>`
1081
+ : nothing}
1082
+ </div>
1083
+ </sp-accordion-item>
1084
+ `;
1085
+ }
1086
+
1087
+ // ─── Layout selection panel ─────────────────────────────────────────────────
1088
+
1089
+ function renderLayoutSelectionPanel(/** @type {any} */ ctx) {
1090
+ const { el, layoutPath } = view.layoutSelection;
1091
+ const tagName = el?.tagName?.toLowerCase() || "element";
1092
+ const className = el?.className || "";
1093
+ const displayPath = layoutPath || "layout";
1094
+
1095
+ return html`
1096
+ <div class="style-sidebar">
1097
+ <sp-accordion allow-multiple size="s">
1098
+ <sp-accordion-item label="Layout Element" open>
1099
+ <div class="style-section-body">
1100
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
1101
+ <span
1102
+ style="font-size:9px;padding:2px 6px;background:var(--spectrum-purple-600);color:white;border-radius:3px;text-transform:uppercase;letter-spacing:0.5px"
1103
+ >Layout</span
1104
+ >
1105
+ <code style="font-size:12px;font-family:monospace">&lt;${tagName}&gt;</code>
1106
+ </div>
1107
+ ${className
1108
+ ? html`<div class="style-row">
1109
+ <div class="style-row-label">
1110
+ <sp-field-label size="s">Class</sp-field-label>
1111
+ </div>
1112
+ <span style="font-size:11px;color:var(--fg-dim);word-break:break-all"
1113
+ >${className}</span
1114
+ >
1115
+ </div>`
1116
+ : nothing}
1117
+ <div style="font-size:10px;color:var(--fg-dim);padding:4px 0;font-style:italic">
1118
+ This element is part of the page layout. Edit it by opening the layout file.
1119
+ </div>
1120
+ <span class="kv-add" @click=${() => ctx.navigateToComponent(displayPath)}
1121
+ >Open Layout →</span
1122
+ >
1123
+ </div>
1124
+ </sp-accordion-item>
1125
+ </sp-accordion>
1126
+ </div>
1127
+ `;
1128
+ }
1129
+
843
1130
  // ─── Main entry point ───────────────────────────────────────────────────────
844
1131
 
845
1132
  /**
@@ -850,6 +1137,11 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
850
1137
  export function renderPropertiesPanelTemplate(ctx) {
851
1138
  const S = getState();
852
1139
 
1140
+ // Layout element selected — show read-only info with link to open layout
1141
+ if (view.layoutSelection) {
1142
+ return renderLayoutSelectionPanel(ctx);
1143
+ }
1144
+
853
1145
  if (!S.selection) {
854
1146
  if (S.mode === "content") {
855
1147
  return renderFrontmatterOnlyPanel();
@@ -1323,15 +1615,17 @@ export function renderPropertiesPanelTemplate(ctx) {
1323
1615
  })()
1324
1616
  : nothing;
1325
1617
 
1618
+ const pageT = isRoot ? renderPageSection(node) : nothing;
1619
+
1326
1620
  // ── Assemble ──
1327
1621
  const tpl = html`
1328
1622
  <div class="style-sidebar">
1329
1623
  <sp-accordion allow-multiple size="s">
1330
- ${frontmatterT} ${isMapNode ? repeaterT : elemT} ${isMapNode ? nothing : observedAttrsT}
1331
- ${isMapNode ? nothing : switchT} ${isMapNode ? nothing : compPropsT}
1332
- ${isMapNode ? nothing : attrSectionTemplates} ${isMapNode ? nothing : customSectionT}
1333
- ${isMapNode ? nothing : mediaT} ${isMapNode ? nothing : cssPropsT}
1334
- ${isMapNode ? nothing : cssPartsT}
1624
+ ${pageT} ${frontmatterT} ${isMapNode ? repeaterT : elemT}
1625
+ ${isMapNode ? nothing : observedAttrsT} ${isMapNode ? nothing : switchT}
1626
+ ${isMapNode ? nothing : compPropsT} ${isMapNode ? nothing : attrSectionTemplates}
1627
+ ${isMapNode ? nothing : customSectionT} ${isMapNode ? nothing : mediaT}
1628
+ ${isMapNode ? nothing : cssPropsT} ${isMapNode ? nothing : cssPartsT}
1335
1629
  </sp-accordion>
1336
1630
  </div>
1337
1631
  `;
@@ -19,7 +19,7 @@ import {
19
19
  projectState,
20
20
  } from "../store.js";
21
21
  import { view } from "../view.js";
22
- import { defineElement } from "@jxsuite/runtime";
22
+ import { defineElement, setSkipServerFunctions } from "@jxsuite/runtime";
23
23
  import { componentRegistry } from "../files/components.js";
24
24
  import { getEffectiveStyle, getEffectiveMedia } from "../site-context.js";
25
25
  import { parseMediaEntries, activeBreakpointsForWidth } from "../utils/canvas-media.js";
@@ -351,6 +351,7 @@ export function buildStylebookElement(entry, rootStyle, activeBreakpoints, paren
351
351
  }
352
352
  }
353
353
  if (activeBreakpoints) {
354
+ // Check media overrides nested inside the tag style (selector wraps media)
354
355
  for (const [key, val] of Object.entries(tagStyle)) {
355
356
  if (!key.startsWith("@") || typeof val !== "object") continue;
356
357
  const mediaName = key.slice(1);
@@ -367,6 +368,27 @@ export function buildStylebookElement(entry, rootStyle, activeBreakpoints, paren
367
368
  }
368
369
  }
369
370
  }
371
+ // Check top-level @media keys for tag-specific overrides (media wraps selector)
372
+ if (activeBreakpoints) {
373
+ const selector = compoundSelector || `& ${entry.tag}`;
374
+ for (const [key, val] of Object.entries(rootStyle)) {
375
+ if (!key.startsWith("@") || typeof val !== "object") continue;
376
+ const mediaName = key.slice(1);
377
+ if (mediaName === "--") continue;
378
+ if (activeBreakpoints.has(mediaName)) {
379
+ const mediaTagStyle = /** @type {any} */ (val)[selector];
380
+ if (mediaTagStyle && typeof mediaTagStyle === "object") {
381
+ for (const [prop, v] of Object.entries(mediaTagStyle)) {
382
+ if (typeof v === "string" || typeof v === "number") {
383
+ try {
384
+ /** @type {any} */ (el.style)[prop] = v;
385
+ } catch {}
386
+ }
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
370
392
  if (entry.children) {
371
393
  for (const child of entry.children) {
372
394
  el.appendChild(buildStylebookElement(child, rootStyle, activeBreakpoints, entry.tag));
@@ -382,6 +404,7 @@ export function buildStylebookElement(entry, rootStyle, activeBreakpoints, paren
382
404
  * @returns {Promise<HTMLElement>}
383
405
  */
384
406
  export async function renderComponentPreview(comp) {
407
+ setSkipServerFunctions(true);
385
408
  try {
386
409
  if (comp.source === "npm") {
387
410
  if (!customElements.get(comp.tagName)) {
@@ -416,7 +439,13 @@ export async function renderComponentPreview(comp) {
416
439
  */
417
440
  function hasTagStyle(rootStyle, tag) {
418
441
  const s = rootStyle[`& ${tag}`];
419
- return s && typeof s === "object" && Object.keys(s).length > 0;
442
+ if (s && typeof s === "object" && Object.keys(s).length > 0) return true;
443
+ const selector = `& ${tag}`;
444
+ for (const [key, val] of Object.entries(rootStyle)) {
445
+ if (!key.startsWith("@") || typeof val !== "object") continue;
446
+ if (/** @type {any} */ (val)[selector]) return true;
447
+ }
448
+ return false;
420
449
  }
421
450
 
422
451
  /**
@@ -163,6 +163,20 @@ function handleChangeType(fieldName, newType, rerender) {
163
163
  saveProjectConfig();
164
164
  }
165
165
 
166
+ /**
167
+ * @param {string} fieldName
168
+ * @param {string} target
169
+ * @param {() => void} rerender
170
+ */
171
+ function handleChangeRefTarget(fieldName, target, rerender) {
172
+ const schema = getSelectedSchema();
173
+ if (!schema?.properties) return;
174
+
175
+ schema.properties[fieldName] = { $ref: `#/contentTypes/${target}` };
176
+ rerender();
177
+ saveProjectConfig();
178
+ }
179
+
166
180
  // ─── Nested field handlers ───────────────────────────────────────────────────
167
181
 
168
182
  /**
@@ -367,6 +381,7 @@ export function renderContentTypesEditor(container) {
367
381
  onToggleRequired: (n) => handleToggleRequired(n, rerender),
368
382
  onRename: (oldN, newN) => handleRenameField(oldN, newN, rerender),
369
383
  onChangeType: (n, t) => handleChangeType(n, t, rerender),
384
+ onChangeRefTarget: (n, target) => handleChangeRefTarget(n, target, rerender),
370
385
  onAddNestedField: (p, s) => handleAddNestedField(p, s, rerender),
371
386
  onDeleteNested: (p, c) => handleDeleteNested(p, c, rerender),
372
387
  onToggleNestedRequired: (p, c) => handleToggleNestedRequired(p, c, rerender),
@@ -375,7 +390,13 @@ export function renderContentTypesEditor(container) {
375
390
  };
376
391
 
377
392
  const fieldCards = Object.entries(properties).map(([name, def]) =>
378
- fieldCardTpl(name, /** @type {any} */ (def), required.includes(name), handlers),
393
+ fieldCardTpl(
394
+ name,
395
+ /** @type {any} */ (def),
396
+ required.includes(name),
397
+ handlers,
398
+ contentTypeNames,
399
+ ),
379
400
  );
380
401
 
381
402
  editorTpl = html`
@@ -5,7 +5,17 @@
5
5
 
6
6
  import { html, nothing } from "lit-html";
7
7
 
8
- export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "date"];
8
+ export const FIELD_TYPES = [
9
+ "string",
10
+ "number",
11
+ "boolean",
12
+ "array",
13
+ "object",
14
+ "date",
15
+ "image",
16
+ "gallery",
17
+ "reference",
18
+ ];
9
19
 
10
20
  /**
11
21
  * @typedef {{
@@ -14,6 +24,7 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
14
24
  * required?: string[];
15
25
  * items?: any;
16
26
  * format?: string;
27
+ * $ref?: string;
17
28
  * }} SchemaProperty
18
29
  */
19
30
 
@@ -23,6 +34,7 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
23
34
  * onToggleRequired: (name: string) => void;
24
35
  * onRename: (oldName: string, newName: string) => void;
25
36
  * onChangeType: (name: string, newType: string) => void;
37
+ * onChangeRefTarget?: (name: string, target: string) => void;
26
38
  * onAddNestedField?: (
27
39
  * parentName: string,
28
40
  * state: { name: string; type: string; required: boolean },
@@ -34,6 +46,20 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
34
46
  * }} FieldHandlers
35
47
  */
36
48
 
49
+ /**
50
+ * Detect the studio field type from a JSON Schema property definition.
51
+ *
52
+ * @param {SchemaProperty} schema
53
+ * @returns {string}
54
+ */
55
+ export function detectFieldType(schema) {
56
+ if (schema.$ref) return "reference";
57
+ if (schema.format === "image") return "image";
58
+ if (schema.format === "date") return "date";
59
+ if (schema.type === "array" && schema.items?.format === "image") return "gallery";
60
+ return schema.type || "string";
61
+ }
62
+
37
63
  /**
38
64
  * Render a single schema field as an inline-editable form row.
39
65
  *
@@ -41,13 +67,16 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
41
67
  * @param {SchemaProperty} fieldSchema — JSON Schema property definition
42
68
  * @param {boolean} isRequired
43
69
  * @param {FieldHandlers} handlers
70
+ * @param {string[]} [contentTypeNames] - Available content type names for reference target picker
44
71
  * @returns {any}
45
72
  */
46
- export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
47
- const type = fieldSchema.format === "date" ? "date" : fieldSchema.type || "string";
73
+ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers, contentTypeNames = []) {
74
+ const type = detectFieldType(fieldSchema);
48
75
  const isNested = type === "object";
76
+ const isRef = type === "reference";
49
77
  const nestedProps = fieldSchema.properties || {};
50
78
  const nestedRequired = fieldSchema.required || [];
79
+ const refTarget = fieldSchema.$ref ? fieldSchema.$ref.replace("#/contentTypes/", "") : "";
51
80
 
52
81
  return html`
53
82
  <div class="schema-field-card">
@@ -87,6 +116,23 @@ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
87
116
  <sp-icon-delete slot="icon"></sp-icon-delete>
88
117
  </sp-action-button>
89
118
  </div>
119
+ ${isRef && contentTypeNames.length
120
+ ? html`
121
+ <div class="schema-field-ref-target">
122
+ <sp-picker
123
+ size="s"
124
+ label="Target"
125
+ value=${refTarget}
126
+ @change=${(/** @type {any} */ e) => {
127
+ if (handlers.onChangeRefTarget)
128
+ handlers.onChangeRefTarget(fieldName, e.target.value);
129
+ }}
130
+ >
131
+ ${contentTypeNames.map((n) => html`<sp-menu-item value=${n}>${n}</sp-menu-item>`)}
132
+ </sp-picker>
133
+ </div>
134
+ `
135
+ : nothing}
90
136
  ${isNested
91
137
  ? html`
92
138
  <div class="schema-field-nested">
@@ -119,7 +165,7 @@ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
119
165
  * @returns {any}
120
166
  */
121
167
  function nestedFieldCardTpl(parentName, childName, childSchema, isRequired, handlers) {
122
- const type = childSchema.format === "date" ? "date" : childSchema.type || "string";
168
+ const type = detectFieldType(childSchema);
123
169
 
124
170
  return html`
125
171
  <div class="schema-field-card schema-field-card--nested">
@@ -300,6 +346,12 @@ export function schemaForType(type) {
300
346
  return { type: "object", properties: {}, required: [] };
301
347
  case "date":
302
348
  return { type: "string", format: "date" };
349
+ case "image":
350
+ return { type: "string", format: "image" };
351
+ case "gallery":
352
+ return { type: "array", items: { type: "string", format: "image" } };
353
+ case "reference":
354
+ return { $ref: "#/contentTypes/" };
303
355
  default:
304
356
  return { type: "string" };
305
357
  }
@@ -314,6 +366,7 @@ export function schemaForType(type) {
314
366
  */
315
367
  export function yamlDefault(type, format) {
316
368
  if (format === "date") return new Date().toISOString().split("T")[0];
369
+ if (format === "image") return '""';
317
370
  switch (type) {
318
371
  case "boolean":
319
372
  return "false";