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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -1
  3. data/app/assets/javascripts/active_canvas/admin/data_panel.js +357 -0
  4. data/app/assets/javascripts/active_canvas/admin/field_builder.js +84 -0
  5. data/app/assets/javascripts/active_canvas/admin/media_select_preview.js +11 -0
  6. data/app/assets/javascripts/active_canvas/editor/ac_bindings.js +81 -0
  7. data/app/assets/javascripts/active_canvas/editor/ac_chips.js +192 -0
  8. data/app/assets/javascripts/active_canvas/editor/code_panel.js +10 -7
  9. data/app/assets/javascripts/active_canvas/editor/directive_traits.js +95 -0
  10. data/app/assets/javascripts/active_canvas/editor/live_data.js +178 -0
  11. data/app/assets/javascripts/active_canvas/editor/panels.js +23 -7
  12. data/app/assets/javascripts/active_canvas/editor/preview_modal.js +112 -0
  13. data/app/assets/javascripts/active_canvas/editor/template_validator.js +95 -0
  14. data/app/assets/javascripts/active_canvas/editor.js +21 -3
  15. data/app/assets/stylesheets/active_canvas/editor.css +626 -0
  16. data/app/controllers/active_canvas/admin/application_controller.rb +8 -0
  17. data/app/controllers/active_canvas/admin/collection_items_controller.rb +93 -0
  18. data/app/controllers/active_canvas/admin/collections_controller.rb +51 -0
  19. data/app/controllers/active_canvas/admin/form_submissions_controller.rb +51 -0
  20. data/app/controllers/active_canvas/admin/pages_controller.rb +127 -5
  21. data/app/controllers/active_canvas/form_submissions_controller.rb +90 -0
  22. data/app/controllers/active_canvas/pages_controller.rb +8 -0
  23. data/app/controllers/active_canvas/redirects_controller.rb +10 -0
  24. data/app/helpers/active_canvas/admin/collection_items_helper.rb +33 -0
  25. data/app/models/active_canvas/auto_drop.rb +69 -0
  26. data/app/models/active_canvas/collection.rb +98 -0
  27. data/app/models/active_canvas/collection_item.rb +66 -0
  28. data/app/models/active_canvas/collection_item_version.rb +18 -0
  29. data/app/models/active_canvas/current.rb +2 -0
  30. data/app/models/active_canvas/form_submission.rb +9 -0
  31. data/app/models/active_canvas/page.rb +76 -15
  32. data/app/models/active_canvas/page_redirect.rb +7 -0
  33. data/app/services/active_canvas/collection_schema.rb +110 -0
  34. data/app/services/active_canvas/collection_source.rb +81 -0
  35. data/app/services/active_canvas/content_sanitizer.rb +33 -21
  36. data/app/services/active_canvas/form_schema.rb +48 -0
  37. data/app/services/active_canvas/form_stamper.rb +86 -0
  38. data/app/services/active_canvas/template_renderer/binding_resolver.rb +138 -0
  39. data/app/services/active_canvas/template_renderer/directive_expander.rb +54 -0
  40. data/app/services/active_canvas/template_renderer.rb +160 -0
  41. data/app/views/active_canvas/admin/collection_items/_field_input.html.erb +40 -0
  42. data/app/views/active_canvas/admin/collection_items/_form.html.erb +28 -0
  43. data/app/views/active_canvas/admin/collection_items/edit.html.erb +24 -0
  44. data/app/views/active_canvas/admin/collection_items/history.html.erb +34 -0
  45. data/app/views/active_canvas/admin/collection_items/index.html.erb +63 -0
  46. data/app/views/active_canvas/admin/collection_items/new.html.erb +9 -0
  47. data/app/views/active_canvas/admin/collections/_field_builder.html.erb +35 -0
  48. data/app/views/active_canvas/admin/collections/_form.html.erb +31 -0
  49. data/app/views/active_canvas/admin/collections/edit.html.erb +10 -0
  50. data/app/views/active_canvas/admin/collections/index.html.erb +45 -0
  51. data/app/views/active_canvas/admin/collections/new.html.erb +9 -0
  52. data/app/views/active_canvas/admin/form_submissions/index.html.erb +80 -0
  53. data/app/views/active_canvas/admin/form_submissions/show.html.erb +99 -0
  54. data/app/views/active_canvas/admin/pages/_data_panel.html.erb +81 -0
  55. data/app/views/active_canvas/admin/pages/_form.html.erb +10 -0
  56. data/app/views/active_canvas/admin/pages/editor.html.erb +62 -1
  57. data/app/views/active_canvas/pages/show.html.erb +4 -1
  58. data/app/views/layouts/active_canvas/admin/application.html.erb +15 -0
  59. data/config/routes.rb +20 -0
  60. data/db/migrate/20260517100001_add_template_enabled_and_bindings_to_active_canvas_pages.rb +6 -0
  61. data/db/migrate/20260517100002_add_bindings_to_active_canvas_page_versions.rb +6 -0
  62. data/db/migrate/20260702000001_create_active_canvas_page_redirects.rb +11 -0
  63. data/db/migrate/20260702000002_create_active_canvas_form_submissions.rb +14 -0
  64. data/db/migrate/20260702000003_create_active_canvas_collections.rb +12 -0
  65. data/db/migrate/20260702000004_create_active_canvas_collection_items.rb +16 -0
  66. data/db/migrate/20260702000005_create_active_canvas_collection_item_versions.rb +17 -0
  67. data/lib/active_canvas/configuration.rb +32 -1
  68. data/lib/active_canvas/data_sources/errors.rb +51 -0
  69. data/lib/active_canvas/data_sources/param_spec.rb +64 -0
  70. data/lib/active_canvas/data_sources/registration.rb +60 -0
  71. data/lib/active_canvas/data_sources/source.rb +47 -0
  72. data/lib/active_canvas/data_sources.rb +54 -0
  73. data/lib/active_canvas/engine.rb +15 -0
  74. data/lib/active_canvas/version.rb +1 -1
  75. data/lib/active_canvas.rb +6 -0
  76. metadata +85 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72294f59d8513e96f33a6320c30e07ff22a94a404c7184145dd3ec0f2720c620
4
- data.tar.gz: 6bdb95ac11f18b17de836347fa6a701dd2160e31da5792eef809d4231546b6b5
3
+ metadata.gz: 4ed0bd1aa5668045e321bcd222a3efb4bd5085bef5a543e23ad9446d4150e9be
4
+ data.tar.gz: d621f1e61c16427c4f00c1412d491e6ae1916cf4a4b134ce3e6d8db1d9730109
5
5
  SHA512:
6
- metadata.gz: d9b3065b225b1b0a6775920d27a536adec1905520f78bc9242362d578462a5a552aabeb399ff8e06af570801d186e0bf6061da05f2a8b031a5834a39c034e054
7
- data.tar.gz: ab54bbe8bfafc2cb60900c11bfa4f02716f81742bffa3eea7c623d9c91ab1aeada485197eb968cd605e7cb17e6676f17c73a965f8c753e224f5789f0f7b04562
6
+ metadata.gz: e2f5babe38d167c0a09d589b1eff5576b092aacdbbe0713bb106453da48b82a6c940523ea2ec4b35788fd7e0445351458141cf1f0fb7cc7cef7cdf6decf8142b
7
+ data.tar.gz: f086909f84dac86a3d9fc625f7bebcf42c16801d675f29a5170446c76b3ad008991bff8ae6264d11e27d3b549d46cb862ede994704cdbb8d11330587e4ed1cf6
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # ActiveCanvas
2
2
 
3
- ![ActiveCanvas Demo](docs/images/active-canvas-demo.gif)
3
+ ![Live data in the ActiveCanvas editor: the switch flips between rendered values and Liquid source](docs/images/active-canvas-live-data.gif)
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
+ ![The public Showcase page: a hero from literal bindings and one card per team member](docs/images/showcase-public.jpg)
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
+ ![The editor with the Data tab open next to the canvas showing the team cards](docs/images/editor-data-panel.jpg)
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
+ ![The Plans collection items grid with status, publish and history actions](docs/images/collections-items.jpg)
37
+
38
+ **Version history.** Every save is a version with size deltas, a quick diff and a full view.
39
+
40
+ ![The version history of a page with quick diff and view details actions](docs/images/version-history.jpg)
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&hellip;</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 => ({ '<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;' }[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
+ })();