diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index 2b42dfce5..92a9003e0 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -79,7 +79,7 @@ def create end if success - redirect_to resources_path + redirect_to resource_path(@resource) else @resource = @resource.decorate set_form_variables @@ -106,7 +106,7 @@ def update if success flash[:notice] = "Resource updated." - redirect_to resources_path + redirect_to resource_path(@resource) else set_form_variables flash[:alert] = "Failed to update Resource." diff --git a/app/frontend/javascript/controllers/file_preview_controller.js b/app/frontend/javascript/controllers/file_preview_controller.js index 42bbaccdf..064db5f68 100644 --- a/app/frontend/javascript/controllers/file_preview_controller.js +++ b/app/frontend/javascript/controllers/file_preview_controller.js @@ -1,30 +1,109 @@ import { Controller } from "@hotwired/stimulus" +const FILE_TYPE_ICONS = { + "application/pdf": "fa-regular fa-file-pdf", + "application/zip": "fa-regular fa-file-zipper", + "application/msword": "fa-regular fa-file-word", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "fa-regular fa-file-word", + "application/vnd.oasis.opendocument.text": "fa-regular fa-file-lines", + "text/html": "fa-regular fa-file-code", +} + export default class extends Controller { static targets = ["input", "preview", "placeholder", "filename"] + connect() { + this.handleUploadError = this.onUploadError.bind(this) + this.handleUploadEnd = this.onUploadEnd.bind(this) + this.element.addEventListener("direct-upload:error", this.handleUploadError) + this.element.addEventListener("direct-upload:end", this.handleUploadEnd) + } + + disconnect() { + this.element.removeEventListener("direct-upload:error", this.handleUploadError) + this.element.removeEventListener("direct-upload:end", this.handleUploadEnd) + } + update(event) { const file = event.target.files[0] if (!file) return + this.clearError() + // Update filename if (this.hasFilenameTarget) { this.filenameTarget.textContent = file.name } - // Update preview image (if you have one) - if (this.hasPreviewTarget) { - const reader = new FileReader() - reader.onload = e => { - this.previewTarget.src = e.target.result - this.previewTarget.classList.remove("hidden") - } - reader.readAsDataURL(file) + if (file.type.startsWith("image/")) { + this.showImagePreview(file) + } else { + this.showFileIcon(file.type) } + } + + // Ensure a placeholder div exists (server may not render one when a persisted file exists) + ensurePlaceholder() { + if (this.hasPlaceholderTarget) return this.placeholderTarget + + const wrapper = this.hasPreviewTarget + ? this.previewTarget.parentElement + : this.element.querySelector(".flex.flex-col") + + const placeholder = document.createElement("div") + placeholder.dataset.filePreviewTarget = "placeholder" + placeholder.className = "absolute inset-0 flex items-center justify-center border border-gray-300 shadow-sm bg-gray-50 text-gray-400 text-6xl" + wrapper.appendChild(placeholder) + return placeholder + } + + showImagePreview(file) { + if (!this.hasPreviewTarget) return + + const reader = new FileReader() + reader.onload = e => { + this.previewTarget.src = e.target.result + this.previewTarget.classList.remove("hidden") + } + reader.readAsDataURL(file) - // Hide placeholder if present if (this.hasPlaceholderTarget) { this.placeholderTarget.classList.add("hidden") } } + + showFileIcon(mimeType) { + const placeholder = this.ensurePlaceholder() + const iconClass = FILE_TYPE_ICONS[mimeType] || "fa-regular fa-file" + placeholder.innerHTML = `` + placeholder.classList.remove("hidden") + + if (this.hasPreviewTarget) { + this.previewTarget.classList.add("hidden") + } + } + + onUploadError(event) { + event.preventDefault() + const { error } = event.detail + this.showError(`Upload failed: ${error}`) + } + + onUploadEnd(event) { + this.clearError() + } + + showError(message) { + this.clearError() + const errorEl = document.createElement("p") + errorEl.className = "text-red-500 text-sm mt-1" + errorEl.dataset.filePreviewTarget = "error" + errorEl.textContent = message + this.element.appendChild(errorEl) + } + + clearError() { + const existing = this.element.querySelector('[data-file-preview-target="error"]') + if (existing) existing.remove() + } } diff --git a/app/models/asset.rb b/app/models/asset.rb index c2d0acc9e..056f89dcd 100644 --- a/app/models/asset.rb +++ b/app/models/asset.rb @@ -5,6 +5,9 @@ class Asset < ApplicationRecord "image/jpeg", "image/png", "image/gif", + "image/webp", + "image/heic", + "image/heif", "application/pdf", "application/zip", "application/msword", # Word .doc @@ -39,6 +42,29 @@ def self.allowed_types_for_owner(owner) end end + CONTENT_TYPE_LABELS = { + "image/jpeg" => "JPG", + "image/png" => "PNG", + "image/gif" => "GIF", + "image/webp" => "WebP", + "image/heic" => "HEIC", + "image/heif" => "HEIF", + "application/pdf" => "PDF", + "application/zip" => "ZIP", + "application/msword" => "DOC", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "DOCX", + "application/vnd.oasis.opendocument.text" => "ODT", + "text/html" => "HTML" + }.freeze + + def self.accept_attribute + self::ACCEPTED_CONTENT_TYPES.join(",") + end + + def self.accepted_types_label + self::ACCEPTED_CONTENT_TYPES.map { |ct| CONTENT_TYPE_LABELS[ct] || ct }.join(", ") + end + belongs_to :owner, polymorphic: true, optional: true, touch: true belongs_to :report, optional: true @@ -55,16 +81,10 @@ def self.allowed_types_for_owner(owner) def file_type return unless file.attached? - allowed_types = - case type - when "PrimaryAsset" - PrimaryAsset::ACCEPTED_CONTENT_TYPES - else - ACCEPTED_CONTENT_TYPES - end + allowed_types = self.class::ACCEPTED_CONTENT_TYPES unless allowed_types.include?(file.content_type) - errors.add(:file, "type is not allowed for #{type.underscore.humanize}") + errors.add(:file, "type not accepted") end end end diff --git a/app/models/downloadable_asset.rb b/app/models/downloadable_asset.rb index 558f55bce..1fa2c8b85 100644 --- a/app/models/downloadable_asset.rb +++ b/app/models/downloadable_asset.rb @@ -3,6 +3,9 @@ class DownloadableAsset < Asset "image/jpeg", "image/png", "image/gif", + "image/webp", + "image/heic", + "image/heif", "application/pdf", "application/zip", "application/msword", # Word .doc diff --git a/app/models/primary_asset.rb b/app/models/primary_asset.rb index 66d2ce6e8..5e74e62cb 100644 --- a/app/models/primary_asset.rb +++ b/app/models/primary_asset.rb @@ -1,3 +1,9 @@ class PrimaryAsset < Asset - ACCEPTED_CONTENT_TYPES = [ "image/jpeg", "image/png" ].freeze + ACCEPTED_CONTENT_TYPES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + "image/heif" + ].freeze end diff --git a/app/models/rich_text_asset.rb b/app/models/rich_text_asset.rb index 4694dcebd..e0069a023 100644 --- a/app/models/rich_text_asset.rb +++ b/app/models/rich_text_asset.rb @@ -5,6 +5,9 @@ class RichTextAsset < Asset "image/jpeg", "image/png", "image/gif", + "image/webp", + "image/heic", + "image/heif", "application/pdf", "application/zip", "application/msword", # Word .doc diff --git a/app/views/assets/_display_assets.html.erb b/app/views/assets/_display_assets.html.erb index 76d95bc4d..a0cbabb96 100644 --- a/app/views/assets/_display_assets.html.erb +++ b/app/views/assets/_display_assets.html.erb @@ -1,4 +1,4 @@
- <%= render "assets/display_image", resource: resource, file: (defined?(file) ? file : nil), variant: (defined?(variant) ? variant : :hero) %> - <%= render "assets/display_gallery_media", resource: resource %> + <%= render "assets/display_image", resource: resource, file: (defined?(file) ? file : nil), variant: (defined?(variant) ? variant : :hero), link: (defined?(link) ? link : nil) %> + <%= render "assets/display_gallery_media", resource: resource, link: (defined?(link) ? link : nil) %>
diff --git a/app/views/assets/_display_gallery_media.html.erb b/app/views/assets/_display_gallery_media.html.erb index e7786cfe9..4a91c2dad 100644 --- a/app/views/assets/_display_gallery_media.html.erb +++ b/app/views/assets/_display_gallery_media.html.erb @@ -4,7 +4,7 @@ diff --git a/app/views/assets/_display_image.html.erb b/app/views/assets/_display_image.html.erb index a2f8332cf..20ca894cc 100644 --- a/app/views/assets/_display_image.html.erb +++ b/app/views/assets/_display_image.html.erb @@ -4,7 +4,7 @@ <% file ||= item&.file || resource&.try(field_name)&.file || file %> <% variant ||= :gallery %> <% link_to_object ||= false %> -<% link ||= link_to_object %> +<% link = (defined?(link) && !link.nil?) ? link : link_to_object %> <% display_open_pdf_link ||= false %> <% idx ||= 0 %> <% item_type ||= "PrimaryAsset" %> diff --git a/app/views/community_news/show.html.erb b/app/views/community_news/show.html.erb index 7de94edee..a219cbfb0 100644 --- a/app/views/community_news/show.html.erb +++ b/app/views/community_news/show.html.erb @@ -34,7 +34,7 @@ - <%= render "assets/display_assets", resource: @community_news %> + <%= render "assets/display_assets", resource: @community_news, link: true %>
diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 2dc5a111c..8b45eb41f 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -60,7 +60,7 @@
- <%= render "assets/display_assets", resource: @event %> + <%= render "assets/display_assets", resource: @event, link: true %>
diff --git a/app/views/resources/_resource_card.html.erb b/app/views/resources/_resource_card.html.erb index c21a1c24b..b2b59504b 100644 --- a/app/views/resources/_resource_card.html.erb +++ b/app/views/resources/_resource_card.html.erb @@ -52,7 +52,11 @@ <% end %> -
+
+ <% if allowed_to?(:edit?, resource) %> +

<%= resource.created_at.strftime("%b %-d, %Y") %>

+ <% end %> +
<% if resource.downloadable_asset&.file&.attached? %> <%= link_to( content_tag(:span, "", class: "fa fa-download"), @@ -69,6 +73,7 @@ data: {turbo_frame: "_top", turbo_prefetch: false } ) %> <% end %> +
diff --git a/app/views/resources/show.html.erb b/app/views/resources/show.html.erb index c426a94c3..1dac56d35 100644 --- a/app/views/resources/show.html.erb +++ b/app/views/resources/show.html.erb @@ -52,7 +52,7 @@
- <%= render "assets/display_assets", resource: @resource, file: @resource.display_image, variant: :hero %> + <%= render "assets/display_assets", resource: @resource, file: @resource.display_image, variant: :hero, link: true %>
diff --git a/app/views/shared/_form_image_field.html.erb b/app/views/shared/_form_image_field.html.erb index 08e7efbb0..2bcb8a5e8 100644 --- a/app/views/shared/_form_image_field.html.erb +++ b/app/views/shared/_form_image_field.html.erb @@ -20,6 +20,8 @@ <% use_profile_placeholder ||= false %> <% show_file_field = show_file_field.to_s.present? ? show_file_field : true %> <% show_existing = show_existing.to_s.present? ? show_existing : true %> +<% accept_attr = asset.class.respond_to?(:accept_attribute) ? asset.class.accept_attribute : nil %> +<% accepted_label = asset.class.respond_to?(:accepted_types_label) ? asset.class.accepted_types_label : nil %> <% file = if file.present? file @@ -92,6 +94,7 @@
<%= f.file_field field_name, + accept: accept_attr, data: { file_preview_target: "input", action: "change->file-preview#update" @@ -118,9 +121,20 @@ <% end %>
+ + <% if accepted_label.present? %> +

Accepts: <%= accepted_label %>

+ <% end %> <% end %> -<%= f.error :file, wrap_with: { tag: :p, class: "text-red-500 text-sm mt-1" } %> +<% if asset.errors[:file].any? %> +
+

+ + File type not accepted +

+
+<% end %> diff --git a/app/views/stories/show.html.erb b/app/views/stories/show.html.erb index 526304ab3..582d0e78d 100644 --- a/app/views/stories/show.html.erb +++ b/app/views/stories/show.html.erb @@ -56,7 +56,7 @@
- <%= render "assets/display_assets", resource: @story %> + <%= render "assets/display_assets", resource: @story, link: true %>
diff --git a/app/views/story_ideas/show.html.erb b/app/views/story_ideas/show.html.erb index 9879ff5bf..54c6fd695 100644 --- a/app/views/story_ideas/show.html.erb +++ b/app/views/story_ideas/show.html.erb @@ -35,7 +35,7 @@

- <%= render "assets/display_assets", resource: @story_idea %> + <%= render "assets/display_assets", resource: @story_idea, link: true %>
diff --git a/app/views/story_shares/show.html.erb b/app/views/story_shares/show.html.erb index 2e5aff078..7a83307ba 100644 --- a/app/views/story_shares/show.html.erb +++ b/app/views/story_shares/show.html.erb @@ -8,7 +8,8 @@
<%= render "assets/display_image", resource: @story, - variant: :hero %> + variant: :hero, + link: true %>
@@ -81,7 +82,7 @@ <% if @story.assets.count > 1 %>
- <%= render "assets/display_assets", resource: @story %> + <%= render "assets/display_assets", resource: @story, link: true %>
<% end %>
diff --git a/app/views/tutorials/show.html.erb b/app/views/tutorials/show.html.erb index a06951984..017db120e 100644 --- a/app/views/tutorials/show.html.erb +++ b/app/views/tutorials/show.html.erb @@ -36,7 +36,7 @@ <% end %> - <%= render "assets/display_assets", resource: @tutorial %> + <%= render "assets/display_assets", resource: @tutorial, link: true %> diff --git a/app/views/workshop_ideas/show.html.erb b/app/views/workshop_ideas/show.html.erb index 040ef44ce..931dff7ce 100644 --- a/app/views/workshop_ideas/show.html.erb +++ b/app/views/workshop_ideas/show.html.erb @@ -101,6 +101,6 @@
- <%= render "assets/display_assets", resource: @workshop_idea %> + <%= render "assets/display_assets", resource: @workshop_idea, link: true %>
diff --git a/app/views/workshop_variation_ideas/show.html.erb b/app/views/workshop_variation_ideas/show.html.erb index 648dac667..bad75f3f3 100644 --- a/app/views/workshop_variation_ideas/show.html.erb +++ b/app/views/workshop_variation_ideas/show.html.erb @@ -47,6 +47,6 @@
- <%= render "assets/display_assets", resource: @workshop_variation_idea %> + <%= render "assets/display_assets", resource: @workshop_variation_idea, link: true %>
diff --git a/app/views/workshop_variations/show.html.erb b/app/views/workshop_variations/show.html.erb index 4d9b9bfcc..9337971ab 100644 --- a/app/views/workshop_variations/show.html.erb +++ b/app/views/workshop_variations/show.html.erb @@ -46,7 +46,7 @@ - <%= render "assets/display_assets", resource: @workshop_variation %> + <%= render "assets/display_assets", resource: @workshop_variation, link: true %> <% if @workshop_variation.youtube_url.present? %> diff --git a/package-lock.json b/package-lock.json index fd72a7c2c..5da9f8fc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "awbw", "dependencies": { "@fortawesome/fontawesome-free": "^7.0.1", "@hotwired/stimulus": "^3.2.2", @@ -1092,6 +1093,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.5", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.5", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", diff --git a/package.json b/package.json index a3275fc94..34b9e6b99 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ { + "name": "awbw", "devDependencies": { "@tailwindcss/typography": "^0.5.19", "rollup-plugin-visualizer": "^6.0.5", diff --git a/spec/requests/resources_spec.rb b/spec/requests/resources_spec.rb index 8fb7bfb24..ddb603fbc 100644 --- a/spec/requests/resources_spec.rb +++ b/spec/requests/resources_spec.rb @@ -78,10 +78,10 @@ }.to change(Resource, :count).by(1) end - it "redirects to the resources index" do + it "redirects to the created resource" do post resources_url, params: { resource: valid_attributes } - expect(response).to redirect_to(resources_url) + expect(response).to redirect_to(resource_url(Resource.last)) end end @@ -114,11 +114,11 @@ expect(resource.title).to eq("Updated Resource Title") end - it "redirects to the resources index" do + it "redirects to the updated resource" do resource = Resource.create! valid_attributes patch resource_url(resource), params: { resource: new_attributes } - expect(response).to redirect_to(resources_url) + expect(response).to redirect_to(resource_url(resource)) end end diff --git a/spec/views/resources/show.html.erb_spec.rb b/spec/views/resources/show.html.erb_spec.rb new file mode 100644 index 000000000..b51caea2f --- /dev/null +++ b/spec/views/resources/show.html.erb_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe "resources/show", type: :view do + let(:admin) { create(:user, :admin) } + let(:resource) { create(:resource, :published, user: admin) } + + before do + sign_in admin + allow(view).to receive(:current_user).and_return(admin) + allow(view).to receive(:allowed_to?).and_return(true) + assign(:resource, resource.decorate) + assign(:mentions, []) + end + + context "when resource has an attached primary image" do + before do + create(:primary_asset, :with_file, owner: resource) + resource.reload + render + end + + it "wraps the hero image in a link that opens in a new tab" do + expect(rendered).to have_css("a.display-image-link[target='_blank'][rel='noopener noreferrer']") + end + end + + context "when resource has an attached gallery image" do + before do + create(:gallery_asset, :with_file, owner: resource) + resource.reload + render + end + + it "wraps the gallery image in a link that opens in a new tab" do + expect(rendered).to have_css("a.display-image-link[target='_blank'][rel='noopener noreferrer']") + end + end + + context "when resource has no attached image" do + before { render } + + it "renders without errors" do + expect(rendered).to have_text(resource.title) + end + end +end