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,304 @@
1
+ module Journeybook
2
+ module Admin
3
+ class PagesController < ApplicationController
4
+ before_action :set_page, only: %i[edit update source update_source component new_component add_component move_component toggle_component_visibility reorder_components delete_component destroy preview publish draft duplicate errors]
5
+ before_action :set_sites, only: %i[new edit create update]
6
+
7
+ def index
8
+ scope = current_site ? current_site.pages.includes(:site) : Page.none
9
+ scope = scope.by_page_type(params[:type]) if params[:type].present?
10
+ @pages = scope.order(updated_at: :desc)
11
+ end
12
+
13
+ def new
14
+ @page = Page.new(site: current_site || Site.order(:name).first, locale: current_site&.default_locale || "en", status: "draft", markdown: starter_markdown)
15
+ @templates = Journeybook.templates.for_site(@page.site)
16
+ @selected_template = template_for(params[:template], @page.site)
17
+ apply_template if @selected_template
18
+ end
19
+
20
+ def edit
21
+ set_editor_state
22
+ end
23
+
24
+ def source
25
+ end
26
+
27
+ def create
28
+ @page = Page.new(page_params)
29
+ template = template_for(params[:template_key], @page.site)
30
+ apply_template_path(template) if template
31
+
32
+ if @page.save
33
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id), notice: "Page created."
34
+ else
35
+ render :new, status: :unprocessable_entity
36
+ end
37
+ end
38
+
39
+ def update
40
+ if @page.update(page_params)
41
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: params[:tab].presence || "settings"), notice: "Page updated."
42
+ else
43
+ set_editor_state
44
+ render :edit, status: :unprocessable_entity
45
+ end
46
+ end
47
+
48
+ def update_source
49
+ if @page.update(source_params)
50
+ redirect_to source_admin_page_path(@page, site_id: @page.site_id), notice: "Page source updated."
51
+ else
52
+ render :source, status: :unprocessable_entity
53
+ end
54
+ end
55
+
56
+ def component
57
+ component_index = params[:component_index].to_i
58
+ markdown = ComponentTagUpdater.new.update(@page.markdown, component_index, component_props)
59
+
60
+ if @page.update(markdown: markdown)
61
+ redirect_params = { site_id: @page.site_id, tab: "layout" }
62
+ redirect_params[:component] = component_index unless params[:save_and_back].present?
63
+ redirect_to edit_admin_page_path(@page, **redirect_params), notice: "Component updated."
64
+ else
65
+ set_sites
66
+ set_editor_state
67
+ render :edit, status: :unprocessable_entity
68
+ end
69
+ rescue IndexError
70
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), alert: "Component not found."
71
+ end
72
+
73
+ def new_component
74
+ @components = available_components.sort_by { |component| component.name.to_s }
75
+ @selected_component = component_for(params[:component]) || @components.first
76
+ end
77
+
78
+ def add_component
79
+ component = component_for(params[:component_name])
80
+
81
+ unless component
82
+ redirect_to new_component_admin_page_path(@page, site_id: @page.site_id), alert: "Component not found."
83
+ return
84
+ end
85
+
86
+ component_index = ComponentParser.new.parse(@page.markdown).length
87
+ markdown = ComponentTagUpdater.new.append(@page.markdown, component.name, blank_props(component))
88
+
89
+ if @page.update(markdown: markdown)
90
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout", component: component_index), notice: "Component added."
91
+ else
92
+ @components = available_components
93
+ @selected_component = component
94
+ render :new_component, status: :unprocessable_entity
95
+ end
96
+ end
97
+
98
+ def move_component
99
+ markdown = ComponentTagUpdater.new.move(@page.markdown, component_index, params[:direction])
100
+
101
+ if @page.update(markdown: markdown)
102
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), notice: "Component moved."
103
+ else
104
+ set_sites
105
+ set_editor_state
106
+ render :edit, status: :unprocessable_entity
107
+ end
108
+ rescue IndexError
109
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), alert: "Component cannot be moved."
110
+ end
111
+
112
+ def reorder_components
113
+ markdown = ComponentTagUpdater.new.reorder(@page.markdown, component_indexes)
114
+ @page.update!(markdown: markdown)
115
+
116
+ head :no_content
117
+ rescue ArgumentError
118
+ head :unprocessable_entity
119
+ end
120
+
121
+ def toggle_component_visibility
122
+ markdown = ComponentTagUpdater.new.toggle_visibility(@page.markdown, component_index)
123
+ @page.update!(markdown: markdown)
124
+
125
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), notice: "Component visibility updated."
126
+ rescue IndexError
127
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), alert: "Component not found."
128
+ end
129
+
130
+ def delete_component
131
+ markdown = ComponentTagUpdater.new.delete(@page.markdown, component_index)
132
+
133
+ if @page.update(markdown: markdown)
134
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), notice: "Component deleted."
135
+ else
136
+ set_sites
137
+ set_editor_state
138
+ render :edit, status: :unprocessable_entity
139
+ end
140
+ rescue IndexError
141
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), alert: "Component not found."
142
+ end
143
+
144
+ def destroy
145
+ @page.destroy
146
+ redirect_to admin_pages_path(site_id: current_site&.id), notice: "Page deleted."
147
+ end
148
+
149
+ def preview
150
+ @journeybook_page = @page
151
+ @journeybook_html = PageRenderer.new.render(
152
+ @page,
153
+ form_authenticity_token: form_authenticity_token,
154
+ component_index: preview_component_index,
155
+ component_anchors: preview_component_index.nil?
156
+ )
157
+
158
+ render "journeybook/admin/pages/public_preview", layout: @page.layout_name
159
+ end
160
+
161
+ def errors
162
+ @publishing_errors = @page.component_validation_errors
163
+ end
164
+
165
+ def publish
166
+ if @page.update(status: "published", published_at: Time.current)
167
+ redirect_to admin_pages_path(site_id: @page.site_id), notice: "Page published."
168
+ else
169
+ redirect_to edit_admin_page_path(@page, site_id: @page.site_id, tab: "layout"), alert: @page.errors.full_messages.to_sentence
170
+ end
171
+ end
172
+
173
+ def draft
174
+ @page.update!(status: "draft")
175
+ redirect_to admin_pages_path(site_id: @page.site_id), notice: "Page moved to draft."
176
+ end
177
+
178
+ def duplicate
179
+ duplicate = @page.dup
180
+ duplicate.title = duplicate_title
181
+ duplicate.slug = nil
182
+ duplicate.path = duplicate_path
183
+ duplicate.status = "draft"
184
+ duplicate.published_at = nil
185
+
186
+ if duplicate.save
187
+ redirect_to edit_admin_page_path(duplicate, site_id: duplicate.site_id), notice: "Page duplicated as a draft."
188
+ else
189
+ redirect_to admin_pages_path(site_id: @page.site_id), alert: duplicate.errors.full_messages.to_sentence
190
+ end
191
+ end
192
+
193
+ private
194
+
195
+ def set_page
196
+ scope = current_site ? current_site.pages : Page.all
197
+ @page = scope.find(params[:id])
198
+ end
199
+
200
+ def set_sites
201
+ @sites = Site.order(:name)
202
+ end
203
+
204
+ def set_editor_state
205
+ @editor_tab = params[:tab] == "settings" ? "settings" : "layout"
206
+ @editor_pages = current_site ? current_site.pages.order(:title) : Page.includes(:site).order(:title)
207
+ @page_components = ComponentParser.new.parse(@page.markdown)
208
+ @media_assets = MediaAsset.order(created_at: :desc).limit(24)
209
+ @selected_component_index = params[:component].presence&.to_i
210
+ @selected_component_index = nil unless @selected_component_index && @page_components[@selected_component_index]
211
+ @selected_page_component = @page_components[@selected_component_index] if @selected_component_index
212
+ @selected_component_registration = Journeybook.components.registered(@selected_page_component.name) if @selected_page_component
213
+ @linkable_pages = @page.site.pages.order(:title)
214
+ @parent_pages = @page.site.pages.kb_articles.where.not(id: @page.id).order(:title)
215
+ end
216
+
217
+ def page_params
218
+ params.require(:page).permit(:site_id, :title, :path, :markdown, :meta_title, :meta_description, :status, :published_at, :layout, :locale, :page_type, :excerpt, :author_name, :parent_id, :topic, :position)
219
+ end
220
+
221
+ def source_params
222
+ params.require(:page).permit(:markdown)
223
+ end
224
+
225
+ def component_props
226
+ params.fetch(:component_props, {}).permit!.to_h
227
+ end
228
+
229
+ def available_components
230
+ Journeybook.components.for_site(@page.site).to_a
231
+ end
232
+
233
+ def component_for(name)
234
+ available_components.find { |component| component.name.to_s == name.to_s }
235
+ end
236
+
237
+ def blank_props(component)
238
+ component.normalized_props.transform_values { "" }
239
+ end
240
+
241
+ def component_index
242
+ params[:component_index].to_i
243
+ end
244
+
245
+ def component_indexes
246
+ Array(params[:component_indexes]).map(&:to_i)
247
+ end
248
+
249
+ def preview_component_index
250
+ Integer(params[:component], exception: false)
251
+ end
252
+
253
+ def starter_markdown
254
+ "{{ hero }}"
255
+ end
256
+
257
+ def template_for(key, site)
258
+ return unless key.present?
259
+
260
+ Journeybook.templates.for_site(site).find { |template| template.key.to_s == key.to_s }
261
+ end
262
+
263
+ def apply_template
264
+ @page.page_type = @selected_template.page_type
265
+ @page.markdown = @selected_template.markdown
266
+ end
267
+
268
+ def apply_template_path(template)
269
+ return unless template
270
+
271
+ @page.page_type = template.page_type
272
+ return unless template.path_prefix && @page.path.blank?
273
+
274
+ @page.path = "#{template.path_prefix}/#{@page.title.to_s.parameterize}"
275
+ end
276
+
277
+ def duplicate_title
278
+ base = "Copy of #{@page.title}"
279
+ return base unless @page.site.pages.where(locale: @page.locale, title: base).exists?
280
+
281
+ suffix = 2
282
+ loop do
283
+ title = "#{base} (#{suffix})"
284
+ return title unless @page.site.pages.where(locale: @page.locale, title: title).exists?
285
+
286
+ suffix += 1
287
+ end
288
+ end
289
+
290
+ def duplicate_path
291
+ base = @page.path == "/" ? "/#{@page.slug.presence || @page.title.parameterize}" : @page.path
292
+ candidate = "#{base}-copy"
293
+ suffix = 2
294
+
295
+ while @page.site.pages.where(locale: @page.locale, path: candidate).exists?
296
+ candidate = "#{base}-copy-#{suffix}"
297
+ suffix += 1
298
+ end
299
+
300
+ candidate
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,51 @@
1
+ module Journeybook
2
+ module Admin
3
+ class RedirectsController < ApplicationController
4
+ before_action :set_redirect, only: %i[edit update destroy]
5
+
6
+ def index
7
+ @redirects = Redirect.order(:from)
8
+ end
9
+
10
+ def new
11
+ @redirect = Redirect.new(status_code: 301)
12
+ end
13
+
14
+ def create
15
+ @redirect = Redirect.new(redirect_params)
16
+
17
+ if @redirect.save
18
+ redirect_to admin_redirects_path, notice: "Redirect created."
19
+ else
20
+ render :new, status: :unprocessable_entity
21
+ end
22
+ end
23
+
24
+ def edit
25
+ end
26
+
27
+ def update
28
+ if @redirect.update(redirect_params)
29
+ redirect_to admin_redirects_path, notice: "Redirect updated."
30
+ else
31
+ render :edit, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ @redirect.destroy
37
+ redirect_to admin_redirects_path, notice: "Redirect deleted."
38
+ end
39
+
40
+ private
41
+
42
+ def set_redirect
43
+ @redirect = Redirect.find(params[:id])
44
+ end
45
+
46
+ def redirect_params
47
+ params.require(:redirect).permit(:from, :to, :status_code)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,51 @@
1
+ module Journeybook
2
+ module Admin
3
+ class SitesController < ApplicationController
4
+ before_action :set_site, only: %i[edit update destroy]
5
+
6
+ def index
7
+ @sites = Site.order(:name)
8
+ end
9
+
10
+ def new
11
+ @site = Site.new(default_locale: "en", path_prefix: "/", layout: Journeybook.configuration.default_layout)
12
+ end
13
+
14
+ def edit
15
+ end
16
+
17
+ def create
18
+ @site = Site.new(site_params)
19
+
20
+ if @site.save
21
+ redirect_to admin_sites_path, notice: "Site created."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def update
28
+ if @site.update(site_params)
29
+ redirect_to admin_sites_path, notice: "Site updated."
30
+ else
31
+ render :edit, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ @site.destroy
37
+ redirect_to admin_sites_path, notice: "Site deleted."
38
+ end
39
+
40
+ private
41
+
42
+ def set_site
43
+ @site = Site.find(params[:id])
44
+ end
45
+
46
+ def site_params
47
+ params.require(:site).permit(:name, :key, :host, :path_prefix, :layout, :default_locale)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,52 @@
1
+ module Journeybook
2
+ class ApplicationController < ActionController::Base
3
+ layout "journeybook/application"
4
+
5
+ before_action :authenticate_journeybook_admin!
6
+ before_action :set_current_site_context
7
+ helper_method :admin_nav_active?, :current_site, :site_context_path
8
+
9
+ private
10
+
11
+ attr_reader :current_site
12
+
13
+ def set_current_site_context
14
+ requested_site = Journeybook::Site.find_by(id: params[:site_id]) if params[:site_id].present?
15
+ session[:journeybook_site_id] = requested_site.id if requested_site
16
+
17
+ @current_site = Journeybook::Site.find_by(id: session[:journeybook_site_id]) || Journeybook::Site.order(:name).first
18
+ session[:journeybook_site_id] = @current_site.id if @current_site
19
+ end
20
+
21
+ def site_context_path(site)
22
+ url_for(request.query_parameters.merge(site_id: site.id))
23
+ end
24
+
25
+ def admin_nav_active?(section)
26
+ case section
27
+ when :dashboard
28
+ controller_path == "journeybook/admin/dashboard"
29
+ when :pages
30
+ controller_path == "journeybook/admin/pages"
31
+ when :sites
32
+ controller_path == "journeybook/admin/sites"
33
+ when :redirects
34
+ controller_path == "journeybook/admin/redirects"
35
+ when :components
36
+ controller_path == "journeybook/admin/components"
37
+ when :library
38
+ controller_path == "journeybook/admin/media_assets"
39
+ when :ai
40
+ controller_path == "journeybook/admin/ai_pages"
41
+ when :navigation
42
+ controller_path.start_with?("journeybook/admin/navigation_")
43
+ end
44
+ end
45
+
46
+ def authenticate_journeybook_admin!
47
+ return if Journeybook.configuration.admin_authenticator.call(self)
48
+
49
+ head :forbidden
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ module Journeybook
2
+ class FormSubmissionsController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+
5
+ FORM_FIELDS = %w[firstname lastname name email phone company contact_subject message privacy subject].freeze
6
+ MAX_REQUEST_BYTES = 20.kilobytes
7
+ MAX_FIELD_LENGTH = 5_000
8
+
9
+ rate_limit to: 5, within: 1.minute, by: -> { request.remote_ip }, only: :create, with: -> {
10
+ redirect_to redirect_location, alert: "Please wait before sending another message."
11
+ }
12
+
13
+ def create
14
+ return redirect_to redirect_location, notice: confirmation_text if honeypot_triggered?
15
+
16
+ validate_submission!
17
+ FormMailer.new.deliver(fields: form_fields)
18
+ redirect_to redirect_location, notice: confirmation_text
19
+ rescue SubmissionError, FormMailer::ConfigurationError, PostmarkClient::Error => error
20
+ redirect_to redirect_location, alert: error.message
21
+ end
22
+
23
+ private
24
+
25
+ class SubmissionError < StandardError; end
26
+
27
+ def form_fields
28
+ params.permit(*FORM_FIELDS).to_h.transform_values { |value| value.to_s.strip }
29
+ end
30
+
31
+ def validate_submission!
32
+ raise SubmissionError, "The form submission is too large." if request.content_length.to_i > MAX_REQUEST_BYTES
33
+
34
+ fields = form_fields
35
+ raise SubmissionError, "Please provide a valid email address." unless fields["email"].match?(URI::MailTo::EMAIL_REGEXP)
36
+ raise SubmissionError, "Please enter a message." if params.key?(:message) && fields["message"].blank?
37
+ raise SubmissionError, "The form submission contains a field that is too long." if fields.values.any? { |value| value.length > MAX_FIELD_LENGTH }
38
+ end
39
+
40
+ def honeypot_triggered?
41
+ params[:website].present?
42
+ end
43
+
44
+ def redirect_location
45
+ return_to = params[:return_to].to_s
46
+ return return_to if return_to.start_with?("/") && !return_to.start_with?("//")
47
+
48
+ request.referer.presence || "/"
49
+ end
50
+
51
+ def confirmation_text
52
+ params[:confirmation_text].to_s.strip.presence || "Thanks, your message was sent."
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,4 @@
1
+ module Journeybook
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,11 @@
1
+ module Journeybook
2
+ module NavigationHelper
3
+ def journeybook_menu(key, site: nil)
4
+ site ||= @journeybook_site || @journeybook_page&.site
5
+ return unless site
6
+
7
+ @journeybook_navigation_menus ||= {}
8
+ @journeybook_navigation_menus[[ site.id, key.to_s ]] ||= site.navigation_menus.includes(items: :page).find_by(key: key.to_s)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import ComponentSearchController from "journeybook/component_search_controller"
3
+
4
+ const application = Application.start()
5
+ application.register("journeybook-component-search", ComponentSearchController)
@@ -0,0 +1,13 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [ "input", "item" ]
5
+
6
+ filter() {
7
+ const query = this.inputTarget.value.trim().toLowerCase()
8
+
9
+ this.itemTargets.forEach((item) => {
10
+ item.hidden = Boolean(query) && !item.dataset.componentSearchName.toLowerCase().includes(query)
11
+ })
12
+ }
13
+ }
@@ -0,0 +1,4 @@
1
+ module Journeybook
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
File without changes
@@ -0,0 +1,6 @@
1
+ module Journeybook
2
+ class AiStylePrompt < ApplicationRecord
3
+ validates :name, :prompt, presence: true
4
+ validates :name, uniqueness: true
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module Journeybook
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,26 @@
1
+ module Journeybook
2
+ class MediaAsset < ApplicationRecord
3
+ IMAGE_CONTENT_TYPES = %w[image/png image/jpeg image/webp image/gif image/svg+xml].freeze
4
+
5
+ validates :title, :storage_key, :original_filename, :content_type, :byte_size, presence: true
6
+ validates :storage_key, uniqueness: true
7
+ validates :content_type, inclusion: { in: IMAGE_CONTENT_TYPES }
8
+ validates :byte_size, numericality: { greater_than: 0 }
9
+
10
+ before_destroy :delete_storage_object
11
+
12
+ def public_url
13
+ Journeybook::Storage::S3Client.new.public_url(storage_key)
14
+ end
15
+
16
+ def tag_list
17
+ tags.to_s.split(",").map(&:strip).reject(&:blank?)
18
+ end
19
+
20
+ private
21
+
22
+ def delete_storage_object
23
+ Journeybook::Storage::S3Client.new.delete(storage_key)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ module Journeybook
2
+ class NavigationItem < ApplicationRecord
3
+ belongs_to :navigation_menu
4
+ belongs_to :page, class_name: "Page", optional: true
5
+
6
+ before_validation :normalize_external_url
7
+
8
+ validates :label, presence: true
9
+ validates :external_url, format: { with: %r{\A(?:https?://|/|#)}, message: "must be an absolute URL, path, or anchor" }, allow_blank: true
10
+ validate :has_destination
11
+ validate :page_belongs_to_menu_site
12
+
13
+ scope :ordered, -> { order(:position, :id) }
14
+
15
+ def displayable?
16
+ visible? && (external_url.present? || page&.published?)
17
+ end
18
+
19
+ def href
20
+ navigation_menu.site.public_path_for(page) || external_url
21
+ end
22
+
23
+ def target
24
+ "_blank" if open_in_new_window?
25
+ end
26
+
27
+ private
28
+
29
+ def normalize_external_url
30
+ self.external_url = external_url.to_s.strip.presence
31
+ end
32
+
33
+ def has_destination
34
+ return if page.present? ^ external_url.present?
35
+
36
+ errors.add(:base, "Select either a page or an external URL")
37
+ end
38
+
39
+ def page_belongs_to_menu_site
40
+ return unless page && page.site_id != navigation_menu.site_id
41
+
42
+ errors.add(:page, "must belong to the same site as the menu")
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ module Journeybook
2
+ class NavigationMenu < ApplicationRecord
3
+ belongs_to :site
4
+ has_many :items, class_name: "NavigationItem", dependent: :destroy
5
+
6
+ before_validation :normalize_key
7
+
8
+ validates :key, :title, presence: true
9
+ validates :key, uniqueness: { scope: :site_id }
10
+
11
+ scope :ordered, -> { order(:position, :title) }
12
+
13
+ def visible_items
14
+ @visible_items ||= items.includes(:page).order(:position, :id).select(&:displayable?)
15
+ end
16
+
17
+ private
18
+
19
+ def normalize_key
20
+ self.key = key.to_s.parameterize(separator: "_").underscore
21
+ end
22
+ end
23
+ end