panda_cms 0.5.10 → 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/Rakefile +0 -1
  4. data/app/assets/builds/panda_cms.css +2432 -1
  5. data/app/assets/config/panda_cms_manifest.js +3 -0
  6. data/app/assets/stylesheets/panda_cms/application.tailwind.css +3 -27
  7. data/app/builders/panda_cms/form_builder.rb +1 -1
  8. data/app/components/panda_cms/admin/button_component.rb +6 -3
  9. data/app/components/panda_cms/admin/flash_message_component.rb +1 -1
  10. data/app/components/panda_cms/admin/tag_component.rb +1 -1
  11. data/app/components/panda_cms/code_component.rb +62 -0
  12. data/app/components/panda_cms/menu_component.html.erb +3 -0
  13. data/app/components/panda_cms/menu_component.rb +8 -1
  14. data/app/components/panda_cms/page_menu_component.html.erb +12 -8
  15. data/app/components/panda_cms/page_menu_component.rb +24 -13
  16. data/app/components/panda_cms/rich_text_component.html.erb +6 -38
  17. data/app/components/panda_cms/rich_text_component.rb +32 -7
  18. data/app/components/panda_cms/text_component.rb +25 -22
  19. data/app/controllers/panda_cms/admin/block_contents_controller.rb +1 -1
  20. data/app/controllers/panda_cms/admin/dashboard_controller.rb +14 -6
  21. data/app/controllers/panda_cms/admin/menus_controller.rb +1 -54
  22. data/app/controllers/panda_cms/admin/pages_controller.rb +2 -1
  23. data/app/controllers/panda_cms/admin/sessions_controller.rb +13 -6
  24. data/app/controllers/panda_cms/application_controller.rb +1 -1
  25. data/app/controllers/panda_cms/pages_controller.rb +1 -1
  26. data/app/controllers/panda_cms/posts_controller.rb +1 -1
  27. data/app/helpers/panda_cms/application_helper.rb +2 -2
  28. data/app/javascript/panda_cms/@editorjs--editorjs.js +2577 -0
  29. data/app/javascript/panda_cms/@hotwired--stimulus.js +4 -0
  30. data/app/javascript/panda_cms/@hotwired--turbo.js +160 -0
  31. data/app/javascript/panda_cms/@rails--actioncable--src.js +4 -0
  32. data/app/javascript/panda_cms/application_panda_cms.js +4 -0
  33. data/app/javascript/panda_cms/controllers/dashboard_controller.js +7 -0
  34. data/app/javascript/panda_cms/controllers/editor_controller.js +247 -0
  35. data/app/javascript/panda_cms/controllers/index.js +45 -0
  36. data/app/javascript/panda_cms/controllers/slug_controller.js +48 -0
  37. data/app/javascript/panda_cms/editor/plain_text_editor.js +102 -0
  38. data/app/javascript/panda_cms/editor/resource_loader.js +69 -0
  39. data/app/javascript/panda_cms/editor/rich_text_editor.js +89 -0
  40. data/app/javascript/panda_cms/tailwindcss-stimulus-components.js +4 -0
  41. data/app/lib/panda_cms/demo_site_generator.rb +1 -3
  42. data/app/lib/panda_cms/editor_js/blocks/alert.rb +32 -0
  43. data/app/lib/panda_cms/editor_js/blocks/base.rb +28 -0
  44. data/app/lib/panda_cms/editor_js/blocks/header.rb +13 -0
  45. data/app/lib/panda_cms/editor_js/blocks/image.rb +34 -0
  46. data/app/lib/panda_cms/editor_js/blocks/list.rb +30 -0
  47. data/app/lib/panda_cms/editor_js/blocks/paragraph.rb +13 -0
  48. data/app/lib/panda_cms/editor_js/blocks/quote.rb +27 -0
  49. data/app/lib/panda_cms/editor_js/blocks/table.rb +48 -0
  50. data/app/lib/panda_cms/editor_js/renderer.rb +120 -0
  51. data/app/lib/panda_cms/slug.rb +1 -1
  52. data/app/models/panda_cms/block.rb +2 -2
  53. data/app/models/panda_cms/block_content.rb +12 -2
  54. data/app/models/panda_cms/page.rb +9 -3
  55. data/app/models/panda_cms/post.rb +1 -1
  56. data/app/models/panda_cms/template.rb +4 -2
  57. data/app/models/panda_cms/user.rb +9 -1
  58. data/app/views/panda_cms/admin/dashboard/show.html.erb +11 -9
  59. data/app/views/panda_cms/admin/forms/new.html.erb +6 -7
  60. data/app/views/panda_cms/admin/menus/index.html.erb +0 -2
  61. data/app/views/panda_cms/admin/pages/edit.html.erb +22 -19
  62. data/app/views/panda_cms/admin/pages/new.html.erb +6 -7
  63. data/app/views/panda_cms/admin/posts/_form.html.erb +4 -4
  64. data/app/views/panda_cms/admin/sessions/new.html.erb +1 -2
  65. data/app/views/panda_cms/admin/shared/_sidebar.html.erb +12 -16
  66. data/app/views/panda_cms/shared/_header.html.erb +13 -14
  67. data/app/views/panda_cms/shared/_importmap.html.erb +32 -0
  68. data/config/importmap.rb +13 -10
  69. data/config/initializers/panda_cms.rb +57 -55
  70. data/config/routes.rb +9 -9
  71. data/config/tailwind.config.js +1 -0
  72. data/db/migrate/20240205223709_create_panda_cms_pages.rb +6 -4
  73. data/db/migrate/20240315125411_add_status_to_panda_cms_pages.rb +9 -0
  74. data/db/migrate/20241031205109_add_cached_content_to_panda_cms_block_contents.rb +5 -0
  75. data/db/seeds.rb +1 -0
  76. data/lib/generators/panda_cms/install_generator.rb +3 -0
  77. data/lib/panda_cms/engine.rb +42 -29
  78. data/lib/panda_cms/version.rb +1 -1
  79. data/lib/panda_cms.rb +58 -10
  80. data/lib/tasks/panda_cms.rake +41 -57
  81. data/public/panda-cms-assets/rich_text_editor.css +568 -0
  82. metadata +228 -304
  83. data/app/javascript/base.js +0 -37
  84. data/app/javascript/controllers/menu_controller.js +0 -19
  85. data/app/javascript/controllers/text_controller.js +0 -78
  86. data/app/javascript/controllers/text_field_update_controller.js +0 -51
  87. data/app/javascript/vendor/stimulus-components-rails-nested-form.js +0 -2
  88. data/app/javascript/vendor/tailwindcss-stimulus-components.js +0 -2
  89. data/app/views/panda_cms/admin/menus/_form.html.erb +0 -21
  90. data/app/views/panda_cms/admin/menus/_menu_item_fields.html.erb +0 -7
  91. data/app/views/panda_cms/admin/menus/edit.html.erb +0 -58
  92. data/app/views/panda_cms/admin/menus/new.html.erb +0 -5
  93. data/db/migrate/20240804110225_add_status_to_panda_cms_pages.rb +0 -7
  94. data/public/panda-cms-assets/javascripts/base.js +0 -37
  95. data/public/panda-cms-assets/javascripts/controllers/menu_controller.js +0 -19
  96. data/public/panda-cms-assets/javascripts/controllers/text_field_update_controller.js +0 -23
  97. data/public/panda-cms-assets/javascripts/embed/editable.js +0 -358
  98. data/public/panda-cms-assets/javascripts/embed/rich_text.css +0 -1294
  99. data/public/panda-cms-assets/javascripts/vendor/stimulus-components-rails-nested-form.js +0 -2
  100. data/public/panda-cms-assets/javascripts/vendor/stimulus-loading.js +0 -113
  101. data/public/panda-cms-assets/javascripts/vendor/tailwindcss-stimulus-components.js +0 -2
@@ -0,0 +1,32 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Alert < Base
5
+ def render
6
+ message = sanitize(data["message"])
7
+ type = data["type"] || "primary"
8
+
9
+ html_safe(
10
+ "<div class=\"#{alert_classes(type)} p-4 mb-4 rounded-lg\">" \
11
+ "#{message}" \
12
+ "</div>"
13
+ )
14
+ end
15
+
16
+ private
17
+
18
+ def alert_classes(type)
19
+ case type
20
+ when "primary" then "bg-blue-100 text-blue-800"
21
+ when "secondary" then "bg-gray-100 text-gray-800"
22
+ when "success" then "bg-green-100 text-green-800"
23
+ when "danger" then "bg-red-100 text-red-800"
24
+ when "warning" then "bg-yellow-100 text-yellow-800"
25
+ when "info" then "bg-indigo-100 text-indigo-800"
26
+ else "bg-blue-100 text-blue-800"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,28 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Base
5
+ attr_reader :data, :options
6
+
7
+ def initialize(data, options = {})
8
+ @data = data
9
+ @options = options
10
+ end
11
+
12
+ def render
13
+ ""
14
+ end
15
+
16
+ private
17
+
18
+ def sanitize(text)
19
+ Rails::Html::SafeListSanitizer.new.sanitize(text, tags: %w[b i u a code])
20
+ end
21
+
22
+ def html_safe(text)
23
+ text.to_s.html_safe
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Header < Base
5
+ def render
6
+ content = sanitize(data["text"])
7
+ level = data["level"] || 2
8
+ html_safe("<h#{level}>#{content}</h#{level}>")
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,34 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Image < Base
5
+ def render
6
+ url = data["url"]
7
+ caption = sanitize(data["caption"])
8
+ with_border = data["withBorder"]
9
+ with_background = data["withBackground"]
10
+ stretched = data["stretched"]
11
+
12
+ css_classes = ["prose"]
13
+ css_classes << "border" if with_border
14
+ css_classes << "bg-gray-100" if with_background
15
+ css_classes << "w-full" if stretched
16
+
17
+ html_safe(<<~HTML)
18
+ <figure class="#{css_classes.join(" ")}">
19
+ <img src="#{url}" alt="#{caption}" />
20
+ #{caption_element(caption)}
21
+ </figure>
22
+ HTML
23
+ end
24
+
25
+ private
26
+
27
+ def caption_element(caption)
28
+ return "" if caption.blank?
29
+ "<figcaption>#{caption}</figcaption>"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class List < Base
5
+ def render
6
+ list_type = (data["style"] == "ordered") ? "ol" : "ul"
7
+ html_safe(
8
+ "<#{list_type}>" \
9
+ "#{render_items(data["items"])}" \
10
+ "</#{list_type}>"
11
+ )
12
+ end
13
+
14
+ private
15
+
16
+ def render_items(items)
17
+ items.map do |item|
18
+ content = item.is_a?(Hash) ? item["content"] : item
19
+ nested = (item.is_a?(Hash) && item["items"].present?) ? render_nested(item["items"]) : ""
20
+ "<li>#{sanitize(content)}#{nested}</li>"
21
+ end.join
22
+ end
23
+
24
+ def render_nested(items)
25
+ self.class.new({"items" => items, "style" => data["style"]}).render
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Paragraph < Base
5
+ def render
6
+ content = sanitize(data["text"])
7
+ return "" if content.blank?
8
+ html_safe("<p>#{content}</p>")
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Quote < Base
5
+ def render
6
+ text = sanitize(data["text"])
7
+ caption = sanitize(data["caption"])
8
+ alignment = data["alignment"] || "left"
9
+
10
+ html_safe(
11
+ "<figure class=\"text-#{alignment}\">" \
12
+ "<blockquote><p>#{text}</p></blockquote>" \
13
+ "#{caption_element(caption)}" \
14
+ "</figure>"
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def caption_element(caption)
21
+ return "" if caption.blank?
22
+ "<figcaption>#{caption}</figcaption>"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,48 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ module Blocks
4
+ class Table < Base
5
+ def render
6
+ content = data["content"]
7
+ with_headings = data["withHeadings"]
8
+
9
+ html_safe(<<~HTML)
10
+ <div class="overflow-x-auto">
11
+ <table class="min-w-full">
12
+ #{render_rows(content, with_headings)}
13
+ </table>
14
+ </div>
15
+ HTML
16
+ end
17
+
18
+ private
19
+
20
+ def render_rows(content, with_headings)
21
+ rows = []
22
+ index = 0
23
+
24
+ while index < content.length
25
+ rows << if index == 0 && with_headings
26
+ render_header_row(content[index])
27
+ else
28
+ render_data_row(content[index])
29
+ end
30
+ index += 1
31
+ end
32
+
33
+ rows.join("\n")
34
+ end
35
+
36
+ def render_header_row(row)
37
+ cells = row.map { |cell| "<th>#{sanitize(cell)}</th>" }
38
+ "<tr>#{cells.join}</tr>"
39
+ end
40
+
41
+ def render_data_row(row)
42
+ cells = row.map { |cell| "<td>#{sanitize(cell)}</td>" }
43
+ "<tr>#{cells.join}</tr>"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,120 @@
1
+ module PandaCms
2
+ module EditorJs
3
+ class Renderer
4
+ attr_reader :content, :options, :custom_renderers, :cache_store
5
+
6
+ def initialize(content, options = {})
7
+ @content = content
8
+ @options = options
9
+ @custom_renderers = options.delete(:custom_renderers) || {}
10
+ @cache_store = options.delete(:cache_store) || Rails.cache
11
+ @validate_html = options.delete(:validate_html) || false
12
+ end
13
+
14
+ def render
15
+ return "" if content.nil? || !content.is_a?(Hash) || !content["blocks"]
16
+
17
+ blocks = remove_empty_paragraphs(content["blocks"])
18
+ rendered = blocks.map { |block| render_block_with_cache(block) }.join("\n")
19
+
20
+ @validate_html ? validate_html(rendered) : rendered
21
+ end
22
+
23
+ def section(blocks)
24
+ "<section class=\"content-section\">#{render_blocks(blocks)}</section>"
25
+ end
26
+
27
+ def article(blocks, title: nil)
28
+ content = []
29
+ content << "<h1>#{title}</h1>" if title
30
+ content << render_blocks(blocks)
31
+ "<article>#{content.join("\n")}</article>"
32
+ end
33
+
34
+ private
35
+
36
+ def render_blocks(blocks)
37
+ blocks.map { |block| render_block_with_cache(block) }.join("\n")
38
+ end
39
+
40
+ def validate_html(html)
41
+ # First check if we have matching numbers of opening and closing tags
42
+ opening_tags = html.scan(/<([a-z]+)[^>]*>/i)
43
+ closing_tags = html.scan(/<\/([a-z]+)>/i)
44
+
45
+ # Early return if tag counts don't match
46
+ return "" unless opening_tags.length == closing_tags.length
47
+
48
+ # Check tag order and nesting
49
+ stack = []
50
+ tag_pattern = /<\/?([a-z]+)[^>]*>/i
51
+ position = 0
52
+
53
+ while (match = html[position..].match(tag_pattern))
54
+ tag_name = match[1].downcase
55
+ is_closing = match[0].start_with?("</")
56
+
57
+ if is_closing
58
+ return "" if stack.pop != tag_name
59
+ else
60
+ stack.push(tag_name)
61
+ end
62
+
63
+ position += match.begin(0) + match[0].length
64
+ end
65
+
66
+ stack.empty? ? html : ""
67
+ end
68
+
69
+ def render_block_with_cache(block)
70
+ return "" if @validate_html && has_invalid_html?(block["data"])
71
+
72
+ cache_key = "editor_js_block/#{block["type"]}/#{Digest::MD5.hexdigest(block["data"].to_json)}"
73
+ cache_store.fetch(cache_key) do
74
+ renderer_for(block).render
75
+ end
76
+ end
77
+
78
+ def remove_empty_paragraphs(blocks)
79
+ blocks.reject do |block|
80
+ block["type"] == "paragraph" &&
81
+ block["data"]["text"].blank? &&
82
+ (blocks.last == block || next_block_is_empty?(blocks, block))
83
+ end
84
+ end
85
+
86
+ def next_block_is_empty?(blocks, current_block)
87
+ current_index = blocks.index(current_block)
88
+ next_block = blocks[current_index + 1]
89
+ next_block && next_block["type"] == "paragraph" && next_block["data"]["text"].blank?
90
+ end
91
+
92
+ def renderer_for(block)
93
+ if custom_renderers[block["type"]]
94
+ custom_renderers[block["type"]].new(block["data"], options)
95
+ else
96
+ default_renderer_for(block)
97
+ end
98
+ end
99
+
100
+ def default_renderer_for(block)
101
+ renderer_class = "PandaCms::EditorJs::Blocks::#{block["type"].classify}".constantize
102
+ renderer_class.new(block["data"], options)
103
+ rescue NameError
104
+ PandaCms::EditorJs::Blocks::Base.new(block["data"], options)
105
+ end
106
+
107
+ private
108
+
109
+ def has_invalid_html?(data)
110
+ data.values.any? do |value|
111
+ next unless value.is_a?(String)
112
+ opening_tags = value.scan(/<([a-z]+)[^>]*>/i)
113
+ closing_tags = value.scan(/<\/([a-z]+)>/i)
114
+ opening_tags.length != closing_tags.length ||
115
+ opening_tags.map(&:first) != closing_tags.map(&:first)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -5,7 +5,7 @@ module PandaCms
5
5
  #
6
6
  # @param string [String] The provided string to turn into a slug
7
7
  # @return string Generated slug
8
- # @see text_field_update_controller.js should also implement this logic
8
+ # @see slug_controller.js should also implement this logic
9
9
  def self.generate(string)
10
10
  # Trim whitespace and downcase the string
11
11
  string = string.to_s.strip.downcase
@@ -16,12 +16,12 @@ module PandaCms
16
16
  plain_text: "plain_text",
17
17
  rich_text: "rich_text",
18
18
  iframe: "iframe",
19
- list: "list"
19
+ list: "list",
20
+ code: "code"
20
21
  # image: "image",
21
22
  # video: "video",
22
23
  # audio: "audio",
23
24
  # file: "file",
24
- # code: "code",
25
25
  # iframe: "iframe",
26
26
  # quote: "quote",
27
27
  # list: "list"
@@ -1,5 +1,3 @@
1
- require "redcarpet"
2
-
3
1
  module PandaCms
4
2
  class BlockContent < ApplicationRecord
5
3
  self.table_name = "panda_cms_block_contents"
@@ -12,5 +10,17 @@ module PandaCms
12
10
  belongs_to :block, foreign_key: :panda_cms_block_id, class_name: "PandaCms::Block", inverse_of: :block_contents, optional: false
13
11
 
14
12
  validates :block, presence: true, uniqueness: {scope: :page}
13
+
14
+ before_save :generate_cache
15
+
16
+ private
17
+
18
+ def generate_cache
19
+ self.cached_content = if content.is_a?(Hash) && content.dig("source") == "editorJS"
20
+ EditorJs::Renderer.new(content).render
21
+ else
22
+ content
23
+ end
24
+ end
15
25
  end
16
26
  end
@@ -18,15 +18,21 @@ module PandaCms
18
18
  has_many :menu_items, foreign_key: :panda_cms_page_id, class_name: "PandaCms::MenuItem", inverse_of: :page
19
19
  has_many :menus, through: :menu_items
20
20
  has_many :menus_of_parent, through: :parent, source: :menus
21
- has_one :page_menu, foreign_key: :panda_cms_menu_id, class_name: "PandaCms::Menu", inverse_of: :start_page
21
+ has_one :page_menu, foreign_key: :start_page_id, class_name: "PandaCms::Menu"
22
22
 
23
23
  validates :title, presence: true
24
+
24
25
  validates :path,
25
26
  presence: true,
26
27
  uniqueness: true,
27
28
  format: {with: /\A\/.*\z/, message: "must start with a forward slash"}
28
- validates :parent, presence: true, unless: -> { path == "/" }
29
- validates :panda_cms_template_id, presence: true
29
+
30
+ validates :parent,
31
+ presence: true,
32
+ unless: -> { path == "/" }
33
+
34
+ validates :panda_cms_template_id,
35
+ presence: true
30
36
 
31
37
  scope :ordered, -> { order(:lft) }
32
38
 
@@ -44,7 +44,7 @@ module PandaCms
44
44
  end
45
45
 
46
46
  def path
47
- "/" + PandaCms.posts[:prefix] + slug.to_s
47
+ "/" + PandaCms.config.posts[:prefix] + slug.to_s
48
48
  end
49
49
 
50
50
  def formatted_slug
@@ -24,9 +24,11 @@ module PandaCms
24
24
  validate :validate_template_file_exists
25
25
 
26
26
  # Scopes
27
- scope :ordered, -> { order(:sort_order) }
28
27
  scope :available, -> { where("max_uses IS NULL OR (pages_count < max_uses)") }
29
- scope :most_used, -> { order(pages_count: :desc).first }
28
+
29
+ def self.default
30
+ find_by(file_path: "layouts/page") || first
31
+ end
30
32
 
31
33
  # Generate missing blocks for all templates
32
34
  # @return [void]
@@ -2,7 +2,9 @@ module PandaCms
2
2
  class User < ApplicationRecord
3
3
  validates :firstname, presence: true
4
4
  validates :lastname, presence: true
5
- validates :email, presence: true, uniqueness: {case_sensitive: true}
5
+ validates :email, presence: true, uniqueness: true
6
+
7
+ before_save :downcase_email
6
8
 
7
9
  def is_admin?
8
10
  admin
@@ -15,5 +17,11 @@ module PandaCms
15
17
  def self.for_select_list(scope = :all, order = {firstname: :asc, lastname: :asc})
16
18
  PandaCms::User.send(scope).order(order).map { |u| [u.name, u.id] }
17
19
  end
20
+
21
+ private
22
+
23
+ def downcase_email
24
+ self.email = email.to_s.downcase
25
+ end
18
26
  end
19
27
  end
@@ -1,10 +1,12 @@
1
- <%= render PandaCms::Admin::ContainerComponent.new do |container| %>
2
- <% container.with_heading(text: "Dashboard", level: 1) do |heading| %>
3
- <% heading.with_button(action: :add, text: "Add Page", link: new_admin_page_path) %>
1
+ <div class="" data-controller="dashboard">
2
+ <%= render PandaCms::Admin::ContainerComponent.new do |container| %>
3
+ <% container.with_heading(text: "Dashboard", level: 1) do |heading| %>
4
+ <% heading.with_button(action: :add, text: "Add Page", link: new_admin_page_path) %>
5
+ <% end %>
6
+ <dl class="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-3">
7
+ <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Today", value: PandaCms::Visit.group_by_day(:visited_at, last: 1).count.values.first) %>
8
+ <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Week", value: PandaCms::Visit.group_by_week(:visited_at, last: 1).count.values.first) %>
9
+ <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Month", value: PandaCms::Visit.group_by_month(:visited_at, last: 1).count.values.first) %>
10
+ </dl>
4
11
  <% end %>
5
- <dl class="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-3">
6
- <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Today", value: PandaCms::Visit.group_by_day(:visited_at, last: 1).count.values.first) %>
7
- <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Week", value: PandaCms::Visit.group_by_week(:visited_at, last: 1).count.values.first) %>
8
- <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Month", value: PandaCms::Visit.group_by_month(:visited_at, last: 1).count.values.first) %>
9
- </dl>
10
- <% end %>
12
+ </div>
@@ -1,14 +1,13 @@
1
1
  <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.with_heading(text: "Add Page", level: 1) do |heading| %>
3
3
  <% end %>
4
-
5
4
  <%= panda_cms_form_with model: page, url: admin_pages_path, method: :post do |f| %>
6
- <% options = nested_set_options(PandaCms::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
7
- <div data-controller="text-field-update">
8
- <input type="hidden" value="<%= PandaCms::Current.root %>" data-text-field-update-target="existing_root">
9
- <%= f.select :parent_id, options, {}, { "data-text-field-update-target": "input_select", "data-action": "change->text-field-update#setPrePath" } %>
10
- <%= f.text_field :title, { data: { "text-field-update-target": "input_text", action: "focusout->text-field-update#generatePath" } } %>
11
- <%= f.text_field :path, { data: { prefix: PandaCms::Current.root, "text-field-update-target": "output_text" } } %>
5
+ <% options = nested_set_options(PandaCms::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
6
+ <div data-controller="slug">
7
+ <input type="hidden" value="<%= PandaCms::Current.root %>" data-slug-target="existing_root">
8
+ <%= f.select :parent_id, options, {}, { "data-slug-target": "input_select", "data-action": "change->slug#setPrePath" } %>
9
+ <%= f.text_field :title, { data: { "slug-target": "input_text", action: "focusout->slug#generatePath" } } %>
10
+ <%= f.text_field :path, { data: { prefix: PandaCms::Current.root, "slug-target": "output_text" } } %>
12
11
  <%= f.collection_select :panda_cms_template_id, PandaCms::Template.available, :id, :name %>
13
12
  <%= f.button %>
14
13
  </div>
@@ -1,8 +1,6 @@
1
1
  <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.with_heading(text: "Menus", level: 1) do |heading| %>
3
- <% heading.with_button(action: :add, text: "Add Menu", link: new_admin_menu_path) %>
4
3
  <% end %>
5
-
6
4
  <%= render PandaCms::Admin::TableComponent.new(term: "menu", rows: menus) do |table| %>
7
5
  <% table.column("Name") { |menu| link_to menu.name, edit_admin_menu_path(menu) } %>
8
6
  <% table.column("Kind") { |menu| render PandaCms::Admin::TagComponent.new(status: :active, text: menu.kind.titleize) } %>
@@ -1,26 +1,29 @@
1
1
  <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.with_heading(text: "#{page.title}", level: 1) %>
3
-
4
3
  <% component.with_slideover(title: "Page Details") do %>
5
4
  <%= panda_cms_form_with model: page, url: admin_page_path, method: :put do |f| %>
6
- <%= f.text_field :title, class: "block w-full rounded-md border-0 p-2 text-gray-900 ring-1 ring-inset ring-mid placeholder:text-gray-300 focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
7
-
8
- <%= f.text_field :template, value: template.name, readonly: true, class: "read-only:bg-gray-100 block w-full rounded-md border-0 p-2 text-gray-900 ring-1 ring-inset ring-mid placeholder:text-gray-300 focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
9
-
10
- <%= f.select :status, options_for_select([["Active", "active"], ["Draft", "draft"], ["Hidden", "hidden"], ["Archived", "archived"]], selected: page.status), {}, class: "block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-mid focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
11
-
12
- <%= f.submit "Save" %>
5
+ <%= f.text_field :title, class: "block w-full rounded-md border-0 p-2 text-gray-900 ring-1 ring-inset ring-mid placeholder:text-gray-300 focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
6
+ <%= f.text_field :template, value: template.name, readonly: true, class: "read-only:bg-gray-100 block w-full rounded-md border-0 p-2 text-gray-900 ring-1 ring-inset ring-mid placeholder:text-gray-300 focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
7
+ <%= f.select :status, options_for_select([["Active", "active"], ["Draft", "draft"], ["Hidden", "hidden"], ["Archived", "archived"]], selected: page.status), {}, class: "block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-mid focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
8
+ <%= f.submit "Save" %>
13
9
  <% end %>
14
10
  <% end %>
15
-
16
- <a class="block mb-2 -mt-4 text-sm text-black/60" target="_blank" href="<%= @page.path %>"><%= @page.path %> <i class="ml-2 fa-solid fa-arrow-up-right-from-square"></i></a>
17
-
18
- <iframe id="editablePageFrame" src="<%= page.path %>?embed_id=<%= page.id %>" class="p-0 m-0 w-full h-full border border-slate-200"></iframe>
19
-
20
- <script src="/panda-cms-assets/javascripts/embed/editable.js"></script>
21
- <script>
22
- document.addEventListener("DOMContentLoaded", function() {
23
- const editable = new EditableController("<%= page.id %>", document.getElementById("editablePageFrame"));
24
- });
25
- </script>
11
+ <div class="grid grid-cols-2 mb-4 -mt-5">
12
+ <div>
13
+ <a class="inline-block mb-2 text-sm text-black/60" target="_blank" href="<%= @page.path %>"><%= @page.path %> <i class="ml-2 fa-solid fa-arrow-up-right-from-square"></i></a>
14
+ </div>
15
+ <div class="relative -mt-5">
16
+ <span class="absolute right-0"><%= render PandaCms::Admin::ButtonComponent.new(text: "Save Changes", action: :save_inactive, icon: "check", link: "#", size: :regular, id: "saveEditableButton") %></span>
17
+ </div>
18
+ </div>
19
+ <%= content_tag :iframe, nil,
20
+ src: "#{page.path}?embed_id=#{page.id}",
21
+ class: "p-0 m-0 w-full h-full border border-slate-200",
22
+ id: "editablePageFrame",
23
+ data: {
24
+ controller: "editor",
25
+ editor_page_id_value: @page.id,
26
+ editor_admin_path_value: "#{admin_dashboard_url}",
27
+ editor_autosave_value: false
28
+ } %>
26
29
  <% end %>
@@ -1,14 +1,13 @@
1
1
  <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.with_heading(text: "Add Page", level: 1) do |heading| %>
3
3
  <% end %>
4
-
5
4
  <%= panda_cms_form_with model: page, url: admin_pages_path, method: :post do |f| %>
6
- <% options = nested_set_options(PandaCms::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
7
- <div data-controller="text-field-update">
8
- <input type="hidden" value="<%= PandaCms::Current.root %>" data-text-field-update-target="existing_root">
9
- <%= f.select :parent_id, options, {}, { "data-text-field-update-target": "input_select", "data-action": "change->text-field-update#setPrePath" } %>
10
- <%= f.text_field :title, { data: { "text-field-update-target": "input_text", action: "focusout->text-field-update#generatePath" } } %>
11
- <%= f.text_field :path, { meta: t(".path.meta"), data: { prefix: PandaCms::Current.root, "text-field-update-target": "output_text" } } %>
5
+ <% options = nested_set_options(PandaCms::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
6
+ <div data-controller="slug">
7
+ <input type="hidden" value="<%= PandaCms::Current.root %>" data-slug-target="existing_root">
8
+ <%= f.select :parent_id, options, {}, { "data-slug-target": "input_select", "data-action": "change->slug#setPrePath" } %>
9
+ <%= f.text_field :title, { data: { "slug-target": "input_text", action: "focusout->slug#generatePath" } } %>
10
+ <%= f.text_field :path, { meta: t(".path.meta"), data: { prefix: PandaCms::Current.root, "slug-target": "output_text" } } %>
12
11
  <%= f.collection_select :panda_cms_template_id, PandaCms::Template.available, :id, :name %>
13
12
  <%= f.button %>
14
13
  </div>
@@ -1,8 +1,8 @@
1
1
  <%= panda_cms_form_with model: post, url: url do |f| %>
2
- <div data-controller="text-field-update">
3
- <input type="hidden" value="<%= PandaCms::Current.root %>" data-text-field-update-target="existing_root">
4
- <%= f.text_field :title, { required: true, data: { "text-field-update-target": "input_text", action: "focusout->text-field-update#generatePath" } } %>
5
- <%= f.text_field :slug, { required: true, data: { prefix: PandaCms::Current.root + "/#{PandaCms.posts[:prefix]}", "text-field-update-target": "output_text" } } %>
2
+ <div data-controller="slug">
3
+ <input type="hidden" value="<%= PandaCms::Current.root %>" data-slug-target="existing_root">
4
+ <%= f.text_field :title, { required: true, data: { "slug-target": "input_text", action: "focusout->slug#generatePath" } } %>
5
+ <%= f.text_field :slug, { required: true, data: { prefix: PandaCms::Current.root + "/#{PandaCms.config.posts[:prefix]}", "slug-target": "output_text" } } %>
6
6
  <%= f.select :user_id, PandaCms::User.for_select_list %>
7
7
  <%= f.datetime_field :published_at, { required: true } %>
8
8
  <%= f.select :status, PandaCms::Post.statuses.keys.map { |status| [status.humanize, status] } %>
@@ -3,10 +3,9 @@
3
3
  <img src="/panda-cms-assets/panda-nav.png" class="py-2 mx-auto w-auto h-32">
4
4
  <h2 class="mt-10 mb-6 text-2xl font-bold text-center text-white"><%= t("panda_cms.admin.sessions.new.title") %></h2>
5
5
  </div>
6
-
7
6
  <% @providers.each do |provider| %>
8
7
  <div class="mt-4 text-center sm:mx-auto sm:w-full sm:max-w-sm">
9
- <%= form_tag "#{PandaCms.admin_path}/auth/#{provider}", method: "post", data: {turbo: false} do %>
8
+ <%= form_tag "#{PandaCms.root_path}/auth/#{provider}", method: "post", data: {turbo: false} do %>
10
9
  <input type="hidden" name="redirect_uri" value="<%= admin_login_callback_url(provider: provider) %>">
11
10
  <button type="submit" id="button-sign-in-<%= provider %>" class="inline-flex gap-x-2 items-center py-2.5 px-3.5 mx-auto mb-4 bg-white rounded-md border min-w-56 border-neutral-400">
12
11
  <i class="fa-brands fa-<%= provider %> text-xl mr-1"></i>