diff --git a/.gitignore b/.gitignore index fd721f2..7ea21b9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ !/tmp/storage/.keep /public/assets +/public/uploads/** +!/public/uploads/**/.keep # Ignore key files for decrypting credentials and more. /config/*.key diff --git a/Gemfile.lock b/Gemfile.lock index d0c1cad..11d9db0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -248,6 +248,7 @@ GEM ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) fileutils (1.8.0) @@ -422,6 +423,8 @@ GEM racc (~> 1.4) nokogiri (1.19.0-arm-linux-musl) racc (~> 1.4) + nokogiri (1.19.0-arm64-darwin) + racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-musl) @@ -715,6 +718,7 @@ GEM thread_safe (0.3.6) thruster (0.1.16) thruster (0.1.16-aarch64-linux) + thruster (0.1.16-arm64-darwin) thruster (0.1.16-x86_64-linux) tilt (2.7.0) timeout (0.6.0) @@ -772,6 +776,7 @@ PLATFORMS aarch64-linux-musl arm-linux-gnu arm-linux-musl + arm64-darwin-25 x86_64-linux x86_64-linux-gnu x86_64-linux-musl diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb new file mode 100644 index 0000000..101b969 --- /dev/null +++ b/app/controllers/admin/settings_controller.rb @@ -0,0 +1,75 @@ +module Admin + class SettingsController < AdminController + before_action :load_all_settings, only: %i(index update) + + def index; end + + def update + SETTINGS.each do |group, types| + permitted_keys = types.keys.map(&:to_s) + processed_params = process_image_uploads(params) + new_settings = processed_params.permit(permitted_keys).to_h + changes = new_settings.select { |key, value| @all_settings[group][key.to_sym] != value } + + next unless changes.any? + + current = Setting.get(group) || {} + Setting.set(group, current.merge(changes)) + end + + redirect_to admin_settings_path, notice: t("admin.settings.updated.success") + end + + def destroy + key = params[:key] + group = group_for_key(key) + return redirect_to admin_settings_path unless group + + if image_setting?(group, key) + old_url = Setting.get(group, include_defaults: true)&.dig(key.to_sym) + ImageUploader.delete_by_url(old_url) + end + Setting.unset_within(group, key) + redirect_to admin_settings_path, notice: t("admin.settings.deleted.success"), status: :see_other + end + + def upload_image + return head :unprocessable_entity unless params[:image].respond_to?(:tempfile) + + uploader = ImageUploader.new + uploader.store!(params[:image]) + render json: { url: uploader.url } + rescue CarrierWave::IntegrityError, CarrierWave::ProcessingError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def load_all_settings + @all_settings = Setting.get_multiple(SETTINGS.keys, include_defaults: true) + end + + def group_for_key(key) + SETTINGS.find { |_group, types| types.key?(key.to_sym) }&.first + end + + def process_image_uploads(params) + image_keys = SETTINGS.flat_map { |_group, types| types.select { |_k, v| v == :image }.keys } + modified = params.to_unsafe_h + + image_keys.each do |key| + next unless params[key].respond_to?(:tempfile) + + uploader = ImageUploader.new + uploader.store!(params[key]) + modified[key] = uploader.url + end + + ActionController::Parameters.new(modified) + end + + def image_setting?(group, key) + key.present? && SETTINGS.dig(group, key.to_sym) == :image + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 52f7c16..ef08b53 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,6 +10,8 @@ class ApplicationController < ActionController::Base before_action :authenticate_user!, unless: :pdf_request? + before_action :load_header_settings + before_action :configure_permitted_parameters, if: :devise_controller? rescue_from ActiveRecord::RecordNotFound do @@ -28,6 +30,12 @@ def configure_permitted_parameters devise_parameter_sanitizer.permit(:sign_up, keys: [:access_code]) end + def load_header_settings + @header_settings = Rails.cache.fetch(Setting.cache_key_for(:appearance, include_defaults: true)) do + Setting.get(:appearance, include_defaults: true) + end + end + private def after_sign_in_path_for(resource_or_scope) diff --git a/app/javascript/components/admin/ImageUpload.jsx b/app/javascript/components/admin/ImageUpload.jsx new file mode 100644 index 0000000..fe4ac98 --- /dev/null +++ b/app/javascript/components/admin/ImageUpload.jsx @@ -0,0 +1,503 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Modal } from "bootstrap"; + +const MODAL_STYLES = { + dialog: { width: "66.67vw", maxWidth: 600 }, + content: { height: 400 }, + body: { overflow: "auto", minHeight: 0 } +}; + +const isImageFile = (file) => file?.type?.startsWith("image/"); + +class DropZone extends React.Component { + handleKeyDown = (e) => { + const { isDisabled, onSelect } = this.props; + if (!isDisabled && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onSelect(); + } + }; + + render() { + const { + isDisabled, + isDragOver, + onSelect, + onDragOver, + onDragLeave, + onDrop, + dragDropHint, + pasteHint, + ariaLabel, + uploading, + uploadPending + } = this.props; + + const style = { + minHeight: 140, + padding: 24, + border: "2px dashed", + borderRadius: 8, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 8, + cursor: isDisabled ? "not-allowed" : "pointer", + opacity: isDisabled ? 0.6 : 1, + transition: "border-color 0.2s, background-color 0.2s", + ...(isDragOver + ? { borderColor: "var(--bs-primary)", backgroundColor: "rgba(var(--bs-primary-rgb), 0.08)" } + : { borderColor: "var(--bs-border-color)", backgroundColor: "var(--bs-light)" }) + }; + + return ( +
+ {uploadPending ? ( +
+ {uploading} +
+ ) : ( + <> +