panda-cms 0.10.0 → 0.10.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.
- checksums.yaml +4 -4
- data/README.md +2 -11
- data/app/components/panda/cms/code_component.rb +45 -8
- data/app/components/panda/cms/menu_component.rb +9 -3
- data/app/components/panda/cms/page_menu_component.rb +9 -1
- data/app/components/panda/cms/rich_text_component.rb +49 -17
- data/app/components/panda/cms/text_component.rb +46 -14
- data/app/controllers/panda/cms/admin/menus_controller.rb +2 -2
- data/app/controllers/panda/cms/admin/pages_controller.rb +6 -2
- data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
- data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
- data/app/controllers/panda/cms/pages_controller.rb +7 -2
- data/app/controllers/panda/cms/posts_controller.rb +16 -0
- data/app/helpers/panda/cms/application_helper.rb +2 -3
- data/app/helpers/panda/cms/asset_helper.rb +14 -72
- data/app/helpers/panda/cms/forms_helper.rb +60 -0
- data/app/helpers/panda/cms/seo_helper.rb +85 -0
- data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +4 -0
- data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
- data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
- data/app/javascript/panda/cms/controllers/index.js +6 -0
- data/app/javascript/panda/cms/controllers/menu_form_controller.js +14 -1
- data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
- data/app/javascript/panda/cms/stimulus-loading.js +2 -1
- data/app/models/panda/cms/menu.rb +12 -0
- data/app/models/panda/cms/page.rb +106 -0
- data/app/models/panda/cms/post.rb +97 -0
- data/app/views/layouts/homepage.html.erb +1 -4
- data/app/views/layouts/page.html.erb +1 -4
- data/app/views/panda/cms/admin/dashboard/show.html.erb +1 -1
- data/app/views/panda/cms/admin/files/index.html.erb +1 -1
- data/app/views/panda/cms/admin/forms/show.html.erb +3 -3
- data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +3 -3
- data/app/views/panda/cms/admin/menus/edit.html.erb +12 -14
- data/app/views/panda/cms/admin/menus/index.html.erb +1 -1
- data/app/views/panda/cms/admin/menus/new.html.erb +5 -7
- data/app/views/panda/cms/admin/pages/edit.html.erb +139 -20
- data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
- data/app/views/panda/cms/admin/posts/_form.html.erb +41 -2
- data/app/views/panda/cms/admin/posts/edit.html.erb +1 -1
- data/app/views/panda/cms/admin/posts/index.html.erb +4 -4
- data/app/views/shared/_header.html.erb +1 -4
- data/config/brakeman.ignore +38 -0
- data/config/importmap.rb +8 -6
- data/config/locales/en.yml +41 -0
- data/config/routes.rb +1 -1
- data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
- data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
- data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
- data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
- data/lib/panda/cms/asset_loader.rb +27 -77
- data/lib/panda/cms/bulk_editor.rb +288 -12
- data/lib/panda/cms/engine/asset_config.rb +49 -0
- data/lib/panda/cms/engine/autoload_config.rb +19 -0
- data/lib/panda/cms/engine/backtrace_config.rb +42 -0
- data/lib/panda/cms/engine/core_config.rb +106 -0
- data/lib/panda/cms/engine/helper_config.rb +20 -0
- data/lib/panda/cms/engine/route_config.rb +34 -0
- data/lib/panda/cms/engine/view_component_config.rb +31 -0
- data/lib/panda/cms/engine.rb +44 -221
- data/lib/panda/cms.rb +10 -0
- data/lib/panda-cms/version.rb +1 -1
- data/lib/panda-cms.rb +16 -2
- metadata +20 -22
- data/app/javascript/panda_cms/stimulus-loading.js +0 -39
- data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
- data/config/initializers/inflections.rb +0 -5
- data/lib/tasks/assets.rake +0 -540
|
@@ -24,51 +24,9 @@ module Panda
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Include only Panda CMS JavaScript
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if Panda::CMS::AssetLoader.use_github_assets?
|
|
32
|
-
# GitHub-hosted assets with integrity check
|
|
33
|
-
version = Panda::CMS::AssetLoader.send(:asset_version)
|
|
34
|
-
integrity = asset_integrity(version, "panda-cms-#{version}.js")
|
|
35
|
-
|
|
36
|
-
tag_options = {
|
|
37
|
-
src: js_url
|
|
38
|
-
}
|
|
39
|
-
# In CI environment, don't use defer to ensure immediate execution
|
|
40
|
-
tag_options[:defer] = true unless ENV["GITHUB_ACTIONS"] == "true"
|
|
41
|
-
# Standalone bundles should NOT use type: "module" - they're regular scripts
|
|
42
|
-
# Only use type: "module" for importmap/ES module assets
|
|
43
|
-
if !js_url.include?("panda-cms-assets")
|
|
44
|
-
tag_options[:type] = "module"
|
|
45
|
-
end
|
|
46
|
-
tag_options[:integrity] = integrity if integrity
|
|
47
|
-
tag_options[:crossorigin] = "anonymous" if integrity
|
|
48
|
-
|
|
49
|
-
content_tag(:script, "", tag_options)
|
|
50
|
-
elsif js_url.include?("panda-cms-assets")
|
|
51
|
-
# Development assets - check if it's a standalone bundle or importmap
|
|
52
|
-
defer_option = (ENV["GITHUB_ACTIONS"] == "true") ? {} : {defer: true}
|
|
53
|
-
javascript_include_tag(js_url, **defer_option)
|
|
54
|
-
# Standalone bundle - don't use type: "module"
|
|
55
|
-
else
|
|
56
|
-
# Development mode - Load JavaScript with import map
|
|
57
|
-
# Files are served by Rack::Static middleware from engine's app/javascript
|
|
58
|
-
importmap_html = <<~HTML
|
|
59
|
-
<script type="importmap">
|
|
60
|
-
{
|
|
61
|
-
"imports": {
|
|
62
|
-
"@hotwired/stimulus": "/panda/core/vendor/@hotwired--stimulus.js",
|
|
63
|
-
"@hotwired/turbo": "/panda/core/vendor/@hotwired--turbo.js"
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
</script>
|
|
67
|
-
<script type="module" src="/panda/cms/application_panda_cms.js"></script>
|
|
68
|
-
HTML
|
|
69
|
-
importmap_html.html_safe
|
|
70
|
-
end
|
|
71
|
-
end
|
|
27
|
+
#
|
|
28
|
+
# Delegates to Core's helper which automatically includes all registered modules.
|
|
29
|
+
alias_method :panda_cms_javascript, :panda_core_javascript
|
|
72
30
|
|
|
73
31
|
# Include only Panda CMS CSS
|
|
74
32
|
def panda_cms_stylesheet
|
|
@@ -117,39 +75,16 @@ module Panda
|
|
|
117
75
|
version = Panda::CMS::VERSION
|
|
118
76
|
js_url = Panda::CMS::AssetLoader.javascript_url
|
|
119
77
|
css_url = Panda::CMS::AssetLoader.css_url
|
|
120
|
-
|
|
121
|
-
compiled_available = Panda::CMS::AssetLoader.send(:compiled_assets_available?)
|
|
122
|
-
|
|
123
|
-
# Additional CI debugging
|
|
124
|
-
asset_file_exists = js_url && File.exist?(Rails.root.join("public#{js_url}"))
|
|
125
|
-
ci_env = ENV["GITHUB_ACTIONS"] == "true"
|
|
126
|
-
|
|
127
|
-
# Check what script tag will be generated
|
|
128
|
-
script_tag_preview = if using_github
|
|
129
|
-
tag_options = {src: js_url}
|
|
130
|
-
tag_options[:defer] = true unless ci_env
|
|
131
|
-
if !js_url.include?("panda-cms-assets")
|
|
132
|
-
tag_options[:type] = "module"
|
|
133
|
-
end
|
|
134
|
-
"Script tag: <script#{tag_options.map { |k, v| (v == true) ? " #{k}" : " #{k}=\"#{v}\"" }.join}></script>"
|
|
135
|
-
else
|
|
136
|
-
"Using development assets"
|
|
137
|
-
end
|
|
78
|
+
Panda::CMS::AssetLoader.use_github_assets?
|
|
138
79
|
|
|
139
80
|
debug_info = [
|
|
140
81
|
"<!-- Panda CMS Asset Debug Info -->",
|
|
141
82
|
"<!-- Version: #{version} -->",
|
|
142
|
-
"<!-- Using
|
|
143
|
-
"<!-- Compiled assets available: #{compiled_available} -->",
|
|
83
|
+
"<!-- Using importmaps: true (no compilation) -->",
|
|
144
84
|
"<!-- JavaScript URL: #{js_url} -->",
|
|
145
|
-
"<!-- CSS URL: #{css_url || "
|
|
85
|
+
"<!-- CSS URL: #{css_url || "CSS from panda-core"} -->",
|
|
146
86
|
"<!-- Rails environment: #{Rails.env} -->",
|
|
147
|
-
"<!--
|
|
148
|
-
"<!-- Rails root: #{Rails.root} -->",
|
|
149
|
-
"<!-- CI environment: #{ci_env} -->",
|
|
150
|
-
"<!-- #{script_tag_preview} -->",
|
|
151
|
-
"<!-- Params embed_id: #{params[:embed_id] if respond_to?(:params)} -->",
|
|
152
|
-
"<!-- Compiled at: #{Time.now.utc.iso8601} -->"
|
|
87
|
+
"<!-- Rails root: #{Rails.root} -->"
|
|
153
88
|
]
|
|
154
89
|
|
|
155
90
|
debug_info.join("\n").html_safe
|
|
@@ -194,6 +129,13 @@ module Panda
|
|
|
194
129
|
].join("\n").html_safe
|
|
195
130
|
end
|
|
196
131
|
|
|
132
|
+
# Returns asset HTML for injection into iframe
|
|
133
|
+
# Used by editor_iframe_controller to inject assets dynamically
|
|
134
|
+
# Returns the raw HTML string (not JSON-encoded)
|
|
135
|
+
def panda_cms_injectable_assets
|
|
136
|
+
panda_cms_complete_assets.to_s
|
|
137
|
+
end
|
|
138
|
+
|
|
197
139
|
private
|
|
198
140
|
|
|
199
141
|
def asset_integrity(version, filename)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module CMS
|
|
5
|
+
module FormsHelper
|
|
6
|
+
# Generates a hidden timing field for spam protection
|
|
7
|
+
# This should be included in all forms that submit to Panda::CMS::FormSubmissionsController
|
|
8
|
+
#
|
|
9
|
+
# @example In your form
|
|
10
|
+
# <%= form_with url: form_submissions_path(form.id), method: :post do |f| %>
|
|
11
|
+
# <%= panda_cms_form_timestamp %>
|
|
12
|
+
# <%= f.text_field :name %>
|
|
13
|
+
# <%= f.submit "Submit" %>
|
|
14
|
+
# <% end %>
|
|
15
|
+
#
|
|
16
|
+
# @return [String] HTML hidden input with current timestamp
|
|
17
|
+
def panda_cms_form_timestamp
|
|
18
|
+
hidden_field_tag "_form_timestamp", Time.current.to_i
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Generates a complete spam-protected form wrapper
|
|
22
|
+
# Includes timing protection and invisible captcha honeypot
|
|
23
|
+
#
|
|
24
|
+
# @param form [Panda::CMS::Form] The form model
|
|
25
|
+
# @param options [Hash] Additional options for form_with
|
|
26
|
+
# @yield [FormBuilder] The form builder
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# <%= panda_cms_protected_form(form) do |f| %>
|
|
30
|
+
# <%= f.text_field :name %>
|
|
31
|
+
# <%= f.email_field :email %>
|
|
32
|
+
# <%= f.text_area :message %>
|
|
33
|
+
# <%= f.submit "Send Message" %>
|
|
34
|
+
# <% end %>
|
|
35
|
+
def panda_cms_protected_form(form, options = {}, &block)
|
|
36
|
+
default_options = {
|
|
37
|
+
url: "/forms/#{form.id}",
|
|
38
|
+
method: :post,
|
|
39
|
+
data: {turbo: false}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
form_with(**default_options.merge(options)) do |f|
|
|
43
|
+
concat panda_cms_form_timestamp
|
|
44
|
+
concat invisible_captcha_field
|
|
45
|
+
yield f
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Generates the invisible captcha honeypot field
|
|
50
|
+
# This is a hidden field that bots typically fill out but humans don't
|
|
51
|
+
#
|
|
52
|
+
# @return [String] HTML for invisible captcha field
|
|
53
|
+
def invisible_captcha_field
|
|
54
|
+
# invisible_captcha gem automatically adds this, but we can add it manually if needed
|
|
55
|
+
# The field name "spinner" is configured in invisible_captcha initializer
|
|
56
|
+
text_field_tag :spinner, nil, style: "position: absolute; left: -9999px; width: 1px; height: 1px;", tabindex: -1, autocomplete: "off", aria_hidden: true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module CMS
|
|
5
|
+
module SEOHelper
|
|
6
|
+
#
|
|
7
|
+
# Renders all SEO meta tags for a given page or post
|
|
8
|
+
#
|
|
9
|
+
# @param resource [Panda::CMS::Page, Panda::CMS::Post] The page or post to render meta tags for
|
|
10
|
+
# @return [String] HTML meta tags
|
|
11
|
+
# @visibility public
|
|
12
|
+
#
|
|
13
|
+
def render_seo_meta_tags(resource)
|
|
14
|
+
return "" if resource.blank?
|
|
15
|
+
|
|
16
|
+
tags = []
|
|
17
|
+
|
|
18
|
+
# Basic SEO tags
|
|
19
|
+
tags << tag.meta(name: "description", content: resource.effective_seo_description) if resource.effective_seo_description.present?
|
|
20
|
+
tags << tag.meta(name: "keywords", content: resource.seo_keywords) if resource.seo_keywords.present?
|
|
21
|
+
tags << tag.meta(name: "robots", content: resource.robots_meta_content)
|
|
22
|
+
tags << tag.link(rel: "canonical", href: canonical_url_for(resource))
|
|
23
|
+
|
|
24
|
+
# Open Graph tags
|
|
25
|
+
tags << tag.meta(property: "og:title", content: resource.effective_og_title)
|
|
26
|
+
tags << tag.meta(property: "og:description", content: resource.effective_og_description) if resource.effective_og_description.present?
|
|
27
|
+
tags << tag.meta(property: "og:type", content: resource.og_type)
|
|
28
|
+
tags << tag.meta(property: "og:url", content: canonical_url_for(resource))
|
|
29
|
+
|
|
30
|
+
# Open Graph image
|
|
31
|
+
if resource.og_image.attached?
|
|
32
|
+
og_image_url = url_for(resource.og_image.variant(:og_share))
|
|
33
|
+
tags << tag.meta(property: "og:image", content: og_image_url)
|
|
34
|
+
tags << tag.meta(property: "og:image:width", content: "1200")
|
|
35
|
+
tags << tag.meta(property: "og:image:height", content: "630")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Twitter Card tags (with fallback to OG)
|
|
39
|
+
tags << tag.meta(name: "twitter:card", content: "summary_large_image")
|
|
40
|
+
tags << tag.meta(name: "twitter:title", content: resource.effective_og_title)
|
|
41
|
+
tags << tag.meta(name: "twitter:description", content: resource.effective_og_description) if resource.effective_og_description.present?
|
|
42
|
+
|
|
43
|
+
# Twitter image (same as OG)
|
|
44
|
+
if resource.og_image.attached?
|
|
45
|
+
tags << tag.meta(name: "twitter:image", content: url_for(resource.og_image.variant(:og_share)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
safe_join(tags, "\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#
|
|
52
|
+
# Renders just the page title with SEO optimization
|
|
53
|
+
#
|
|
54
|
+
# @param resource [Panda::CMS::Page, Panda::CMS::Post] The page or post
|
|
55
|
+
# @param separator [String] Separator between page title and site name
|
|
56
|
+
# @param site_name [String] The site name (optional)
|
|
57
|
+
# @return [String] Formatted page title
|
|
58
|
+
# @visibility public
|
|
59
|
+
#
|
|
60
|
+
def seo_title(resource, separator: " · ", site_name: nil)
|
|
61
|
+
parts = [resource.effective_seo_title]
|
|
62
|
+
parts << site_name if site_name.present?
|
|
63
|
+
safe_join(parts, separator)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
#
|
|
69
|
+
# Generates the full canonical URL for a resource
|
|
70
|
+
#
|
|
71
|
+
# @param resource [Panda::CMS::Page, Panda::CMS::Post] The page or post
|
|
72
|
+
# @return [String] Full canonical URL
|
|
73
|
+
# @visibility private
|
|
74
|
+
#
|
|
75
|
+
def canonical_url_for(resource)
|
|
76
|
+
# If canonical_url is a full URL, use it as-is
|
|
77
|
+
return resource.canonical_url if resource.canonical_url&.match?(%r{\Ahttps?://})
|
|
78
|
+
|
|
79
|
+
# Otherwise, construct from the path
|
|
80
|
+
path = resource.effective_canonical_url
|
|
81
|
+
"#{request.protocol}#{request.host_with_port}#{path}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -3,5 +3,9 @@ console.debug("[Panda CMS] Controllers loading...");
|
|
|
3
3
|
import "./controllers/index.js"
|
|
4
4
|
console.debug("[Panda CMS] Controllers loaded...");
|
|
5
5
|
|
|
6
|
+
// Mark that Panda CMS JavaScript has loaded
|
|
7
|
+
window.pandaCmsLoaded = true
|
|
8
|
+
console.debug("[Panda CMS] Ready!");
|
|
9
|
+
|
|
6
10
|
// Editor resources are now handled by panda-editor gem
|
|
7
11
|
// The panda-editor gem will load its own resources when needed
|
|
@@ -8,7 +8,8 @@ export default class extends Controller {
|
|
|
8
8
|
static values = {
|
|
9
9
|
pageId: Number,
|
|
10
10
|
adminPath: String,
|
|
11
|
-
autosave: Boolean
|
|
11
|
+
autosave: Boolean,
|
|
12
|
+
assets: String
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
connect() {
|
|
@@ -65,6 +66,33 @@ export default class extends Controller {
|
|
|
65
66
|
this.body = this.frameDocument.body
|
|
66
67
|
this.head = this.frameDocument.head
|
|
67
68
|
|
|
69
|
+
// Inject CMS assets into the iframe head
|
|
70
|
+
if (this.hasAssetsValue && this.assetsValue) {
|
|
71
|
+
console.debug("[Panda CMS] Injecting assets into iframe", {
|
|
72
|
+
assetsLength: this.assetsValue.length,
|
|
73
|
+
assetsPreview: this.assetsValue.substring(0, 200)
|
|
74
|
+
})
|
|
75
|
+
const assetsHTML = this.assetsValue
|
|
76
|
+
const tempDiv = document.createElement('div')
|
|
77
|
+
tempDiv.innerHTML = assetsHTML
|
|
78
|
+
|
|
79
|
+
// Append each node to the iframe's head
|
|
80
|
+
Array.from(tempDiv.childNodes).forEach(node => {
|
|
81
|
+
if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
|
|
82
|
+
const importedNode = this.frameDocument.importNode(node, true)
|
|
83
|
+
this.head.appendChild(importedNode)
|
|
84
|
+
console.debug("[Panda CMS] Injected node:", node.nodeName)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
console.debug("[Panda CMS] Assets injected successfully - head element count:", this.head.children.length)
|
|
89
|
+
} else {
|
|
90
|
+
console.warn("[Panda CMS] No assets to inject", {
|
|
91
|
+
hasAssetsValue: this.hasAssetsValue,
|
|
92
|
+
assetsValue: this.assetsValue
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
// Ensure iframe content is properly positioned but doesn't block UI
|
|
69
97
|
this.body.style.position = "relative"
|
|
70
98
|
this.body.style.zIndex = "1"
|
|
@@ -545,11 +573,10 @@ export default class extends Controller {
|
|
|
545
573
|
}
|
|
546
574
|
|
|
547
575
|
setupSlideoverHandling() {
|
|
548
|
-
// Watch for slideover
|
|
549
|
-
const slideoverToggle = document.getElementById('slideover-toggle')
|
|
576
|
+
// Watch for slideover visibility changes
|
|
550
577
|
const slideover = document.getElementById('slideover')
|
|
551
578
|
|
|
552
|
-
if (
|
|
579
|
+
if (slideover) {
|
|
553
580
|
const observer = new MutationObserver((mutations) => {
|
|
554
581
|
mutations.forEach((mutation) => {
|
|
555
582
|
if (mutation.attributeName === 'class') {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["input", "preview", "dropzone", "fileInfo", "removeButton"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
// Setup drag and drop handlers
|
|
8
|
+
if (this.hasDropzoneTarget) {
|
|
9
|
+
this.setupDragAndDrop()
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
setupDragAndDrop() {
|
|
14
|
+
const dropzone = this.dropzoneTarget
|
|
15
|
+
|
|
16
|
+
// Prevent default drag behaviors
|
|
17
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
18
|
+
dropzone.addEventListener(eventName, this.preventDefaults.bind(this), false)
|
|
19
|
+
document.body.addEventListener(eventName, this.preventDefaults.bind(this), false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// Highlight drop zone when item is dragged over it
|
|
23
|
+
['dragenter', 'dragover'].forEach(eventName => {
|
|
24
|
+
dropzone.addEventListener(eventName, this.highlight.bind(this), false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
;['dragleave', 'drop'].forEach(eventName => {
|
|
28
|
+
dropzone.addEventListener(eventName, this.unhighlight.bind(this), false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Handle dropped files
|
|
32
|
+
dropzone.addEventListener('drop', this.handleDrop.bind(this), false)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
preventDefaults(e) {
|
|
36
|
+
e.preventDefault()
|
|
37
|
+
e.stopPropagation()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
highlight(e) {
|
|
41
|
+
this.dropzoneTarget.classList.add('border-indigo-600', 'bg-indigo-50', 'dark:bg-indigo-950', 'dark:border-indigo-400')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
unhighlight(e) {
|
|
45
|
+
this.dropzoneTarget.classList.remove('border-indigo-600', 'bg-indigo-50', 'dark:bg-indigo-950', 'dark:border-indigo-400')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
handleDrop(e) {
|
|
49
|
+
const dt = e.dataTransfer
|
|
50
|
+
const files = dt.files
|
|
51
|
+
|
|
52
|
+
if (files.length > 0) {
|
|
53
|
+
// Update the file input with the dropped file
|
|
54
|
+
this.inputTarget.files = files
|
|
55
|
+
this.handleFileSelect()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Triggered when user selects file via input or drag-drop
|
|
60
|
+
handleFileSelect() {
|
|
61
|
+
const file = this.inputTarget.files[0]
|
|
62
|
+
|
|
63
|
+
if (!file) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Show file preview if it's an image
|
|
68
|
+
if (file.type.startsWith('image/')) {
|
|
69
|
+
this.showImagePreview(file)
|
|
70
|
+
} else {
|
|
71
|
+
this.showFileInfo(file)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
showImagePreview(file) {
|
|
76
|
+
const reader = new FileReader()
|
|
77
|
+
|
|
78
|
+
reader.onload = (e) => {
|
|
79
|
+
if (this.hasPreviewTarget) {
|
|
80
|
+
// Create or update preview image
|
|
81
|
+
const existingImage = this.previewTarget.querySelector('img')
|
|
82
|
+
if (existingImage) {
|
|
83
|
+
existingImage.src = e.target.result
|
|
84
|
+
} else {
|
|
85
|
+
const img = document.createElement('img')
|
|
86
|
+
img.src = e.target.result
|
|
87
|
+
img.className = 'max-w-xs rounded border border-gray-300 dark:border-gray-600'
|
|
88
|
+
this.previewTarget.innerHTML = ''
|
|
89
|
+
this.previewTarget.appendChild(img)
|
|
90
|
+
}
|
|
91
|
+
this.previewTarget.classList.remove('hidden')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Show file info with remove button
|
|
95
|
+
this.showFileInfo(file, true)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
reader.readAsDataURL(file)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
showFileInfo(file, withPreview = false) {
|
|
102
|
+
if (!this.hasFileInfoTarget) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fileSize = this.humanFileSize(file.size)
|
|
107
|
+
const fileName = file.name
|
|
108
|
+
|
|
109
|
+
this.fileInfoTarget.innerHTML = `
|
|
110
|
+
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
|
|
111
|
+
<div class="flex items-center gap-x-3 flex-1 min-w-0">
|
|
112
|
+
<svg class="size-8 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
113
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
|
114
|
+
</svg>
|
|
115
|
+
<div class="flex-1 min-w-0">
|
|
116
|
+
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">${fileName}</p>
|
|
117
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">${fileSize}</p>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<button type="button"
|
|
121
|
+
data-action="click->file-upload#removeFile"
|
|
122
|
+
class="flex-shrink-0 ml-3 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
|
|
123
|
+
<svg class="size-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
124
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
125
|
+
</svg>
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
`
|
|
129
|
+
this.fileInfoTarget.classList.remove('hidden')
|
|
130
|
+
|
|
131
|
+
// Hide the dropzone upload area
|
|
132
|
+
if (this.hasDropzoneTarget) {
|
|
133
|
+
this.dropzoneTarget.classList.add('hidden')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
removeFile() {
|
|
138
|
+
// Clear the file input
|
|
139
|
+
this.inputTarget.value = ''
|
|
140
|
+
|
|
141
|
+
// Hide preview and file info
|
|
142
|
+
if (this.hasPreviewTarget) {
|
|
143
|
+
this.previewTarget.classList.add('hidden')
|
|
144
|
+
this.previewTarget.innerHTML = ''
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (this.hasFileInfoTarget) {
|
|
148
|
+
this.fileInfoTarget.classList.add('hidden')
|
|
149
|
+
this.fileInfoTarget.innerHTML = ''
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Show the dropzone again
|
|
153
|
+
if (this.hasDropzoneTarget) {
|
|
154
|
+
this.dropzoneTarget.classList.remove('hidden')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
humanFileSize(bytes) {
|
|
159
|
+
if (bytes === 0) return '0 Bytes'
|
|
160
|
+
const k = 1024
|
|
161
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
|
162
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
163
|
+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -21,6 +21,12 @@ pandaCmsApplication.register("tree", TreeController)
|
|
|
21
21
|
import FileGalleryController from "./file_gallery_controller.js"
|
|
22
22
|
pandaCmsApplication.register("file-gallery", FileGalleryController)
|
|
23
23
|
|
|
24
|
+
import FileUploadController from "./file_upload_controller.js"
|
|
25
|
+
pandaCmsApplication.register("file-upload", FileUploadController)
|
|
26
|
+
|
|
27
|
+
import PageFormController from "./page_form_controller.js"
|
|
28
|
+
pandaCmsApplication.register("page-form", PageFormController)
|
|
29
|
+
|
|
24
30
|
import NestedFormController from "./nested_form_controller.js"
|
|
25
31
|
pandaCmsApplication.register("nested-form", NestedFormController)
|
|
26
32
|
|
|
@@ -10,25 +10,38 @@ export default class extends Controller {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
kindChanged(event) {
|
|
13
|
+
console.log("[menu-form] kindChanged called", event)
|
|
13
14
|
this.updateFieldsVisibility()
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
updateFieldsVisibility() {
|
|
18
|
+
console.log("[menu-form] updateFieldsVisibility called")
|
|
17
19
|
const kindSelect = this.element.querySelector('select[name*="[kind]"]')
|
|
18
|
-
|
|
20
|
+
console.log("[menu-form] kindSelect found:", !!kindSelect)
|
|
21
|
+
|
|
22
|
+
if (!kindSelect) {
|
|
23
|
+
console.error("[menu-form] Could not find kind select!")
|
|
24
|
+
return
|
|
25
|
+
}
|
|
19
26
|
|
|
20
27
|
const selectedKind = kindSelect.value
|
|
28
|
+
console.log("[menu-form] selectedKind:", selectedKind)
|
|
21
29
|
|
|
22
30
|
if (selectedKind === "auto") {
|
|
23
31
|
// Show start page field, hide menu items section
|
|
32
|
+
console.log("[menu-form] AUTO - Showing start page field")
|
|
24
33
|
if (this.hasStartPageFieldTarget) {
|
|
34
|
+
console.log("[menu-form] Removing hidden from start page field")
|
|
25
35
|
this.startPageFieldTarget.classList.remove("hidden")
|
|
36
|
+
} else {
|
|
37
|
+
console.error("[menu-form] Start page field target not found!")
|
|
26
38
|
}
|
|
27
39
|
if (this.hasMenuItemsSectionTarget) {
|
|
28
40
|
this.menuItemsSectionTarget.classList.add("hidden")
|
|
29
41
|
}
|
|
30
42
|
} else {
|
|
31
43
|
// Hide start page field, show menu items section
|
|
44
|
+
console.log("[menu-form] STATIC - Hiding start page field")
|
|
32
45
|
if (this.hasStartPageFieldTarget) {
|
|
33
46
|
this.startPageFieldTarget.classList.add("hidden")
|
|
34
47
|
}
|