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,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,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,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
|
+
}
|
|
File without changes
|
|
@@ -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
|