active_canvas 0.0.4 → 0.1.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.
- checksums.yaml +4 -4
- data/README.md +23 -1
- data/app/assets/javascripts/active_canvas/admin/data_panel.js +357 -0
- data/app/assets/javascripts/active_canvas/admin/field_builder.js +84 -0
- data/app/assets/javascripts/active_canvas/admin/media_select_preview.js +11 -0
- data/app/assets/javascripts/active_canvas/editor/ac_bindings.js +81 -0
- data/app/assets/javascripts/active_canvas/editor/ac_chips.js +192 -0
- data/app/assets/javascripts/active_canvas/editor/code_panel.js +10 -7
- data/app/assets/javascripts/active_canvas/editor/directive_traits.js +95 -0
- data/app/assets/javascripts/active_canvas/editor/live_data.js +178 -0
- data/app/assets/javascripts/active_canvas/editor/panels.js +23 -7
- data/app/assets/javascripts/active_canvas/editor/preview_modal.js +112 -0
- data/app/assets/javascripts/active_canvas/editor/template_validator.js +95 -0
- data/app/assets/javascripts/active_canvas/editor.js +21 -3
- data/app/assets/stylesheets/active_canvas/editor.css +626 -0
- data/app/controllers/active_canvas/admin/application_controller.rb +8 -0
- data/app/controllers/active_canvas/admin/collection_items_controller.rb +93 -0
- data/app/controllers/active_canvas/admin/collections_controller.rb +51 -0
- data/app/controllers/active_canvas/admin/form_submissions_controller.rb +51 -0
- data/app/controllers/active_canvas/admin/pages_controller.rb +127 -5
- data/app/controllers/active_canvas/form_submissions_controller.rb +90 -0
- data/app/controllers/active_canvas/pages_controller.rb +8 -0
- data/app/controllers/active_canvas/redirects_controller.rb +10 -0
- data/app/helpers/active_canvas/admin/collection_items_helper.rb +33 -0
- data/app/models/active_canvas/auto_drop.rb +69 -0
- data/app/models/active_canvas/collection.rb +98 -0
- data/app/models/active_canvas/collection_item.rb +66 -0
- data/app/models/active_canvas/collection_item_version.rb +18 -0
- data/app/models/active_canvas/current.rb +2 -0
- data/app/models/active_canvas/form_submission.rb +9 -0
- data/app/models/active_canvas/page.rb +76 -15
- data/app/models/active_canvas/page_redirect.rb +7 -0
- data/app/services/active_canvas/collection_schema.rb +110 -0
- data/app/services/active_canvas/collection_source.rb +81 -0
- data/app/services/active_canvas/content_sanitizer.rb +33 -21
- data/app/services/active_canvas/form_schema.rb +48 -0
- data/app/services/active_canvas/form_stamper.rb +86 -0
- data/app/services/active_canvas/template_renderer/binding_resolver.rb +138 -0
- data/app/services/active_canvas/template_renderer/directive_expander.rb +54 -0
- data/app/services/active_canvas/template_renderer.rb +160 -0
- data/app/views/active_canvas/admin/collection_items/_field_input.html.erb +40 -0
- data/app/views/active_canvas/admin/collection_items/_form.html.erb +28 -0
- data/app/views/active_canvas/admin/collection_items/edit.html.erb +24 -0
- data/app/views/active_canvas/admin/collection_items/history.html.erb +34 -0
- data/app/views/active_canvas/admin/collection_items/index.html.erb +63 -0
- data/app/views/active_canvas/admin/collection_items/new.html.erb +9 -0
- data/app/views/active_canvas/admin/collections/_field_builder.html.erb +35 -0
- data/app/views/active_canvas/admin/collections/_form.html.erb +31 -0
- data/app/views/active_canvas/admin/collections/edit.html.erb +10 -0
- data/app/views/active_canvas/admin/collections/index.html.erb +45 -0
- data/app/views/active_canvas/admin/collections/new.html.erb +9 -0
- data/app/views/active_canvas/admin/form_submissions/index.html.erb +80 -0
- data/app/views/active_canvas/admin/form_submissions/show.html.erb +99 -0
- data/app/views/active_canvas/admin/pages/_data_panel.html.erb +81 -0
- data/app/views/active_canvas/admin/pages/_form.html.erb +10 -0
- data/app/views/active_canvas/admin/pages/editor.html.erb +62 -1
- data/app/views/active_canvas/pages/show.html.erb +4 -1
- data/app/views/layouts/active_canvas/admin/application.html.erb +15 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20260517100001_add_template_enabled_and_bindings_to_active_canvas_pages.rb +6 -0
- data/db/migrate/20260517100002_add_bindings_to_active_canvas_page_versions.rb +6 -0
- data/db/migrate/20260702000001_create_active_canvas_page_redirects.rb +11 -0
- data/db/migrate/20260702000002_create_active_canvas_form_submissions.rb +14 -0
- data/db/migrate/20260702000003_create_active_canvas_collections.rb +12 -0
- data/db/migrate/20260702000004_create_active_canvas_collection_items.rb +16 -0
- data/db/migrate/20260702000005_create_active_canvas_collection_item_versions.rb +17 -0
- data/lib/active_canvas/configuration.rb +32 -1
- data/lib/active_canvas/data_sources/errors.rb +51 -0
- data/lib/active_canvas/data_sources/param_spec.rb +64 -0
- data/lib/active_canvas/data_sources/registration.rb +60 -0
- data/lib/active_canvas/data_sources/source.rb +47 -0
- data/lib/active_canvas/data_sources.rb +54 -0
- data/lib/active_canvas/engine.rb +15 -0
- data/lib/active_canvas/version.rb +1 -1
- data/lib/active_canvas.rb +6 -0
- metadata +85 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ed0bd1aa5668045e321bcd222a3efb4bd5085bef5a543e23ad9446d4150e9be
|
|
4
|
+
data.tar.gz: d621f1e61c16427c4f00c1412d491e6ae1916cf4a4b134ce3e6d8db1d9730109
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2f5babe38d167c0a09d589b1eff5576b092aacdbbe0713bb106453da48b82a6c940523ea2ec4b35788fd7e0445351458141cf1f0fb7cc7cef7cdf6decf8142b
|
|
7
|
+
data.tar.gz: f086909f84dac86a3d9fc625f7bebcf42c16801d675f29a5170446c76b3ad008991bff8ae6264d11e27d3b549d46cb862ede994704cdbb8d11330587e4ed1cf6
|
data/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# ActiveCanvas
|
|
2
2
|
|
|
3
|
-

|
|
4
|
+
|
|
5
|
+
*The Live data switch: every chip shows the value it renders, loops show every item, conditions dim what they hide. Switch it off to see the Liquid source. The saved page always keeps the source.*
|
|
4
6
|
|
|
5
7
|
A mountable Rails engine that turns any Rails app into a full-featured CMS. Includes a visual drag-and-drop editor (GrapesJS), AI-powered content generation, Tailwind CSS compilation, media management, page versioning, and SEO controls -- all behind an admin interface that works out of the box.
|
|
6
8
|
|
|
@@ -14,9 +16,29 @@ A mountable Rails engine that turns any Rails app into a full-featured CMS. Incl
|
|
|
14
16
|
- **Header & Footer Partials** -- Reusable components, togglable per page
|
|
15
17
|
- **SEO** -- Meta tags, Open Graph, Twitter Cards, JSON-LD structured data
|
|
16
18
|
- **Page Types** -- Categorize pages (blog posts, landing pages, etc.)
|
|
19
|
+
- **Dynamic Content** -- Bind host-app data sources or admin-managed collections, repeat and show elements with attributes, see real data while editing ([docs](docs/dynamic_content.md))
|
|
20
|
+
- **Collections** -- Typed content lists (text, rich text, number, boolean, date, media, select) with draft and published versions, editable in the admin without code
|
|
17
21
|
- **Authentication** -- Pluggable auth (Devise, custom, or HTTP Basic)
|
|
18
22
|
- **Isolated Namespace** -- No conflicts with your host application
|
|
19
23
|
|
|
24
|
+
## A look inside
|
|
25
|
+
|
|
26
|
+
**A page built from collections.** Cards, pricing with a featured badge, FAQs, filters, loop modifiers and conditions, all from data the admin edits.
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
**Bindings edited in place.** The Data tab lists every binding. A literal shows its value in a text box, a collection shows its parameters, and the canvas updates as you type.
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
**Collections in the admin.** Typed fields, a draft and a published version per item, publish and unpublish, and a history of every publish.
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
**Version history.** Every save is a version with size deltas, a quick diff and a full view.
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
20
42
|
## Requirements
|
|
21
43
|
|
|
22
44
|
- Ruby 3.1+
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const container = document.getElementById('data-panel-container');
|
|
3
|
+
if (!container) return;
|
|
4
|
+
|
|
5
|
+
let registry = []; // [{ name, params: { limit: { type, default, ... } } }]
|
|
6
|
+
let bindings = {}; // { localName: { source, params } | { source: '_literal', value } }
|
|
7
|
+
const NAME_RE = /^[a-z][a-z0-9_]*$/;
|
|
8
|
+
|
|
9
|
+
const csrf = () => document.querySelector('meta[name="csrf-token"]').content;
|
|
10
|
+
const post = (url, body) => fetch(url, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf(), 'Accept': 'application/json' },
|
|
13
|
+
body: JSON.stringify(body),
|
|
14
|
+
}).then(r => r.json().then(b => ({ ok: r.ok, body: b })));
|
|
15
|
+
const get = (url) => fetch(url, { headers: { Accept: 'application/json' } }).then(r => r.json());
|
|
16
|
+
|
|
17
|
+
function init() {
|
|
18
|
+
loadBindings();
|
|
19
|
+
renderBindings();
|
|
20
|
+
const addBtn = document.getElementById('ac-add-binding-btn');
|
|
21
|
+
const emptyAddBtn = document.getElementById('ac-empty-add-btn');
|
|
22
|
+
addBtn.disabled = true;
|
|
23
|
+
emptyAddBtn.disabled = true;
|
|
24
|
+
loadRegistry()
|
|
25
|
+
.then(() => {
|
|
26
|
+
addBtn.disabled = false;
|
|
27
|
+
emptyAddBtn.disabled = false;
|
|
28
|
+
renderBindings(); // labels and snippets need the registry
|
|
29
|
+
})
|
|
30
|
+
.catch(err => {
|
|
31
|
+
console.error('ActiveCanvas: data sources unavailable', err);
|
|
32
|
+
showPanelMessage('Data sources could not be loaded. Reload the page to try again.');
|
|
33
|
+
});
|
|
34
|
+
addBtn.addEventListener('click', showForm);
|
|
35
|
+
emptyAddBtn.addEventListener('click', showForm);
|
|
36
|
+
const toggle = document.getElementById('ac-template-toggle');
|
|
37
|
+
if (toggle) toggle.addEventListener('change', onTemplateToggle);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function showPanelMessage(text) {
|
|
41
|
+
const el = document.getElementById('ac-panel-message');
|
|
42
|
+
if (!el) return;
|
|
43
|
+
el.textContent = text;
|
|
44
|
+
el.hidden = !text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Saving with template_enabled flips how the editor loads the page, so a
|
|
48
|
+
// successful save is followed by a reload.
|
|
49
|
+
function onTemplateToggle(e) {
|
|
50
|
+
const toggle = e.target;
|
|
51
|
+
const save = window.ActiveCanvasEditor && window.ActiveCanvasEditor.saveContent;
|
|
52
|
+
if (!save) { toggle.checked = !toggle.checked; return; }
|
|
53
|
+
toggle.disabled = true;
|
|
54
|
+
save(false, { template_enabled: toggle.checked }).then(ok => {
|
|
55
|
+
if (ok) { window.location.reload(); return; }
|
|
56
|
+
toggle.checked = !toggle.checked;
|
|
57
|
+
toggle.disabled = false;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadBindings() {
|
|
62
|
+
bindings = window.ActiveCanvasBindings.read();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function persistBindings() {
|
|
66
|
+
window.ActiveCanvasBindings.write(bindings);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadRegistry() {
|
|
70
|
+
const url = container.dataset.dataSourcesUrl;
|
|
71
|
+
return get(url).then(json => {
|
|
72
|
+
registry = json;
|
|
73
|
+
window.ActiveCanvasBindings.setRegistry(json);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderBindings() {
|
|
78
|
+
const list = document.getElementById('ac-bindings-list');
|
|
79
|
+
const names = Object.keys(bindings);
|
|
80
|
+
list.dataset.empty = names.length === 0 ? 'true' : 'false';
|
|
81
|
+
list.innerHTML = names.map(name => bindingRowHTML(name, bindings[name])).join('');
|
|
82
|
+
wireRowEvents(list);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function bindingRowHTML(name, spec) {
|
|
86
|
+
const B = window.ActiveCanvasBindings;
|
|
87
|
+
const src = B.source(spec.source);
|
|
88
|
+
const item = B.itemName(name);
|
|
89
|
+
const firstField = src && src.fields && src.fields[0] ? src.fields[0].id : 'title';
|
|
90
|
+
const sourceLabel = spec.source === '_literal' ? 'Literal' : ((src && src.label) || spec.source);
|
|
91
|
+
const snippet = `{{ ${name} }}`;
|
|
92
|
+
const loopSnippet = `<div data-ac-for="${item} in ${name}">{{ ${item}.${firstField} }}</div>`;
|
|
93
|
+
return `
|
|
94
|
+
<li class="data-panel-row">
|
|
95
|
+
<div class="data-panel-row-header">
|
|
96
|
+
<strong class="data-panel-row-name">${escape(name)}</strong>
|
|
97
|
+
<span class="data-panel-row-source">${escape(sourceLabel)}</span>
|
|
98
|
+
<button type="button" class="data-panel-row-action" data-edit="${escape(name)}" title="Edit binding">
|
|
99
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
100
|
+
<path d="M12 20h9"/>
|
|
101
|
+
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
|
102
|
+
</svg>
|
|
103
|
+
</button>
|
|
104
|
+
<button type="button" class="data-panel-row-action data-panel-row-remove" data-remove="${escape(name)}" title="Remove binding">
|
|
105
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
106
|
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
107
|
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
108
|
+
</svg>
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
${valueLineHTML(name, spec)}
|
|
112
|
+
<div class="data-panel-row-chips">
|
|
113
|
+
<button type="button" class="data-panel-chip" data-snippet="${escape(snippet)}" title="Copy ${escape(snippet)}">{{ }}</button>
|
|
114
|
+
${B.isList(name) ? `<button type="button" class="data-panel-chip" data-snippet="${escape(loopSnippet)}" title="Copy loop">for…</button>` : ''}
|
|
115
|
+
</div>
|
|
116
|
+
<details class="data-panel-sample" data-sample-for="${escape(name)}">
|
|
117
|
+
<summary>Sample data</summary>
|
|
118
|
+
<pre class="data-panel-sample-body">Loading…</pre>
|
|
119
|
+
</details>
|
|
120
|
+
</li>
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A literal shows its value in a text box you can type in. Any other
|
|
125
|
+
// binding shows its parameters; the pencil opens the form to change them.
|
|
126
|
+
function valueLineHTML(name, spec) {
|
|
127
|
+
if (spec.source === '_literal') {
|
|
128
|
+
if (typeof spec.value !== 'string') {
|
|
129
|
+
return `<code class="data-panel-row-params" title="Edit with the pencil">${escape(JSON.stringify(spec.value))}</code>`;
|
|
130
|
+
}
|
|
131
|
+
return `<input type="text" class="data-panel-row-literal" data-literal="${escape(name)}" value="${escape(spec.value)}" placeholder="Value" title="The value of {{ ${escape(name)} }}">`;
|
|
132
|
+
}
|
|
133
|
+
const params = Object.entries(spec.params || {}).filter(([, v]) => v != null && v !== '');
|
|
134
|
+
return `<span class="data-panel-row-params">${params.length ? params.map(([k, v]) => `${escape(k)}: ${escape(v)}`).join(' · ') : 'default parameters'}</span>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function wireRowEvents(list) {
|
|
138
|
+
list.querySelectorAll('[data-literal]').forEach(input => {
|
|
139
|
+
input.addEventListener('change', () => {
|
|
140
|
+
bindings[input.dataset.literal].value = input.value;
|
|
141
|
+
persistBindings();
|
|
142
|
+
});
|
|
143
|
+
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
|
|
144
|
+
});
|
|
145
|
+
list.querySelectorAll('[data-edit]').forEach(btn => {
|
|
146
|
+
btn.addEventListener('click', () => showForm(btn.dataset.edit));
|
|
147
|
+
});
|
|
148
|
+
list.querySelectorAll('[data-remove]').forEach(btn => {
|
|
149
|
+
btn.addEventListener('click', () => {
|
|
150
|
+
delete bindings[btn.dataset.remove];
|
|
151
|
+
persistBindings();
|
|
152
|
+
renderBindings();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
list.querySelectorAll('.data-panel-chip').forEach(chip => {
|
|
156
|
+
chip.addEventListener('click', () => copySnippet(chip));
|
|
157
|
+
});
|
|
158
|
+
list.querySelectorAll('.data-panel-sample').forEach(details => {
|
|
159
|
+
details.addEventListener('toggle', () => {
|
|
160
|
+
if (details.open && !details.dataset.loaded) loadSample(details);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function loadSample(details) {
|
|
166
|
+
const body = details.querySelector('.data-panel-sample-body');
|
|
167
|
+
const url = container.dataset.sampleDataUrl;
|
|
168
|
+
body.textContent = 'Loading…';
|
|
169
|
+
post(url, { binding: details.dataset.sampleFor, bindings: window.ActiveCanvasBindings.readJson() })
|
|
170
|
+
.then(({ ok, body: json }) => {
|
|
171
|
+
details.dataset.loaded = 'true';
|
|
172
|
+
body.textContent = ok ? JSON.stringify(json.rows, null, 2) : `Could not load: ${json.error || 'unknown error'}`;
|
|
173
|
+
})
|
|
174
|
+
.catch(() => { body.textContent = 'Could not reach the server.'; });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function copySnippet(chip) {
|
|
178
|
+
const text = chip.dataset.snippet;
|
|
179
|
+
const write = navigator.clipboard?.writeText(text);
|
|
180
|
+
if (write && typeof write.then === 'function') {
|
|
181
|
+
write.then(() => flashCopied(chip, 'Copied!')).catch(() => flashCopied(chip, 'Press Ctrl+C'));
|
|
182
|
+
} else {
|
|
183
|
+
flashCopied(chip, 'Copy unavailable');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function flashCopied(el, message) {
|
|
188
|
+
const original = el.textContent;
|
|
189
|
+
el.textContent = message || 'Copied!';
|
|
190
|
+
el.classList.add('is-copied');
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
el.textContent = original;
|
|
193
|
+
el.classList.remove('is-copied');
|
|
194
|
+
}, 1000);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// With a name, the form opens prefilled to edit that binding in place.
|
|
198
|
+
function showForm(existing) {
|
|
199
|
+
showPanelMessage('');
|
|
200
|
+
const list = document.getElementById('ac-bindings-list');
|
|
201
|
+
if (list.querySelector('#ac-binding-form')) return;
|
|
202
|
+
const tpl = document.getElementById('ac-binding-form-template');
|
|
203
|
+
const form = tpl.content.firstElementChild.cloneNode(true);
|
|
204
|
+
const li = document.createElement('li');
|
|
205
|
+
li.className = 'data-panel-add-card-wrapper';
|
|
206
|
+
li.appendChild(form);
|
|
207
|
+
list.dataset.empty = 'false';
|
|
208
|
+
list.prepend(li);
|
|
209
|
+
renderSources(form);
|
|
210
|
+
form.addEventListener('submit', onSave);
|
|
211
|
+
form.querySelector('#ac-binding-cancel').addEventListener('click', hideForm);
|
|
212
|
+
form.querySelector('[data-cancel]').addEventListener('click', hideForm);
|
|
213
|
+
form.querySelector('select[name="source"]').addEventListener('change', () => renderParamInputs(form));
|
|
214
|
+
form.querySelector('input[name="name"]').addEventListener('input', () => showNameError(form, ''));
|
|
215
|
+
if (typeof existing === 'string' && bindings[existing]) {
|
|
216
|
+
prefill(form, existing, bindings[existing]);
|
|
217
|
+
} else {
|
|
218
|
+
form.querySelector('input[name="name"]').focus();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function prefill(form, name, spec) {
|
|
223
|
+
form.dataset.editing = name;
|
|
224
|
+
form.querySelector('.data-panel-add-card-title').textContent = `Edit ${name}`;
|
|
225
|
+
form.querySelector('button[type="submit"]').textContent = 'Save changes';
|
|
226
|
+
const nameInput = form.querySelector('input[name="name"]');
|
|
227
|
+
nameInput.value = name;
|
|
228
|
+
nameInput.readOnly = true;
|
|
229
|
+
const sel = form.querySelector('select[name="source"]');
|
|
230
|
+
if (![...sel.options].some(o => o.value === spec.source)) return;
|
|
231
|
+
sel.value = spec.source;
|
|
232
|
+
renderParamInputs(form);
|
|
233
|
+
const params = spec.source === '_literal' ? { value: spec.value } : (spec.params || {});
|
|
234
|
+
Object.entries(params).forEach(([pname, value]) => {
|
|
235
|
+
const el = form.querySelector(`[name="param_${pname}"]`);
|
|
236
|
+
if (!el || value == null) return;
|
|
237
|
+
if (el.type === 'checkbox') el.checked = !!value;
|
|
238
|
+
else el.value = typeof value === 'string' ? value : JSON.stringify(value);
|
|
239
|
+
if (pname === 'filter_field') el.dispatchEvent(new Event('change'));
|
|
240
|
+
});
|
|
241
|
+
const filterValue = form.querySelector('[name="param_filter_value"]');
|
|
242
|
+
if (filterValue && params.filter_value != null) filterValue.value = params.filter_value;
|
|
243
|
+
const first = form.querySelector('#ac-binding-params input, #ac-binding-params select');
|
|
244
|
+
if (first) first.focus();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function hideForm() {
|
|
248
|
+
renderBindings();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function showNameError(form, text) {
|
|
252
|
+
const el = form.querySelector('#ac-binding-name-error');
|
|
253
|
+
if (!el) return;
|
|
254
|
+
el.textContent = text;
|
|
255
|
+
el.hidden = !text;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderSources(form) {
|
|
259
|
+
const sel = form.querySelector('select[name="source"]');
|
|
260
|
+
const groups = [
|
|
261
|
+
['Values', registry.filter(s => s.kind === 'literal')],
|
|
262
|
+
['Data sources', registry.filter(s => s.kind === 'source')],
|
|
263
|
+
['Collections', registry.filter(s => s.kind === 'collection')]
|
|
264
|
+
];
|
|
265
|
+
sel.innerHTML = groups
|
|
266
|
+
.filter(([, list]) => list.length > 0)
|
|
267
|
+
.map(([label, list]) =>
|
|
268
|
+
`<optgroup label="${escape(label)}">` +
|
|
269
|
+
list.map(s => `<option value="${escape(s.name)}">${escape(s.label || s.name)}</option>`).join('') +
|
|
270
|
+
'</optgroup>')
|
|
271
|
+
.join('');
|
|
272
|
+
renderParamInputs(form);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderParamInputs(form) {
|
|
276
|
+
const sel = form.querySelector('select[name="source"]');
|
|
277
|
+
const source = registry.find(s => s.name === sel.value);
|
|
278
|
+
const target = form.querySelector('#ac-binding-params');
|
|
279
|
+
if (!source) { target.innerHTML = ''; return; }
|
|
280
|
+
target.innerHTML = Object.entries(source.params || {})
|
|
281
|
+
.map(([pname, spec]) => paramInputHTML(pname, spec || {}))
|
|
282
|
+
.join('');
|
|
283
|
+
if (source.kind === 'collection') wireFilterValue(form, source);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// One input per param spec: allowed -> select (with labels), integer ->
|
|
287
|
+
// number with min/max from range, boolean -> checkbox, else text.
|
|
288
|
+
function paramInputHTML(pname, spec, allowedOverride) {
|
|
289
|
+
const label = `<span class="data-panel-field-label">${escape(pname)}</span>`;
|
|
290
|
+
const allowed = allowedOverride || spec.allowed;
|
|
291
|
+
if (allowed) {
|
|
292
|
+
const labels = spec.labels || {};
|
|
293
|
+
const blank = spec.default == null ? '<option value="">— none —</option>' : '';
|
|
294
|
+
const opts = allowed.map(v =>
|
|
295
|
+
`<option value="${escape(v)}" ${String(spec.default) === String(v) ? 'selected' : ''}>${escape(labels[v] || v)}</option>`
|
|
296
|
+
).join('');
|
|
297
|
+
return `<label class="data-panel-field">${label}<select name="param_${escape(pname)}">${blank}${opts}</select></label>`;
|
|
298
|
+
}
|
|
299
|
+
if (spec.type === 'integer') {
|
|
300
|
+
const range = Array.isArray(spec.range) ? `min="${escape(spec.range[0])}" max="${escape(spec.range[1])}"` : '';
|
|
301
|
+
return `<label class="data-panel-field">${label}<input type="number" name="param_${escape(pname)}" ${range} value="${escape(spec.default ?? '')}"></label>`;
|
|
302
|
+
}
|
|
303
|
+
if (spec.type === 'boolean') {
|
|
304
|
+
return `<label class="data-panel-field data-panel-field-inline"><input type="checkbox" name="param_${escape(pname)}" ${spec.default ? 'checked' : ''}>${label}</label>`;
|
|
305
|
+
}
|
|
306
|
+
return `<label class="data-panel-field">${label}<input type="text" name="param_${escape(pname)}" value="${escape(spec.default ?? '')}"></label>`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// When the chosen filter field is a select field, offer its options.
|
|
310
|
+
function wireFilterValue(form, source) {
|
|
311
|
+
const fieldSel = form.querySelector('[name="param_filter_field"]');
|
|
312
|
+
if (!fieldSel) return;
|
|
313
|
+
fieldSel.addEventListener('change', () => {
|
|
314
|
+
const field = (source.fields || []).find(f => f.id === fieldSel.value);
|
|
315
|
+
const current = form.querySelector('[name="param_filter_value"]');
|
|
316
|
+
if (!current) return;
|
|
317
|
+
const options = field && field.type === 'select' ? field.options : null;
|
|
318
|
+
current.closest('label').outerHTML = paramInputHTML('filter_value', source.params.filter_value || {}, options);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function onSave(e) {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
const form = e.target;
|
|
325
|
+
const name = form.name.value.trim();
|
|
326
|
+
if (!NAME_RE.test(name)) {
|
|
327
|
+
showNameError(form, 'Use lowercase letters, digits and underscores, starting with a letter.');
|
|
328
|
+
form.querySelector('input[name="name"]').focus();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (bindings[name] && form.dataset.editing !== name && !window.confirm(`A binding named "${name}" already exists. Replace it?`)) return;
|
|
332
|
+
const sourceName = form.source.value;
|
|
333
|
+
const source = registry.find(s => s.name === sourceName);
|
|
334
|
+
if (!source) { showPanelMessage('Pick a source.'); return; }
|
|
335
|
+
const params = {};
|
|
336
|
+
Object.keys(source.params || {}).forEach(pname => {
|
|
337
|
+
const el = form.querySelector(`[name="param_${pname}"]`);
|
|
338
|
+
if (!el) return;
|
|
339
|
+
if (el.type === 'checkbox') params[pname] = el.checked;
|
|
340
|
+
else if (el.type === 'number') params[pname] = el.value === '' ? null : Number(el.value);
|
|
341
|
+
else params[pname] = el.value === '' ? null : el.value;
|
|
342
|
+
});
|
|
343
|
+
if (sourceName === '_literal') {
|
|
344
|
+
bindings[name] = { source: '_literal', value: params.value };
|
|
345
|
+
} else {
|
|
346
|
+
bindings[name] = { source: sourceName, params: params };
|
|
347
|
+
}
|
|
348
|
+
persistBindings();
|
|
349
|
+
hideForm();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function escape(s) {
|
|
353
|
+
return String(s).replace(/[<>&"']/g, c => ({ '<':'<','>':'>','&':'&','"':'"',"'":''' }[c]));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
357
|
+
})();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const builder = document.getElementById('ac-field-builder');
|
|
3
|
+
if (!builder) return;
|
|
4
|
+
|
|
5
|
+
const list = document.getElementById('ac-fields-list');
|
|
6
|
+
const template = document.getElementById('ac-field-row-template');
|
|
7
|
+
const hidden = document.getElementById('ac-fields-json');
|
|
8
|
+
const form = builder.closest('form');
|
|
9
|
+
|
|
10
|
+
function addRow(field) {
|
|
11
|
+
const row = template.content.firstElementChild.cloneNode(true);
|
|
12
|
+
if (field) {
|
|
13
|
+
row.querySelector('[data-field="id"]').value = field.id || '';
|
|
14
|
+
row.querySelector('[data-field="label"]').value = field.label || '';
|
|
15
|
+
row.querySelector('[data-field="type"]').value = field.type || 'text';
|
|
16
|
+
row.querySelector('[data-field="required"]').checked = !!field.required;
|
|
17
|
+
row.querySelector('[data-field="options"]').value = (field.options || []).join(', ');
|
|
18
|
+
}
|
|
19
|
+
toggleOptions(row);
|
|
20
|
+
showId(row);
|
|
21
|
+
wireRow(row);
|
|
22
|
+
list.appendChild(row);
|
|
23
|
+
return row;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function wireRow(row) {
|
|
27
|
+
row.querySelector('[data-field="label"]').addEventListener('input', () => showId(row));
|
|
28
|
+
row.querySelector('[data-field="type"]').addEventListener('change', () => toggleOptions(row));
|
|
29
|
+
row.querySelector('[data-remove]').addEventListener('click', () => {
|
|
30
|
+
const existing = row.querySelector('[data-field="id"]').value.trim() !== '';
|
|
31
|
+
if (existing && !confirm('Remove this field? Existing item data for it is kept but no longer shown.')) return;
|
|
32
|
+
row.remove();
|
|
33
|
+
});
|
|
34
|
+
row.querySelector('[data-move="up"]').addEventListener('click', () => {
|
|
35
|
+
if (row.previousElementSibling) list.insertBefore(row, row.previousElementSibling);
|
|
36
|
+
});
|
|
37
|
+
row.querySelector('[data-move="down"]').addEventListener('click', () => {
|
|
38
|
+
if (row.nextElementSibling) list.insertBefore(row.nextElementSibling, row);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toggleOptions(row) {
|
|
43
|
+
const isSelect = row.querySelector('[data-field="type"]').value === 'select';
|
|
44
|
+
row.querySelector('[data-field="options"]').hidden = !isSelect;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Existing rows show their stable id; new rows preview the id the server
|
|
48
|
+
// will derive from the label. Approximation: the server also transliterates
|
|
49
|
+
// accents and de-duplicates, so the saved id may differ.
|
|
50
|
+
function showId(row) {
|
|
51
|
+
const idEl = row.querySelector('[data-field-id-display]');
|
|
52
|
+
const existing = row.querySelector('[data-field="id"]').value.trim();
|
|
53
|
+
if (existing) { idEl.textContent = existing; return; }
|
|
54
|
+
const label = row.querySelector('[data-field="label"]').value;
|
|
55
|
+
let id = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
56
|
+
if (id && !/^[a-z]/.test(id)) id = 'f_' + id;
|
|
57
|
+
idEl.textContent = id ? `${id} (assigned on save)` : '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function serialize() {
|
|
61
|
+
const rows = Array.from(list.querySelectorAll('.ac-field-row'));
|
|
62
|
+
const fields = rows.map(row => {
|
|
63
|
+
const id = row.querySelector('[data-field="id"]').value.trim();
|
|
64
|
+
const type = row.querySelector('[data-field="type"]').value;
|
|
65
|
+
const options = row.querySelector('[data-field="options"]').value
|
|
66
|
+
.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
67
|
+
const field = {
|
|
68
|
+
label: row.querySelector('[data-field="label"]').value.trim(),
|
|
69
|
+
type: type,
|
|
70
|
+
required: row.querySelector('[data-field="required"]').checked,
|
|
71
|
+
options: type === 'select' ? options : []
|
|
72
|
+
};
|
|
73
|
+
if (id) field.id = id;
|
|
74
|
+
return field;
|
|
75
|
+
});
|
|
76
|
+
hidden.value = JSON.stringify(fields);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
document.getElementById('ac-add-field-btn').addEventListener('click', () => addRow(null).querySelector('[data-field="label"]').focus());
|
|
80
|
+
if (form) form.addEventListener('submit', serialize);
|
|
81
|
+
|
|
82
|
+
const initial = JSON.parse(document.getElementById('ac-fields-data').textContent || '[]');
|
|
83
|
+
initial.forEach(addRow);
|
|
84
|
+
})();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
document.querySelectorAll('select[data-media-select]').forEach(function(select) {
|
|
3
|
+
const preview = select.parentElement.querySelector('.ac-media-preview');
|
|
4
|
+
if (!preview) return;
|
|
5
|
+
select.addEventListener('change', function() {
|
|
6
|
+
const url = select.options[select.selectedIndex].dataset.url;
|
|
7
|
+
if (url) { preview.src = url; preview.style.display = ''; }
|
|
8
|
+
else { preview.removeAttribute('src'); preview.style.display = 'none'; }
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
})();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActiveCanvas Editor - Bindings store
|
|
3
|
+
*
|
|
4
|
+
* The one place that knows where the page's bindings live while editing
|
|
5
|
+
* (localStorage, seeded from the server on every load) and which data
|
|
6
|
+
* sources exist. Every other module reads and writes through here.
|
|
7
|
+
*
|
|
8
|
+
* Events on document:
|
|
9
|
+
* ac:bindings-changed { bindings } after write()
|
|
10
|
+
* ac:registry-loaded { registry } after setRegistry()
|
|
11
|
+
*/
|
|
12
|
+
(function() {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const container = document.getElementById('data-panel-container');
|
|
16
|
+
const pageId = container ? container.dataset.pageId : null;
|
|
17
|
+
const key = pageId ? `ac:bindings:${pageId}` : null;
|
|
18
|
+
let registry = [];
|
|
19
|
+
|
|
20
|
+
function read() {
|
|
21
|
+
if (!key) return {};
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(localStorage.getItem(key) || '{}') || {};
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function save(bindings) {
|
|
30
|
+
if (!key) return;
|
|
31
|
+
try { localStorage.setItem(key, JSON.stringify(bindings)); } catch (e) { /* storage unavailable */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function write(bindings) {
|
|
35
|
+
save(bindings);
|
|
36
|
+
document.dispatchEvent(new CustomEvent('ac:bindings-changed', { detail: { bindings } }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJson() {
|
|
40
|
+
return JSON.stringify(read());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setRegistry(list) {
|
|
44
|
+
registry = Array.isArray(list) ? list : [];
|
|
45
|
+
document.dispatchEvent(new CustomEvent('ac:registry-loaded', { detail: { registry } }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function source(name) {
|
|
49
|
+
return registry.find(s => s.name === name) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// True when the binding resolves to something a loop can iterate.
|
|
53
|
+
function isList(name) {
|
|
54
|
+
const spec = read()[name];
|
|
55
|
+
if (!spec) return false;
|
|
56
|
+
if (spec.source === '_literal') return Array.isArray(spec.value);
|
|
57
|
+
const src = source(spec.source);
|
|
58
|
+
return !!src && (src.kind === 'collection' || src.list === true);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listNames() {
|
|
62
|
+
return Object.keys(read()).filter(isList);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function itemName(name) {
|
|
66
|
+
const spec = read()[name];
|
|
67
|
+
const src = spec ? source(spec.source) : null;
|
|
68
|
+
return (src && src.item_name) || 'item';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The server's copy wins on load: the page may have been saved elsewhere.
|
|
72
|
+
const seed = document.getElementById('ac-server-bindings');
|
|
73
|
+
if (seed) {
|
|
74
|
+
try { save(JSON.parse(seed.textContent || '{}')); } catch (e) { save({}); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
window.ActiveCanvasBindings = {
|
|
78
|
+
pageId, read, save, write, readJson, setRegistry, source, isList, listNames, itemName,
|
|
79
|
+
registry: () => registry
|
|
80
|
+
};
|
|
81
|
+
})();
|