not_pressed-core 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 (157) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +41 -0
  3. data/README.md +285 -0
  4. data/app/assets/javascripts/not_pressed/lightbox.js +110 -0
  5. data/app/assets/stylesheets/not_pressed/admin.css +1161 -0
  6. data/app/assets/stylesheets/not_pressed/content.css +193 -0
  7. data/app/assets/stylesheets/not_pressed/gallery.css +117 -0
  8. data/app/assets/stylesheets/not_pressed/themes/starter.css +118 -0
  9. data/app/controllers/not_pressed/admin/base_controller.rb +21 -0
  10. data/app/controllers/not_pressed/admin/categories_controller.rb +53 -0
  11. data/app/controllers/not_pressed/admin/content_blocks_controller.rb +73 -0
  12. data/app/controllers/not_pressed/admin/dashboard_controller.rb +19 -0
  13. data/app/controllers/not_pressed/admin/forms_controller.rb +86 -0
  14. data/app/controllers/not_pressed/admin/media_attachments_controller.rb +94 -0
  15. data/app/controllers/not_pressed/admin/pages_controller.rb +122 -0
  16. data/app/controllers/not_pressed/admin/plugins_controller.rb +121 -0
  17. data/app/controllers/not_pressed/admin/settings_controller.rb +19 -0
  18. data/app/controllers/not_pressed/admin/tags_controller.rb +37 -0
  19. data/app/controllers/not_pressed/admin/themes_controller.rb +104 -0
  20. data/app/controllers/not_pressed/application_controller.rb +6 -0
  21. data/app/controllers/not_pressed/blog_controller.rb +83 -0
  22. data/app/controllers/not_pressed/form_submissions_controller.rb +70 -0
  23. data/app/controllers/not_pressed/pages_controller.rb +36 -0
  24. data/app/controllers/not_pressed/robots_controller.rb +34 -0
  25. data/app/controllers/not_pressed/sitemaps_controller.rb +12 -0
  26. data/app/helpers/not_pressed/admin_helper.rb +41 -0
  27. data/app/helpers/not_pressed/application_helper.rb +6 -0
  28. data/app/helpers/not_pressed/code_injection_helper.rb +29 -0
  29. data/app/helpers/not_pressed/content_helper.rb +13 -0
  30. data/app/helpers/not_pressed/form_helper.rb +80 -0
  31. data/app/helpers/not_pressed/media_helper.rb +28 -0
  32. data/app/helpers/not_pressed/seo_helper.rb +69 -0
  33. data/app/helpers/not_pressed/theme_helper.rb +42 -0
  34. data/app/mailers/not_pressed/application_mailer.rb +10 -0
  35. data/app/mailers/not_pressed/form_mailer.rb +15 -0
  36. data/app/models/concerns/not_pressed/sluggable.rb +43 -0
  37. data/app/models/not_pressed/category.rb +16 -0
  38. data/app/models/not_pressed/content_block.rb +46 -0
  39. data/app/models/not_pressed/form.rb +55 -0
  40. data/app/models/not_pressed/form_field.rb +23 -0
  41. data/app/models/not_pressed/form_submission.rb +19 -0
  42. data/app/models/not_pressed/media_attachment.rb +68 -0
  43. data/app/models/not_pressed/page.rb +182 -0
  44. data/app/models/not_pressed/page_version.rb +15 -0
  45. data/app/models/not_pressed/setting.rb +20 -0
  46. data/app/models/not_pressed/tag.rb +15 -0
  47. data/app/models/not_pressed/tagging.rb +12 -0
  48. data/app/plugins/not_pressed/analytics_plugin.rb +106 -0
  49. data/app/plugins/not_pressed/callout_block_plugin.rb +43 -0
  50. data/app/themes/not_pressed/starter_theme.rb +26 -0
  51. data/app/views/layouts/not_pressed/admin.html.erb +745 -0
  52. data/app/views/layouts/not_pressed/application.html.erb +12 -0
  53. data/app/views/layouts/not_pressed/page.html.erb +22 -0
  54. data/app/views/not_pressed/admin/categories/index.html.erb +58 -0
  55. data/app/views/not_pressed/admin/content_blocks/_block.html.erb +18 -0
  56. data/app/views/not_pressed/admin/content_blocks/_block_picker.html.erb +32 -0
  57. data/app/views/not_pressed/admin/content_blocks/create.turbo_stream.erb +3 -0
  58. data/app/views/not_pressed/admin/content_blocks/destroy.turbo_stream.erb +1 -0
  59. data/app/views/not_pressed/admin/content_blocks/editors/_callout.html.erb +38 -0
  60. data/app/views/not_pressed/admin/content_blocks/editors/_code.html.erb +26 -0
  61. data/app/views/not_pressed/admin/content_blocks/editors/_divider.html.erb +4 -0
  62. data/app/views/not_pressed/admin/content_blocks/editors/_form.html.erb +16 -0
  63. data/app/views/not_pressed/admin/content_blocks/editors/_gallery.html.erb +75 -0
  64. data/app/views/not_pressed/admin/content_blocks/editors/_heading.html.erb +26 -0
  65. data/app/views/not_pressed/admin/content_blocks/editors/_html.html.erb +13 -0
  66. data/app/views/not_pressed/admin/content_blocks/editors/_image.html.erb +56 -0
  67. data/app/views/not_pressed/admin/content_blocks/editors/_quote.html.erb +24 -0
  68. data/app/views/not_pressed/admin/content_blocks/editors/_text.html.erb +28 -0
  69. data/app/views/not_pressed/admin/content_blocks/editors/_video.html.erb +25 -0
  70. data/app/views/not_pressed/admin/dashboard/index.html.erb +60 -0
  71. data/app/views/not_pressed/admin/forms/_field_row.html.erb +33 -0
  72. data/app/views/not_pressed/admin/forms/_form.html.erb +75 -0
  73. data/app/views/not_pressed/admin/forms/edit.html.erb +1 -0
  74. data/app/views/not_pressed/admin/forms/index.html.erb +32 -0
  75. data/app/views/not_pressed/admin/forms/new.html.erb +1 -0
  76. data/app/views/not_pressed/admin/forms/submissions.html.erb +34 -0
  77. data/app/views/not_pressed/admin/media_attachments/_media_card.html.erb +21 -0
  78. data/app/views/not_pressed/admin/media_attachments/_picker.html.erb +19 -0
  79. data/app/views/not_pressed/admin/media_attachments/edit.html.erb +57 -0
  80. data/app/views/not_pressed/admin/media_attachments/index.html.erb +48 -0
  81. data/app/views/not_pressed/admin/pages/_form.html.erb +177 -0
  82. data/app/views/not_pressed/admin/pages/_page_tree_node.html.erb +32 -0
  83. data/app/views/not_pressed/admin/pages/edit.html.erb +69 -0
  84. data/app/views/not_pressed/admin/pages/index.html.erb +21 -0
  85. data/app/views/not_pressed/admin/pages/new.html.erb +1 -0
  86. data/app/views/not_pressed/admin/pages/preview.html.erb +17 -0
  87. data/app/views/not_pressed/admin/plugins/_settings_form.html.erb +59 -0
  88. data/app/views/not_pressed/admin/plugins/index.html.erb +48 -0
  89. data/app/views/not_pressed/admin/plugins/show.html.erb +54 -0
  90. data/app/views/not_pressed/admin/settings/code_injection.html.erb +21 -0
  91. data/app/views/not_pressed/admin/shared/_breadcrumbs.html.erb +14 -0
  92. data/app/views/not_pressed/admin/shared/_flash.html.erb +7 -0
  93. data/app/views/not_pressed/admin/shared/_modal.html.erb +9 -0
  94. data/app/views/not_pressed/admin/shared/_sidebar.html.erb +18 -0
  95. data/app/views/not_pressed/admin/tags/index.html.erb +52 -0
  96. data/app/views/not_pressed/admin/themes/index.html.erb +60 -0
  97. data/app/views/not_pressed/admin/themes/show.html.erb +66 -0
  98. data/app/views/not_pressed/blog/_post_card.html.erb +24 -0
  99. data/app/views/not_pressed/blog/feed.rss.builder +22 -0
  100. data/app/views/not_pressed/blog/index.html.erb +56 -0
  101. data/app/views/not_pressed/blog/show.html.erb +41 -0
  102. data/app/views/not_pressed/form_mailer/submission_notification.text.erb +8 -0
  103. data/app/views/not_pressed/pages/show.html.erb +4 -0
  104. data/app/views/themes/starter/layouts/not_pressed/default.html.erb +36 -0
  105. data/app/views/themes/starter/layouts/not_pressed/full_width.html.erb +36 -0
  106. data/app/views/themes/starter/layouts/not_pressed/sidebar.html.erb +41 -0
  107. data/config/routes.rb +81 -0
  108. data/db/migrate/20260310000001_create_not_pressed_pages.rb +20 -0
  109. data/db/migrate/20260310000002_create_not_pressed_content_blocks.rb +17 -0
  110. data/db/migrate/20260310000003_create_not_pressed_media_attachments.rb +14 -0
  111. data/db/migrate/20260310000004_add_content_type_to_not_pressed_pages.rb +8 -0
  112. data/db/migrate/20260310000005_add_publishing_fields_to_not_pressed_pages.rb +8 -0
  113. data/db/migrate/20260310000006_create_not_pressed_page_versions.rb +16 -0
  114. data/db/migrate/20260310000007_add_settings_fields_to_not_pressed_pages.rb +11 -0
  115. data/db/migrate/20260311000001_create_not_pressed_forms.rb +42 -0
  116. data/db/migrate/20260311000002_add_seo_fields_to_not_pressed_pages.rb +10 -0
  117. data/db/migrate/20260311000003_add_code_injection_to_not_pressed_pages.rb +8 -0
  118. data/db/migrate/20260311000004_create_not_pressed_settings.rb +14 -0
  119. data/db/migrate/20260312000001_create_not_pressed_categories.rb +16 -0
  120. data/db/migrate/20260312000002_create_not_pressed_tags.rb +15 -0
  121. data/db/migrate/20260312000003_create_not_pressed_taggings.rb +14 -0
  122. data/db/migrate/20260312000004_add_category_id_to_not_pressed_pages.rb +7 -0
  123. data/lib/generators/not_pressed/install/install_generator.rb +52 -0
  124. data/lib/generators/not_pressed/install/templates/initializer.rb.tt +89 -0
  125. data/lib/generators/not_pressed/install/templates/seeds.rb.tt +131 -0
  126. data/lib/generators/not_pressed/plugin/plugin_generator.rb +37 -0
  127. data/lib/generators/not_pressed/plugin/templates/plugin.rb.tt +23 -0
  128. data/lib/not_pressed/admin/authentication.rb +48 -0
  129. data/lib/not_pressed/admin/menu_registry.rb +100 -0
  130. data/lib/not_pressed/configuration.rb +77 -0
  131. data/lib/not_pressed/content_type.rb +23 -0
  132. data/lib/not_pressed/content_type_builder.rb +51 -0
  133. data/lib/not_pressed/content_type_registry.rb +45 -0
  134. data/lib/not_pressed/engine.rb +132 -0
  135. data/lib/not_pressed/hooks.rb +166 -0
  136. data/lib/not_pressed/navigation/builder.rb +148 -0
  137. data/lib/not_pressed/navigation/menu.rb +54 -0
  138. data/lib/not_pressed/navigation/menu_item.rb +33 -0
  139. data/lib/not_pressed/navigation/node.rb +45 -0
  140. data/lib/not_pressed/navigation/partial_parser.rb +96 -0
  141. data/lib/not_pressed/navigation/route_inspector.rb +98 -0
  142. data/lib/not_pressed/navigation.rb +6 -0
  143. data/lib/not_pressed/plugin.rb +354 -0
  144. data/lib/not_pressed/plugin_importer.rb +133 -0
  145. data/lib/not_pressed/plugin_manager.rb +196 -0
  146. data/lib/not_pressed/plugin_packager.rb +129 -0
  147. data/lib/not_pressed/rendering/block_renderer.rb +222 -0
  148. data/lib/not_pressed/rendering/renderer_registry.rb +154 -0
  149. data/lib/not_pressed/rendering.rb +8 -0
  150. data/lib/not_pressed/seo/sitemap_builder.rb +61 -0
  151. data/lib/not_pressed/theme.rb +191 -0
  152. data/lib/not_pressed/theme_importer.rb +133 -0
  153. data/lib/not_pressed/theme_packager.rb +180 -0
  154. data/lib/not_pressed/theme_registry.rb +123 -0
  155. data/lib/not_pressed/version.rb +5 -0
  156. data/lib/not_pressed.rb +65 -0
  157. metadata +258 -0
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ class RobotsController < ApplicationController
5
+ def show
6
+ render plain: robots_content, content_type: "text/plain"
7
+ end
8
+
9
+ private
10
+
11
+ def robots_content
12
+ custom = NotPressed.configuration.robots_txt
13
+
14
+ case custom
15
+ when Proc
16
+ custom.call(request)
17
+ when String
18
+ custom
19
+ else
20
+ default_robots_content
21
+ end
22
+ end
23
+
24
+ def default_robots_content
25
+ sitemap_url = not_pressed.sitemap_url(host: request.host, port: request.port, protocol: request.protocol)
26
+
27
+ <<~ROBOTS
28
+ User-agent: *
29
+ Allow: /
30
+ Sitemap: #{sitemap_url}
31
+ ROBOTS
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ class SitemapsController < ApplicationController
5
+ def show
6
+ expires_in 1.hour, public: true
7
+
8
+ builder = NotPressed::Seo::SitemapBuilder.new(base_url: root_url)
9
+ render xml: builder.generate
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module AdminHelper
5
+ def admin_breadcrumb(label, path = nil)
6
+ @breadcrumbs ||= []
7
+ @breadcrumbs << { label: label, path: path }
8
+ end
9
+
10
+ def status_badge(status)
11
+ content_tag(:span, status.to_s.humanize, class: "np-badge np-badge--#{status}")
12
+ end
13
+
14
+ def admin_nav_item(label, path, icon: nil)
15
+ css_class = current_page?(path) ? "np-nav-active" : nil
16
+ link_to label, path, class: css_class
17
+ end
18
+
19
+ def time_ago_short(time)
20
+ return "" if time.nil?
21
+
22
+ seconds = (Time.current - time).to_i
23
+ minutes = seconds / 60
24
+ hours = minutes / 60
25
+ days = hours / 24
26
+ weeks = days / 7
27
+
28
+ if seconds < 60
29
+ "#{seconds}s ago"
30
+ elsif minutes < 60
31
+ "#{minutes}m ago"
32
+ elsif hours < 24
33
+ "#{hours}h ago"
34
+ elsif days < 7
35
+ "#{days}d ago"
36
+ else
37
+ "#{weeks}w ago"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module ApplicationHelper
5
+ end
6
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module CodeInjectionHelper
5
+ def head_injection(page = nil)
6
+ parts = []
7
+
8
+ global = NotPressed.configuration.global_head_code
9
+ global = NotPressed::Setting.get("global_head_code") if global.nil? && defined?(NotPressed::Setting)
10
+ parts << global if global.present?
11
+
12
+ parts << page.head_code if page&.head_code.present?
13
+
14
+ parts.join("\n").html_safe
15
+ end
16
+
17
+ def body_injection(page = nil)
18
+ parts = []
19
+
20
+ global = NotPressed.configuration.global_body_code
21
+ global = NotPressed::Setting.get("global_body_code") if global.nil? && defined?(NotPressed::Setting)
22
+ parts << global if global.present?
23
+
24
+ parts << page.body_code if page&.body_code.present?
25
+
26
+ parts.join("\n").html_safe
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module ContentHelper
5
+ def render_page_content(page)
6
+ NotPressed::Rendering::BlockRenderer.render_page(page)
7
+ end
8
+
9
+ def render_block(block)
10
+ NotPressed::Rendering::BlockRenderer.render(block)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module FormHelper
5
+ def render_form(form)
6
+ return "" unless form
7
+
8
+ fields_html = form.form_fields.ordered.map { |field| render_form_field(field) }.join("\n")
9
+ action_url = not_pressed.form_submissions_path(form_slug: form.slug)
10
+ token = respond_to?(:form_authenticity_token) ? form_authenticity_token : ""
11
+ token_field = token.present? ? %(\n<input type="hidden" name="authenticity_token" value="#{np_escape(token)}">) : ""
12
+
13
+ %(<form action="#{action_url}" method="post" class="np-public-form">#{token_field}
14
+ #{fields_html}
15
+ <div class="np-form-submit"><input type="submit" value="Submit" class="np-form-submit-btn"></div>
16
+ </form>).html_safe
17
+ end
18
+
19
+ private
20
+
21
+ def render_form_field(field)
22
+ input_html = case field.field_type.to_s
23
+ when "text", "email", "number", "phone", "url", "date"
24
+ input_type = field.field_type_phone? ? "tel" : field.field_type.to_s
25
+ %(<input type="#{input_type}" name="submission[#{form_field_param_name(field)}]" id="#{form_field_id(field)}" class="np-form-input" placeholder="#{np_escape(field.placeholder.to_s)}"#{required_attr(field)}>)
26
+ when "textarea"
27
+ %(<textarea name="submission[#{form_field_param_name(field)}]" id="#{form_field_id(field)}" class="np-form-input" placeholder="#{np_escape(field.placeholder.to_s)}" rows="4"#{required_attr(field)}></textarea>)
28
+ when "select"
29
+ options = parse_options(field.options)
30
+ opts_html = %(<option value="">Select...</option>) + options.map { |o| %(<option value="#{np_escape(o)}">#{np_escape(o)}</option>) }.join
31
+ %(<select name="submission[#{form_field_param_name(field)}]" id="#{form_field_id(field)}" class="np-form-input"#{required_attr(field)}>#{opts_html}</select>)
32
+ when "radio"
33
+ options = parse_options(field.options)
34
+ options.map { |o|
35
+ %(<label class="np-form-radio-label"><input type="radio" name="submission[#{form_field_param_name(field)}]" value="#{np_escape(o)}"#{required_attr(field)}> #{np_escape(o)}</label>)
36
+ }.join("\n")
37
+ when "checkbox"
38
+ options = parse_options(field.options)
39
+ if options.any?
40
+ options.map { |o|
41
+ %(<label class="np-form-checkbox-label"><input type="checkbox" name="submission[#{form_field_param_name(field)}][]" value="#{np_escape(o)}"> #{np_escape(o)}</label>)
42
+ }.join("\n")
43
+ else
44
+ %(<input type="checkbox" name="submission[#{form_field_param_name(field)}]" id="#{form_field_id(field)}" value="1"#{required_attr(field)}>)
45
+ end
46
+ when "file_upload"
47
+ %(<input type="file" name="submission[#{form_field_param_name(field)}]" id="#{form_field_id(field)}" class="np-form-input"#{required_attr(field)}>)
48
+ else
49
+ %(<input type="text" name="submission[#{form_field_param_name(field)}]" id="#{form_field_id(field)}" class="np-form-input"#{required_attr(field)}>)
50
+ end
51
+
52
+ %(<div class="np-form-field">
53
+ <label class="np-form-label" for="#{form_field_id(field)}">#{np_escape(field.label)}#{" *" if field.required?}</label>
54
+ #{input_html}
55
+ </div>)
56
+ end
57
+
58
+ def form_field_param_name(field)
59
+ np_escape(field.label.parameterize(separator: "_"))
60
+ end
61
+
62
+ def form_field_id(field)
63
+ "field_#{field.id}"
64
+ end
65
+
66
+ def required_attr(field)
67
+ field.required? ? ' required="required"' : ""
68
+ end
69
+
70
+ def parse_options(options_string)
71
+ return [] if options_string.blank?
72
+
73
+ options_string.split("\n").map(&:strip).reject(&:blank?)
74
+ end
75
+
76
+ def np_escape(text)
77
+ ERB::Util.html_escape(text)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module MediaHelper
5
+ def media_url(media_attachment, variant: nil)
6
+ return nil if media_attachment.nil? || !media_attachment.file.attached?
7
+
8
+ if variant
9
+ variant_obj = media_attachment.send(variant)
10
+ return nil unless variant_obj
11
+
12
+ url_for(variant_obj)
13
+ else
14
+ url_for(media_attachment.file)
15
+ end
16
+ end
17
+
18
+ def media_image_tag(media_attachment, variant: nil, **options)
19
+ return nil if media_attachment.nil? || !media_attachment.image?
20
+
21
+ src = media_url(media_attachment, variant: variant)
22
+ return nil unless src
23
+
24
+ options[:alt] ||= media_attachment.alt_text
25
+ tag.img(src: src, **options)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module SeoHelper
5
+ def seo_meta_tags(page, request = nil)
6
+ return "".html_safe if page.nil?
7
+
8
+ tags = []
9
+ separator = NotPressed.configuration.seo_title_separator
10
+ site_name = NotPressed.configuration.site_name
11
+
12
+ # Title
13
+ title_text = page.meta_title_or_title
14
+ full_title = site_name.present? ? "#{title_text}#{separator}#{site_name}" : title_text
15
+ tags << content_tag(:title, full_title)
16
+
17
+ # Meta description
18
+ if page.meta_description.present?
19
+ tags << tag(:meta, name: "description", content: page.meta_description)
20
+ end
21
+
22
+ # Robots
23
+ robots = page.meta_robots_tag
24
+ if robots.present?
25
+ tags << tag(:meta, name: "robots", content: robots)
26
+ end
27
+
28
+ # Canonical URL
29
+ if request
30
+ tags << tag(:link, rel: "canonical", href: page.effective_canonical_url(request))
31
+ end
32
+
33
+ # Open Graph tags
34
+ tags << tag(:meta, property: "og:title", content: title_text)
35
+
36
+ if page.meta_description.present?
37
+ tags << tag(:meta, property: "og:description", content: page.meta_description)
38
+ end
39
+
40
+ if page.og_image_url.present?
41
+ tags << tag(:meta, property: "og:image", content: page.og_image_url)
42
+ end
43
+
44
+ if request
45
+ tags << tag(:meta, property: "og:url", content: page.effective_canonical_url(request))
46
+ end
47
+
48
+ tags << tag(:meta, property: "og:type", content: page.og_type) if page.og_type.present?
49
+
50
+ if site_name.present?
51
+ tags << tag(:meta, property: "og:site_name", content: site_name)
52
+ end
53
+
54
+ # Twitter Card tags
55
+ tags << tag(:meta, name: "twitter:card", content: page.twitter_card) if page.twitter_card.present?
56
+ tags << tag(:meta, name: "twitter:title", content: title_text)
57
+
58
+ if page.meta_description.present?
59
+ tags << tag(:meta, name: "twitter:description", content: page.meta_description)
60
+ end
61
+
62
+ if page.og_image_url.present?
63
+ tags << tag(:meta, name: "twitter:image", content: page.og_image_url)
64
+ end
65
+
66
+ safe_join(tags, "\n")
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module ThemeHelper
5
+ def theme_stylesheet_tag
6
+ theme = NotPressed::ThemeRegistry.active
7
+ return unless theme&.theme_stylesheet
8
+
9
+ stylesheet_link_tag theme.theme_stylesheet, media: "all"
10
+ end
11
+
12
+ def theme_color_overrides
13
+ theme = NotPressed::ThemeRegistry.active
14
+ return unless theme
15
+
16
+ colors = theme.theme_color_scheme
17
+ return if colors.empty?
18
+
19
+ overrides = colors.map do |color|
20
+ setting_key = "theme.#{theme.plugin_name}.color_#{color[:key]}"
21
+ value = begin
22
+ NotPressed::Setting.get(setting_key)
23
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
24
+ nil
25
+ end
26
+ value ||= color[:default]
27
+ " --np-color-#{color[:key]}: #{value};"
28
+ end
29
+
30
+ tag.style(":root {\n#{overrides.join("\n")}\n}".html_safe)
31
+ end
32
+
33
+ def active_theme_class
34
+ theme = NotPressed::ThemeRegistry.active
35
+ if theme
36
+ "np-theme-#{theme.plugin_name.to_s.parameterize}"
37
+ else
38
+ "np-theme-default"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_mailer"
4
+
5
+ module NotPressed
6
+ class ApplicationMailer < ::ActionMailer::Base
7
+ default from: "noreply@example.com"
8
+ layout false
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ class FormMailer < ApplicationMailer
5
+ def submission_notification(form_submission)
6
+ @form_submission = form_submission
7
+ @form = form_submission.form
8
+
9
+ mail(
10
+ to: @form.email_recipient,
11
+ subject: "New submission: #{@form.name}"
12
+ )
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module Sluggable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ validates :slug, presence: true,
9
+ uniqueness: true,
10
+ format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "must be lowercase alphanumeric with hyphens" }
11
+
12
+ before_validation :generate_slug, on: :create
13
+ end
14
+
15
+ private
16
+
17
+ def generate_slug
18
+ return if slug.present?
19
+
20
+ source = slug_source_attribute
21
+ return if source.blank?
22
+
23
+ base_slug = source.encode("UTF-8").parameterize
24
+ candidate = base_slug
25
+ counter = 2
26
+
27
+ while self.class.where(slug: candidate).exists?
28
+ candidate = "#{base_slug}-#{counter}"
29
+ counter += 1
30
+ end
31
+
32
+ self.slug = candidate
33
+ end
34
+
35
+ def slug_source_attribute
36
+ if respond_to?(:title)
37
+ title
38
+ elsif respond_to?(:name)
39
+ name
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ class Category < ActiveRecord::Base
5
+ self.table_name = "not_pressed_categories"
6
+
7
+ include NotPressed::Sluggable
8
+
9
+ has_many :pages, class_name: "NotPressed::Page", foreign_key: :category_id,
10
+ dependent: :nullify, inverse_of: :category
11
+
12
+ validates :name, presence: true, uniqueness: true
13
+
14
+ scope :ordered, -> { order(:name) }
15
+ end
16
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ class ContentBlock < ActiveRecord::Base
5
+ self.table_name = "not_pressed_content_blocks"
6
+
7
+ BUILT_IN_TYPES = %w[text heading image video code quote divider html form].freeze
8
+
9
+ mattr_accessor :custom_types, default: []
10
+
11
+ belongs_to :page, class_name: "NotPressed::Page", inverse_of: :content_blocks
12
+
13
+ validates :block_type, presence: true,
14
+ inclusion: { in: ->(_) { NotPressed::ContentBlock.registered_types } }
15
+ validates :position, presence: true,
16
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
17
+
18
+ scope :ordered, -> { order(position: :asc) }
19
+
20
+ def self.register_type(type_name)
21
+ custom_types << type_name.to_s unless custom_types.include?(type_name.to_s)
22
+ end
23
+
24
+ def self.registered_types
25
+ BUILT_IN_TYPES + custom_types
26
+ end
27
+
28
+ def self.default_content_for(block_type)
29
+ custom = NotPressed::Rendering::RendererRegistry.default_content(block_type)
30
+ return custom if custom
31
+
32
+ case block_type.to_s
33
+ when "text" then { "body" => "" }
34
+ when "heading" then { "text" => "", "level" => 2 }
35
+ when "image" then { "url" => "", "alt" => "", "caption" => "" }
36
+ when "video" then { "url" => "", "caption" => "" }
37
+ when "code" then { "code" => "", "language" => "" }
38
+ when "quote" then { "text" => "", "attribution" => "" }
39
+ when "divider" then {}
40
+ when "html" then { "markup" => "" }
41
+ when "form" then { "form_id" => nil }
42
+ else {}
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ class Form < ActiveRecord::Base
5
+ self.table_name = "not_pressed_forms"
6
+
7
+ has_many :form_fields, class_name: "NotPressed::FormField",
8
+ foreign_key: :form_id,
9
+ dependent: :destroy,
10
+ inverse_of: :form do
11
+ def ordered
12
+ order(:position)
13
+ end
14
+ end
15
+ accepts_nested_attributes_for :form_fields, allow_destroy: true
16
+ has_many :form_submissions, class_name: "NotPressed::FormSubmission",
17
+ foreign_key: :form_id,
18
+ dependent: :destroy,
19
+ inverse_of: :form
20
+
21
+ # Hook system callbacks
22
+ after_create { NotPressed::Hooks.fire(:after_form_create, self) }
23
+ after_save { NotPressed::Hooks.fire(:after_form_save, self) }
24
+
25
+ enum :status, { draft: 0, active: 1, archived: 2 }, prefix: true
26
+
27
+ validates :name, presence: true
28
+ validates :slug, presence: true,
29
+ uniqueness: true,
30
+ format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "must be lowercase alphanumeric with hyphens" }
31
+
32
+ before_validation :generate_slug, on: :create
33
+
34
+ scope :active, -> { where(status: :active) }
35
+ scope :ordered, -> { order(:name) }
36
+
37
+ private
38
+
39
+ def generate_slug
40
+ return if slug.present?
41
+ return if name.blank?
42
+
43
+ base_slug = name.encode("UTF-8").parameterize
44
+ candidate = base_slug
45
+ counter = 2
46
+
47
+ while self.class.where(slug: candidate).exists?
48
+ candidate = "#{base_slug}-#{counter}"
49
+ counter += 1
50
+ end
51
+
52
+ self.slug = candidate
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ class FormField < ActiveRecord::Base
5
+ self.table_name = "not_pressed_form_fields"
6
+
7
+ belongs_to :form, class_name: "NotPressed::Form", counter_cache: false, inverse_of: :form_fields
8
+
9
+ enum :field_type, {
10
+ text: 0, email: 1, textarea: 2, select: 3, checkbox: 4,
11
+ radio: 5, number: 6, phone: 7, url: 8, date: 9, file_upload: 10
12
+ }, prefix: true
13
+
14
+ validates :label, presence: true
15
+ validates :field_type, presence: true
16
+
17
+ scope :ordered, -> { order(:position) }
18
+
19
+ def has_options?
20
+ field_type_select? || field_type_radio? || field_type_checkbox?
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ class FormSubmission < ActiveRecord::Base
5
+ self.table_name = "not_pressed_form_submissions"
6
+
7
+ belongs_to :form, class_name: "NotPressed::Form", counter_cache: :submissions_count, inverse_of: :form_submissions
8
+
9
+ # Hook system callbacks
10
+ after_create { NotPressed::Hooks.fire(:after_form_submit, self.form, self) }
11
+
12
+ validates :data, presence: true
13
+ validates :submitted_at, presence: true
14
+
15
+ before_validation -> { self.submitted_at ||= Time.current }, on: :create
16
+
17
+ scope :recent, -> { order(submitted_at: :desc) }
18
+ end
19
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ class MediaAttachment < ActiveRecord::Base
5
+ self.table_name = "not_pressed_media_attachments"
6
+
7
+ IMAGE_CONTENT_TYPES = %w[image/jpeg image/png image/gif image/webp image/svg+xml].freeze
8
+
9
+ has_one_attached :file
10
+
11
+ # Hook system callbacks
12
+ after_create { NotPressed::Hooks.fire(:after_media_upload, self) }
13
+ after_destroy { NotPressed::Hooks.fire(:after_media_destroy, self) }
14
+
15
+ validate :file_attached, on: :create
16
+
17
+ before_save :extract_file_metadata, if: -> { file.attached? }
18
+
19
+ scope :images, -> { where(content_type: IMAGE_CONTENT_TYPES) }
20
+ scope :documents, -> { where.not(content_type: IMAGE_CONTENT_TYPES) }
21
+ scope :recent, -> { order(created_at: :desc) }
22
+
23
+ def image?
24
+ content_type.in?(IMAGE_CONTENT_TYPES)
25
+ end
26
+
27
+ def thumbnail
28
+ return nil unless image?
29
+
30
+ file.variant(resize_to_limit: [200, 200])
31
+ end
32
+
33
+ def small
34
+ return nil unless image?
35
+
36
+ file.variant(resize_to_limit: [400, 400])
37
+ end
38
+
39
+ def medium
40
+ return nil unless image?
41
+
42
+ file.variant(resize_to_limit: [800, 800])
43
+ end
44
+
45
+ def large
46
+ return nil unless image?
47
+
48
+ file.variant(resize_to_limit: [1200, 1200])
49
+ end
50
+
51
+ def hero
52
+ return nil unless image?
53
+
54
+ file.variant(resize_to_limit: [1920, 1080])
55
+ end
56
+
57
+ private
58
+
59
+ def file_attached
60
+ errors.add(:file, "must be attached") unless file.attached?
61
+ end
62
+
63
+ def extract_file_metadata
64
+ self.content_type = file.blob.content_type
65
+ self.file_size = file.blob.byte_size
66
+ end
67
+ end
68
+ end