panda-cms 0.7.0 → 0.7.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/panda.cms.css +0 -50
  3. data/app/components/panda/cms/admin/table_component.html.erb +1 -1
  4. data/app/components/panda/cms/code_component.rb +2 -1
  5. data/app/components/panda/cms/rich_text_component.html.erb +86 -2
  6. data/app/components/panda/cms/rich_text_component.rb +131 -20
  7. data/app/controllers/panda/cms/admin/block_contents_controller.rb +18 -7
  8. data/app/controllers/panda/cms/admin/files_controller.rb +22 -12
  9. data/app/controllers/panda/cms/admin/posts_controller.rb +33 -11
  10. data/app/controllers/panda/cms/pages_controller.rb +29 -0
  11. data/app/controllers/panda/cms/posts_controller.rb +26 -4
  12. data/app/helpers/panda/cms/admin/posts_helper.rb +23 -32
  13. data/app/helpers/panda/cms/posts_helper.rb +32 -0
  14. data/app/javascript/panda/cms/controllers/dashboard_controller.js +0 -1
  15. data/app/javascript/panda/cms/controllers/editor_form_controller.js +134 -11
  16. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +395 -130
  17. data/app/javascript/panda/cms/controllers/slug_controller.js +33 -43
  18. data/app/javascript/panda/cms/editor/editor_js_config.js +202 -73
  19. data/app/javascript/panda/cms/editor/editor_js_initializer.js +243 -194
  20. data/app/javascript/panda/cms/editor/plain_text_editor.js +1 -1
  21. data/app/javascript/panda/cms/editor/resource_loader.js +89 -0
  22. data/app/javascript/panda/cms/editor/rich_text_editor.js +162 -0
  23. data/app/models/panda/cms/page.rb +18 -0
  24. data/app/models/panda/cms/post.rb +61 -3
  25. data/app/models/panda/cms/redirect.rb +2 -2
  26. data/app/views/panda/cms/admin/posts/_form.html.erb +15 -4
  27. data/app/views/panda/cms/admin/posts/index.html.erb +5 -3
  28. data/config/routes.rb +34 -6
  29. data/db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb +5 -0
  30. data/lib/panda/cms/editor_js_content.rb +14 -1
  31. data/lib/panda/cms/engine.rb +3 -6
  32. data/lib/panda-cms/version.rb +1 -1
  33. data/lib/panda-cms.rb +5 -11
  34. metadata +290 -35
@@ -0,0 +1,162 @@
1
+ import EditorJS from "@editorjs/editorjs"
2
+ import Paragraph from "@editorjs/paragraph"
3
+ import Header from "@editorjs/header"
4
+ import List from "@editorjs/list"
5
+ import Quote from "@editorjs/quote"
6
+ import Table from "@editorjs/table"
7
+ import NestedList from "@editorjs/nested-list"
8
+
9
+ export default class RichTextEditor {
10
+ constructor(element, iframe) {
11
+ this.element = element
12
+ this.iframe = iframe
13
+ this.editor = null
14
+ this.initialized = false
15
+ this.initialize()
16
+ }
17
+
18
+ async initialize() {
19
+ if (this.initialized) return
20
+ console.debug("[Panda CMS] Initializing EditorJS")
21
+
22
+ try {
23
+ let content = this.element.dataset.editableContent || ""
24
+ let previousData = this.element.dataset.editablePreviousData || ""
25
+ console.debug("[Panda CMS] Initial content:", content)
26
+ console.debug("[Panda CMS] Previous data:", previousData)
27
+
28
+ let parsedContent
29
+ if (previousData) {
30
+ try {
31
+ // Try to decode base64 first
32
+ const decodedData = atob(previousData)
33
+ console.debug("[Panda CMS] Decoded base64 data:", decodedData)
34
+ parsedContent = JSON.parse(decodedData)
35
+ console.debug("[Panda CMS] Successfully parsed base64 data:", parsedContent)
36
+ } catch (e) {
37
+ console.debug("[Panda CMS] Not base64 encoded or invalid, trying direct JSON parse:", e)
38
+ try {
39
+ parsedContent = JSON.parse(previousData)
40
+ console.debug("[Panda CMS] Successfully parsed JSON data:", parsedContent)
41
+ } catch (e2) {
42
+ console.error("[Panda CMS] Failed to parse previous data:", e2)
43
+ parsedContent = this.getDefaultContent()
44
+ }
45
+ }
46
+ } else if (content) {
47
+ try {
48
+ parsedContent = JSON.parse(content)
49
+ console.debug("[Panda CMS] Successfully parsed content:", parsedContent)
50
+ } catch (e) {
51
+ console.error("[Panda CMS] Failed to parse content:", e)
52
+ parsedContent = this.getDefaultContent()
53
+ }
54
+ } else {
55
+ parsedContent = this.getDefaultContent()
56
+ }
57
+
58
+ // Create holder element before initialization
59
+ const holderId = `editor-${Math.random().toString(36).substr(2, 9)}`
60
+ const holderElement = document.createElement("div")
61
+ holderElement.id = holderId
62
+ holderElement.className = "editor-js-holder codex-editor"
63
+
64
+ // Clear any existing content and append holder
65
+ this.element.textContent = ""
66
+ this.element.appendChild(holderElement)
67
+
68
+ // Initialize EditorJS
69
+ this.editor = new EditorJS({
70
+ holder: holderId,
71
+ data: parsedContent,
72
+ placeholder: "Click to start writing...",
73
+ tools: {
74
+ paragraph: {
75
+ class: Paragraph,
76
+ inlineToolbar: true
77
+ },
78
+ header: {
79
+ class: Header,
80
+ inlineToolbar: true
81
+ },
82
+ list: {
83
+ class: NestedList,
84
+ inlineToolbar: true,
85
+ config: {
86
+ defaultStyle: 'unordered',
87
+ enableLineBreaks: true
88
+ }
89
+ },
90
+ quote: {
91
+ class: Quote,
92
+ inlineToolbar: true
93
+ },
94
+ table: {
95
+ class: Table,
96
+ inlineToolbar: true
97
+ }
98
+ },
99
+ onChange: () => {
100
+ this.save()
101
+ }
102
+ })
103
+
104
+ await this.editor.isReady
105
+ this.initialized = true
106
+ console.debug("[Panda CMS] EditorJS initialized successfully")
107
+ } catch (error) {
108
+ console.error("[Panda CMS] Error initializing EditorJS:", error)
109
+ }
110
+ }
111
+
112
+ getDefaultContent() {
113
+ return {
114
+ time: Date.now(),
115
+ blocks: [
116
+ {
117
+ type: "paragraph",
118
+ data: {
119
+ text: ""
120
+ }
121
+ }
122
+ ],
123
+ version: "2.28.2"
124
+ }
125
+ }
126
+
127
+ async save() {
128
+ if (!this.editor) return null
129
+
130
+ try {
131
+ const savedData = await this.editor.save()
132
+ const jsonString = JSON.stringify(savedData)
133
+ // Store both base64 and regular JSON
134
+ this.element.dataset.editablePreviousData = btoa(jsonString)
135
+ this.element.dataset.editableContent = jsonString
136
+ return jsonString
137
+ } catch (error) {
138
+ console.error("[Panda CMS] Error saving EditorJS content:", error)
139
+ return null
140
+ }
141
+ }
142
+
143
+ async clear() {
144
+ if (!this.editor) return
145
+
146
+ try {
147
+ await this.editor.clear()
148
+ this.element.dataset.editablePreviousData = ""
149
+ this.element.dataset.editableContent = ""
150
+ } catch (error) {
151
+ console.error("[Panda CMS] Error clearing EditorJS content:", error)
152
+ }
153
+ }
154
+
155
+ destroy() {
156
+ if (this.editor) {
157
+ this.editor.destroy()
158
+ this.editor = null
159
+ this.initialized = false
160
+ }
161
+ }
162
+ }
@@ -67,6 +67,7 @@ module Panda
67
67
  generate_content_blocks
68
68
  update_existing_menu_items
69
69
  update_auto_menus
70
+ create_redirect_if_path_changed
70
71
  end
71
72
 
72
73
  def generate_content_blocks
@@ -91,6 +92,23 @@ module Panda
91
92
  def update_existing_menu_items
92
93
  menu_items.where.not(text: title).update_all(text: title)
93
94
  end
95
+
96
+ def create_redirect_if_path_changed
97
+ return unless saved_change_to_path?
98
+
99
+ old_path = saved_changes["path"].first
100
+ new_path = saved_changes["path"].last
101
+
102
+ # Create a redirect from the old path to the new path
103
+ Panda::CMS::Redirect.create!(
104
+ origin_panda_cms_page_id: id,
105
+ destination_panda_cms_page_id: id,
106
+ status_code: 301,
107
+ visits: 0,
108
+ origin_path: old_path,
109
+ destination_path: new_path
110
+ )
111
+ end
94
112
  end
95
113
  end
96
114
  end
@@ -5,6 +5,9 @@ module Panda
5
5
  class Post < ApplicationRecord
6
6
  include ::Panda::CMS::EditorJsContent
7
7
 
8
+ after_commit :clear_menu_cache
9
+ before_validation :format_slug
10
+
8
11
  self.table_name = "panda_cms_posts"
9
12
 
10
13
  has_paper_trail versions: {
@@ -12,18 +15,20 @@ module Panda
12
15
  }
13
16
 
14
17
  belongs_to :user, class_name: "Panda::CMS::User"
18
+ belongs_to :author, class_name: "Panda::CMS::User"
15
19
 
16
20
  validates :title, presence: true
17
21
  validates :slug,
18
22
  presence: true,
19
23
  uniqueness: true,
20
24
  format: {
21
- with: /\A\/[a-z0-9-]+\z/,
22
- message: "must start with a forward slash and contain only lowercase letters, numbers, and hyphens"
25
+ with: %r{\A(/\d{4}/\d{2}/[a-z0-9-]+|/[a-z0-9-]+)\z},
26
+ message: "must be in format /YYYY/MM/slug or /slug with only lowercase letters, numbers, and hyphens"
23
27
  }
24
28
 
25
29
  scope :ordered, -> { order(published_at: :desc) }
26
30
  scope :with_user, -> { includes(:user) }
31
+ scope :with_author, -> { includes(:author) }
27
32
 
28
33
  enum :status, {
29
34
  active: "active",
@@ -33,7 +38,31 @@ module Panda
33
38
  }
34
39
 
35
40
  def to_param
36
- slug.delete_prefix("/")
41
+ # For date-based URLs, return just the slug portion
42
+ parts = CGI.unescape(slug).delete_prefix("/").split("/")
43
+ if parts.length == 3 # year/month/slug format
44
+ parts.last
45
+ else
46
+ parts.first
47
+ end
48
+ end
49
+
50
+ def year
51
+ return nil unless slug.match?(%r{\A/\d{4}/})
52
+ slug.split("/")[1]
53
+ end
54
+
55
+ def month
56
+ return nil unless slug.match?(%r{\A/\d{4}/\d{2}/})
57
+ slug.split("/")[2]
58
+ end
59
+
60
+ def route_params
61
+ if year && month
62
+ {year: year, month: month, slug: to_param}
63
+ else
64
+ {slug: to_param}
65
+ end
37
66
  end
38
67
 
39
68
  def admin_param
@@ -55,6 +84,35 @@ module Panda
55
84
  text = text.squish if squish
56
85
  text.truncate(length).html_safe
57
86
  end
87
+
88
+ private
89
+
90
+ def clear_menu_cache
91
+ Rails.cache.delete("panda_cms_news_months")
92
+ end
93
+
94
+ def format_slug
95
+ return if slug.blank?
96
+
97
+ # Remove any leading/trailing slashes and decode
98
+ self.slug = CGI.unescape(slug.strip.gsub(%r{^/+|/+$}, ""))
99
+
100
+ # Handle the case where we already have a properly formatted slug
101
+ if slug.match?(%r{\A\d{4}/\d{2}/[^/]+\z})
102
+ return self.slug = "/#{slug}"
103
+ end
104
+
105
+ # Handle the case where we have a date-prefixed slug (from JS)
106
+ if (match = slug.match(%r{\A(\d{4})-(\d{2})-(.+)\z}))
107
+ year, month, base_slug = match[1], match[2], match[3]
108
+ return self.slug = "/#{year}/#{month}/#{base_slug}"
109
+ end
110
+
111
+ # For new slugs without any date structure
112
+ base_slug = slug.downcase.gsub(/[^a-z0-9-]+/, "-").gsub(/^-+|-+$/, "")
113
+ date_prefix = published_at.present? ? published_at.strftime("%Y/%m") : Time.current.strftime("%Y/%m")
114
+ self.slug = "/#{date_prefix}/#{base_slug}"
115
+ end
58
116
  end
59
117
  end
60
118
  end
@@ -1,8 +1,8 @@
1
1
  module Panda
2
2
  module CMS
3
3
  class Redirect < ApplicationRecord
4
- belongs_to :origin_page, class_name: "Panda::CMS::Page", foreign_key: :origin_panda_cms_page_id
5
- belongs_to :destination_page, class_name: "Panda::CMS::Page", foreign_key: :destination_panda_cms_page_id
4
+ belongs_to :origin_page, class_name: "Panda::CMS::Page", foreign_key: :origin_panda_cms_page_id, optional: true
5
+ belongs_to :destination_page, class_name: "Panda::CMS::Page", foreign_key: :destination_panda_cms_page_id, optional: true
6
6
 
7
7
  validates :status_code, presence: true
8
8
  validates :visits, presence: true
@@ -1,7 +1,18 @@
1
1
  <%= panda_cms_form_with model: post, url: url, method: post.persisted? ? :put : :post do |f| %>
2
- <%= 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:leading-6 hover:pointer" %>
3
- <%= f.text_field :slug, 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:leading-6 hover:pointer" %>
4
- <%= f.select :user_id, Panda::CMS::User.admin.map { |u| [u.name, u.id] }, {}, 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:leading-6 hover:pointer" %>
2
+ <div data-controller="slug" data-slug-add-date-prefix-value="true">
3
+ <%= f.text_field :title,
4
+ 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:leading-6 hover:pointer",
5
+ data: {
6
+ "slug-target": "input_text",
7
+ action: "focusout->slug#generatePath"
8
+ } %>
9
+ <%= f.text_field :slug,
10
+ 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:leading-6 hover:pointer",
11
+ data: {
12
+ "slug-target": "output_text"
13
+ } %>
14
+ </div>
15
+ <%= f.select :author_id, Panda::CMS::User.admin.map { |u| [u.name, u.id] }, {}, 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:leading-6 hover:pointer" %>
5
16
  <%= f.datetime_field :published_at, 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:leading-6 hover:pointer" %>
6
17
  <%= f.select :status, options_for_select([["Active", "active"], ["Draft", "draft"], ["Hidden", "hidden"], ["Archived", "archived"]], selected: post.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:leading-6 hover:pointer" %>
7
18
 
@@ -16,7 +27,7 @@
16
27
  } %>
17
28
  <div id="<%= editor_id %>"
18
29
  data-editor-form-target="editorContainer"
19
- class="max-w-full block ml-12 bg-white pt-1 mb-4 mt-2 border border-mid rounded-md min-h-[300px]">
30
+ class="max-w-full block bg-white pt-1 mb-4 mt-2 border border-mid rounded-md min-h-[300px]">
20
31
  </div>
21
32
  </div>
22
33
 
@@ -5,13 +5,15 @@
5
5
 
6
6
  <%= render Panda::CMS::Admin::TableComponent.new(term: "post", rows: posts) do |table| %>
7
7
  <% table.column("Title") do |post| %>
8
- <div class="">
8
+ <div>
9
9
  <%= link_to post.title, edit_admin_post_path(post.admin_param), class: "block h-full w-full" %>
10
- <span class="block text-xs text-black/60"><%= post_path(post) %></span>
10
+ <span class="block text-xs text-black/60">
11
+ <%= CGI.unescape("#{Panda::CMS.config.posts[:prefix]}#{post.slug}") %>
12
+ </span>
11
13
  </div>
12
14
  <% end %>
13
15
  <% table.column("Status") { |post| render Panda::CMS::Admin::TagComponent.new(status: post.status) } %>
14
- <% table.column("Published") { |post| render Panda::CMS::Admin::UserActivityComponent.new(at: post.published_at, user: post.user)} %>
16
+ <% table.column("Published") { |post| render Panda::CMS::Admin::UserActivityComponent.new(at: post.published_at, user: post.author)} %>
15
17
  <% table.column("Last Updated") { |post| render Panda::CMS::Admin::UserActivityComponent.new(whodunnit_to: post)} %>
16
18
  <% end %>
17
19
 
data/config/routes.rb CHANGED
@@ -35,14 +35,42 @@ Panda::CMS::Engine.routes.draw do
35
35
  # OmniAuth additionally adds a GET route for "#{Panda::CMS.route_namespace}/auth/:provider" but doesn't name it
36
36
  delete Panda::CMS.route_namespace, to: "admin/sessions#destroy", as: :admin_logout
37
37
 
38
+ ### APPENDED ROUTES ###
39
+
40
+ # TODO: Allow multiple types of post in future
38
41
  if Panda::CMS.config.posts[:enabled]
39
- # TODO: Allow multiple types of post in future
40
- # TODO: This now requires a page to be created, make it explicit (with a rendering posts helper?)
41
- # get Panda::CMS.posts[:prefix], to: "posts#index", as: :posts
42
- get "#{Panda::CMS.config.posts[:prefix]}/:slug", to: "posts#show", as: :post
43
- end
42
+ get Panda::CMS.config.posts[:prefix], to: "posts#index", as: :posts
44
43
 
45
- ### APPENDED ROUTES ###
44
+ # Route for date-based URLs that won't encode slashes
45
+ get "#{Panda::CMS.config.posts[:prefix]}/:year/:month/:slug",
46
+ to: "posts#show",
47
+ as: :post_with_date,
48
+ constraints: {
49
+ year: /\d{4}/,
50
+ month: /\d{2}/,
51
+ slug: /[^\/]+/,
52
+ format: /html|json|xml/
53
+ }
54
+
55
+ # Route for non-date URLs
56
+ get "#{Panda::CMS.config.posts[:prefix]}/:slug",
57
+ to: "posts#show",
58
+ as: :post,
59
+ constraints: {
60
+ slug: /[^\/]+/,
61
+ format: /html|json|xml/
62
+ }
63
+
64
+ # Route for month archive
65
+ get "#{Panda::CMS.config.posts[:prefix]}/:year/:month",
66
+ to: "posts#by_month",
67
+ as: :posts_by_month,
68
+ constraints: {
69
+ year: /\d{4}/,
70
+ month: /\d{2}/,
71
+ format: /html|json|xml/
72
+ }
73
+ end
46
74
 
47
75
  # See lib/panda/cms/engine.rb
48
76
  end
@@ -0,0 +1,5 @@
1
+ class AddAuthorIdToPandaCMSPosts < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :panda_cms_posts, :author, type: :uuid, foreign_key: {to_table: :panda_cms_users}
4
+ end
5
+ end
@@ -12,10 +12,23 @@ module Panda::CMS::EditorJsContent
12
12
 
13
13
  def generate_cached_content
14
14
  if content.is_a?(String)
15
- self.cached_content = content
15
+ begin
16
+ parsed_content = JSON.parse(content)
17
+ self.cached_content = if parsed_content.is_a?(Hash) && parsed_content["blocks"].present?
18
+ Panda::CMS::EditorJs::Renderer.new(parsed_content).render
19
+ else
20
+ content
21
+ end
22
+ rescue JSON::ParserError
23
+ # If it's not JSON, treat it as plain text
24
+ self.cached_content = content
25
+ end
16
26
  elsif content.is_a?(Hash) && content["blocks"].present?
17
27
  # Process EditorJS content
18
28
  self.cached_content = Panda::CMS::EditorJs::Renderer.new(content).render
29
+ else
30
+ # For any other case, store as is
31
+ self.cached_content = content.to_s
19
32
  end
20
33
  end
21
34
  end
@@ -1,15 +1,12 @@
1
- require "importmap-rails"
2
- require "turbo-rails"
3
- require "stimulus-rails"
4
- require "view_component"
5
-
1
+ require "rubygems"
2
+ require "panda/core"
6
3
  require "panda/cms/railtie"
7
4
 
8
5
  module Panda
9
6
  module CMS
10
7
  class Engine < ::Rails::Engine
11
8
  isolate_namespace Panda::CMS
12
- engine_name "panda_cms"
9
+ engine_name "panda-cms"
13
10
 
14
11
  # Add services directory to autoload paths
15
12
  config.autoload_paths += %W[
@@ -1,5 +1,5 @@
1
1
  module Panda
2
2
  module CMS
3
- VERSION = "0.7.0"
3
+ VERSION = "0.7.3"
4
4
  end
5
5
  end
data/lib/panda-cms.rb CHANGED
@@ -1,14 +1,8 @@
1
- require "dry-configurable"
2
- require "importmap-rails"
3
- require "lookbook"
4
- require "omniauth"
5
- require "omniauth/rails_csrf_protection"
6
- require "omniauth/strategies/microsoft_graph"
7
- require "omniauth/strategies/google_oauth2"
8
- require "omniauth/strategies/github"
9
- require "paper_trail"
10
- require "view_component"
11
- require "zeitwerk"
1
+ require "rubygems"
2
+ require "panda/core"
3
+ require "panda/cms/railtie"
4
+
5
+ require "capybara/rspec"
12
6
 
13
7
  module Panda
14
8
  module CMS