active_canvas 0.0.5 → 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
@@ -0,0 +1,138 @@
1
+ require "cgi"
2
+
3
+ module ActiveCanvas
4
+ class TemplateRenderer
5
+ # Walks page.bindings JSON, calls each data source, and returns a
6
+ # string-keyed Liquid assigns hash. Nothing reaches Liquid unless it is a
7
+ # Drop, an escaped scalar, or a Hash/Array made of those.
8
+ class BindingResolver
9
+ def initialize(bindings, silent_errors: false)
10
+ @bindings = bindings || {}
11
+ @silent_errors = silent_errors
12
+ end
13
+
14
+ def resolve
15
+ @bindings.each_with_object({}) do |(name, spec), acc|
16
+ acc[name.to_s] = resolve_one(spec)
17
+ end
18
+ end
19
+
20
+ # A JSON-friendly peek at one binding for the editor's Data panel: up to
21
+ # `rows` entries, Drops flattened to hashes, escaping undone so the
22
+ # author sees the real text and the real field names.
23
+ def sample(name, rows: 3)
24
+ spec = @bindings[name.to_s] || @bindings[name.to_sym]
25
+ raise DataSources::UnknownSource.new(name) unless spec
26
+
27
+ value = resolve_one(sample_spec(spec, rows))
28
+ value.is_a?(Array) ? value.first(rows).map { |v| plain(v) } : plain(value)
29
+ end
30
+
31
+ private
32
+
33
+ # Caps a collection or param-limited source's `limit` param to the row
34
+ # count so `sample` does not load 100 items to show 3.
35
+ def sample_spec(spec, rows)
36
+ return spec unless spec.is_a?(Hash)
37
+
38
+ params = spec["params"] || spec[:params]
39
+ return spec unless params
40
+
41
+ source_name = (spec["source"] || spec[:source]).to_s
42
+ cappable = Collection.exists?(slug: source_name) ||
43
+ (DataSources.registered?(source_name) && DataSources.lookup(source_name).params.key?(:limit))
44
+ return spec unless cappable
45
+
46
+ spec.merge("params" => params.to_h.merge("limit" => rows))
47
+ end
48
+
49
+ def resolve_one(spec)
50
+ raise DataSources::UnknownSource.new(spec.inspect) unless spec.is_a?(Hash)
51
+
52
+ source_name = (spec["source"] || spec[:source]).to_s
53
+ raise DataSources::UnknownSource.new(source_name) if source_name.blank?
54
+
55
+ params = spec["params"] || spec[:params] || {}
56
+ params = {} unless params.is_a?(Hash)
57
+
58
+ if source_name == "_literal"
59
+ to_liquid_value(spec.key?("value") ? spec["value"] : spec[:value])
60
+ elsif DataSources.registered?(source_name)
61
+ fetch(DataSources.lookup(source_name), params)
62
+ elsif (collection = Collection.find_by(slug: source_name))
63
+ to_liquid_value(CollectionSource.new(collection).resolve(params))
64
+ else
65
+ raise DataSources::UnknownSource.new(source_name)
66
+ end
67
+ end
68
+
69
+ # Silent sources swallow every StandardError, including InvalidParam and UnsafeData; preview mode still raises.
70
+ def fetch(source, params)
71
+ wrap(source.call(params.to_h.transform_keys(&:to_sym)), source)
72
+ rescue StandardError => e
73
+ raise unless @silent_errors && source.on_error == :silent
74
+ Rails.logger.warn("[ActiveCanvas] data source #{source.name.inspect} failed silently: #{e.class}: #{e.message}")
75
+ nil
76
+ end
77
+
78
+ def wrap(result, source)
79
+ if result.is_a?(Array) || result.is_a?(Set) || (result.respond_to?(:to_ary) && !result.is_a?(String))
80
+ result.map { |item| wrap_one(item, source) }
81
+ else
82
+ wrap_one(result, source)
83
+ end
84
+ end
85
+
86
+ def wrap_one(item, source)
87
+ return item if item.nil? || item.is_a?(::Liquid::Drop)
88
+ return to_liquid_value(source.drop_class.new(item), source: source) if source.drop_class
89
+
90
+ case item
91
+ when ::Liquid::Drop, String, Symbol, Numeric, true, false, nil, Date, Time, Hash, Array
92
+ to_liquid_value(item, source: source)
93
+ else
94
+ if source.auto_drop_config[:attributes].any?
95
+ AutoDrop.new(item, **source.auto_drop_config)
96
+ else
97
+ raise DataSources::UnsafeData.new(source.name, item.class.name)
98
+ end
99
+ end
100
+ end
101
+
102
+ # The only door into Liquid. Strings are escaped unless already html_safe,
103
+ # containers are walked, and anything else is refused.
104
+ def to_liquid_value(value, source: nil)
105
+ case value
106
+ when ::Liquid::Drop, Numeric, true, false, nil, Date, Time then value
107
+ when String then value.html_safe? ? value : ERB::Util.html_escape(value)
108
+ when Symbol then ERB::Util.html_escape(value.to_s)
109
+ when Hash then value.each_with_object({}) { |(k, v), acc| acc[k.to_s] = to_liquid_value(v, source: source) }
110
+ when Array then value.map { |v| to_liquid_value(v, source: source) }
111
+ else raise DataSources::UnsafeData.new(source&.name || :_literal, value.class.name)
112
+ end
113
+ end
114
+
115
+ # Flattens a resolved value for JSON. Drops expose only what Liquid
116
+ # would (`invokable_methods`, zero-arity), depth is capped so a drop
117
+ # that points back at itself cannot recurse, and anything outside the
118
+ # Liquid boundary is rendered as text, never serialized as an object.
119
+ def plain(value, depth = 0)
120
+ case value
121
+ when AutoDrop
122
+ depth > 2 ? value.to_s : plain(value.to_h, depth + 1)
123
+ when ::Liquid::Drop
124
+ return value.to_s if depth > 2
125
+ (value.class.invokable_methods.to_a - [ "to_liquid" ]).sort
126
+ .select { |m| value.method(m).arity.zero? }
127
+ .each_with_object({}) { |m, acc| acc[m.to_s] = plain(value.invoke_drop(m), depth + 1) }
128
+ when Hash then value.each_with_object({}) { |(k, v), acc| acc[k.to_s] = plain(v, depth + 1) }
129
+ when Array then value.first(3).map { |v| plain(v, depth + 1) }
130
+ when String then CGI.unescapeHTML(value.to_s)
131
+ when Symbol then value.to_s
132
+ when Numeric, true, false, nil, Date, Time then value
133
+ else value.to_s
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,54 @@
1
+ module ActiveCanvas
2
+ class TemplateRenderer
3
+ # Turns `data-ac-for="item in list"` and `data-ac-if="cond"` attributes
4
+ # into the Liquid block tags around their element. Attributes survive every
5
+ # HTML parser, so this is how the editor expresses loops and conditions
6
+ # without text tags that tables and sanitizers would move. The loop goes
7
+ # outside the condition. Text nodes inserted through the DOM are never
8
+ # foster-parented, so a loop on a <tr> stays inside its <tbody>.
9
+ class DirectiveExpander
10
+ FOR_RE = /\A\s*[A-Za-z_][\w.]*\s+in\s+\S.*\z/m
11
+
12
+ def initialize(source)
13
+ @source = source.to_s
14
+ end
15
+
16
+ def expand
17
+ return @source unless @source.include?("data-ac-for") || @source.include?("data-ac-if")
18
+
19
+ fragment = Nokogiri::HTML5.fragment(@source)
20
+ fragment.css("[data-ac-for], [data-ac-if]").each { |element| expand_element(element) }
21
+ fragment.to_html
22
+ end
23
+
24
+ private
25
+
26
+ def expand_element(element)
27
+ loop_expr = element.remove_attribute("data-ac-for")&.value
28
+ cond_expr = element.remove_attribute("data-ac-if")&.value
29
+
30
+ [ loop_expr, cond_expr ].compact.each do |expr|
31
+ raise Liquid::SyntaxError, "Invalid directive #{expr.inspect}: \"%}\" is not allowed" if expr.include?("%}")
32
+ end
33
+
34
+ # Siblings are inserted adjacent to the element, so the outer tag
35
+ # (for) goes in first on the left and last on the right.
36
+ if loop_expr
37
+ raise Liquid::SyntaxError, "Invalid data-ac-for #{loop_expr.inspect}: expected \"item in list\"" unless loop_expr.match?(FOR_RE)
38
+ element.add_previous_sibling(text("{% for #{loop_expr.strip} %}", element))
39
+ element.add_next_sibling(text("{% endfor %}", element))
40
+ end
41
+
42
+ if cond_expr
43
+ raise Liquid::SyntaxError, "Invalid data-ac-if: the condition is blank" if cond_expr.strip.empty?
44
+ element.add_previous_sibling(text("{% if #{cond_expr.strip} %}", element))
45
+ element.add_next_sibling(text("{% endif %}", element))
46
+ end
47
+ end
48
+
49
+ def text(content, element)
50
+ Nokogiri::XML::Text.new(content, element.document)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,160 @@
1
+ require "cgi"
2
+
3
+ module ActiveCanvas
4
+ class TemplateRenderer
5
+ MODES = %i[public preview].freeze
6
+ PUBLIC_FALLBACK = "<!-- dynamic block unavailable -->".freeze
7
+
8
+ def initialize(page, mode:)
9
+ raise ArgumentError, "mode must be one of #{MODES.inspect}" unless MODES.include?(mode)
10
+ @page = page
11
+ @mode = mode
12
+ end
13
+
14
+ def render
15
+ return @page.content.to_s unless @page.template_enabled?
16
+ render_dynamic
17
+ rescue StandardError => e
18
+ handle_error(e)
19
+ end
20
+
21
+ MAX_LOOP_COPIES = 10
22
+
23
+ # What the editor's live-data view needs: the text each chip displays in
24
+ # its first occurrence, and for every loop and condition element (in
25
+ # document order) how it rendered: the item count, the rendered copies
26
+ # after the first (so the editor can show them as ghosts), and whether a
27
+ # condition holds. Nested elements are measured inside the first copy of
28
+ # their enclosing loop. Lenient render; only text and rendered HTML that
29
+ # the editor treats as display-only leave this method.
30
+ def chip_values(decorated_html)
31
+ source, markers = mark_for_probe(decorated_html)
32
+ source = DirectiveExpander.new(source).expand
33
+ source = decode_entities_in_liquid_tags(source)
34
+
35
+ assigns = BindingResolver.new(@page.bindings, silent_errors: true).resolve
36
+ template = Liquid::Template.parse(source, error_mode: :strict, line_numbers: true)
37
+ template.resource_limits.render_length_limit = ActiveCanvas.config.template_render_length_limit
38
+ template.resource_limits.render_score_limit = ActiveCanvas.config.template_render_score_limit
39
+ template.resource_limits.assign_score_limit = ActiveCanvas.config.template_assign_score_limit
40
+ rendered = template.render(assigns, exception_renderer: ->(e) { raise e if e.is_a?(Liquid::MemoryError); "" })
41
+
42
+ fragment = Nokogiri::HTML5.fragment(rendered)
43
+ values = fragment.css("[data-ac-id]").each_with_object({}) do |chip, acc|
44
+ acc[chip["data-ac-id"]] ||= chip.text.strip.truncate(200)
45
+ end
46
+ loops = markers[:loops].map do |marker|
47
+ copies = occurrences(fragment, marker)
48
+ { expr: marker[:expr], count: copies.size, copies: copies.drop(1).first(MAX_LOOP_COPIES).map { |c| strip_probe_markers(c).to_html } }
49
+ end
50
+ conds = markers[:conds].map { |marker| { expr: marker[:expr], shown: occurrences(fragment, marker).any? } }
51
+ { values: values, loops: loops, conds: conds }
52
+ rescue StandardError => e
53
+ { values: {}, loops: [], conds: [], error: e.message }
54
+ end
55
+
56
+ private
57
+
58
+ def preview?
59
+ @mode == :preview
60
+ end
61
+
62
+ # Preview is strict so authors see every mistake. Public is forgiving: an
63
+ # undefined variable, an unknown filter or a node that raises renders empty
64
+ # and the rest of the page survives; only syntax errors, resource limits
65
+ # and failures before rendering fall back to the comment. The canvas never
66
+ # holds rendered output, so there is nothing to undo here.
67
+ def render_dynamic
68
+ source = DirectiveExpander.new(@page.content.to_s).expand
69
+ source = decode_entities_in_liquid_tags(source)
70
+
71
+ assigns = BindingResolver.new(@page.bindings, silent_errors: !preview?).resolve
72
+ template = Liquid::Template.parse(source, error_mode: :strict, line_numbers: true)
73
+ template.resource_limits.render_length_limit = ActiveCanvas.config.template_render_length_limit
74
+ template.resource_limits.render_score_limit = ActiveCanvas.config.template_render_score_limit
75
+ template.resource_limits.assign_score_limit = ActiveCanvas.config.template_assign_score_limit
76
+ rendered = if preview?
77
+ template.render!(assigns, strict_variables: true, strict_filters: true)
78
+ else
79
+ template.render(assigns, exception_renderer: method(:render_node_error))
80
+ end
81
+ ContentSanitizer.sanitize_html(rendered)
82
+ end
83
+
84
+ # WYSIWYG editors (GrapeJS) encode `<`, `>`, `&`, `"` as HTML entities when
85
+ # serializing text nodes, including inside Liquid tags the user typed. So
86
+ # `{% if a > b %}` becomes `{% if a &gt; b %}` after editor round-trip,
87
+ # which Liquid rejects with a syntax error. Decode entities inside tags
88
+ # before parsing so user-authored Liquid keeps working.
89
+ def decode_entities_in_liquid_tags(source)
90
+ source.gsub(/\{\{.+?\}\}|\{%.+?%\}/m) do |tag|
91
+ CGI.unescapeHTML(tag)
92
+ end
93
+ end
94
+
95
+ def handle_error(error)
96
+ if preview?
97
+ raise DataSources::TemplateRenderError.new(
98
+ error.message,
99
+ line: error.try(:line_number),
100
+ column: error.try(:column),
101
+ original: error
102
+ )
103
+ end
104
+
105
+ Rails.error.report(error, handled: true, source: "active_canvas", context: { page_id: @page.id })
106
+ Rails.logger.warn("[ActiveCanvas] dynamic page #{@page.id} render failed: #{error.class}: #{error.message}")
107
+ PUBLIC_FALLBACK
108
+ end
109
+
110
+ # Public mode: a single broken node renders empty and is reported; only a
111
+ # resource limit stops the whole render (re-raised so `render` falls back).
112
+ def render_node_error(error)
113
+ raise error if error.is_a?(Liquid::MemoryError)
114
+ Rails.error.report(error, handled: true, source: "active_canvas", context: { page_id: @page.id })
115
+ Rails.logger.warn("[ActiveCanvas] dynamic page #{@page.id} node failed: #{error.class}: #{error.message}")
116
+ ""
117
+ end
118
+
119
+ # Chips render their source (their text may hold a stale value in the
120
+ # editor). Loop and condition elements get a marker attribute with a
121
+ # document-order id and remember their nearest enclosing loop, so their
122
+ # copies can be found and scoped after the Liquid tags are consumed.
123
+ def mark_for_probe(html)
124
+ fragment = Nokogiri::HTML5.fragment(html)
125
+ fragment.css("[data-ac-id]").each { |chip| chip.content = chip["data-ac-source"] if chip["data-ac-source"] }
126
+
127
+ markers = { loops: [], conds: [] }
128
+ fragment.css("[data-ac-for], [data-ac-if]").each_with_index do |el, i|
129
+ parent = el.ancestors.find { |a| a["data-ac-probe-loop"] }
130
+ parent_loop = parent && parent["data-ac-probe-loop"]
131
+ if el["data-ac-for"]
132
+ el["data-ac-probe-loop"] = "l#{i}"
133
+ markers[:loops] << { id: "l#{i}", attr: "data-ac-probe-loop", expr: el["data-ac-for"], parent: parent_loop }
134
+ end
135
+ if el["data-ac-if"]
136
+ el["data-ac-probe-cond"] = "k#{i}"
137
+ markers[:conds] << { id: "k#{i}", attr: "data-ac-probe-cond", expr: el["data-ac-if"], parent: parent_loop }
138
+ end
139
+ end
140
+ [ fragment.to_html, markers ]
141
+ end
142
+
143
+ # The rendered copies of a marked element, inside the first copy of its
144
+ # enclosing loop (the whole page when it is not nested).
145
+ def occurrences(fragment, marker)
146
+ scope = marker[:parent] ? fragment.at_css("[data-ac-probe-loop=\"#{marker[:parent]}\"]") : fragment
147
+ return [] unless scope
148
+ scope.css("[#{marker[:attr]}=\"#{marker[:id]}\"]").to_a
149
+ end
150
+
151
+ def strip_probe_markers(element)
152
+ copy = element.dup
153
+ ([ copy ] + copy.css("[data-ac-probe-loop], [data-ac-probe-cond]").to_a).each do |el|
154
+ el.remove_attribute("data-ac-probe-loop")
155
+ el.remove_attribute("data-ac-probe-cond")
156
+ end
157
+ copy
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,40 @@
1
+ <div class="form-group">
2
+ <label for="item_data_<%= field["id"] %>">
3
+ <%= field["label"] %><% if field["required"] %> <span style="color:var(--danger,#c00);">*</span><% end %>
4
+ </label>
5
+
6
+ <% name = "item[data][#{field["id"]}]" %>
7
+ <% dom_id = "item_data_#{field["id"]}" %>
8
+
9
+ <% case field["type"] %>
10
+ <% when "textarea" %>
11
+ <%= text_area_tag name, value, id: dom_id, class: "form-control", rows: 4 %>
12
+ <% when "rich_text" %>
13
+ <%= text_area_tag name, value, id: dom_id, class: "form-control", rows: 8 %>
14
+ <small>Raw HTML. Sanitized on save.</small>
15
+ <% when "number" %>
16
+ <%= number_field_tag name, value, id: dom_id, class: "form-control", step: "any" %>
17
+ <% when "boolean" %>
18
+ <%= hidden_field_tag name, "0" %>
19
+ <%= check_box_tag name, "1", ActiveModel::Type::Boolean.new.cast(value), id: dom_id %>
20
+ <% when "date" %>
21
+ <%= date_field_tag name, value, id: dom_id, class: "form-control" %>
22
+ <% when "select" %>
23
+ <%= select_tag name, options_for_select(Array(field["options"]), value), id: dom_id, class: "form-control", include_blank: true %>
24
+ <% when "media" %>
25
+ <% selected_media = media.find { |m| m.id.to_s == value.to_s } %>
26
+ <select name="<%= name %>" id="<%= dom_id %>" class="form-control" data-media-select>
27
+ <option value="">&mdash; none &mdash;</option>
28
+ <% media.each do |m| %>
29
+ <option value="<%= m.id %>" data-url="<%= m.url %>" <%= "selected" if value.to_s == m.id.to_s %>><%= m.filename %></option>
30
+ <% end %>
31
+ </select>
32
+ <% if selected_media&.url.present? %>
33
+ <%= image_tag selected_media.url, class: "ac-media-preview", alt: "", style: "max-height:80px;margin-top:.5rem;" %>
34
+ <% else %>
35
+ <img class="ac-media-preview" alt="" style="max-height:80px;margin-top:.5rem;display:none;">
36
+ <% end %>
37
+ <% else %>
38
+ <%= text_field_tag name, value, id: dom_id, class: "form-control" %>
39
+ <% end %>
40
+ </div>
@@ -0,0 +1,28 @@
1
+ <% form_url = @item.persisted? ? admin_collection_item_path(@collection, @item) : admin_collection_items_path(@collection) %>
2
+ <%= form_with model: @item, url: form_url, method: (@item.persisted? ? :patch : :post) do |form| %>
3
+ <% if @item.errors.any? %>
4
+ <div class="error-messages">
5
+ <ul>
6
+ <% @item.errors.full_messages.each do |message| %>
7
+ <li><%= message %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <div class="form-group">
14
+ <%= form.label :slug, "Item slug (optional)" %>
15
+ <%= form.text_field :slug, class: "form-control" %>
16
+ </div>
17
+
18
+ <% @collection.fields.each do |field| %>
19
+ <%= render "field_input", field: field, value: (@item.draft_data || {})[field["id"]], media: @media %>
20
+ <% end %>
21
+
22
+ <div class="form-actions">
23
+ <%= form.submit "Save draft", class: "btn btn-primary" %>
24
+ <%= link_to "Cancel", admin_collection_items_path(@collection), class: "btn btn-secondary" %>
25
+ </div>
26
+ <% end %>
27
+
28
+ <%= javascript_include_tag "active_canvas/admin/media_select_preview" %>
@@ -0,0 +1,24 @@
1
+ <% content_for :page_title, "Edit #{@collection.name} item" %>
2
+
3
+ <div class="page-header">
4
+ <h2>Edit item</h2>
5
+ <div>
6
+ <%= link_to "History", history_admin_collection_item_path(@collection, @item), class: "btn btn-secondary" %>
7
+ <% if @item.status != "published" %>
8
+ <%= button_to "Publish", publish_admin_collection_item_path(@collection, @item), method: :patch, class: "btn btn-primary", form: { style: "display:inline" } %>
9
+ <% else %>
10
+ <% if @item.pending_changes? %>
11
+ <%= button_to "Publish changes", publish_admin_collection_item_path(@collection, @item), method: :patch, class: "btn btn-primary", form: { style: "display:inline" } %>
12
+ <% end %>
13
+ <%= button_to "Unpublish", unpublish_admin_collection_item_path(@collection, @item), method: :patch, class: "btn btn-secondary", form: { style: "display:inline" } %>
14
+ <% end %>
15
+ </div>
16
+ </div>
17
+
18
+ <% if @item.status == "published" && @item.pending_changes? %>
19
+ <p class="help-text" style="margin:0 0 1rem;">This item has draft changes that visitors do not see yet. Publish changes to make them live.</p>
20
+ <% end %>
21
+
22
+ <div class="card" style="padding:1.5rem;">
23
+ <%= render "form" %>
24
+ </div>
@@ -0,0 +1,34 @@
1
+ <% content_for :page_title, "Item history" %>
2
+
3
+ <div class="page-header">
4
+ <h2>History</h2>
5
+ <%= link_to "Back to item", edit_admin_collection_item_path(@collection, @item), class: "btn btn-secondary" %>
6
+ </div>
7
+
8
+ <div class="card">
9
+ <% if @versions.any? %>
10
+ <% labels = @collection.fields.each_with_object({}) { |f, acc| acc[f["id"]] = f["label"] } %>
11
+ <% @versions.each do |version| %>
12
+ <div style="padding:1rem;border-bottom:1px solid var(--border,#eee);">
13
+ <h3>Version <%= version.version_number %></h3>
14
+ <p>
15
+ <small>
16
+ <%= version.created_at.strftime("%Y-%m-%d %H:%M") %>
17
+ <% if version.changed_by.present? %> &middot; by <%= version.changed_by %><% end %>
18
+ </small>
19
+ </p>
20
+ <dl>
21
+ <% (version.data || {}).each do |field_id, value| %>
22
+ <dt><%= labels[field_id] || field_id %><% unless labels.key?(field_id) %> <small>(removed field)</small><% end %></dt>
23
+ <dd><%= truncate(value.to_s, length: 120) %></dd>
24
+ <% end %>
25
+ </dl>
26
+ </div>
27
+ <% end %>
28
+ <% else %>
29
+ <div class="empty-state">
30
+ <h3>No versions yet</h3>
31
+ <p>Publishing this item will record a snapshot here.</p>
32
+ </div>
33
+ <% end %>
34
+ </div>
@@ -0,0 +1,63 @@
1
+ <% content_for :page_title, "#{@collection.name} items" %>
2
+
3
+ <div class="page-header">
4
+ <h2><%= @collection.name %> &middot; Items</h2>
5
+ <%= link_to "New item", new_admin_collection_item_path(@collection), class: "btn btn-primary" %>
6
+ </div>
7
+
8
+ <div class="card">
9
+ <%= form_with url: admin_collection_items_path(@collection), method: :get, local: true, style: "padding:1rem;" do %>
10
+ <%= label_tag :status, "Status" %>
11
+ <%= select_tag :status,
12
+ options_for_select([ [ "All", "" ], [ "Draft", "draft" ], [ "Published", "published" ] ], params[:status]),
13
+ class: "form-control", style: "display:inline-block;width:auto;", onchange: "this.form.requestSubmit ? this.form.requestSubmit() : this.form.submit()" %>
14
+ <%= submit_tag "Filter", class: "btn btn-sm btn-secondary" %>
15
+ <% end %>
16
+
17
+ <% if @items.any? %>
18
+ <table>
19
+ <thead>
20
+ <tr>
21
+ <% @leading_fields.each do |field| %>
22
+ <th><%= field["label"] %></th>
23
+ <% end %>
24
+ <th>Status</th>
25
+ <th>Updated</th>
26
+ <th></th>
27
+ </tr>
28
+ </thead>
29
+ <tbody>
30
+ <% @items.each do |item| %>
31
+ <tr>
32
+ <% @leading_fields.each do |field| %>
33
+ <td><%= collection_cell(item, field) %></td>
34
+ <% end %>
35
+ <td><%= collection_status_badge(item) %></td>
36
+ <td><%= item.updated_at.strftime("%Y-%m-%d %H:%M") %></td>
37
+ <td class="actions" style="white-space:nowrap;">
38
+ <%= link_to "Edit", edit_admin_collection_item_path(@collection, item), class: "btn btn-sm btn-secondary" %>
39
+ <% if item.status != "published" || item.pending_changes? %>
40
+ <%= button_to (item.status == "published" ? "Publish changes" : "Publish"), publish_admin_collection_item_path(@collection, item),
41
+ method: :patch, class: "btn btn-sm btn-primary", form: { style: "display:inline" } %>
42
+ <% end %>
43
+ <% if item.status == "published" %>
44
+ <%= button_to "Unpublish", unpublish_admin_collection_item_path(@collection, item),
45
+ method: :patch, class: "btn btn-sm btn-secondary", form: { style: "display:inline" } %>
46
+ <% end %>
47
+ <%= link_to "History", history_admin_collection_item_path(@collection, item), class: "btn btn-sm btn-secondary" %>
48
+ <%= button_to "Delete", admin_collection_item_path(@collection, item), method: :delete,
49
+ class: "btn btn-sm btn-danger", form: { style: "display:inline" },
50
+ onclick: "return confirm('Delete this item?')" %>
51
+ </td>
52
+ </tr>
53
+ <% end %>
54
+ </tbody>
55
+ </table>
56
+ <% else %>
57
+ <div class="empty-state">
58
+ <h3>No items yet</h3>
59
+ <p>Add an item to populate this collection.</p>
60
+ <%= link_to "New item", new_admin_collection_item_path(@collection), class: "btn btn-primary" %>
61
+ </div>
62
+ <% end %>
63
+ </div>
@@ -0,0 +1,9 @@
1
+ <% content_for :page_title, "New #{@collection.name} item" %>
2
+
3
+ <div class="page-header">
4
+ <h2>New item</h2>
5
+ </div>
6
+
7
+ <div class="card" style="padding:1.5rem;">
8
+ <%= render "form" %>
9
+ </div>
@@ -0,0 +1,35 @@
1
+ <div class="form-group" id="ac-field-builder">
2
+ <label>Fields</label>
3
+ <p><small>Define the typed columns of this collection. Field IDs are generated from labels and never change once created.</small></p>
4
+
5
+ <script type="application/json" id="ac-fields-data"><%= raw json_escape(@collection.fields_json) %></script>
6
+ <ul id="ac-fields-list" style="list-style:none;padding:0;margin:0;"></ul>
7
+ <button type="button" id="ac-add-field-btn" class="btn btn-sm btn-secondary">Add field</button>
8
+
9
+ <%= form.hidden_field :fields_json, id: "ac-fields-json" %>
10
+
11
+ <template id="ac-field-row-template">
12
+ <li class="ac-field-row card" style="display:flex;gap:.5rem;align-items:center;padding:.5rem;margin-bottom:.5rem;">
13
+ <input type="hidden" data-field="id">
14
+ <span style="flex:2;display:flex;flex-direction:column;gap:2px;">
15
+ <input type="text" data-field="label" class="form-control" placeholder="Label">
16
+ <code data-field-id-display class="ac-field-id" style="font-size:.7rem;opacity:.7;" title="Use this in the page: {{ item.<id> }}"></code>
17
+ </span>
18
+ <select data-field="type" class="form-control" style="flex:1;">
19
+ <option value="text">Text</option>
20
+ <option value="textarea">Textarea</option>
21
+ <option value="rich_text">Rich text (HTML)</option>
22
+ <option value="number">Number</option>
23
+ <option value="boolean">Boolean</option>
24
+ <option value="date">Date</option>
25
+ <option value="select">Select</option>
26
+ <option value="media">Media</option>
27
+ </select>
28
+ <input type="text" data-field="options" class="form-control ac-field-options" placeholder="a, b, c" style="flex:1;" hidden>
29
+ <label style="white-space:nowrap;"><input type="checkbox" data-field="required"> Required</label>
30
+ <button type="button" data-move="up" class="btn btn-sm btn-secondary" title="Move up">&uarr;</button>
31
+ <button type="button" data-move="down" class="btn btn-sm btn-secondary" title="Move down">&darr;</button>
32
+ <button type="button" data-remove class="btn btn-sm btn-danger" title="Remove">&times;</button>
33
+ </li>
34
+ </template>
35
+ </div>
@@ -0,0 +1,31 @@
1
+ <%= form_with model: [ :admin, @collection ] do |form| %>
2
+ <% if @collection.errors.any? %>
3
+ <div class="error-messages">
4
+ <ul>
5
+ <% @collection.errors.full_messages.each do |message| %>
6
+ <li><%= message %></li>
7
+ <% end %>
8
+ </ul>
9
+ </div>
10
+ <% end %>
11
+
12
+ <div class="form-group">
13
+ <%= form.label :name %>
14
+ <%= form.text_field :name, class: "form-control" %>
15
+ </div>
16
+
17
+ <div class="form-group">
18
+ <%= form.label :slug %>
19
+ <%= form.text_field :slug, class: "form-control" %>
20
+ <small>The Liquid source name used in <code>{% for x in slug %}</code>. Leave blank to derive from the name.</small>
21
+ </div>
22
+
23
+ <%= render "field_builder", form: form %>
24
+
25
+ <div class="form-actions">
26
+ <%= form.submit "Save collection", class: "btn btn-primary" %>
27
+ <%= link_to "Cancel", admin_collections_path, class: "btn btn-secondary" %>
28
+ </div>
29
+ <% end %>
30
+
31
+ <%= javascript_include_tag "active_canvas/admin/field_builder" %>
@@ -0,0 +1,10 @@
1
+ <% content_for :page_title, @collection.name %>
2
+
3
+ <div class="page-header">
4
+ <h2>Edit collection</h2>
5
+ <%= link_to "Manage items", admin_collection_items_path(@collection), class: "btn btn-secondary" %>
6
+ </div>
7
+
8
+ <div class="card" style="padding:1.5rem;">
9
+ <%= render "form" %>
10
+ </div>