panda-cms 0.7.0 → 0.7.2

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 (33) 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 +4 -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 +4 -0
  32. data/lib/panda-cms/version.rb +1 -1
  33. metadata +5 -2
@@ -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
@@ -3,8 +3,12 @@ require "turbo-rails"
3
3
  require "stimulus-rails"
4
4
  require "view_component"
5
5
 
6
+ require "panda/core"
6
7
  require "panda/cms/railtie"
7
8
 
9
+ require "omniauth"
10
+ require "omniauth/rails_csrf_protection"
11
+
8
12
  module Panda
9
13
  module CMS
10
14
  class Engine < ::Rails::Engine
@@ -1,5 +1,5 @@
1
1
  module Panda
2
2
  module CMS
3
- VERSION = "0.7.0"
3
+ VERSION = "0.7.2"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: panda-cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Panda Software Limited
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-01-02 00:00:00.000000000 Z
10
+ date: 2025-01-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: panda-core
@@ -452,6 +452,7 @@ files:
452
452
  - app/helpers/panda/cms/admin/posts_helper.rb
453
453
  - app/helpers/panda/cms/application_helper.rb
454
454
  - app/helpers/panda/cms/pages_helper.rb
455
+ - app/helpers/panda/cms/posts_helper.rb
455
456
  - app/helpers/panda/cms/theme_helper.rb
456
457
  - app/javascript/panda/cms/@editorjs--editorjs.js
457
458
  - app/javascript/panda/cms/@hotwired--stimulus.js
@@ -468,6 +469,7 @@ files:
468
469
  - app/javascript/panda/cms/editor/editor_js_initializer.js
469
470
  - app/javascript/panda/cms/editor/plain_text_editor.js
470
471
  - app/javascript/panda/cms/editor/resource_loader.js
472
+ - app/javascript/panda/cms/editor/rich_text_editor.js
471
473
  - app/javascript/panda/cms/tailwindcss-stimulus-components.js
472
474
  - app/jobs/panda/cms/application_job.rb
473
475
  - app/jobs/panda/cms/record_visit_job.rb
@@ -581,6 +583,7 @@ files:
581
583
  - db/migrate/20241120110943_add_editor_js_to_posts.rb
582
584
  - db/migrate/20241120113859_add_cached_content_to_panda_cms_posts.rb
583
585
  - db/migrate/20241123234140_remove_post_tag_id_from_posts.rb
586
+ - db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb
584
587
  - db/migrate/migrate
585
588
  - db/seeds.rb
586
589
  - lib/generators/panda/cms/install_generator.rb