plum-cms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +252 -0
  5. data/app/assets/builds/tailwind.css +2 -0
  6. data/app/assets/images/plum-mark.svg +7 -0
  7. data/app/assets/images/table-needs-logo.svg +83 -0
  8. data/app/assets/javascripts/plum/application.js +15 -0
  9. data/app/assets/stylesheets/application.css +10 -0
  10. data/app/assets/stylesheets/plum/control_panel.css +2 -0
  11. data/app/assets/tailwind/application.css +47 -0
  12. data/app/controllers/plum/application_controller.rb +34 -0
  13. data/app/controllers/plum/cp/assets_controller.rb +84 -0
  14. data/app/controllers/plum/cp/base_controller.rb +45 -0
  15. data/app/controllers/plum/cp/content_types_controller.rb +59 -0
  16. data/app/controllers/plum/cp/dashboard_controller.rb +10 -0
  17. data/app/controllers/plum/cp/entries_controller.rb +240 -0
  18. data/app/controllers/plum/cp/form_definitions_controller.rb +105 -0
  19. data/app/controllers/plum/cp/form_submissions_controller.rb +26 -0
  20. data/app/controllers/plum/cp/globals_controller.rb +84 -0
  21. data/app/controllers/plum/cp/nav_items_controller.rb +68 -0
  22. data/app/controllers/plum/cp/nav_menus_controller.rb +56 -0
  23. data/app/controllers/plum/cp/site_settings_controller.rb +87 -0
  24. data/app/controllers/plum/cp/taxonomies_controller.rb +56 -0
  25. data/app/controllers/plum/cp/terms_controller.rb +55 -0
  26. data/app/controllers/plum/cp/theme_previews_controller.rb +27 -0
  27. data/app/controllers/plum/cp/themes_controller.rb +98 -0
  28. data/app/controllers/plum/form_submissions_controller.rb +44 -0
  29. data/app/controllers/plum/pages_controller.rb +161 -0
  30. data/app/controllers/plum/sessions_controller.rb +25 -0
  31. data/app/controllers/plum/theme_assets_controller.rb +14 -0
  32. data/app/helpers/application_helper.rb +2 -0
  33. data/app/helpers/markdown_helper.rb +21 -0
  34. data/app/helpers/plum/cp/assets_helper.rb +21 -0
  35. data/app/helpers/plum/cp/theme_settings_helper.rb +26 -0
  36. data/app/javascript/application.js +18 -0
  37. data/app/javascript/controllers/application.js +9 -0
  38. data/app/javascript/controllers/index.js +4 -0
  39. data/app/javascript/controllers/plum/blocks_editor_controller.js +275 -0
  40. data/app/javascript/controllers/plum/blueprint_controller.js +120 -0
  41. data/app/javascript/controllers/plum/form_fields_controller.js +84 -0
  42. data/app/javascript/controllers/plum/image_picker_controller.js +143 -0
  43. data/app/javascript/controllers/plum/theme_settings_controller.js +17 -0
  44. data/app/models/plum/application_record.rb +5 -0
  45. data/app/models/plum/asset.rb +69 -0
  46. data/app/models/plum/content_type.rb +35 -0
  47. data/app/models/plum/entry.rb +67 -0
  48. data/app/models/plum/entry_term.rb +8 -0
  49. data/app/models/plum/form_definition.rb +68 -0
  50. data/app/models/plum/form_submission.rb +76 -0
  51. data/app/models/plum/global.rb +24 -0
  52. data/app/models/plum/nav_item.rb +58 -0
  53. data/app/models/plum/nav_menu.rb +24 -0
  54. data/app/models/plum/site.rb +112 -0
  55. data/app/models/plum/site_scoped.rb +17 -0
  56. data/app/models/plum/site_setting.rb +35 -0
  57. data/app/models/plum/taxonomy.rb +24 -0
  58. data/app/models/plum/term.rb +23 -0
  59. data/app/models/plum/user.rb +16 -0
  60. data/app/services/plum/base_blocks.rb +161 -0
  61. data/app/services/plum/block_editor_config.rb +65 -0
  62. data/app/services/plum/block_library.rb +31 -0
  63. data/app/services/plum/builder_renderer.rb +63 -0
  64. data/app/services/plum/field_expander.rb +78 -0
  65. data/app/services/plum/form_renderer.rb +110 -0
  66. data/app/services/plum/liquid_context.rb +335 -0
  67. data/app/services/plum/liquid_filters.rb +58 -0
  68. data/app/services/plum/liquid_renderer.rb +49 -0
  69. data/app/services/plum/theme.rb +160 -0
  70. data/app/services/plum/theme_asset_path.rb +31 -0
  71. data/app/services/plum/theme_package_installer.rb +287 -0
  72. data/app/services/plum/theme_registry.rb +63 -0
  73. data/app/services/plum/theme_resolver.rb +55 -0
  74. data/app/services/plum/theme_settings_params.rb +54 -0
  75. data/app/themes/bagel-boy/assets/screenshot.svg +92 -0
  76. data/app/themes/bagel-boy/assets/theme.css +184 -0
  77. data/app/themes/bagel-boy/blocks/hours.liquid +4 -0
  78. data/app/themes/bagel-boy/blocks/menu_item.liquid +7 -0
  79. data/app/themes/bagel-boy/layouts/base.liquid +64 -0
  80. data/app/themes/bagel-boy/templates/collections/posts.liquid +32 -0
  81. data/app/themes/bagel-boy/templates/entries/_default.liquid +13 -0
  82. data/app/themes/bagel-boy/templates/entries/landing.liquid +4 -0
  83. data/app/themes/bagel-boy/templates/entries/pages.liquid +18 -0
  84. data/app/themes/bagel-boy/templates/entries/posts.liquid +8 -0
  85. data/app/themes/bagel-boy/templates/index.liquid +10 -0
  86. data/app/themes/bagel-boy/templates/search.liquid +28 -0
  87. data/app/themes/bagel-boy/theme.yml +47 -0
  88. data/app/themes/bagel-shop/assets/screenshot.svg +28 -0
  89. data/app/themes/bagel-shop/assets/theme.css +7 -0
  90. data/app/themes/bagel-shop/layouts/base.liquid +507 -0
  91. data/app/themes/bagel-shop/templates/entries/_default.liquid +15 -0
  92. data/app/themes/bagel-shop/templates/entries/pages.liquid +12 -0
  93. data/app/themes/bagel-shop/templates/entries/posts.liquid +19 -0
  94. data/app/themes/bagel-shop/templates/index.liquid +55 -0
  95. data/app/themes/bagel-shop/theme.yml +30 -0
  96. data/app/themes/default/assets/screenshot.svg +24 -0
  97. data/app/themes/default/assets/theme.css +89 -0
  98. data/app/themes/default/layouts/base.liquid +117 -0
  99. data/app/themes/default/templates/collections/_default.liquid +16 -0
  100. data/app/themes/default/templates/collections/posts.liquid +27 -0
  101. data/app/themes/default/templates/entries/_default.liquid +26 -0
  102. data/app/themes/default/templates/entries/landing.liquid +5 -0
  103. data/app/themes/default/templates/entries/pages.liquid +17 -0
  104. data/app/themes/default/templates/entries/posts.liquid +20 -0
  105. data/app/themes/default/templates/index.liquid +22 -0
  106. data/app/themes/default/templates/search.liquid +28 -0
  107. data/app/themes/default/templates/taxonomies/_default.liquid +25 -0
  108. data/app/themes/default/templates/taxonomies/_default_index.liquid +14 -0
  109. data/app/themes/default/theme.yml +23 -0
  110. data/app/views/layouts/plum/cp.html.erb +138 -0
  111. data/app/views/layouts/plum/session.html.erb +43 -0
  112. data/app/views/plum/cp/assets/_form.html.erb +44 -0
  113. data/app/views/plum/cp/assets/edit.html.erb +14 -0
  114. data/app/views/plum/cp/assets/index.html.erb +52 -0
  115. data/app/views/plum/cp/assets/new.html.erb +9 -0
  116. data/app/views/plum/cp/content_types/_form.html.erb +113 -0
  117. data/app/views/plum/cp/content_types/edit.html.erb +8 -0
  118. data/app/views/plum/cp/content_types/index.html.erb +48 -0
  119. data/app/views/plum/cp/content_types/new.html.erb +5 -0
  120. data/app/views/plum/cp/content_types/show.html.erb +59 -0
  121. data/app/views/plum/cp/dashboard/show.html.erb +50 -0
  122. data/app/views/plum/cp/entries/_form.html.erb +196 -0
  123. data/app/views/plum/cp/entries/edit.html.erb +9 -0
  124. data/app/views/plum/cp/entries/index.html.erb +51 -0
  125. data/app/views/plum/cp/entries/new.html.erb +5 -0
  126. data/app/views/plum/cp/form_definitions/_form.html.erb +90 -0
  127. data/app/views/plum/cp/form_definitions/edit.html.erb +11 -0
  128. data/app/views/plum/cp/form_definitions/index.html.erb +43 -0
  129. data/app/views/plum/cp/form_definitions/new.html.erb +5 -0
  130. data/app/views/plum/cp/form_definitions/show.html.erb +70 -0
  131. data/app/views/plum/cp/form_submissions/show.html.erb +20 -0
  132. data/app/views/plum/cp/globals/_form.html.erb +44 -0
  133. data/app/views/plum/cp/globals/edit.html.erb +11 -0
  134. data/app/views/plum/cp/globals/index.html.erb +41 -0
  135. data/app/views/plum/cp/globals/new.html.erb +5 -0
  136. data/app/views/plum/cp/globals/show.html.erb +14 -0
  137. data/app/views/plum/cp/nav_items/_form.html.erb +56 -0
  138. data/app/views/plum/cp/nav_items/edit.html.erb +11 -0
  139. data/app/views/plum/cp/nav_items/new.html.erb +6 -0
  140. data/app/views/plum/cp/nav_menus/_form.html.erb +33 -0
  141. data/app/views/plum/cp/nav_menus/_nav_item.html.erb +24 -0
  142. data/app/views/plum/cp/nav_menus/edit.html.erb +11 -0
  143. data/app/views/plum/cp/nav_menus/index.html.erb +41 -0
  144. data/app/views/plum/cp/nav_menus/new.html.erb +5 -0
  145. data/app/views/plum/cp/nav_menus/show.html.erb +24 -0
  146. data/app/views/plum/cp/shared/_icon.html.erb +39 -0
  147. data/app/views/plum/cp/shared/_image_picker.html.erb +38 -0
  148. data/app/views/plum/cp/site_settings/edit.html.erb +145 -0
  149. data/app/views/plum/cp/site_settings/show.html.erb +62 -0
  150. data/app/views/plum/cp/taxonomies/_form.html.erb +38 -0
  151. data/app/views/plum/cp/taxonomies/edit.html.erb +2 -0
  152. data/app/views/plum/cp/taxonomies/index.html.erb +38 -0
  153. data/app/views/plum/cp/taxonomies/new.html.erb +2 -0
  154. data/app/views/plum/cp/taxonomies/show.html.erb +46 -0
  155. data/app/views/plum/cp/terms/_form.html.erb +30 -0
  156. data/app/views/plum/cp/terms/edit.html.erb +2 -0
  157. data/app/views/plum/cp/terms/new.html.erb +2 -0
  158. data/app/views/plum/cp/themes/index.html.erb +160 -0
  159. data/app/views/plum/form_mailer/submission_notification.html.erb +13 -0
  160. data/app/views/plum/form_mailer/submission_notification.text.erb +7 -0
  161. data/app/views/plum/sessions/new.html.erb +20 -0
  162. data/compat/plum-cms.rb +1 -0
  163. data/config/locales/en.yml +31 -0
  164. data/config/plum_importmap.rb +4 -0
  165. data/config/plum_routes.rb +34 -0
  166. data/db/engine_migrate/20260101000001_create_plum_tables.rb +154 -0
  167. data/lib/generators/plum/install/install_generator.rb +54 -0
  168. data/lib/generators/plum/install/templates/plum_initializer.rb +37 -0
  169. data/lib/plum/configuration.rb +88 -0
  170. data/lib/plum/content_source.rb +45 -0
  171. data/lib/plum/content_source_context.rb +30 -0
  172. data/lib/plum/content_source_registry.rb +120 -0
  173. data/lib/plum/engine.rb +30 -0
  174. data/lib/plum/liquid_tags/form_tag.rb +17 -0
  175. data/lib/plum/version.rb +7 -0
  176. data/lib/plum.rb +11 -0
  177. data/vendor/javascript/lexxy.js +11859 -0
  178. metadata +405 -0
@@ -0,0 +1,68 @@
1
+ module Plum
2
+ module Cp
3
+ class NavItemsController < BaseController
4
+ before_action :set_nav_menu
5
+ before_action :set_nav_item, only: [ :edit, :update, :destroy ]
6
+ before_action :set_form_options, only: [ :new, :create, :edit, :update ]
7
+ before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy ]
8
+
9
+ def new
10
+ @nav_item = @nav_menu.nav_items.build(site: current_site, position: next_position)
11
+ end
12
+
13
+ def create
14
+ @nav_item = @nav_menu.nav_items.build(nav_item_params)
15
+ @nav_item.site = current_site
16
+
17
+ if @nav_item.save
18
+ redirect_to cp_nav_menu_path(@nav_menu), notice: "Navigation item 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 @nav_item.update(nav_item_params)
29
+ redirect_to cp_nav_menu_path(@nav_menu), notice: "Navigation item updated"
30
+ else
31
+ render :edit, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ @nav_item.destroy
37
+ redirect_to cp_nav_menu_path(@nav_menu), notice: "Navigation item deleted"
38
+ end
39
+
40
+ private
41
+
42
+ def set_nav_menu
43
+ @nav_menu = current_site.nav_menus.find(params[:nav_menu_id])
44
+ end
45
+
46
+ def set_nav_item
47
+ @nav_item = @nav_menu.nav_items.find(params[:id])
48
+ end
49
+
50
+ def set_form_options
51
+ @entries = current_site.entries.includes(:content_type).order(:title)
52
+ @parent_options = @nav_menu.nav_items.where.not(id: @nav_item&.id).includes(:entry).order(:position, :label)
53
+ end
54
+
55
+ def next_position
56
+ @nav_menu.nav_items.maximum(:position).to_i + 1
57
+ end
58
+
59
+ def nav_item_params
60
+ permitted = params.require(:nav_item).permit(:label, :url, :entry_id, :parent_id, :position)
61
+ permitted[:entry_id] = nil if permitted[:entry_id].blank?
62
+ permitted[:parent_id] = nil if permitted[:parent_id].blank?
63
+ permitted[:position] = next_position if permitted[:position].blank?
64
+ permitted
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,56 @@
1
+ module Plum
2
+ module Cp
3
+ class NavMenusController < BaseController
4
+ before_action :set_nav_menu, only: [ :show, :edit, :update, :destroy ]
5
+ before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy ]
6
+
7
+ def index
8
+ @nav_menus = current_site.nav_menus.includes(:nav_items).order(:name)
9
+ end
10
+
11
+ def show
12
+ @nav_items = @nav_menu.root_items.includes(:entry, children: :entry)
13
+ end
14
+
15
+ def new
16
+ @nav_menu = current_site.nav_menus.build
17
+ end
18
+
19
+ def create
20
+ @nav_menu = current_site.nav_menus.build(nav_menu_params)
21
+
22
+ if @nav_menu.save
23
+ redirect_to cp_nav_menu_path(@nav_menu), notice: "Navigation menu created"
24
+ else
25
+ render :new, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def edit
30
+ end
31
+
32
+ def update
33
+ if @nav_menu.update(nav_menu_params)
34
+ redirect_to cp_nav_menu_path(@nav_menu), notice: "Navigation menu updated"
35
+ else
36
+ render :edit, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ def destroy
41
+ @nav_menu.destroy
42
+ redirect_to cp_nav_menus_path, notice: "Navigation menu deleted"
43
+ end
44
+
45
+ private
46
+
47
+ def set_nav_menu
48
+ @nav_menu = current_site.nav_menus.find(params[:id])
49
+ end
50
+
51
+ def nav_menu_params
52
+ params.require(:nav_menu).permit(:name, :handle)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,87 @@
1
+ module Plum
2
+ module Cp
3
+ class SiteSettingsController < BaseController
4
+ helper_method :theme_setting_input_id,
5
+ :theme_setting_input_name,
6
+ :theme_setting_value,
7
+ :theme_setting_checked?
8
+
9
+ before_action :set_site_settings
10
+ before_action :set_theme_options
11
+ before_action :require_admin, only: [ :edit, :update ]
12
+
13
+ def show
14
+ redirect_to edit_cp_site_settings_path
15
+ end
16
+
17
+ def edit
18
+ end
19
+
20
+ def update
21
+ settings_attributes = site_settings_params
22
+ theme_settings_by_theme = settings_attributes.delete(:theme_settings_by_theme).to_h
23
+ @site_settings.assign_attributes(settings_attributes)
24
+
25
+ if @site_settings.save
26
+ current_site.update!(theme_settings: theme_settings_for(@site_settings.theme_name, theme_settings_by_theme))
27
+ redirect_to edit_cp_site_settings_path, notice: "Site settings updated"
28
+ else
29
+ set_theme_options
30
+ render :edit, status: :unprocessable_entity
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def set_site_settings
37
+ @site_settings = SiteSetting.instance(current_site)
38
+ end
39
+
40
+ def set_theme_options
41
+ registry = ThemeRegistry.new
42
+ @themes = registry.all
43
+ @current_theme = registry.fetch(@site_settings.theme_name)
44
+ end
45
+
46
+ def theme_settings_for(theme_name, theme_settings_by_theme)
47
+ theme = ThemeRegistry.new.fetch(theme_name)
48
+ raw_settings = theme_settings_by_theme[theme.handle] || {}
49
+ ThemeSettingsParams.new(theme).normalize(raw_settings)
50
+ end
51
+
52
+ def theme_setting_input_id(theme, field)
53
+ "theme_settings_#{theme.handle.parameterize}_#{field['handle'].to_s.parameterize}"
54
+ end
55
+
56
+ def theme_setting_input_name(theme, field)
57
+ "site_setting[theme_settings_by_theme][#{theme.handle}][#{field['handle']}]"
58
+ end
59
+
60
+ def theme_setting_value(field)
61
+ settings = current_site.theme_settings.to_h
62
+ handle = field["handle"].to_s
63
+
64
+ settings.key?(handle) ? settings[handle] : field["default"]
65
+ end
66
+
67
+ def theme_setting_checked?(field)
68
+ ActiveModel::Type::Boolean.new.cast(theme_setting_value(field))
69
+ end
70
+
71
+ def site_settings_params
72
+ params.require(:site_setting).permit(
73
+ :name,
74
+ :tagline,
75
+ :logo,
76
+ :favicon,
77
+ :seo_title,
78
+ :seo_description,
79
+ :primary_color,
80
+ :support_email,
81
+ :theme_name,
82
+ theme_settings_by_theme: {}
83
+ )
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,56 @@
1
+ module Plum
2
+ module Cp
3
+ class TaxonomiesController < BaseController
4
+ before_action :set_taxonomy, only: [ :show, :edit, :update, :destroy ]
5
+ before_action :require_admin, only: [ :new, :create, :edit, :update, :destroy ]
6
+
7
+ def index
8
+ @taxonomies = current_site.taxonomies.order(:name)
9
+ end
10
+
11
+ def show
12
+ @terms = @taxonomy.terms.ordered
13
+ end
14
+
15
+ def new
16
+ @taxonomy = current_site.taxonomies.build
17
+ end
18
+
19
+ def create
20
+ @taxonomy = current_site.taxonomies.build(taxonomy_params)
21
+
22
+ if @taxonomy.save
23
+ redirect_to "#{cp_prefix}/taxonomies/#{@taxonomy.id}", notice: "Taxonomy created"
24
+ else
25
+ render :new, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ def edit
30
+ end
31
+
32
+ def update
33
+ if @taxonomy.update(taxonomy_params)
34
+ redirect_to "#{cp_prefix}/taxonomies/#{@taxonomy.id}", notice: "Taxonomy updated"
35
+ else
36
+ render :edit, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ def destroy
41
+ @taxonomy.destroy
42
+ redirect_to "#{cp_prefix}/taxonomies", notice: "Taxonomy deleted"
43
+ end
44
+
45
+ private
46
+
47
+ def set_taxonomy
48
+ @taxonomy = current_site.taxonomies.find(params[:id])
49
+ end
50
+
51
+ def taxonomy_params
52
+ params.require(:taxonomy).permit(:name, :handle, :slug)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,55 @@
1
+ module Plum
2
+ module Cp
3
+ class TermsController < BaseController
4
+ before_action :set_taxonomy
5
+ before_action :set_term, only: [ :edit, :update, :destroy ]
6
+ before_action :require_admin, only: [ :new, :create, :edit, :update, :destroy ]
7
+
8
+ def new
9
+ @term = @taxonomy.terms.build(site: current_site)
10
+ end
11
+
12
+ def create
13
+ @term = @taxonomy.terms.build(term_params)
14
+ @term.site = current_site
15
+ @term.position = @taxonomy.terms.maximum(:position).to_i + 1
16
+
17
+ if @term.save
18
+ redirect_to "#{cp_prefix}/taxonomies/#{@taxonomy.id}", notice: "Term 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 @term.update(term_params)
29
+ redirect_to "#{cp_prefix}/taxonomies/#{@taxonomy.id}", notice: "Term updated"
30
+ else
31
+ render :edit, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ @term.destroy
37
+ redirect_to "#{cp_prefix}/taxonomies/#{@taxonomy.id}", notice: "Term deleted"
38
+ end
39
+
40
+ private
41
+
42
+ def set_taxonomy
43
+ @taxonomy = current_site.taxonomies.find(params[:taxonomy_id])
44
+ end
45
+
46
+ def set_term
47
+ @term = @taxonomy.terms.find(params[:id])
48
+ end
49
+
50
+ def term_params
51
+ params.require(:term).permit(:name, :slug)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,27 @@
1
+ module Plum
2
+ module Cp
3
+ class ThemePreviewsController < BaseController
4
+ def show
5
+ theme = ThemeRegistry.new.find(params[:handle])
6
+ return render file: Rails.public_path.join("404.html"), status: :not_found, layout: false unless theme
7
+
8
+ context = LiquidContext.new(
9
+ controller: self,
10
+ site: current_site,
11
+ theme_name: theme.handle,
12
+ theme_settings: preview_theme_settings(theme)
13
+ )
14
+
15
+ html = LiquidRenderer.render_template("index", context.to_h)
16
+ render html: html.html_safe, layout: false
17
+ end
18
+
19
+ private
20
+
21
+ def preview_theme_settings(theme)
22
+ raw_settings = params.fetch(:theme_settings, {}).permit!.to_h if params[:theme_settings].respond_to?(:permit!)
23
+ ThemeSettingsParams.new(theme).normalize(raw_settings.presence || current_site.theme_settings)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,98 @@
1
+ module Plum
2
+ module Cp
3
+ class ThemesController < BaseController
4
+ helper_method :theme_screenshot_url,
5
+ :theme_initials,
6
+ :theme_setting_input_id,
7
+ :theme_setting_input_name,
8
+ :theme_setting_value,
9
+ :theme_setting_checked?
10
+
11
+ before_action :set_themes
12
+ before_action :require_admin, only: [ :create, :update ]
13
+
14
+ def index
15
+ end
16
+
17
+ def create
18
+ installer = ThemePackageInstaller.new(params[:theme_package])
19
+
20
+ if installer.install
21
+ redirect_to cp_themes_path, notice: "Theme installed"
22
+ else
23
+ @theme_package_errors = installer.errors
24
+ render :index, status: :unprocessable_entity
25
+ end
26
+ end
27
+
28
+ def update
29
+ theme = ThemeRegistry.new.find(params[:id])
30
+
31
+ if theme
32
+ site_settings.update!(theme_name: theme.handle)
33
+ current_site.update!(
34
+ theme_name: theme.handle,
35
+ theme_settings: ThemeSettingsParams.new(theme).normalize(theme_settings_params_for(theme))
36
+ )
37
+ redirect_to cp_themes_path, notice: theme_update_notice(theme)
38
+ else
39
+ redirect_to cp_themes_path, alert: "Theme not found"
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def set_themes
46
+ registry = ThemeRegistry.new
47
+
48
+ @themes = registry.all
49
+ @active_theme = registry.fetch(site_settings.theme_name)
50
+ end
51
+
52
+ def theme_screenshot_url(theme)
53
+ return if theme.screenshot_path.blank?
54
+
55
+ theme_asset_path(theme.handle, theme.screenshot_path)
56
+ end
57
+
58
+ def theme_initials(theme)
59
+ theme.name.split.first(2).map { |part| part.first.to_s.upcase }.join.presence || theme.handle.first.to_s.upcase
60
+ end
61
+
62
+ def theme_setting_input_id(theme, field)
63
+ "theme_settings_#{theme.handle.parameterize}_#{field['handle'].to_s.parameterize}"
64
+ end
65
+
66
+ def theme_setting_input_name(field)
67
+ "theme_settings[#{field['handle']}]"
68
+ end
69
+
70
+ def theme_setting_value(field)
71
+ settings = current_site.theme_settings.to_h
72
+ handle = field["handle"].to_s
73
+
74
+ settings.key?(handle) ? settings[handle] : field["default"]
75
+ end
76
+
77
+ def theme_setting_checked?(field)
78
+ ActiveModel::Type::Boolean.new.cast(theme_setting_value(field))
79
+ end
80
+
81
+ def theme_settings_params_for(theme)
82
+ raw_settings = params[:theme_settings].respond_to?(:permit!) ? params[:theme_settings].permit!.to_h : nil
83
+ return raw_settings if raw_settings
84
+ return current_site.theme_settings if current_site.theme_name == theme.handle
85
+
86
+ {}
87
+ end
88
+
89
+ def theme_update_notice(theme)
90
+ params[:theme_settings].present? && current_site.theme_name == theme.handle ? "Theme settings updated" : "Theme activated"
91
+ end
92
+
93
+ def site_settings
94
+ @site_settings ||= SiteSetting.instance(current_site)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,44 @@
1
+ module Plum
2
+ class FormSubmissionsController < ApplicationController
3
+ def create
4
+ form_definition = current_site.form_definitions.find_by!(handle: params[:handle])
5
+ submission = form_definition.form_submissions.build(
6
+ site: current_site,
7
+ data: submission_data(form_definition)
8
+ )
9
+
10
+ if submission.save
11
+ deliver_submission_notification(submission)
12
+ redirect_to safe_return_path, notice: "Form submitted"
13
+ else
14
+ redirect_to safe_return_path, alert: submission.errors.full_messages.to_sentence
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def deliver_submission_notification(submission)
21
+ return if submission.form_definition.notification_email.blank?
22
+
23
+ Plum::FormMailer.submission_notification(submission).deliver_later
24
+ rescue StandardError => e
25
+ Rails.logger.error("[Plum] form notification could not be enqueued: #{e.message}")
26
+ end
27
+
28
+ def submission_data(form_definition)
29
+ raw_data = params.dig(:form_submission, :data)
30
+ data = raw_data.respond_to?(:permit!) ? raw_data.permit!.to_h : {}
31
+
32
+ form_definition.field_handles.index_with { |handle| data[handle] }
33
+ end
34
+
35
+ def safe_return_path
36
+ return root_path unless params[:return_to].is_a?(String)
37
+
38
+ return_to = params[:return_to]
39
+ return root_path if return_to.blank? || return_to.start_with?("//") || return_to.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
40
+
41
+ return_to
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,161 @@
1
+ module Plum
2
+ class PagesController < ApplicationController
3
+ HOMEPAGE_SLUG = "home".freeze
4
+
5
+ def home
6
+ @site_settings = SiteSetting.instance(current_site)
7
+ @entry = Entry.for_site(current_site).live
8
+ .includes(:content_type, terms: :taxonomy)
9
+ .find_by(slug: HOMEPAGE_SLUG)
10
+
11
+ template = @entry ? "entries/#{@entry.content_type.handle}" : "index"
12
+ html = Plum::LiquidRenderer.render_template(template, build_context(@entry).to_h)
13
+ render html: html.html_safe, layout: false
14
+ end
15
+
16
+ def search
17
+ query = params[:q].to_s.strip
18
+ context = build_context
19
+ results = Entry.for_site(current_site).live.search(query)
20
+ .includes(:content_type)
21
+ .order(published_at: :desc)
22
+ .limit(50)
23
+
24
+ context_hash = context.to_h.merge(
25
+ "search" => {
26
+ "query" => query,
27
+ "results" => results.map { |e| context.send(:entry_context, e) },
28
+ "total" => results.size
29
+ }
30
+ )
31
+ html = Plum::LiquidRenderer.render_template("search", context_hash)
32
+ render html: html.html_safe, layout: false
33
+ end
34
+
35
+ def show
36
+ slug = params[:slug]
37
+
38
+ @content_type = ContentType.for_site(current_site).find_by(handle: slug)
39
+ if @content_type
40
+ return render_collection(@content_type)
41
+ end
42
+
43
+ if slug.include?("/")
44
+ parts = slug.split("/", 2)
45
+ content_type = ContentType.for_site(current_site).find do |candidate|
46
+ candidate.route_prefix == parts[0]
47
+ end
48
+ if content_type
49
+ @entry = Entry.for_site(current_site).live
50
+ .includes(:content_type, terms: :taxonomy)
51
+ .find_by!(content_type: content_type, slug: parts[1])
52
+ html = Plum::LiquidRenderer.render_template("entries/#{content_type.handle}", build_context(@entry).to_h)
53
+ return render html: html.html_safe, layout: false
54
+ end
55
+
56
+ taxonomy = Taxonomy.for_site(current_site).find_by(slug: parts[0])
57
+ if taxonomy
58
+ term = taxonomy.terms.find_by!(slug: parts[1])
59
+ return render_term_page(taxonomy, term)
60
+ end
61
+ else
62
+ taxonomy = Taxonomy.for_site(current_site).find_by(slug: slug)
63
+ return render_taxonomy_index(taxonomy) if taxonomy
64
+ end
65
+
66
+ @entry = Entry.for_site(current_site).live
67
+ .includes(:content_type, terms: :taxonomy)
68
+ .find_by!(slug: slug)
69
+ template = "entries/#{@entry.content_type.handle}"
70
+ html = Plum::LiquidRenderer.render_template(template, build_context(@entry).to_h)
71
+ render html: html.html_safe, layout: false
72
+ rescue ActiveRecord::RecordNotFound
73
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
74
+ end
75
+
76
+ private
77
+
78
+ PER_PAGE = 12
79
+
80
+ def render_collection(content_type)
81
+ context = build_context
82
+ page = [ params.fetch(:page, 1).to_i, 1 ].max
83
+ all_entries = Entry.for_site(current_site).live
84
+ .where(content_type: content_type)
85
+ .order(published_at: :desc, created_at: :desc)
86
+ total = all_entries.count
87
+ total_pages = (total / PER_PAGE.to_f).ceil
88
+ page = [ page, [ total_pages, 1 ].max ].min
89
+ entries = all_entries.offset((page - 1) * PER_PAGE).limit(PER_PAGE)
90
+
91
+ context_hash = context.to_h.merge(
92
+ "collection" => {
93
+ "title" => content_type.name,
94
+ "handle" => content_type.handle,
95
+ "entries" => entries.map { |e| context.send(:entry_context, e) }
96
+ },
97
+ "pagination" => {
98
+ "current_page" => page,
99
+ "total_pages" => total_pages,
100
+ "total_entries" => total,
101
+ "per_page" => PER_PAGE,
102
+ "previous_url" => page > 1 ? "#{request.path}?page=#{page - 1}" : nil,
103
+ "next_url" => page < total_pages ? "#{request.path}?page=#{page + 1}" : nil
104
+ }
105
+ )
106
+ template = "collections/#{content_type.handle}"
107
+ html = Plum::LiquidRenderer.render_template(template, context_hash)
108
+ render html: html.html_safe, layout: false
109
+ end
110
+
111
+ def render_term_page(taxonomy, term)
112
+ context = build_context
113
+ page = [ params.fetch(:page, 1).to_i, 1 ].max
114
+ all_entries = Entry.for_site(current_site).live
115
+ .joins(:entry_terms)
116
+ .where(plum_entry_terms: { term_id: term.id })
117
+ .order(published_at: :desc)
118
+ total = all_entries.count
119
+ total_pages = [ (total / PER_PAGE.to_f).ceil, 1 ].max
120
+ page = [ page, total_pages ].min
121
+ entries = all_entries.offset((page - 1) * PER_PAGE).limit(PER_PAGE)
122
+
123
+ context_hash = context.to_h.merge(
124
+ "taxonomy" => { "name" => taxonomy.name, "handle" => taxonomy.handle, "slug" => taxonomy.slug },
125
+ "term" => { "name" => term.name, "slug" => term.slug },
126
+ "collection" => {
127
+ "title" => "#{taxonomy.name}: #{term.name}",
128
+ "entries" => entries.map { |e| context.send(:entry_context, e) }
129
+ },
130
+ "pagination" => {
131
+ "current_page" => page, "total_pages" => total_pages, "total_entries" => total, "per_page" => PER_PAGE,
132
+ "previous_url" => page > 1 ? "#{request.path}?page=#{page - 1}" : nil,
133
+ "next_url" => page < total_pages ? "#{request.path}?page=#{page + 1}" : nil
134
+ }
135
+ )
136
+ template = "taxonomies/#{taxonomy.handle}"
137
+ html = Plum::LiquidRenderer.render_template(template, context_hash)
138
+ render html: html.html_safe, layout: false
139
+ end
140
+
141
+ def render_taxonomy_index(taxonomy)
142
+ context = build_context
143
+ terms = taxonomy.terms.ordered
144
+ context_hash = context.to_h.merge(
145
+ "taxonomy" => {
146
+ "name" => taxonomy.name,
147
+ "handle" => taxonomy.handle,
148
+ "slug" => taxonomy.slug,
149
+ "terms" => terms.map { |t| { "name" => t.name, "slug" => t.slug, "url" => "/#{taxonomy.slug}/#{t.slug}", "entries_count" => t.entries.live.count } }
150
+ }
151
+ )
152
+ template = "taxonomies/#{taxonomy.handle}_index"
153
+ html = Plum::LiquidRenderer.render_template(template, context_hash)
154
+ render html: html.html_safe, layout: false
155
+ end
156
+
157
+ def build_context(entry = nil)
158
+ LiquidContext.new(controller: self, site: current_site, entry: entry)
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,25 @@
1
+ module Plum
2
+ class SessionsController < ApplicationController
3
+ layout "plum/session"
4
+
5
+ def new
6
+ redirect_to cp_root_path if logged_in?
7
+ end
8
+
9
+ def create
10
+ user = Plum::User.find_by(email: params[:email])
11
+ if user&.authenticate(params[:password])
12
+ session[:plum_user_id] = user.id
13
+ redirect_to cp_root_path, notice: "Welcome back!"
14
+ else
15
+ flash.now[:alert] = "Invalid email or password"
16
+ render :new, status: :unprocessable_entity
17
+ end
18
+ end
19
+
20
+ def destroy
21
+ session[:plum_user_id] = nil
22
+ redirect_to login_path, notice: "You have been logged out"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ require "rack/mime"
2
+
3
+ module Plum
4
+ class ThemeAssetsController < ApplicationController
5
+ def show
6
+ asset_path = ThemeResolver.new.find_asset(params[:theme_handle], params[:path])
7
+ return head :not_found unless asset_path
8
+
9
+ send_file asset_path,
10
+ type: Rack::Mime.mime_type(asset_path.extname, "application/octet-stream"),
11
+ disposition: "inline"
12
+ end
13
+ end
14
+ end