feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* flex-objects — custom web component field for the plugin-settings form.
|
||||
*
|
||||
* The field's value is an array of blueprint URLs (e.g.
|
||||
* "blueprints://flex-objects/grav-pages.yaml") identifying which flex
|
||||
* directories the plugin should expose. Renders one row per available
|
||||
* blueprint with a segmented Enabled/Disabled toggle, matching the
|
||||
* admin-classic UI semantics. Old saved values may still use legacy URLs
|
||||
* (pages.yaml vs grav-pages.yaml etc.) — the API includes the legacy alias
|
||||
* for each entry so we match either form.
|
||||
*/
|
||||
|
||||
const TAG = window.__GRAV_FIELD_TAG;
|
||||
|
||||
class FlexObjectsField extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._value = []; // current array of enabled blueprint URLs
|
||||
this._field = null; // blueprint field definition
|
||||
this._items = null; // available blueprints fetched from API
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
set field(v) { this._field = v; }
|
||||
get field() { return this._field; }
|
||||
|
||||
set value(v) {
|
||||
const arr = Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
|
||||
this._value = arr;
|
||||
if (this.isConnected && this._items) this._render();
|
||||
}
|
||||
get value() { return this._value; }
|
||||
|
||||
connectedCallback() {
|
||||
this._render();
|
||||
this._fetchBlueprints();
|
||||
}
|
||||
|
||||
// ─── API helpers ───────────────────────────────────────────────────────
|
||||
_apiUrl(path) {
|
||||
return (window.__GRAV_API_SERVER_URL || '') +
|
||||
(window.__GRAV_API_PREFIX || '/api/v1') + path;
|
||||
}
|
||||
|
||||
_headers() {
|
||||
const h = { Accept: 'application/json' };
|
||||
const token = window.__GRAV_API_TOKEN;
|
||||
if (token) h['X-API-Token'] = token;
|
||||
return h;
|
||||
}
|
||||
|
||||
async _fetchBlueprints() {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const resp = await fetch(this._apiUrl('/flex-objects/blueprints'), { headers: this._headers() });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const json = await resp.json();
|
||||
this._items = Array.isArray(json.data) ? json.data : (Array.isArray(json) ? json : []);
|
||||
} catch (e) {
|
||||
this._error = e.message || String(e);
|
||||
this._items = [];
|
||||
} finally {
|
||||
this._loading = false;
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
// True when either the canonical or the legacy URL of `item` is in `value`.
|
||||
_isEnabled(item) {
|
||||
if (this._value.includes(item.url)) return true;
|
||||
return item.legacy_url ? this._value.includes(item.legacy_url) : false;
|
||||
}
|
||||
|
||||
_toggle(item, enabled) {
|
||||
// Drop both forms first to keep the array clean, then add canonical if enabled
|
||||
let next = this._value.filter((u) => u !== item.url && u !== item.legacy_url);
|
||||
if (enabled) next.push(item.url);
|
||||
this._value = next;
|
||||
this._render();
|
||||
this.dispatchEvent(new CustomEvent('change', { detail: next, bubbles: true }));
|
||||
}
|
||||
|
||||
// ─── Rendering ─────────────────────────────────────────────────────────
|
||||
_render() {
|
||||
if (this._loading && !this._items) {
|
||||
this.innerHTML = `<div class="fxo-status">Loading directories…</div>${this._styles()}`;
|
||||
return;
|
||||
}
|
||||
if (this._error && !this._items?.length) {
|
||||
this.innerHTML = `<div class="fxo-status fxo-error">Failed to load directories: ${this._escape(this._error)}</div>${this._styles()}`;
|
||||
return;
|
||||
}
|
||||
if (!this._items?.length) {
|
||||
this.innerHTML = `<div class="fxo-status">No flex directories available.</div>${this._styles()}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = this._items.map((item, i) => {
|
||||
const enabled = this._isEnabled(item);
|
||||
const id = `fxo-${this._uid}-${i}`;
|
||||
const desc = item.description ? this._escape(item.description) : '';
|
||||
return `
|
||||
<div class="fxo-row">
|
||||
<div class="fxo-meta">
|
||||
<div class="fxo-title" ${desc ? `title="${desc}"` : ''}>${this._escape(item.title || item.type)}</div>
|
||||
<div class="fxo-url">${this._escape(item.url)}</div>
|
||||
</div>
|
||||
<div class="fxo-toggle" role="group" aria-label="${this._escape(item.title || item.type)}">
|
||||
<button type="button" data-id="${id}" data-enabled="1" class="${enabled ? 'is-on' : ''}">Enabled</button>
|
||||
<button type="button" data-id="${id}" data-enabled="0" class="${!enabled ? 'is-on' : ''}">Disabled</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
this.innerHTML = `<div class="fxo-list">${rows}</div>${this._styles()}`;
|
||||
|
||||
this.querySelectorAll('.fxo-toggle button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.dataset.id;
|
||||
const wantEnabled = btn.dataset.enabled === '1';
|
||||
const idx = parseInt(id.split('-').pop(), 10);
|
||||
const item = this._items[idx];
|
||||
if (!item) return;
|
||||
if (this._isEnabled(item) === wantEnabled) return;
|
||||
this._toggle(item, wantEnabled);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_styles() {
|
||||
return `
|
||||
<style>
|
||||
.fxo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.fxo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--muted, transparent);
|
||||
}
|
||||
.fxo-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.fxo-title { font-size: 14px; font-weight: 500; color: var(--foreground, #1f2937); }
|
||||
.fxo-url {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground, #6b7280);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fxo-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--background, transparent);
|
||||
}
|
||||
.fxo-toggle button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground, #6b7280);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.fxo-toggle button + button { border-left: 1px solid var(--border, #e5e7eb); }
|
||||
.fxo-toggle button.is-on {
|
||||
background: var(--primary, #3b82f6);
|
||||
color: var(--primary-foreground, #fff);
|
||||
}
|
||||
.fxo-status {
|
||||
padding: 12px;
|
||||
color: var(--muted-foreground, #6b7280);
|
||||
font-size: 13px;
|
||||
}
|
||||
.fxo-error { color: var(--destructive, #dc2626); }
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
_escape(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
get _uid() {
|
||||
if (!this.__uid) this.__uid = Math.random().toString(36).slice(2, 8);
|
||||
return this.__uid;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(TAG, FlexObjectsField);
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Save-Redirect — custom web component field for flex-objects.
|
||||
*
|
||||
* Renders radio buttons for "After Save..." redirect behavior.
|
||||
* The `field` property contains the blueprint definition which
|
||||
* may include an `options` array. If not, defaults are provided.
|
||||
*
|
||||
* Dispatches `change` events with the selected value.
|
||||
*/
|
||||
|
||||
const TAG = window.__GRAV_FIELD_TAG;
|
||||
|
||||
class SaveRedirectField extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._value = 'edit';
|
||||
this._field = null;
|
||||
}
|
||||
|
||||
set field(v) { this._field = v; }
|
||||
get field() { return this._field; }
|
||||
|
||||
set value(v) {
|
||||
const newVal = v ?? 'edit';
|
||||
if (this._value !== newVal) {
|
||||
this._value = newVal;
|
||||
if (this.isConnected) {
|
||||
this._syncChecked();
|
||||
}
|
||||
}
|
||||
}
|
||||
get value() { return this._value; }
|
||||
|
||||
connectedCallback() {
|
||||
this._render();
|
||||
this._syncChecked();
|
||||
}
|
||||
|
||||
_getOptions() {
|
||||
// Check if blueprint provides explicit options
|
||||
if (this._field?.options && Array.isArray(this._field.options)) {
|
||||
return this._field.options.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}));
|
||||
}
|
||||
// Show "Create New Item" only when the current value is create-new
|
||||
// (i.e. when creating a new item — the blueprint default is create-new)
|
||||
const isNewContext = this._value === 'create-new';
|
||||
const options = [];
|
||||
if (isNewContext) {
|
||||
options.push({ value: 'create-new', label: 'Create New Item' });
|
||||
}
|
||||
options.push({ value: 'edit', label: 'Edit Item' });
|
||||
options.push({ value: 'list', label: 'List Items' });
|
||||
return options;
|
||||
}
|
||||
|
||||
_render() {
|
||||
const options = this._getOptions();
|
||||
|
||||
this.innerHTML = `
|
||||
<style>
|
||||
.sr-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sr-label-text {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground, #6b7280);
|
||||
}
|
||||
.sr-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--foreground, #1f2937);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sr-option input {
|
||||
accent-color: var(--primary, #3b82f6);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<div class="sr-container">
|
||||
<span class="sr-label-text">After Save...</span>
|
||||
${options.map(opt => `
|
||||
<label class="sr-option">
|
||||
<input type="radio" name="sr-${this._uid}" value="${opt.value}" />
|
||||
${opt.label}
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind change handlers
|
||||
this.querySelectorAll('input[type="radio"]').forEach(radio => {
|
||||
radio.addEventListener('change', (e) => {
|
||||
const newVal = e.target.value;
|
||||
this._value = newVal;
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: newVal,
|
||||
bubbles: true,
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_syncChecked() {
|
||||
const radios = this.querySelectorAll('input[type="radio"]');
|
||||
radios.forEach(radio => {
|
||||
radio.checked = radio.value === this._value;
|
||||
});
|
||||
}
|
||||
|
||||
get _uid() {
|
||||
if (!this.__uid) {
|
||||
this.__uid = Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
return this.__uid;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(TAG, SaveRedirectField);
|
||||
Reference in New Issue
Block a user