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.
Files changed (134) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +354 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/images/journeybook/.keep +0 -0
  7. data/app/assets/images/journeybook/logo.svg +5 -0
  8. data/app/assets/javascripts/journeybook/sortable.js +96 -0
  9. data/app/assets/stylesheets/journeybook/application.css +1556 -0
  10. data/app/controllers/concerns/.keep +0 -0
  11. data/app/controllers/concerns/journeybook/public_pages.rb +31 -0
  12. data/app/controllers/journeybook/admin/ai_pages_controller.rb +65 -0
  13. data/app/controllers/journeybook/admin/ai_style_prompts_controller.rb +51 -0
  14. data/app/controllers/journeybook/admin/components_controller.rb +57 -0
  15. data/app/controllers/journeybook/admin/dashboard_controller.rb +13 -0
  16. data/app/controllers/journeybook/admin/media_assets_controller.rb +59 -0
  17. data/app/controllers/journeybook/admin/navigation_items_controller.rb +91 -0
  18. data/app/controllers/journeybook/admin/navigation_menus_controller.rb +59 -0
  19. data/app/controllers/journeybook/admin/pages_controller.rb +304 -0
  20. data/app/controllers/journeybook/admin/redirects_controller.rb +51 -0
  21. data/app/controllers/journeybook/admin/sites_controller.rb +51 -0
  22. data/app/controllers/journeybook/application_controller.rb +52 -0
  23. data/app/controllers/journeybook/form_submissions_controller.rb +55 -0
  24. data/app/helpers/journeybook/application_helper.rb +4 -0
  25. data/app/helpers/journeybook/navigation_helper.rb +11 -0
  26. data/app/javascript/journeybook/application.js +5 -0
  27. data/app/javascript/journeybook/component_search_controller.js +13 -0
  28. data/app/jobs/journeybook/application_job.rb +4 -0
  29. data/app/models/concerns/.keep +0 -0
  30. data/app/models/journeybook/ai_style_prompt.rb +6 -0
  31. data/app/models/journeybook/application_record.rb +5 -0
  32. data/app/models/journeybook/media_asset.rb +26 -0
  33. data/app/models/journeybook/navigation_item.rb +45 -0
  34. data/app/models/journeybook/navigation_menu.rb +23 -0
  35. data/app/models/journeybook/page.rb +96 -0
  36. data/app/models/journeybook/redirect.rb +31 -0
  37. data/app/models/journeybook/site.rb +62 -0
  38. data/app/services/journeybook/ai/markdown_generator.rb +15 -0
  39. data/app/services/journeybook/ai/openai_client.rb +54 -0
  40. data/app/services/journeybook/ai/prompt_builder.rb +154 -0
  41. data/app/services/journeybook/ai/template_validator.rb +17 -0
  42. data/app/services/journeybook/component_parser.rb +53 -0
  43. data/app/services/journeybook/component_renderer.rb +108 -0
  44. data/app/services/journeybook/component_tag_updater.rb +132 -0
  45. data/app/services/journeybook/component_validator.rb +111 -0
  46. data/app/services/journeybook/form_mailer.rb +69 -0
  47. data/app/services/journeybook/html_sanitizer.rb +46 -0
  48. data/app/services/journeybook/letter_opener_client.rb +14 -0
  49. data/app/services/journeybook/markdown_renderer.rb +29 -0
  50. data/app/services/journeybook/page_renderer.rb +58 -0
  51. data/app/services/journeybook/page_resolver.rb +55 -0
  52. data/app/services/journeybook/postmark_client.rb +48 -0
  53. data/app/services/journeybook/storage/s3_client.rb +96 -0
  54. data/app/views/journeybook/admin/ai_pages/_draft_form.html.erb +73 -0
  55. data/app/views/journeybook/admin/ai_pages/new.html.erb +43 -0
  56. data/app/views/journeybook/admin/ai_style_prompts/_form.html.erb +16 -0
  57. data/app/views/journeybook/admin/ai_style_prompts/edit.html.erb +2 -0
  58. data/app/views/journeybook/admin/ai_style_prompts/index.html.erb +26 -0
  59. data/app/views/journeybook/admin/ai_style_prompts/new.html.erb +2 -0
  60. data/app/views/journeybook/admin/components/_component.html.erb +17 -0
  61. data/app/views/journeybook/admin/components/_reference.html.erb +9 -0
  62. data/app/views/journeybook/admin/components/index.html.erb +46 -0
  63. data/app/views/journeybook/admin/components/preview.html.erb +13 -0
  64. data/app/views/journeybook/admin/dashboard/show.html.erb +34 -0
  65. data/app/views/journeybook/admin/media_assets/_reference.html.erb +16 -0
  66. data/app/views/journeybook/admin/media_assets/index.html.erb +43 -0
  67. data/app/views/journeybook/admin/media_assets/new.html.erb +41 -0
  68. data/app/views/journeybook/admin/navigation_items/_form.html.erb +46 -0
  69. data/app/views/journeybook/admin/navigation_items/edit.html.erb +2 -0
  70. data/app/views/journeybook/admin/navigation_items/new.html.erb +2 -0
  71. data/app/views/journeybook/admin/navigation_menus/_form.html.erb +10 -0
  72. data/app/views/journeybook/admin/navigation_menus/edit.html.erb +38 -0
  73. data/app/views/journeybook/admin/navigation_menus/index.html.erb +28 -0
  74. data/app/views/journeybook/admin/navigation_menus/new.html.erb +2 -0
  75. data/app/views/journeybook/admin/pages/_collection_field.html.erb +58 -0
  76. data/app/views/journeybook/admin/pages/_form.html.erb +77 -0
  77. data/app/views/journeybook/admin/pages/_image_picker_modal.html.erb +23 -0
  78. data/app/views/journeybook/admin/pages/_table.html.erb +37 -0
  79. data/app/views/journeybook/admin/pages/_url_field.html.erb +25 -0
  80. data/app/views/journeybook/admin/pages/edit.html.erb +547 -0
  81. data/app/views/journeybook/admin/pages/errors.html.erb +25 -0
  82. data/app/views/journeybook/admin/pages/index.html.erb +52 -0
  83. data/app/views/journeybook/admin/pages/new.html.erb +27 -0
  84. data/app/views/journeybook/admin/pages/new_component.html.erb +70 -0
  85. data/app/views/journeybook/admin/pages/public_preview.html.erb +27 -0
  86. data/app/views/journeybook/admin/pages/source.html.erb +23 -0
  87. data/app/views/journeybook/admin/redirects/_form.html.erb +9 -0
  88. data/app/views/journeybook/admin/redirects/edit.html.erb +2 -0
  89. data/app/views/journeybook/admin/redirects/index.html.erb +15 -0
  90. data/app/views/journeybook/admin/redirects/new.html.erb +2 -0
  91. data/app/views/journeybook/admin/shared/_drag_handle.html.erb +3 -0
  92. data/app/views/journeybook/admin/shared/_errors.html.erb +10 -0
  93. data/app/views/journeybook/admin/sites/_form.html.erb +12 -0
  94. data/app/views/journeybook/admin/sites/edit.html.erb +2 -0
  95. data/app/views/journeybook/admin/sites/index.html.erb +26 -0
  96. data/app/views/journeybook/admin/sites/new.html.erb +2 -0
  97. data/app/views/layouts/journeybook/application.html.erb +57 -0
  98. data/config/importmap.rb +3 -0
  99. data/config/routes.rb +39 -0
  100. data/db/migrate/20260627000001_create_journeybook_sites.rb +13 -0
  101. data/db/migrate/20260627000002_create_journeybook_pages.rb +23 -0
  102. data/db/migrate/20260627000003_create_journeybook_redirects.rb +13 -0
  103. data/db/migrate/20260628000004_add_context_fields_to_journeybook_sites.rb +38 -0
  104. data/db/migrate/20260628000005_create_journeybook_media_assets.rb +24 -0
  105. data/db/migrate/20260629000006_create_journeybook_navigation.rb +28 -0
  106. data/db/migrate/20260731000007_add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
  107. data/db/migrate/20260731000008_create_journeybook_ai_style_prompts.rb +12 -0
  108. data/db/migrate/20260801000009_add_blog_fields_to_journeybook_pages.rb +9 -0
  109. data/db/migrate/20260801000010_add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
  110. data/db/migrate/20260801000011_add_position_to_journeybook_pages.rb +6 -0
  111. data/lib/generators/journeybook/install/install_generator.rb +72 -0
  112. data/lib/generators/journeybook/install/templates/add_blog_fields_to_journeybook_pages.rb +9 -0
  113. data/lib/generators/journeybook/install/templates/add_context_fields_to_journeybook_sites.rb +38 -0
  114. data/lib/generators/journeybook/install/templates/add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
  115. data/lib/generators/journeybook/install/templates/add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
  116. data/lib/generators/journeybook/install/templates/add_position_to_journeybook_pages.rb +6 -0
  117. data/lib/generators/journeybook/install/templates/create_journeybook_ai_style_prompts.rb +12 -0
  118. data/lib/generators/journeybook/install/templates/create_journeybook_media_assets.rb +24 -0
  119. data/lib/generators/journeybook/install/templates/create_journeybook_navigation.rb +28 -0
  120. data/lib/generators/journeybook/install/templates/create_journeybook_pages.rb +33 -0
  121. data/lib/generators/journeybook/install/templates/create_journeybook_redirects.rb +13 -0
  122. data/lib/generators/journeybook/install/templates/create_journeybook_sites.rb +13 -0
  123. data/lib/generators/journeybook/install/templates/journeybook.rb +42 -0
  124. data/lib/generators/journeybook/install/templates/pages_controller.rb +3 -0
  125. data/lib/generators/journeybook/install/templates/show.html.erb +10 -0
  126. data/lib/journeybook/component_registry.rb +136 -0
  127. data/lib/journeybook/configuration.rb +55 -0
  128. data/lib/journeybook/engine.rb +19 -0
  129. data/lib/journeybook/page_template_registry.rb +67 -0
  130. data/lib/journeybook/public_page_constraint.rb +23 -0
  131. data/lib/journeybook/version.rb +3 -0
  132. data/lib/journeybook.rb +62 -0
  133. data/lib/tasks/journeybook_tasks.rake +8 -0
  134. metadata +266 -0
@@ -0,0 +1,547 @@
1
+ <% content_for :main_class, "jb-main-wide jb-editor-main" %>
2
+
3
+ <div class="jb-page-editor">
4
+ <aside class="jb-editor-sidebar" aria-label="Page editor">
5
+ <% if @selected_page_component %>
6
+ <div class="jb-editor-pagebar">
7
+ <%= link_to "← Back to page", edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), class: "jb-button jb-button-secondary" %>
8
+ </div>
9
+ <% else %>
10
+ <div class="jb-editor-pagebar">
11
+ <details class="jb-page-switcher">
12
+ <summary>
13
+ <span><%= @page.title %></span>
14
+ </summary>
15
+ <div class="jb-page-switcher-menu">
16
+ <% @editor_pages.each do |page| %>
17
+ <%= link_to edit_admin_page_path(page, site_id: page.site_id, tab: @editor_tab), class: ("is-active" if page == @page) do %>
18
+ <strong><%= page.title %></strong>
19
+ <span><%= page.path %></span>
20
+ <% end %>
21
+ <% end %>
22
+ </div>
23
+ </details>
24
+ </div>
25
+
26
+ <div class="jb-editor-tabs" role="tablist" aria-label="Editor sections">
27
+ <%= link_to "Layout", edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), class: ("is-active" if @editor_tab == "layout"), role: "tab", "aria-selected": (@editor_tab == "layout") %>
28
+ <%= link_to "Settings", edit_admin_page_path(@page, site_id: @page.site_id, tab: "settings"), class: ("is-active" if @editor_tab == "settings"), role: "tab", "aria-selected": (@editor_tab == "settings") %>
29
+ </div>
30
+ <% end %>
31
+
32
+ <%= render "journeybook/admin/shared/errors", record: @page %>
33
+
34
+ <% if @editor_tab == "settings" %>
35
+ <%= form_with model: [:admin, @page], url: admin_page_path(@page, tab: "settings"), method: :patch, class: "jb-form jb-editor-panel", data: { live_preview_form: true } do |form| %>
36
+ <div class="jb-field">
37
+ <%= form.label :site_id %>
38
+ <%= form.collection_select :site_id, @sites, :id, :name %>
39
+ </div>
40
+ <div class="jb-field">
41
+ <%= form.label :page_type %>
42
+ <%= form.select :page_type, Journeybook::Page::PAGE_TYPES %>
43
+ </div>
44
+ <div class="jb-field">
45
+ <%= form.label :status %>
46
+ <%= form.select :status, Journeybook::Page::STATUSES %>
47
+ </div>
48
+ <div class="jb-field">
49
+ <%= form.label :title %>
50
+ <%= form.text_field :title, required: true %>
51
+ </div>
52
+ <div class="jb-field">
53
+ <%= form.label :path %>
54
+ <%= form.text_field :path, required: true, placeholder: "/about" %>
55
+ </div>
56
+ <div class="jb-field">
57
+ <%= form.label :author_name %>
58
+ <%= form.text_field :author_name, placeholder: "Optional author byline" %>
59
+ </div>
60
+ <div class="jb-field">
61
+ <%= form.label :topic %>
62
+ <%= form.text_field :topic, placeholder: "Optional topic, e.g. HR" %>
63
+ </div>
64
+ <% if @page.kb_article? %>
65
+ <div class="jb-field">
66
+ <%= form.label :parent_id, "Parent help article" %>
67
+ <%= form.collection_select :parent_id, @parent_pages, :id, :title, include_blank: "Top-level help article" %>
68
+ </div>
69
+ <div class="jb-field">
70
+ <%= form.label :position, "Order within this section" %>
71
+ <%= form.number_field :position, min: 0 %>
72
+ </div>
73
+ <% end %>
74
+ <div class="jb-field">
75
+ <%= form.label :locale %>
76
+ <%= form.text_field :locale, required: true %>
77
+ </div>
78
+ <div class="jb-field">
79
+ <%= form.label :meta_title %>
80
+ <%= form.text_field :meta_title %>
81
+ </div>
82
+ <div class="jb-field">
83
+ <%= form.label :meta_description %>
84
+ <%= form.text_area :meta_description, class: "jb-editor-textarea-short" %>
85
+ </div>
86
+ <div class="jb-field jb-field-short">
87
+ <%= form.label :excerpt %>
88
+ <%= form.text_area :excerpt, class: "jb-editor-textarea-short" %>
89
+ <small class="jb-help">Short summary for blog cards, RSS, and SEO.</small>
90
+ </div>
91
+ <div class="jb-field">
92
+ <%= form.label :published_at %>
93
+ <%= form.datetime_field :published_at %>
94
+ <small class="jb-help">Future dates schedule the page.</small>
95
+ </div>
96
+
97
+ <div class="jb-actions">
98
+ <%= form.submit "Save settings", class: "jb-button" %>
99
+ </div>
100
+ <% end %>
101
+ <% else %>
102
+ <div class="jb-editor-panel">
103
+ <% if @selected_page_component %>
104
+ <% props = @selected_component_registration&.normalized_props || {} %>
105
+ <%= form_with url: component_admin_page_path(@page, site_id: @page.site_id), method: :patch, class: "jb-form jb-component-editor", data: { live_preview_form: true } do |form| %>
106
+ <%= hidden_field_tag :component_index, @selected_component_index %>
107
+ <div>
108
+ <h2 class="jb-editor-subtitle"><%= @selected_page_component.name %> #<%= @selected_component_index + 1 %></h2>
109
+ </div>
110
+
111
+ <% if props.any? %>
112
+ <% props.each do |name, prop| %>
113
+ <% value = @selected_page_component.props[name.to_sym] %>
114
+ <div class="jb-field">
115
+ <%= label_tag "component_props_#{name}", prop[:label] %>
116
+ <% case prop[:type] %>
117
+ <% when :collection %>
118
+ <%= render "collection_field", name: name, prop: prop, value: value %>
119
+ <% when :long_text %>
120
+ <%= text_area_tag "component_props[#{name}]", value, id: "component_props_#{name}", class: "jb-editor-textarea-short" %>
121
+ <% when :image %>
122
+ <% if @media_assets.any? %>
123
+ <input type="hidden" name="component_props[<%= name %>]" value="<%= value %>" id="component_props_<%= name %>">
124
+ <button type="button" class="jb-image-picker-trigger" data-image-picker-open>
125
+ <% if value.present? %><%= image_tag value, alt: "", class: "jb-image-picker-preview" %><% end %>
126
+ <span data-image-picker-label><%= value.present? ? "Change image" : "Select image" %></span>
127
+ </button>
128
+ <% else %>
129
+ <%= link_to "Add an image to the library", admin_media_assets_path, class: "jb-link-button" %>
130
+ <% end %>
131
+ <% when :url %>
132
+ <%= render "url_field", name: name, value: value %>
133
+ <% when :video %>
134
+ <%= text_field_tag "component_props[#{name}]", value, id: "component_props_#{name}", placeholder: "https://www.youtube.com/watch?v=..." %>
135
+ <% when :select %>
136
+ <%= select_tag "component_props[#{name}]", options_for_select(prop[:options], value), id: "component_props_#{name}" %>
137
+ <% else %>
138
+ <%= text_field_tag "component_props[#{name}]", value, id: "component_props_#{name}" %>
139
+ <% end %>
140
+ </div>
141
+ <% end %>
142
+ <% else %>
143
+ <p class="jb-subtitle">This component has no registered editable props.</p>
144
+ <% end %>
145
+
146
+ <div class="jb-actions jb-component-editor-actions">
147
+ <%= form.submit "Save", class: "jb-button jb-button-secondary" %>
148
+ <%= form.button "Save and back", name: "save_and_back", value: "true", class: "jb-button" %>
149
+ </div>
150
+ <% end %>
151
+ <%= render "image_picker_modal" if @media_assets.any? %>
152
+ <% elsif @page_components.any? %>
153
+ <nav class="jb-component-instance-list" aria-label="Components on this page" data-sortable-list data-sortable-url="<%= reorder_components_admin_page_path(@page, site_id: @page.site_id) %>" data-sortable-param="component_indexes">
154
+ <% @page_components.each_with_index do |component, index| %>
155
+ <div class="jb-component-instance <%= "is-hidden" if component.hidden? %>" data-sortable-item data-sortable-id="<%= index %>" data-preview-component-index="<%= index %>" draggable="true">
156
+ <%= render "journeybook/admin/shared/drag_handle" %>
157
+ <div class="jb-component-instance-main">
158
+ <div class="jb-component-instance-summary">
159
+ <strong><%= component.name %></strong>
160
+ <% if component.props[:title].present? %>
161
+ <span><%= component.props[:title] %></span>
162
+ <% end %>
163
+ </div>
164
+ <div class="jb-component-instance-actions">
165
+ <%= link_to "Edit", edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout", component: index), class: "jb-link-button jb-link-button-small" %>
166
+ <%= button_to component.hidden? ? "Show" : "Hide", toggle_component_visibility_admin_page_path(@page, site_id: @page.site_id), method: :patch, params: { component_index: index }, class: "jb-link-button jb-link-button-small" %>
167
+ <%= button_to "Delete", delete_component_admin_page_path(@page, site_id: @page.site_id), method: :delete, params: { component_index: index }, class: "jb-link-button jb-link-button-small jb-link-button-danger", form: { onsubmit: "return confirm('Delete this component?')" } %>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ <% end %>
172
+ </nav>
173
+ <% else %>
174
+ <p class="jb-subtitle">No Journeybook components are currently present in this page markdown.</p>
175
+ <% end %>
176
+
177
+ <% unless @selected_page_component %>
178
+ <div class="jb-editor-panel-footer">
179
+ <%= link_to "Add component", new_component_admin_page_path(@page, site_id: @page.site_id), class: "jb-button" %>
180
+ </div>
181
+ <% end %>
182
+ </div>
183
+ <% end %>
184
+ </aside>
185
+
186
+ <section class="jb-editor-preview" aria-label="Page preview">
187
+ <div class="jb-editor-preview-topbar">
188
+ <%= link_to "Edit Page", source_admin_page_path(@page, site_id: @page.site_id), class: "jb-button jb-button-secondary jb-edit-page-shortcut", data: { edit_page_shortcut: true }, hidden: true %>
189
+ <div class="jb-preview-tabs" role="tablist" aria-label="Preview viewport">
190
+ <button type="button" class="is-active" data-preview-mode="desktop" role="tab" aria-selected="true">Desktop</button>
191
+ <button type="button" data-preview-mode="mobile" role="tab" aria-selected="false">Mobile</button>
192
+ </div>
193
+ <div class="jb-editor-preview-actions">
194
+ <% publishing_errors = @page.component_validation_errors %>
195
+ <% if publishing_errors.any? %>
196
+ <%= link_to errors_admin_page_path(@page, site_id: @page.site_id), class: "jb-button jb-button-danger" do %>
197
+ <span aria-hidden="true">!</span> Errors
198
+ <% end %>
199
+ <% end %>
200
+ <% if @page.published? %>
201
+ <%= button_to "Unpublish", draft_admin_page_path(@page, site_id: @page.site_id), method: :patch, class: "jb-button jb-button-secondary" %>
202
+ <% else %>
203
+ <%= button_to "Publish", publish_admin_page_path(@page, site_id: @page.site_id), method: :patch, class: "jb-button" %>
204
+ <% end %>
205
+ </div>
206
+ </div>
207
+ <div class="jb-editor-preview-stage">
208
+ <iframe id="jb-editor-preview-frame" src="<%= preview_admin_page_path(@page, component: @selected_component_index) %>" title="Preview of <%= @page.title %>"></iframe>
209
+ </div>
210
+ </section>
211
+ </div>
212
+
213
+ <%= javascript_tag nonce: true do %>
214
+ (() => {
215
+ const previewFrame = document.getElementById("jb-editor-preview-frame");
216
+ const editor = document.querySelector(".jb-page-editor");
217
+ const previewModeButtons = document.querySelectorAll("[data-preview-mode]");
218
+ const forms = document.querySelectorAll("[data-live-preview-form]");
219
+ const previewComponents = document.querySelectorAll("[data-preview-component-index]");
220
+ const imagePickerModal = document.querySelector("[data-image-picker-modal]");
221
+ const editPageShortcut = document.querySelector("[data-edit-page-shortcut]");
222
+ const csrfToken = document.querySelector("meta[name='csrf-token']")?.content;
223
+ const previewModeStorageKey = "journeybook.previewMode";
224
+ const debounceDelay = 500;
225
+ let saveTimer;
226
+ let saveRequest = 0;
227
+ let imagePickerTarget;
228
+
229
+ const toggleEditPageShortcut = (visible) => {
230
+ if (editPageShortcut) editPageShortcut.hidden = !visible;
231
+ };
232
+
233
+ window.addEventListener("keydown", (event) => {
234
+ if (event.key === "Shift") toggleEditPageShortcut(true);
235
+ });
236
+ window.addEventListener("keyup", (event) => {
237
+ if (event.key === "Shift") toggleEditPageShortcut(false);
238
+ });
239
+ window.addEventListener("blur", () => toggleEditPageShortcut(false));
240
+
241
+ const setPreviewMode = (mode) => {
242
+ const mobile = mode === "mobile";
243
+ editor?.classList.toggle("is-mobile-preview", mobile);
244
+ previewModeButtons.forEach((button) => {
245
+ const active = button.dataset.previewMode === mode;
246
+ button.classList.toggle("is-active", active);
247
+ button.setAttribute("aria-selected", active.toString());
248
+ });
249
+ window.sessionStorage.setItem(previewModeStorageKey, mode);
250
+ };
251
+
252
+ const reloadPreview = () => {
253
+ if (!previewFrame) return;
254
+
255
+ const previewUrl = new URL(previewFrame.src, window.location.href);
256
+ previewUrl.searchParams.set("previewed_at", Date.now().toString());
257
+ previewFrame.src = previewUrl.toString();
258
+ };
259
+
260
+ const scrollPreviewTo = (componentIndex) => {
261
+ previewFrame?.contentWindow?.postMessage({ type: "journeybook:scroll-preview", componentIndex }, window.location.origin);
262
+ };
263
+
264
+ const saveForm = async (form) => {
265
+ const requestId = ++saveRequest;
266
+ const response = await fetch(form.action, {
267
+ method: form.method.toUpperCase(),
268
+ body: new FormData(form),
269
+ credentials: "same-origin",
270
+ headers: {
271
+ "Accept": "text/html",
272
+ "X-CSRF-Token": csrfToken
273
+ }
274
+ });
275
+
276
+ if (response.ok && requestId === saveRequest) reloadPreview();
277
+ };
278
+
279
+ const scheduleFormSave = (form) => {
280
+ if (!form) return;
281
+
282
+ window.clearTimeout(saveTimer);
283
+ saveTimer = window.setTimeout(() => saveForm(form).catch(() => {}), debounceDelay);
284
+ };
285
+
286
+ const scheduleSave = (event) => {
287
+ if (event.target.type === "hidden") return;
288
+ scheduleFormSave(event.target.form);
289
+ };
290
+
291
+ const updateImagePickerTrigger = (trigger, imageUrl, imageTitle) => {
292
+ const preview = trigger.querySelector(".jb-image-picker-preview");
293
+ if (preview) preview.remove();
294
+
295
+ const image = document.createElement("img");
296
+ image.src = imageUrl;
297
+ image.alt = "";
298
+ image.className = "jb-image-picker-preview";
299
+ trigger.prepend(image);
300
+ trigger.querySelector("[data-image-picker-label]").textContent = imageTitle || "Change image";
301
+ };
302
+
303
+ const setupUrlEditor = (editor) => {
304
+ const kind = editor.querySelector("[data-url-kind]");
305
+ const value = editor.querySelector("[data-url-value]");
306
+ const page = editor.querySelector("[data-url-page]");
307
+ const external = editor.querySelector("[data-url-external-input]");
308
+ const internalField = editor.querySelector("[data-url-internal]");
309
+ const externalField = editor.querySelector("[data-url-external]");
310
+ const sync = () => {
311
+ const internal = kind.value === "internal";
312
+ internalField.hidden = !internal;
313
+ externalField.hidden = internal;
314
+ value.value = internal ? page.value : external.value;
315
+ scheduleFormSave(editor.closest("form"));
316
+ };
317
+
318
+ kind.addEventListener("change", sync);
319
+ page.addEventListener("change", sync);
320
+ external.addEventListener("input", sync);
321
+ sync();
322
+ };
323
+
324
+ document.addEventListener("click", (event) => {
325
+ const open = event.target.closest("[data-image-picker-open]");
326
+ if (open && imagePickerModal) {
327
+ imagePickerTarget = open.parentElement.querySelector("input[type='hidden']");
328
+ imagePickerModal.showModal();
329
+ return;
330
+ }
331
+
332
+ if (event.target.closest("[data-image-picker-close]")) imagePickerModal?.close();
333
+
334
+ const asset = event.target.closest("[data-image-picker-asset]");
335
+ if (!asset || !imagePickerTarget) return;
336
+
337
+ imagePickerTarget.value = asset.dataset.imageUrl;
338
+ updateImagePickerTrigger(imagePickerTarget.nextElementSibling, asset.dataset.imageUrl, asset.dataset.imageTitle);
339
+ imagePickerTarget.dispatchEvent(new Event("input", { bubbles: true }));
340
+ scheduleFormSave(imagePickerTarget.form);
341
+ imagePickerModal.close();
342
+ });
343
+
344
+ const setupCollection = (collection) => {
345
+ const items = collection.querySelector("[data-collection-items]");
346
+ const input = collection.querySelector("[data-collection-input]");
347
+ const addButton = collection.querySelector("[data-collection-add]");
348
+ const min = Number(collection.dataset.collectionMin || 0);
349
+ const max = collection.dataset.collectionMax ? Number(collection.dataset.collectionMax) : Infinity;
350
+ const schema = JSON.parse(collection.dataset.collectionSchema);
351
+ const pages = JSON.parse(collection.dataset.collectionPages || "[]");
352
+ let draggedItem;
353
+
354
+ const collectionItems = () => [...items.querySelectorAll(":scope > [data-collection-item]")];
355
+ const syncIconEditor = (editor) => {
356
+ if (editor.querySelector("[data-icon-mode]").value !== "icon") return;
357
+ const identifier = editor.querySelector("[data-icon-identifier]").value.trim();
358
+ if (!identifier) return;
359
+ editor.parentElement.querySelector("[data-collection-field]").value = `icon:${editor.querySelector("[data-icon-style]").value}:${editor.querySelector("[data-icon-color]").value}:${identifier}`;
360
+ };
361
+ const syncUrlEditor = (editor) => {
362
+ const internal = editor.querySelector("[data-url-kind]").value === "internal";
363
+ editor.querySelector("[data-url-internal]").hidden = !internal;
364
+ editor.querySelector("[data-url-external]").hidden = internal;
365
+ editor.querySelector("[data-url-value]").value = internal ? editor.querySelector("[data-url-page]").value : editor.querySelector("[data-url-external-input]").value;
366
+ };
367
+ const sync = () => {
368
+ input.value = JSON.stringify(collectionItems().map((item) => Object.fromEntries(
369
+ [...item.querySelectorAll("[data-collection-field]")].map((field) => [field.dataset.collectionKey, field.value])
370
+ )));
371
+ addButton.disabled = collectionItems().length >= max;
372
+ collectionItems().forEach((item) => {
373
+ item.querySelector("[data-collection-remove]").disabled = collectionItems().length <= min;
374
+ });
375
+ };
376
+ const notify = () => collection.querySelector("[data-collection-field]")?.dispatchEvent(new Event("input", { bubbles: true }));
377
+ const addItem = (values = {}) => {
378
+ const item = document.createElement("fieldset");
379
+ item.className = "jb-collection-item";
380
+ item.dataset.collectionItem = "";
381
+ item.draggable = true;
382
+
383
+ const legend = document.createElement("legend");
384
+ legend.textContent = addButton.textContent.replace("Add ", "");
385
+ item.append(legend);
386
+
387
+ const handle = document.createElement("span");
388
+ handle.className = "jb-drag-handle";
389
+ handle.innerHTML = '<svg viewBox="0 0 12 18" aria-hidden="true"><circle cx="3" cy="3" r="1.25"/><circle cx="9" cy="3" r="1.25"/><circle cx="3" cy="9" r="1.25"/><circle cx="9" cy="9" r="1.25"/><circle cx="3" cy="15" r="1.25"/><circle cx="9" cy="15" r="1.25"/></svg>';
390
+ item.append(handle);
391
+
392
+ const fields = document.createElement("div");
393
+ fields.className = "jb-collection-fields";
394
+ Object.entries(schema).forEach(([key, prop]) => {
395
+ const label = document.createElement("label");
396
+ label.textContent = prop.label;
397
+ if (prop.type === "url") {
398
+ const field = document.createElement("input");
399
+ field.type = "hidden";
400
+ field.value = values[key] || "";
401
+ field.dataset.collectionField = "";
402
+ field.dataset.collectionKey = key;
403
+ field.dataset.urlValue = "";
404
+
405
+ const editor = document.createElement("div");
406
+ editor.className = "jb-url-editor";
407
+ editor.dataset.collectionUrlEditor = "";
408
+ editor.append(field);
409
+ const kind = document.createElement("select");
410
+ kind.dataset.urlKind = "";
411
+ kind.add(new Option("Journeybook page", "internal"));
412
+ kind.add(new Option("External URL", "external"));
413
+ const internal = document.createElement("select");
414
+ internal.dataset.urlPage = "";
415
+ internal.dataset.urlInternal = "";
416
+ internal.add(new Option("Select a page", ""));
417
+ pages.forEach((page) => internal.add(new Option(`${page.title} (${page.path})`, page.value)));
418
+ const external = document.createElement("input");
419
+ external.type = "text";
420
+ external.placeholder = "https://example.com";
421
+ external.dataset.urlExternal = "";
422
+ external.dataset.urlExternalInput = "";
423
+ const target = document.createElement("select");
424
+ target.dataset.collectionField = "";
425
+ target.dataset.collectionKey = `${key}_target`;
426
+ target.dataset.urlTarget = "";
427
+ target.add(new Option("Current window", "same_window"));
428
+ target.add(new Option("New window", "open_new_window"));
429
+ editor.append(kind, internal, external, target);
430
+ label.append(editor);
431
+ fields.append(label);
432
+ return;
433
+ }
434
+ const field = prop.type === "long_text" ? document.createElement("textarea") : prop.type === "select" ? document.createElement("select") : document.createElement("input");
435
+ field.dataset.collectionField = "";
436
+ field.dataset.collectionKey = key;
437
+ if (field.tagName === "SELECT") {
438
+ prop.options.forEach((option) => field.add(new Option(option, option)));
439
+ }
440
+ field.value = values[key] || prop.options?.[0] || "";
441
+ if (field.tagName === "TEXTAREA") field.rows = 3;
442
+ else field.type = prop.type === "image" ? "hidden" : "text";
443
+ label.append(field);
444
+ if (prop.type === "image" && imagePickerModal) {
445
+ const picker = document.createElement("button");
446
+ picker.type = "button";
447
+ picker.className = "jb-image-picker-trigger";
448
+ picker.dataset.imagePickerOpen = "";
449
+ picker.innerHTML = '<span data-image-picker-label>Select image</span>';
450
+ label.append(picker);
451
+ }
452
+ if (prop.type === "image" && prop.icons) {
453
+ const iconEditor = document.createElement("div");
454
+ iconEditor.className = "jb-icon-editor";
455
+ iconEditor.dataset.iconEditor = "";
456
+ iconEditor.innerHTML = '<select data-icon-mode><option value="image">Image</option><option value="icon">Font Awesome icon</option></select><input type="text" placeholder="user-cowboy" data-icon-identifier><select data-icon-style></select><select data-icon-color></select>';
457
+ prop.icons.styles.forEach((style) => iconEditor.querySelector("[data-icon-style]").add(new Option(style, style)));
458
+ prop.icons.colors.forEach((color) => iconEditor.querySelector("[data-icon-color]").add(new Option(color, color)));
459
+ label.append(iconEditor);
460
+ }
461
+ fields.append(label);
462
+ });
463
+ item.append(fields);
464
+
465
+ const remove = document.createElement("button");
466
+ remove.type = "button";
467
+ remove.className = "jb-collection-remove";
468
+ remove.dataset.collectionRemove = "";
469
+ remove.textContent = "×";
470
+ remove.title = "Remove";
471
+ remove.setAttribute("aria-label", "Remove item");
472
+ item.append(remove);
473
+ item.querySelectorAll("[data-collection-url-editor]").forEach(syncUrlEditor);
474
+ items.append(item);
475
+ };
476
+
477
+ addButton.addEventListener("click", () => {
478
+ if (collectionItems().length >= max) return;
479
+ addItem();
480
+ sync();
481
+ notify();
482
+ });
483
+ items.addEventListener("input", (event) => {
484
+ const urlEditor = event.target.closest("[data-collection-url-editor]");
485
+ if (urlEditor) syncUrlEditor(urlEditor);
486
+ const iconEditor = event.target.closest("[data-icon-editor]");
487
+ if (iconEditor) syncIconEditor(iconEditor);
488
+ sync();
489
+ });
490
+ items.addEventListener("change", (event) => {
491
+ const urlEditor = event.target.closest("[data-collection-url-editor]");
492
+ if (!urlEditor) return;
493
+ syncUrlEditor(urlEditor);
494
+ sync();
495
+ });
496
+ items.addEventListener("click", (event) => {
497
+ const remove = event.target.closest("[data-collection-remove]");
498
+ if (!remove || collectionItems().length <= min) return;
499
+ if (!window.confirm("Remove this item?")) return;
500
+ remove.closest("[data-collection-item]").remove();
501
+ sync();
502
+ notify();
503
+ });
504
+ items.addEventListener("dragstart", (event) => {
505
+ draggedItem = event.target.closest("[data-collection-item]");
506
+ if (!draggedItem) return;
507
+ event.dataTransfer.setData("text/plain", "collection-item");
508
+ });
509
+ items.addEventListener("dragover", (event) => {
510
+ if (!draggedItem) return;
511
+ event.preventDefault();
512
+ const target = event.target.closest("[data-collection-item]");
513
+ if (!target || target === draggedItem) return;
514
+ target[event.clientY > target.getBoundingClientRect().top + target.offsetHeight / 2 ? "after" : "before"](draggedItem);
515
+ });
516
+ items.addEventListener("dragend", () => {
517
+ if (!draggedItem) return;
518
+ draggedItem = undefined;
519
+ sync();
520
+ notify();
521
+ });
522
+ items.querySelectorAll("[data-collection-url-editor]").forEach(syncUrlEditor);
523
+ items.querySelectorAll("[data-icon-editor]").forEach(syncIconEditor);
524
+ sync();
525
+ };
526
+
527
+ forms.forEach((form) => {
528
+ form.addEventListener("input", scheduleSave);
529
+ form.addEventListener("change", scheduleSave);
530
+ });
531
+
532
+ previewModeButtons.forEach((button) => {
533
+ button.addEventListener("click", () => setPreviewMode(button.dataset.previewMode));
534
+ });
535
+
536
+ previewComponents.forEach((component) => {
537
+ const scrollToComponent = () => scrollPreviewTo(Number(component.dataset.previewComponentIndex));
538
+ component.addEventListener("mouseenter", scrollToComponent);
539
+ component.addEventListener("focusin", scrollToComponent);
540
+ });
541
+
542
+ document.querySelectorAll("[data-collection]").forEach(setupCollection);
543
+ document.querySelectorAll("[data-url-editor]").forEach(setupUrlEditor);
544
+
545
+ setPreviewMode(window.sessionStorage.getItem(previewModeStorageKey) || "desktop");
546
+ })();
547
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <header class="jb-header">
2
+ <div>
3
+ <p class="jb-eyebrow">Publishing errors</p>
4
+ <h1 class="jb-title"><%= @page.title %></h1>
5
+ <p class="jb-subtitle">Fix these issues before publishing this page.</p>
6
+ </div>
7
+ <div class="jb-actions">
8
+ <%= link_to "Back to editor", edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), class: "jb-button jb-button-secondary" %>
9
+ </div>
10
+ </header>
11
+
12
+ <section class="jb-card">
13
+ <% if @publishing_errors.any? %>
14
+ <div class="jb-errors">
15
+ <strong><%= pluralize(@publishing_errors.count, "error") %> prevent publishing:</strong>
16
+ <ul>
17
+ <% @publishing_errors.each do |message| %>
18
+ <li><%= message %></li>
19
+ <% end %>
20
+ </ul>
21
+ </div>
22
+ <% else %>
23
+ <p class="jb-subtitle">This page has no publishing errors.</p>
24
+ <% end %>
25
+ </section>
@@ -0,0 +1,52 @@
1
+ <header class="jb-header">
2
+ <div>
3
+ <p class="jb-eyebrow">Pages</p>
4
+ <h1 class="jb-title">Content pages</h1>
5
+ </div>
6
+ <div class="jb-actions">
7
+ <%= link_to "AI draft", new_admin_ai_page_path, class: "jb-button jb-button-secondary" %>
8
+ <% { blog_post: "New blog post", kb_article: "New help article", glossary_term: "New glossary term" }.each do |key, label| %>
9
+ <% if Journeybook.templates.for_site(current_site).any? { |template| template.key == key } %>
10
+ <%= link_to label, new_admin_page_path(template: key), class: "jb-button jb-button-secondary" %>
11
+ <% end %>
12
+ <% end %>
13
+ <%= link_to "New page", new_admin_page_path, class: "jb-button" %>
14
+ </div>
15
+ </header>
16
+
17
+ <div class="jb-actions">
18
+ <% ["all", *Journeybook::Page::PAGE_TYPES].each do |type| %>
19
+ <% active = (params[:type].presence || "all") == type %>
20
+ <%= link_to type.humanize, admin_pages_path(site_id: current_site&.id, type: type == "all" ? nil : type), class: "jb-button jb-button-secondary#{" is-active" if active}" %>
21
+ <% end %>
22
+ </div>
23
+
24
+ <section class="jb-card jb-card-flush">
25
+ <%= render "table", pages: @pages %>
26
+
27
+ <%= javascript_tag nonce: true do %>
28
+ (() => {
29
+ const editLinks = document.querySelectorAll("[data-source-edit]");
30
+
31
+ const setSourceMode = (enabled) => {
32
+ editLinks.forEach((link) => {
33
+ link.textContent = enabled ? link.dataset.sourceLabel : link.dataset.editLabel;
34
+ link.href = enabled ? link.dataset.sourceHref : link.dataset.editHref;
35
+ });
36
+ };
37
+
38
+ window.addEventListener("keydown", (event) => setSourceMode(event.shiftKey));
39
+ window.addEventListener("keyup", (event) => setSourceMode(event.shiftKey));
40
+ window.addEventListener("blur", () => setSourceMode(false));
41
+
42
+ editLinks.forEach((link) => {
43
+ link.addEventListener("click", (event) => {
44
+ if (!event.shiftKey) return;
45
+
46
+ event.preventDefault();
47
+ window.location.assign(link.dataset.sourceHref);
48
+ });
49
+ });
50
+ })();
51
+ <% end %>
52
+ </section>
@@ -0,0 +1,27 @@
1
+ <% content_for :main_class, "jb-main-wide" %>
2
+
3
+ <header class="jb-header">
4
+ <div>
5
+ <p class="jb-eyebrow">New page</p>
6
+ <h1 class="jb-title">Choose a starting point</h1>
7
+ </div>
8
+ </header>
9
+
10
+ <section class="jb-card">
11
+ <div class="jb-fields">
12
+ <div class="jb-field">
13
+ <strong>Blank page</strong>
14
+ <p class="jb-help">Start with a general content page.</p>
15
+ <%= link_to "Choose blank page", new_admin_page_path(site_id: @page.site_id), class: "jb-button jb-button-secondary" %>
16
+ </div>
17
+ <% @templates.each do |template| %>
18
+ <div class="jb-field">
19
+ <strong><%= template.name %></strong>
20
+ <p class="jb-help"><%= template.description %></p>
21
+ <%= link_to "Use #{template.name}", new_admin_page_path(site_id: @page.site_id, template: template.key), class: "jb-button #{"jb-button-secondary" if template != @selected_template}" %>
22
+ </div>
23
+ <% end %>
24
+ </div>
25
+ </section>
26
+
27
+ <%= render "form", page: @page, sites: @sites, selected_template: @selected_template %>