journeybook 0.1.6.pre.alpha
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 +7 -0
- data/CHANGELOG.md +9 -0
- data/MIT-LICENSE +20 -0
- data/README.md +354 -0
- data/Rakefile +6 -0
- data/app/assets/images/journeybook/.keep +0 -0
- data/app/assets/images/journeybook/logo.svg +5 -0
- data/app/assets/javascripts/journeybook/sortable.js +96 -0
- data/app/assets/stylesheets/journeybook/application.css +1556 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/concerns/journeybook/public_pages.rb +31 -0
- data/app/controllers/journeybook/admin/ai_pages_controller.rb +65 -0
- data/app/controllers/journeybook/admin/ai_style_prompts_controller.rb +51 -0
- data/app/controllers/journeybook/admin/components_controller.rb +57 -0
- data/app/controllers/journeybook/admin/dashboard_controller.rb +13 -0
- data/app/controllers/journeybook/admin/media_assets_controller.rb +59 -0
- data/app/controllers/journeybook/admin/navigation_items_controller.rb +91 -0
- data/app/controllers/journeybook/admin/navigation_menus_controller.rb +59 -0
- data/app/controllers/journeybook/admin/pages_controller.rb +304 -0
- data/app/controllers/journeybook/admin/redirects_controller.rb +51 -0
- data/app/controllers/journeybook/admin/sites_controller.rb +51 -0
- data/app/controllers/journeybook/application_controller.rb +52 -0
- data/app/controllers/journeybook/form_submissions_controller.rb +55 -0
- data/app/helpers/journeybook/application_helper.rb +4 -0
- data/app/helpers/journeybook/navigation_helper.rb +11 -0
- data/app/javascript/journeybook/application.js +5 -0
- data/app/javascript/journeybook/component_search_controller.js +13 -0
- data/app/jobs/journeybook/application_job.rb +4 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/journeybook/ai_style_prompt.rb +6 -0
- data/app/models/journeybook/application_record.rb +5 -0
- data/app/models/journeybook/media_asset.rb +26 -0
- data/app/models/journeybook/navigation_item.rb +45 -0
- data/app/models/journeybook/navigation_menu.rb +23 -0
- data/app/models/journeybook/page.rb +96 -0
- data/app/models/journeybook/redirect.rb +31 -0
- data/app/models/journeybook/site.rb +62 -0
- data/app/services/journeybook/ai/markdown_generator.rb +15 -0
- data/app/services/journeybook/ai/openai_client.rb +54 -0
- data/app/services/journeybook/ai/prompt_builder.rb +154 -0
- data/app/services/journeybook/ai/template_validator.rb +17 -0
- data/app/services/journeybook/component_parser.rb +53 -0
- data/app/services/journeybook/component_renderer.rb +108 -0
- data/app/services/journeybook/component_tag_updater.rb +132 -0
- data/app/services/journeybook/component_validator.rb +111 -0
- data/app/services/journeybook/form_mailer.rb +69 -0
- data/app/services/journeybook/html_sanitizer.rb +46 -0
- data/app/services/journeybook/letter_opener_client.rb +14 -0
- data/app/services/journeybook/markdown_renderer.rb +29 -0
- data/app/services/journeybook/page_renderer.rb +58 -0
- data/app/services/journeybook/page_resolver.rb +55 -0
- data/app/services/journeybook/postmark_client.rb +48 -0
- data/app/services/journeybook/storage/s3_client.rb +96 -0
- data/app/views/journeybook/admin/ai_pages/_draft_form.html.erb +73 -0
- data/app/views/journeybook/admin/ai_pages/new.html.erb +43 -0
- data/app/views/journeybook/admin/ai_style_prompts/_form.html.erb +16 -0
- data/app/views/journeybook/admin/ai_style_prompts/edit.html.erb +2 -0
- data/app/views/journeybook/admin/ai_style_prompts/index.html.erb +26 -0
- data/app/views/journeybook/admin/ai_style_prompts/new.html.erb +2 -0
- data/app/views/journeybook/admin/components/_component.html.erb +17 -0
- data/app/views/journeybook/admin/components/_reference.html.erb +9 -0
- data/app/views/journeybook/admin/components/index.html.erb +46 -0
- data/app/views/journeybook/admin/components/preview.html.erb +13 -0
- data/app/views/journeybook/admin/dashboard/show.html.erb +34 -0
- data/app/views/journeybook/admin/media_assets/_reference.html.erb +16 -0
- data/app/views/journeybook/admin/media_assets/index.html.erb +43 -0
- data/app/views/journeybook/admin/media_assets/new.html.erb +41 -0
- data/app/views/journeybook/admin/navigation_items/_form.html.erb +46 -0
- data/app/views/journeybook/admin/navigation_items/edit.html.erb +2 -0
- data/app/views/journeybook/admin/navigation_items/new.html.erb +2 -0
- data/app/views/journeybook/admin/navigation_menus/_form.html.erb +10 -0
- data/app/views/journeybook/admin/navigation_menus/edit.html.erb +38 -0
- data/app/views/journeybook/admin/navigation_menus/index.html.erb +28 -0
- data/app/views/journeybook/admin/navigation_menus/new.html.erb +2 -0
- data/app/views/journeybook/admin/pages/_collection_field.html.erb +58 -0
- data/app/views/journeybook/admin/pages/_form.html.erb +77 -0
- data/app/views/journeybook/admin/pages/_image_picker_modal.html.erb +23 -0
- data/app/views/journeybook/admin/pages/_table.html.erb +37 -0
- data/app/views/journeybook/admin/pages/_url_field.html.erb +25 -0
- data/app/views/journeybook/admin/pages/edit.html.erb +547 -0
- data/app/views/journeybook/admin/pages/errors.html.erb +25 -0
- data/app/views/journeybook/admin/pages/index.html.erb +52 -0
- data/app/views/journeybook/admin/pages/new.html.erb +27 -0
- data/app/views/journeybook/admin/pages/new_component.html.erb +70 -0
- data/app/views/journeybook/admin/pages/public_preview.html.erb +27 -0
- data/app/views/journeybook/admin/pages/source.html.erb +23 -0
- data/app/views/journeybook/admin/redirects/_form.html.erb +9 -0
- data/app/views/journeybook/admin/redirects/edit.html.erb +2 -0
- data/app/views/journeybook/admin/redirects/index.html.erb +15 -0
- data/app/views/journeybook/admin/redirects/new.html.erb +2 -0
- data/app/views/journeybook/admin/shared/_drag_handle.html.erb +3 -0
- data/app/views/journeybook/admin/shared/_errors.html.erb +10 -0
- data/app/views/journeybook/admin/sites/_form.html.erb +12 -0
- data/app/views/journeybook/admin/sites/edit.html.erb +2 -0
- data/app/views/journeybook/admin/sites/index.html.erb +26 -0
- data/app/views/journeybook/admin/sites/new.html.erb +2 -0
- data/app/views/layouts/journeybook/application.html.erb +57 -0
- data/config/importmap.rb +3 -0
- data/config/routes.rb +39 -0
- data/db/migrate/20260627000001_create_journeybook_sites.rb +13 -0
- data/db/migrate/20260627000002_create_journeybook_pages.rb +23 -0
- data/db/migrate/20260627000003_create_journeybook_redirects.rb +13 -0
- data/db/migrate/20260628000004_add_context_fields_to_journeybook_sites.rb +38 -0
- data/db/migrate/20260628000005_create_journeybook_media_assets.rb +24 -0
- data/db/migrate/20260629000006_create_journeybook_navigation.rb +28 -0
- data/db/migrate/20260731000007_add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
- data/db/migrate/20260731000008_create_journeybook_ai_style_prompts.rb +12 -0
- data/db/migrate/20260801000009_add_blog_fields_to_journeybook_pages.rb +9 -0
- data/db/migrate/20260801000010_add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
- data/db/migrate/20260801000011_add_position_to_journeybook_pages.rb +6 -0
- data/lib/generators/journeybook/install/install_generator.rb +72 -0
- data/lib/generators/journeybook/install/templates/add_blog_fields_to_journeybook_pages.rb +9 -0
- data/lib/generators/journeybook/install/templates/add_context_fields_to_journeybook_sites.rb +38 -0
- data/lib/generators/journeybook/install/templates/add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
- data/lib/generators/journeybook/install/templates/add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
- data/lib/generators/journeybook/install/templates/add_position_to_journeybook_pages.rb +6 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_ai_style_prompts.rb +12 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_media_assets.rb +24 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_navigation.rb +28 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_pages.rb +33 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_redirects.rb +13 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_sites.rb +13 -0
- data/lib/generators/journeybook/install/templates/journeybook.rb +42 -0
- data/lib/generators/journeybook/install/templates/pages_controller.rb +3 -0
- data/lib/generators/journeybook/install/templates/show.html.erb +10 -0
- data/lib/journeybook/component_registry.rb +136 -0
- data/lib/journeybook/configuration.rb +55 -0
- data/lib/journeybook/engine.rb +19 -0
- data/lib/journeybook/page_template_registry.rb +67 -0
- data/lib/journeybook/public_page_constraint.rb +23 -0
- data/lib/journeybook/version.rb +3 -0
- data/lib/journeybook.rb +62 -0
- data/lib/tasks/journeybook_tasks.rake +8 -0
- metadata +266 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<%= form_with model: [:admin, page], class: "jb-form" do |form| %>
|
|
2
|
+
<%= render "journeybook/admin/shared/errors", record: page %>
|
|
3
|
+
<%= form.hidden_field :status, value: "draft" %>
|
|
4
|
+
<%= hidden_field_tag :template_key, selected_template&.key %>
|
|
5
|
+
|
|
6
|
+
<div class="jb-grid jb-grid-2">
|
|
7
|
+
<section class="jb-card jb-form">
|
|
8
|
+
<div class="jb-fields">
|
|
9
|
+
<div class="jb-field">
|
|
10
|
+
<%= form.label :site_id %>
|
|
11
|
+
<%= form.collection_select :site_id, sites, :id, :name %>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="jb-field">
|
|
14
|
+
<%= form.label :title %>
|
|
15
|
+
<%= form.text_field :title, required: true %>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="jb-field">
|
|
18
|
+
<%= form.label :page_type %>
|
|
19
|
+
<%= form.select :page_type, Journeybook::Page::PAGE_TYPES %>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="jb-field">
|
|
22
|
+
<%= form.label :path %>
|
|
23
|
+
<%= form.text_field :path, required: selected_template&.path_prefix.blank?, placeholder: selected_template&.path_hint || "/about" %>
|
|
24
|
+
<% if selected_template&.path_prefix %>
|
|
25
|
+
<small class="jb-help">Leave blank to generate a path from the title.</small>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="jb-field">
|
|
29
|
+
<%= form.label :author_name %>
|
|
30
|
+
<%= form.text_field :author_name, placeholder: "Optional author byline" %>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="jb-field">
|
|
33
|
+
<%= form.label :topic %>
|
|
34
|
+
<%= form.text_field :topic, placeholder: "Optional topic, e.g. HR" %>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="jb-field">
|
|
37
|
+
<%= form.label :locale %>
|
|
38
|
+
<%= form.text_field :locale, required: true %>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="jb-field">
|
|
41
|
+
<%= form.label :meta_title %>
|
|
42
|
+
<%= form.text_field :meta_title %>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="jb-field jb-field-short">
|
|
47
|
+
<%= form.label :excerpt %>
|
|
48
|
+
<%= form.text_area :excerpt %>
|
|
49
|
+
<small class="jb-help">Short summary for blog cards, RSS, and SEO.</small>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="jb-field jb-field-short">
|
|
53
|
+
<%= form.label :meta_description %>
|
|
54
|
+
<%= form.text_area :meta_description %>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="jb-field jb-markdown">
|
|
58
|
+
<%= form.label :markdown, "Page content" %>
|
|
59
|
+
<%= form.text_area :markdown, required: true, spellcheck: false %>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="jb-actions">
|
|
63
|
+
<%= form.submit "Save page", class: "jb-button" %>
|
|
64
|
+
<%= link_to "Back", admin_pages_path, class: "jb-button jb-button-secondary" %>
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
|
|
68
|
+
<div class="jb-reference-stack">
|
|
69
|
+
<%= render "journeybook/admin/components/reference" %>
|
|
70
|
+
<%= render "journeybook/admin/media_assets/reference" %>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<% end %>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<header class="jb-header">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="jb-eyebrow">AI Builder</p>
|
|
4
|
+
<h1 class="jb-title">Generate an AI-powered page</h1>
|
|
5
|
+
<p class="jb-subtitle">Describe the page, choose an approved template when needed, and review the generated draft before saving it.</p>
|
|
6
|
+
</div>
|
|
7
|
+
<%= link_to "Manage style prompts", admin_ai_style_prompts_path, class: "jb-button jb-button-secondary" %>
|
|
8
|
+
</header>
|
|
9
|
+
|
|
10
|
+
<%= form_with url: admin_ai_page_path, scope: :page, class: "jb-form" do |form| %>
|
|
11
|
+
<%= render "journeybook/admin/shared/errors", record: @page %>
|
|
12
|
+
|
|
13
|
+
<section class="jb-card jb-form jb-ai-card">
|
|
14
|
+
<div class="jb-field jb-prompt">
|
|
15
|
+
<label for="prompt">Prompt</label>
|
|
16
|
+
<textarea id="prompt" name="prompt" placeholder="Build a landing page for ..."><%= @prompt %></textarea>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="jb-fields jb-ai-fields">
|
|
19
|
+
<div class="jb-field"><label for="ai_style_prompt_id">Style prompt</label><%= select_tag :ai_style_prompt_id, options_from_collection_for_select(@style_prompts, :id, :name, @style_prompt&.id), include_blank: "No style prompt" %></div>
|
|
20
|
+
<div class="jb-field"><label for="template_key">Template</label><%= select_tag :template_key, options_from_collection_for_select(@templates, :key, :name, @selected_template&.key), include_blank: "General page" %></div>
|
|
21
|
+
<div class="jb-field"><%= form.label :site_id %><%= form.collection_select :site_id, @sites, :id, :name %></div>
|
|
22
|
+
<div class="jb-field"><%= form.label :title %><%= form.text_field :title %></div>
|
|
23
|
+
<div class="jb-field"><%= form.label :path %><%= form.text_field :path, placeholder: "/new-page" %></div>
|
|
24
|
+
<div class="jb-field"><%= form.label :locale %><%= form.text_field :locale %></div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="jb-actions">
|
|
27
|
+
<%= form.submit "Generate page", class: "jb-button" %>
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
32
|
+
<% if @page.markdown.present? %>
|
|
33
|
+
<section class="jb-section-lg">
|
|
34
|
+
<header class="jb-header">
|
|
35
|
+
<div>
|
|
36
|
+
<p class="jb-eyebrow">Generated page</p>
|
|
37
|
+
<h2>Review and save</h2>
|
|
38
|
+
<p class="jb-subtitle">This draft has not been saved yet. Review the page content, then save it.</p>
|
|
39
|
+
</div>
|
|
40
|
+
</header>
|
|
41
|
+
<%= render "draft_form", page: @page, sites: @sites, selected_template: @selected_template %>
|
|
42
|
+
</section>
|
|
43
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%= form_with model: [:admin, style_prompt], class: "jb-form jb-card" do |form| %>
|
|
2
|
+
<%= render "journeybook/admin/shared/errors", record: style_prompt %>
|
|
3
|
+
|
|
4
|
+
<div class="jb-field">
|
|
5
|
+
<%= form.label :name %>
|
|
6
|
+
<%= form.text_field :name, required: true, placeholder: "Feature page" %>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="jb-field jb-prompt">
|
|
9
|
+
<%= form.label :prompt, "Style instructions" %>
|
|
10
|
+
<%= form.text_area :prompt, required: true, placeholder: "Describe the tone, structure, components, and any rules for this kind of page." %>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="jb-actions">
|
|
13
|
+
<%= form.submit class: "jb-button" %>
|
|
14
|
+
<%= link_to "Back", admin_ai_style_prompts_path, class: "jb-button jb-button-secondary" %>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<header class="jb-header">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="jb-eyebrow">AI Builder</p>
|
|
4
|
+
<h1 class="jb-title">Style prompts</h1>
|
|
5
|
+
<p class="jb-subtitle">Create reusable instructions for specific page types, such as feature pages or articles.</p>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="jb-actions">
|
|
8
|
+
<%= link_to "Back to AI Builder", new_admin_ai_page_path, class: "jb-button jb-button-secondary" %>
|
|
9
|
+
<%= link_to "New style prompt", new_admin_ai_style_prompt_path, class: "jb-button" %>
|
|
10
|
+
</div>
|
|
11
|
+
</header>
|
|
12
|
+
|
|
13
|
+
<section class="jb-card jb-card-flush">
|
|
14
|
+
<table class="jb-table">
|
|
15
|
+
<thead><tr><th>Name</th><th>Instructions</th><th></th></tr></thead>
|
|
16
|
+
<tbody>
|
|
17
|
+
<% @style_prompts.each do |style_prompt| %>
|
|
18
|
+
<tr>
|
|
19
|
+
<td><strong><%= style_prompt.name %></strong></td>
|
|
20
|
+
<td><%= truncate(style_prompt.prompt, length: 70) %></td>
|
|
21
|
+
<td><div class="jb-actions"><%= link_to "Edit", edit_admin_ai_style_prompt_path(style_prompt), class: "jb-button jb-button-secondary" %><%= button_to "Delete", admin_ai_style_prompt_path(style_prompt), method: :delete, class: "jb-button jb-button-danger", data: { turbo_confirm: "Delete this style prompt?" } %></div></td>
|
|
22
|
+
</tr>
|
|
23
|
+
<% end %>
|
|
24
|
+
</tbody>
|
|
25
|
+
</table>
|
|
26
|
+
</section>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<div class="jb-component jb-card">
|
|
2
|
+
<div>
|
|
3
|
+
<code>{{ <%= component.name %> }}</code>
|
|
4
|
+
<% if component.metadata[:description].present? %>
|
|
5
|
+
<p class="jb-subtitle"><%= component.metadata[:description] %></p>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<% props = component.normalized_props %>
|
|
9
|
+
<% if props.present? %>
|
|
10
|
+
<div>
|
|
11
|
+
<% props.each do |name, prop| %>
|
|
12
|
+
<span class="jb-badge"><%= name %>: <%= prop[:description].presence || prop[:type].to_s.humanize.downcase %></span>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
16
|
+
<pre class="jb-code"><%= "{{ #{component.name} #{props.keys.map { |key| %(#{key}=\"...\") }.join(" ")} }}" %></pre>
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<aside class="jb-card jb-reference">
|
|
2
|
+
<p class="jb-eyebrow">Component reference</p>
|
|
3
|
+
<p class="jb-subtitle">Paste snippets into the Markdown file. Components render through the host app.</p>
|
|
4
|
+
<div class="jb-grid jb-reference-list">
|
|
5
|
+
<% Journeybook.components.for_site(current_site).each do |component| %>
|
|
6
|
+
<%= render "journeybook/admin/components/component", component: component %>
|
|
7
|
+
<% end %>
|
|
8
|
+
</div>
|
|
9
|
+
</aside>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<% content_for :main_class, "jb-main-wide" %>
|
|
2
|
+
|
|
3
|
+
<header class="jb-header">
|
|
4
|
+
<div>
|
|
5
|
+
<p class="jb-eyebrow">Components</p>
|
|
6
|
+
<h1 class="jb-title">Design library</h1>
|
|
7
|
+
<p class="jb-subtitle">These registered components are available in Markdown and in the AI page builder.</p>
|
|
8
|
+
</div>
|
|
9
|
+
</header>
|
|
10
|
+
|
|
11
|
+
<section class="jb-component-library">
|
|
12
|
+
<aside class="jb-card jb-component-picker" aria-label="Components">
|
|
13
|
+
<p class="jb-eyebrow">Select component</p>
|
|
14
|
+
<nav class="jb-component-nav">
|
|
15
|
+
<% @components.each do |component| %>
|
|
16
|
+
<%= link_to admin_components_path(component: component.name, site_id: current_site&.id), class: ("is-active" if component == @selected_component), "aria-current": ("page" if component == @selected_component) do %>
|
|
17
|
+
<strong><%= component.name %></strong>
|
|
18
|
+
<% if component.metadata[:description].present? %>
|
|
19
|
+
<span><%= component.metadata[:description] %></span>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</nav>
|
|
24
|
+
</aside>
|
|
25
|
+
|
|
26
|
+
<div class="jb-grid">
|
|
27
|
+
<% if @selected_component %>
|
|
28
|
+
<section class="jb-card jb-component-preview-card">
|
|
29
|
+
<header class="jb-header">
|
|
30
|
+
<div>
|
|
31
|
+
<p class="jb-eyebrow">Preview</p>
|
|
32
|
+
<h2 class="jb-card-title"><code>{{ <%= @selected_component.name %> }}</code></h2>
|
|
33
|
+
</div>
|
|
34
|
+
</header>
|
|
35
|
+
|
|
36
|
+
<iframe class="jb-preview jb-component-preview" src="<%= admin_component_preview_path(@selected_component.name, site_id: current_site&.id) %>" title="<%= @selected_component.name %> preview"></iframe>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<%= render "component", component: @selected_component %>
|
|
40
|
+
<% else %>
|
|
41
|
+
<section class="jb-card">
|
|
42
|
+
<p class="jb-subtitle">No components are registered for the current site.</p>
|
|
43
|
+
</section>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<% content_for :title, "#{@selected_component&.name || "Component"} preview" %>
|
|
2
|
+
<% content_for :journeybook_component_preview, true %>
|
|
3
|
+
|
|
4
|
+
<article class="journeybook-page journeybook-component-preview">
|
|
5
|
+
<% if @preview_error.present? %>
|
|
6
|
+
<section>
|
|
7
|
+
<h1>Preview could not be rendered.</h1>
|
|
8
|
+
<p><%= @preview_error %></p>
|
|
9
|
+
</section>
|
|
10
|
+
<% else %>
|
|
11
|
+
<%= @preview_html.html_safe %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</article>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<header class="jb-header">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="jb-eyebrow">Admin</p>
|
|
4
|
+
<h1 class="jb-title">Journeybook</h1>
|
|
5
|
+
<p class="jb-subtitle">Create Markdown pages, compose them with registered Ruby components, and generate AI drafts from the same component library.</p>
|
|
6
|
+
</div>
|
|
7
|
+
<%= link_to "New page", new_admin_page_path, class: "jb-button" %>
|
|
8
|
+
</header>
|
|
9
|
+
|
|
10
|
+
<section class="jb-grid jb-grid-3">
|
|
11
|
+
<div class="jb-card jb-stat">
|
|
12
|
+
<strong><%= @site_count %></strong>
|
|
13
|
+
<span class="jb-stat-label">Sites</span>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="jb-card jb-stat">
|
|
16
|
+
<strong><%= @published_page_count %>/<%= @page_count %></strong>
|
|
17
|
+
<span class="jb-stat-label">Published pages</span>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="jb-card jb-stat">
|
|
20
|
+
<strong><%= @component_count %></strong>
|
|
21
|
+
<span class="jb-stat-label">Components</span>
|
|
22
|
+
</div>
|
|
23
|
+
</section>
|
|
24
|
+
|
|
25
|
+
<section class="jb-card jb-section">
|
|
26
|
+
<div class="jb-header">
|
|
27
|
+
<div>
|
|
28
|
+
<p class="jb-eyebrow">Recent</p>
|
|
29
|
+
<h2 class="jb-card-title">Recently updated pages</h2>
|
|
30
|
+
</div>
|
|
31
|
+
<%= link_to "AI Builder", new_admin_ai_page_path, class: "jb-button jb-button-secondary" %>
|
|
32
|
+
</div>
|
|
33
|
+
<%= render "journeybook/admin/pages/table", pages: @recent_pages %>
|
|
34
|
+
</section>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<aside class="jb-card jb-reference jb-media-reference">
|
|
2
|
+
<p class="jb-eyebrow">Image library</p>
|
|
3
|
+
<p class="jb-subtitle">Paste image URLs into component props.</p>
|
|
4
|
+
<div class="jb-grid jb-reference-list">
|
|
5
|
+
<% Journeybook::MediaAsset.order(created_at: :desc).limit(12).each do |asset| %>
|
|
6
|
+
<div class="jb-media-reference-item">
|
|
7
|
+
<%= image_tag asset.public_url, alt: asset.alt_text.presence || asset.title, class: "jb-media-reference-thumb" %>
|
|
8
|
+
<strong><%= asset.title %></strong>
|
|
9
|
+
<code>image_url="<%= asset.public_url %>"</code>
|
|
10
|
+
<% if asset.alt_text.present? %>
|
|
11
|
+
<code>image_alt="<%= asset.alt_text %>"</code>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
</aside>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<header class="jb-header">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="jb-eyebrow">Library</p>
|
|
4
|
+
<h1 class="jb-title">Image library</h1>
|
|
5
|
+
<p class="jb-subtitle">Global public images uploaded to S3 for use in Journeybook components and AI-generated drafts.</p>
|
|
6
|
+
</div>
|
|
7
|
+
<%= link_to "Upload image", new_admin_media_asset_path, class: "jb-button" %>
|
|
8
|
+
</header>
|
|
9
|
+
|
|
10
|
+
<% if @media_assets.any? %>
|
|
11
|
+
<section class="jb-media-grid">
|
|
12
|
+
<% @media_assets.each do |asset| %>
|
|
13
|
+
<article class="jb-card jb-media-card">
|
|
14
|
+
<%= image_tag asset.public_url, alt: asset.alt_text.presence || asset.title, class: "jb-media-thumb" %>
|
|
15
|
+
<div class="jb-media-body">
|
|
16
|
+
<h2 class="jb-card-title"><%= asset.title %></h2>
|
|
17
|
+
<p class="jb-subtitle"><%= asset.original_filename %></p>
|
|
18
|
+
<% if asset.alt_text.present? %>
|
|
19
|
+
<p><strong>Alt:</strong> <%= asset.alt_text %></p>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% if asset.tags.present? %>
|
|
22
|
+
<p><strong>Tags:</strong> <%= asset.tags %></p>
|
|
23
|
+
<% end %>
|
|
24
|
+
<div class="jb-media-snippets">
|
|
25
|
+
<code>image_url="<%= asset.public_url %>"</code>
|
|
26
|
+
<% if asset.alt_text.present? %>
|
|
27
|
+
<code>image_alt="<%= asset.alt_text %>"</code>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="jb-actions">
|
|
31
|
+
<%= link_to "Open", asset.public_url, class: "jb-button jb-button-secondary", target: "_blank", rel: "noopener" %>
|
|
32
|
+
<%= button_to "Delete", admin_media_asset_path(asset), method: :delete, class: "jb-button jb-button-danger", form: { data: { turbo_confirm: "Delete this image from S3 and the library?" } } %>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</article>
|
|
36
|
+
<% end %>
|
|
37
|
+
</section>
|
|
38
|
+
<% else %>
|
|
39
|
+
<section class="jb-card">
|
|
40
|
+
<h2 class="jb-card-title">No images yet</h2>
|
|
41
|
+
<p class="jb-subtitle">Upload the first image to make it available to editors and the AI Builder.</p>
|
|
42
|
+
</section>
|
|
43
|
+
<% end %>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<header class="jb-header">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="jb-eyebrow">Library</p>
|
|
4
|
+
<h1 class="jb-title">Upload image</h1>
|
|
5
|
+
</div>
|
|
6
|
+
</header>
|
|
7
|
+
|
|
8
|
+
<%= form_with model: [:admin, @media_asset], class: "jb-form jb-card", multipart: true do |form| %>
|
|
9
|
+
<%= render "journeybook/admin/shared/errors", record: @media_asset %>
|
|
10
|
+
|
|
11
|
+
<div class="jb-field">
|
|
12
|
+
<%= form.label :file, "Image file" %>
|
|
13
|
+
<%= form.file_field :file, accept: Journeybook::MediaAsset::IMAGE_CONTENT_TYPES.join(","), required: true %>
|
|
14
|
+
<small class="jb-help">Supported formats: PNG, JPEG, WebP, GIF, and SVG.</small>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="jb-fields">
|
|
18
|
+
<div class="jb-field">
|
|
19
|
+
<%= form.label :title %>
|
|
20
|
+
<%= form.text_field :title, placeholder: "Defaults to the filename" %>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="jb-field">
|
|
23
|
+
<%= form.label :alt_text %>
|
|
24
|
+
<%= form.text_field :alt_text %>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="jb-field">
|
|
27
|
+
<%= form.label :tags %>
|
|
28
|
+
<%= form.text_field :tags, placeholder: "hero, team, product" %>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="jb-field jb-field-short">
|
|
33
|
+
<%= form.label :description %>
|
|
34
|
+
<%= form.text_area :description %>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="jb-actions">
|
|
38
|
+
<%= form.submit "Upload image", class: "jb-button" %>
|
|
39
|
+
<%= link_to "Back", admin_media_assets_path, class: "jb-button jb-button-secondary" %>
|
|
40
|
+
</div>
|
|
41
|
+
<% end %>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<%= form_with model: [:admin, menu, item], class: "jb-form jb-card" do |form| %>
|
|
2
|
+
<%= render "journeybook/admin/shared/errors", record: item %>
|
|
3
|
+
<% link_type = item.page_id.present? ? "internal" : "external" %>
|
|
4
|
+
<div class="jb-fields">
|
|
5
|
+
<div class="jb-field"><%= form.label :label %><%= form.text_field :label, required: true %></div>
|
|
6
|
+
<div class="jb-field">
|
|
7
|
+
<label for="navigation_link_type">Link type</label>
|
|
8
|
+
<%= select_tag :navigation_link_type, options_for_select([["Journeybook page", "internal"], ["External URL", "external"]], link_type), id: "navigation_link_type", data: { navigation_link_type: true } %>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="jb-field" data-navigation-internal <%= "hidden" if link_type == "external" %>>
|
|
11
|
+
<%= form.label :page_id, "Page" %>
|
|
12
|
+
<%= form.collection_select :page_id, pages, :id, :title, { include_blank: "Select a page" }, disabled: link_type == "external" %>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="jb-field" data-navigation-external <%= "hidden" if link_type == "internal" %>>
|
|
15
|
+
<%= form.label :external_url, "External URL" %>
|
|
16
|
+
<%= form.text_field :external_url, placeholder: "https://example.com or /#contact", disabled: link_type == "internal" %>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="jb-field"><%= form.label :description %><%= form.text_area :description, rows: 3 %></div>
|
|
19
|
+
<div></div>
|
|
20
|
+
<div>
|
|
21
|
+
<label class="jb-checkbox-field"><%= form.check_box :visible %><span>Visible</span></label>
|
|
22
|
+
<label class="jb-checkbox-field"><%= form.check_box :open_in_new_window %><span>Open in new window</span></label>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="jb-actions"><%= form.submit class: "jb-button" %><%= link_to "Back", edit_admin_navigation_menu_path(menu), class: "jb-button jb-button-secondary" %></div>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<%= javascript_tag nonce: true do %>
|
|
29
|
+
(() => {
|
|
30
|
+
const type = document.querySelector("[data-navigation-link-type]");
|
|
31
|
+
const internal = document.querySelector("[data-navigation-internal]");
|
|
32
|
+
const external = document.querySelector("[data-navigation-external]");
|
|
33
|
+
if (!type || !internal || !external) return;
|
|
34
|
+
|
|
35
|
+
const sync = () => {
|
|
36
|
+
const internalLink = type.value === "internal";
|
|
37
|
+
internal.hidden = !internalLink;
|
|
38
|
+
external.hidden = internalLink;
|
|
39
|
+
internal.querySelectorAll("select, input").forEach((field) => { field.disabled = !internalLink; });
|
|
40
|
+
external.querySelectorAll("select, input").forEach((field) => { field.disabled = internalLink; });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type.addEventListener("change", sync);
|
|
44
|
+
sync();
|
|
45
|
+
})();
|
|
46
|
+
<% end %>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<header class="jb-header"><div><p class="jb-eyebrow"><%= @menu.title %></p><h1 class="jb-title">Edit menu item</h1></div><%= button_to "Delete item", admin_navigation_menu_navigation_item_path(@menu, @item), method: :delete, class: "jb-button jb-button-danger", data: { turbo_confirm: "Delete this item?" } %></header>
|
|
2
|
+
<%= render "form", menu: @menu, item: @item, pages: @pages %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= form_with model: [:admin, menu], class: "jb-form jb-card" do |form| %>
|
|
2
|
+
<%= render "journeybook/admin/shared/errors", record: menu %>
|
|
3
|
+
<div class="jb-fields">
|
|
4
|
+
<div class="jb-field"><%= form.label :title %><%= form.text_field :title, required: true, placeholder: "Footer: Product" %></div>
|
|
5
|
+
<div class="jb-field"><%= form.label :key, "Layout key" %><%= form.text_field :key, required: true, placeholder: "footer_product" %></div>
|
|
6
|
+
<div class="jb-field"><%= form.label :position %><%= form.number_field :position, min: 0 %></div>
|
|
7
|
+
</div>
|
|
8
|
+
<p class="jb-subtitle">The host layout renders a menu by its key. Use stable keys such as <code>header_learn</code> or <code>footer_product</code>.</p>
|
|
9
|
+
<div class="jb-actions"><%= form.submit class: "jb-button" %><%= link_to "Back", admin_navigation_menus_path, class: "jb-button jb-button-secondary" %></div>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<header class="jb-header">
|
|
2
|
+
<div><p class="jb-eyebrow">Navigation</p><h1 class="jb-title"><%= @menu.title %></h1></div>
|
|
3
|
+
<%= button_to "Delete menu", admin_navigation_menu_path(@menu), method: :delete, class: "jb-button jb-button-danger", data: { turbo_confirm: "Delete this menu and all of its items?" } %>
|
|
4
|
+
</header>
|
|
5
|
+
|
|
6
|
+
<%= render "form", menu: @menu %>
|
|
7
|
+
|
|
8
|
+
<section class="jb-section-lg">
|
|
9
|
+
<div class="jb-header">
|
|
10
|
+
<div>
|
|
11
|
+
<h2 class="jb-card-title">Menu items</h2>
|
|
12
|
+
<p class="jb-subtitle">Items can link to a Journeybook page or to an external URL. Drag the handle to reorder.</p>
|
|
13
|
+
</div>
|
|
14
|
+
<%= link_to "Add item", new_admin_navigation_menu_navigation_item_path(@menu), class: "jb-button" %>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="jb-card jb-card-flush">
|
|
18
|
+
<% if @items.any? %>
|
|
19
|
+
<div class="jb-sortable-list" data-sortable-list data-sortable-url="<%= reorder_admin_navigation_menu_navigation_items_path(@menu) %>" data-sortable-param="item_ids">
|
|
20
|
+
<% @items.each do |item| %>
|
|
21
|
+
<div class="jb-sortable-item" data-sortable-item data-sortable-id="<%= item.id %>" draggable="true">
|
|
22
|
+
<%= render "journeybook/admin/shared/drag_handle" %>
|
|
23
|
+
<div class="jb-sortable-item-body">
|
|
24
|
+
<strong><%= item.label %></strong>
|
|
25
|
+
<% if item.description.present? %><span class="jb-subtitle"><%= item.description %></span><% end %>
|
|
26
|
+
<small><%= item.page ? item.page.path : item.external_url %> · <%= item.visible? ? "Visible" : "Hidden" %></small>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="jb-actions">
|
|
29
|
+
<%= link_to "Edit", edit_admin_navigation_menu_navigation_item_path(@menu, item), class: "jb-button jb-button-secondary" %>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
<% else %>
|
|
35
|
+
<p class="jb-subtitle">This menu has no items yet.</p>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
38
|
+
</section>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<header class="jb-header">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="jb-eyebrow">Navigation</p>
|
|
4
|
+
<h1 class="jb-title">Navigation menus</h1>
|
|
5
|
+
<p class="jb-subtitle">Create menu slots used by the host layout, then add pages or external links.</p>
|
|
6
|
+
</div>
|
|
7
|
+
<%= link_to "New menu", new_admin_navigation_menu_path, class: "jb-button" %>
|
|
8
|
+
</header>
|
|
9
|
+
|
|
10
|
+
<section class="jb-card jb-card-flush">
|
|
11
|
+
<% if @menus.any? %>
|
|
12
|
+
<table class="jb-table">
|
|
13
|
+
<thead><tr><th>Title</th><th>Key</th><th>Items</th><th></th></tr></thead>
|
|
14
|
+
<tbody>
|
|
15
|
+
<% @menus.each do |menu| %>
|
|
16
|
+
<tr>
|
|
17
|
+
<td><strong><%= menu.title %></strong></td>
|
|
18
|
+
<td><code><%= menu.key %></code></td>
|
|
19
|
+
<td><%= menu.items.count %></td>
|
|
20
|
+
<td><%= link_to "Edit", edit_admin_navigation_menu_path(menu), class: "jb-button jb-button-secondary" %></td>
|
|
21
|
+
</tr>
|
|
22
|
+
<% end %>
|
|
23
|
+
</tbody>
|
|
24
|
+
</table>
|
|
25
|
+
<% else %>
|
|
26
|
+
<p class="jb-subtitle">No navigation menus have been configured for this site.</p>
|
|
27
|
+
<% end %>
|
|
28
|
+
</section>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<% items = JSON.parse(value.presence || "[]") rescue [] %>
|
|
2
|
+
<div class="jb-collection" data-collection data-collection-min="<%= prop[:min] %>" data-collection-max="<%= prop[:max] %>" data-collection-schema="<%= ERB::Util.json_escape(prop[:item].to_json) %>" data-collection-pages="<%= ERB::Util.json_escape(@linkable_pages.map { |page| { title: page.title, path: page.path, value: "journeybook://page/#{page.id}" } }.to_json) %>">
|
|
3
|
+
<input type="hidden" name="component_props[<%= name %>]" id="component_props_<%= name %>" value="<%= items.to_json %>" data-collection-input>
|
|
4
|
+
<div class="jb-collection-items" data-collection-items>
|
|
5
|
+
<% items.each_with_index do |item, index| %>
|
|
6
|
+
<fieldset class="jb-collection-item" data-collection-item draggable="true">
|
|
7
|
+
<legend><%= prop[:label] %> <%= index + 1 %></legend>
|
|
8
|
+
<button type="button" class="jb-collection-remove" data-collection-remove aria-label="Remove <%= prop[:label].downcase %> <%= index + 1 %>" title="Remove">×</button>
|
|
9
|
+
<%= render "journeybook/admin/shared/drag_handle" %>
|
|
10
|
+
<div class="jb-collection-fields">
|
|
11
|
+
<% prop[:item].each do |item_name, item_prop| %>
|
|
12
|
+
<% item_value = item[item_name.to_s] || item[item_name.to_sym] %>
|
|
13
|
+
<label><%= item_prop[:label] %>
|
|
14
|
+
<% if item_prop[:type] == :long_text %>
|
|
15
|
+
<textarea rows="3" data-collection-field data-collection-key="<%= item_name %>"><%= item_value %></textarea>
|
|
16
|
+
<% elsif item_prop[:type] == :select %>
|
|
17
|
+
<select data-collection-field data-collection-key="<%= item_name %>"><%= options_for_select(item_prop[:options], item_value) %></select>
|
|
18
|
+
<% elsif item_prop[:type] == :image %>
|
|
19
|
+
<% icon_parts = item_value.to_s.match(%r{\Aicon:([a-z0-9-]+):([a-z0-9-]+):([a-z0-9-]+)\z})&.captures %>
|
|
20
|
+
<input type="hidden" value="<%= item_value %>" data-collection-field data-collection-key="<%= item_name %>">
|
|
21
|
+
<% if @media_assets.any? %>
|
|
22
|
+
<button type="button" class="jb-image-picker-trigger" data-image-picker-open>
|
|
23
|
+
<% if item_value.present? && !icon_parts %><%= image_tag item_value, alt: "", class: "jb-image-picker-preview" %><% end %>
|
|
24
|
+
<span data-image-picker-label><%= item_value.present? ? "Change image" : "Select image" %></span>
|
|
25
|
+
</button>
|
|
26
|
+
<% else %>
|
|
27
|
+
<%= link_to "Add an image to the library", admin_media_assets_path, class: "jb-link-button" %>
|
|
28
|
+
<% end %>
|
|
29
|
+
<% if item_prop[:icons] %>
|
|
30
|
+
<div class="jb-icon-editor" data-icon-editor>
|
|
31
|
+
<select data-icon-mode><option value="image" <%= "selected" unless icon_parts %>>Image</option><option value="icon" <%= "selected" if icon_parts %>>Font Awesome icon</option></select>
|
|
32
|
+
<input type="text" value="<%= icon_parts&.last %>" placeholder="user-cowboy" data-icon-identifier>
|
|
33
|
+
<select data-icon-style><%= options_for_select(item_prop[:icons][:styles], icon_parts&.first) %></select>
|
|
34
|
+
<select data-icon-color><%= options_for_select(item_prop[:icons][:colors], icon_parts&.second) %></select>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% elsif item_prop[:type] == :url %>
|
|
38
|
+
<% page_id = item_value.to_s[%r{\Ajourneybook://page/(\d+)\z}, 1] %>
|
|
39
|
+
<div class="jb-url-editor" data-collection-url-editor>
|
|
40
|
+
<input type="hidden" value="<%= item_value %>" data-collection-field data-collection-key="<%= item_name %>" data-url-value>
|
|
41
|
+
<select data-url-kind><option value="internal" <%= "selected" if page_id %>>Journeybook page</option><option value="external" <%= "selected" unless page_id %>>External URL</option></select>
|
|
42
|
+
<select data-url-page data-url-internal><option value="">Select a page</option><%= options_for_select(@linkable_pages.map { |page| [ "#{page.title} (#{page.path})", "journeybook://page/#{page.id}" ] }, "journeybook://page/#{page_id}") %></select>
|
|
43
|
+
<input type="text" value="<%= page_id ? "" : item_value %>" placeholder="https://example.com" data-url-external data-url-external-input>
|
|
44
|
+
<select data-collection-field data-collection-key="<%= item_name %>_target" data-url-target><option value="same_window">Current window</option><option value="open_new_window" <%= "selected" if item["#{item_name}_target"] == "open_new_window" || item["#{item_name}_target".to_sym] == "open_new_window" %>>New window</option></select>
|
|
45
|
+
</div>
|
|
46
|
+
<% else %>
|
|
47
|
+
<input type="text" value="<%= item_value %>" data-collection-field data-collection-key="<%= item_name %>">
|
|
48
|
+
<% end %>
|
|
49
|
+
</label>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
52
|
+
</fieldset>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
55
|
+
<div>
|
|
56
|
+
<button type="button" class="jb-button" data-collection-add>+ Add item</button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|