From 9c792ba88eb362ea694097b37bb2b9f125ad0868 Mon Sep 17 00:00:00 2001
From: Alex Culea <195758113+alexculealt@users.noreply.github.com>
Date: Mon, 16 Feb 2026 11:03:10 +0200
Subject: [PATCH 1/2] (#16) Allow customization of Admin panel colors and logo
- Add settings model and DB schema
- Implement new LogoUploader to handle admin panel logo customization
- Implement appearance setting edit & logo upload UI
---
.gitignore | 2 +
Gemfile.lock | 5 +
app/controllers/admin/settings_controller.rb | 107 ++++
app/controllers/application_controller.rb | 8 +
.../components/admin/ImageUpload.jsx | 503 ++++++++++++++++++
.../components/admin/Initializer.jsx | 11 +
app/models/setting.rb | 72 +++
app/services/s3_service.rb | 10 +
app/uploaders/image_uploader.rb | 41 ++
app/views/admin/settings/edit.html.erb | 15 +
app/views/admin/settings/edit/_color.html.erb | 7 +
app/views/admin/settings/edit/_image.html.erb | 7 +
app/views/admin/settings/index.html.erb | 74 +++
app/views/admin/settings/show/_color.html.erb | 7 +
app/views/admin/settings/show/_image.html.erb | 23 +
app/views/shared/_header.html.erb | 35 +-
config/initializers/lcms_constants.rb | 20 +
config/locales/admin/en.yml | 51 +-
config/routes.rb | 6 +
db/migrate/20260219081358_create_settings.rb | 11 +
db/schema.rb | 10 +-
spec/factories/settings.rb | 8 +
spec/models/setting_spec.rb | 156 ++++++
spec/requests/admin/settings_spec.rb | 222 ++++++++
24 files changed, 1404 insertions(+), 7 deletions(-)
create mode 100644 app/controllers/admin/settings_controller.rb
create mode 100644 app/javascript/components/admin/ImageUpload.jsx
create mode 100644 app/models/setting.rb
create mode 100644 app/uploaders/image_uploader.rb
create mode 100644 app/views/admin/settings/edit.html.erb
create mode 100644 app/views/admin/settings/edit/_color.html.erb
create mode 100644 app/views/admin/settings/edit/_image.html.erb
create mode 100644 app/views/admin/settings/index.html.erb
create mode 100644 app/views/admin/settings/show/_color.html.erb
create mode 100644 app/views/admin/settings/show/_image.html.erb
create mode 100644 db/migrate/20260219081358_create_settings.rb
create mode 100644 spec/factories/settings.rb
create mode 100644 spec/models/setting_spec.rb
create mode 100644 spec/requests/admin/settings_spec.rb
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..3e8bf47
--- /dev/null
+++ b/app/controllers/admin/settings_controller.rb
@@ -0,0 +1,107 @@
+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
+
+ delete_old_image_if_present(group, key) if image_setting?(group, key)
+ 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
+
+ def delete_old_image_if_present(group, setting_key)
+ settings = Setting.get(group, include_defaults: true)
+ old_url = settings[setting_key.to_sym]
+ return if old_url.blank?
+
+ if stored_locally?(old_url)
+ delete_local_image(old_url)
+ elsif stored_on_s3?(old_url)
+ delete_s3_image(old_url)
+ end
+ rescue StandardError => e
+ Rails.logger.warn "Failed to delete old image: #{e.message}"
+ end
+
+ def stored_locally?(url)
+ url.to_s.start_with?("/uploads/settings/")
+ end
+
+ def stored_on_s3?(url)
+ url.to_s.include?("s3") || url.to_s.include?("amazonaws")
+ end
+
+ def delete_local_image(url)
+ path = Rails.root.join("public", url.to_s.delete_prefix("/"))
+ FileUtils.rm_f(path) if path.exist?
+ end
+
+ def delete_s3_image(url)
+ uri = URI.parse(url)
+ key = URI.decode_www_form_component(uri.path.delete_prefix("/"))
+ return unless key.start_with?("uploads/settings/")
+
+ S3Service.delete_object(key)
+ 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}
+
+ ) : (
+ <>
+
+
{dragDropHint}
+
{pasteHint}
+ >
+ )}
+
+ );
+ }
+}
+
+DropZone.propTypes = {
+ isDisabled: PropTypes.bool.isRequired,
+ isDragOver: PropTypes.bool.isRequired,
+ onSelect: PropTypes.func.isRequired,
+ onDragOver: PropTypes.func.isRequired,
+ onDragLeave: PropTypes.func.isRequired,
+ onDrop: PropTypes.func.isRequired,
+ dragDropHint: PropTypes.string.isRequired,
+ pasteHint: PropTypes.string.isRequired,
+ ariaLabel: PropTypes.string.isRequired
+};
+
+class ImageUpload extends React.Component {
+ constructor(props) {
+ super(props);
+ this.previewModalRef = React.createRef();
+ this.uploadModalRef = React.createRef();
+ this.fileInputRef = React.createRef();
+ this.previewModalInstance = null;
+ this.uploadModalInstance = null;
+ this.state = {
+ currentUrl: props.imageUrl || "",
+ uploadPending: false,
+ uploadError: null,
+ uploadComplete: false,
+ isDragOver: false,
+ wrongFileTypeWarning: null
+ };
+ }
+
+ componentDidMount() {
+ if (this.previewModalRef.current) {
+ this.previewModalInstance = new Modal(this.previewModalRef.current);
+ }
+ if (this.uploadModalRef.current) {
+ this.uploadModalInstance = new Modal(this.uploadModalRef.current);
+ this.uploadModalRef.current.addEventListener("show.bs.modal", this.handleUploadModalShow);
+ this.uploadModalRef.current.addEventListener("shown.bs.modal", this.handleUploadModalShown);
+ this.uploadModalRef.current.addEventListener("hide.bs.modal", this.handleUploadModalHide);
+ this.uploadModalRef.current.addEventListener("hidden.bs.modal", this.handleUploadModalHidden);
+ }
+ this.syncHiddenInput();
+ const form = this.getForm();
+ if (form) form.addEventListener("submit", this.syncHiddenInput);
+ }
+
+ componentWillUnmount() {
+ const form = this.getForm();
+ if (form) form.removeEventListener("submit", this.syncHiddenInput);
+ document.removeEventListener("paste", this.handlePaste);
+ if (this.uploadModalRef.current) {
+ this.uploadModalRef.current.removeEventListener("show.bs.modal", this.handleUploadModalShow);
+ this.uploadModalRef.current.removeEventListener("shown.bs.modal", this.handleUploadModalShown);
+ this.uploadModalRef.current.removeEventListener("hide.bs.modal", this.handleUploadModalHide);
+ this.uploadModalRef.current.removeEventListener("hidden.bs.modal", this.handleUploadModalHidden);
+ }
+ this.previewModalInstance?.dispose();
+ this.uploadModalInstance?.dispose();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.currentUrl !== this.state.currentUrl) {
+ this.syncHiddenInput();
+ }
+ }
+
+ getForm = () => {
+ const { formId } = this.props;
+ return formId ? document.getElementById(formId) : null;
+ };
+
+ syncHiddenInput = () => {
+ const { formId, fieldName } = this.props;
+ if (!formId || !fieldName) return;
+ const input = document.querySelector(`input[form="${formId}"][name="${fieldName}"]`);
+ if (input) input.value = this.state.currentUrl || "";
+ };
+
+ notifyFormChange = () => {
+ const form = this.getForm();
+ if (form) form.dispatchEvent(new Event("change", { bubbles: true }));
+ };
+
+ handleUploadModalShow = () => {
+ this.setState({ uploadComplete: false, wrongFileTypeWarning: null, uploadError: null });
+ };
+
+ handleUploadModalShown = () => {
+ document.addEventListener("paste", this.handlePaste);
+ };
+
+ handleUploadModalHide = (e) => {
+ if (this.state.uploadPending) e.preventDefault();
+ };
+
+ handleUploadModalHidden = () => {
+ document.removeEventListener("paste", this.handlePaste);
+ this.setState({ isDragOver: false, uploadComplete: false, wrongFileTypeWarning: null });
+ };
+
+ showWrongFileTypeWarning = () => {
+ const { wrongFileType } = this.props;
+ this.setState({ wrongFileTypeWarning: wrongFileType });
+ };
+
+ handlePaste = (e) => {
+ if (this.state.uploadComplete) return;
+ const file = e.clipboardData?.files?.[0];
+ if (file) {
+ e.preventDefault();
+ if (isImageFile(file)) {
+ this.uploadFile(file);
+ } else {
+ this.showWrongFileTypeWarning();
+ }
+ }
+ };
+
+ handleFileChange = (e) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ if (isImageFile(file)) {
+ this.uploadFile(file);
+ } else {
+ this.showWrongFileTypeWarning();
+ }
+ e.target.value = "";
+ }
+ };
+
+ handleDragOver = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!this.state.uploadPending && !this.state.uploadComplete) {
+ this.setState({ isDragOver: true });
+ }
+ e.dataTransfer.dropEffect = "copy";
+ };
+
+ handleDragLeave = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.setState({ isDragOver: false });
+ };
+
+ handleDrop = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.setState({ isDragOver: false });
+ if (this.state.uploadPending || this.state.uploadComplete) return;
+ const file = e.dataTransfer?.files?.[0];
+ if (file) {
+ if (isImageFile(file)) {
+ this.uploadFile(file);
+ } else {
+ this.showWrongFileTypeWarning();
+ }
+ }
+ };
+
+ uploadFile = (file) => {
+ const { uploadUrl, fileFieldName = "image" } = this.props;
+ if (!uploadUrl) return;
+
+ this.setState({ uploadPending: true, uploadError: null, wrongFileTypeWarning: null });
+
+ const formData = new FormData();
+ formData.append(fileFieldName, file);
+
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
+
+ fetch(uploadUrl, {
+ method: "POST",
+ headers: {
+ "X-CSRF-Token": csrfToken,
+ "Accept": "application/json"
+ },
+ body: formData
+ })
+ .then((response) => {
+ if (!response.ok) {
+ return response
+ .json()
+ .then((data) => { throw new Error(data.error || `Upload failed (${response.status})`); })
+ .catch(() => { throw new Error(`Upload failed (${response.status})`); });
+ }
+ return response.json();
+ })
+ .then((data) => {
+ this.setState({
+ currentUrl: data.url,
+ uploadPending: false,
+ uploadError: null,
+ uploadComplete: true
+ });
+ this.notifyFormChange();
+ })
+ .catch((err) => {
+ this.setState({ uploadPending: false, uploadError: err.message });
+ });
+ };
+
+ showFilePicker = () => this.fileInputRef.current?.click();
+
+ showPreviewModal = () => this.previewModalInstance?.show();
+
+ showUploadModal = () => this.uploadModalInstance?.show();
+
+ closeUploadModal = () => this.uploadModalInstance?.hide();
+
+ renderPreviewModal() {
+ const { useResetInfo, imagePreviewTitle, close, fullSizeAlt } = this.props;
+ const displayUrl = this.state.currentUrl || this.props.imageUrl;
+
+ return (
+
+
+
+
+
+ {imagePreviewTitle}
+
+
+
+
+

+ {useResetInfo && (
+
+
+ {useResetInfo}
+
+ )}
+
+
+
+
+
+
+
+ );
+ }
+
+ renderUploadModal() {
+ const { uploadPending, uploadError, uploadComplete, isDragOver, wrongFileTypeWarning } = this.state;
+ const {
+ uploadImageTitle,
+ close,
+ uploadSuccess,
+ uploading,
+ dragDropHint,
+ pasteHint,
+ dropZoneAriaLabel
+ } = this.props;
+
+ const isDisabled = uploadPending || uploadComplete;
+
+ return (
+
+
+
+
+
+ {uploadImageTitle}
+
+
+
+
+
+ {uploadComplete && (
+
+
+ {uploadSuccess}
+
+ )}
+ {wrongFileTypeWarning && (
+
+
+ {wrongFileTypeWarning}
+
+ )}
+ {uploadError && (
+
+ {uploadError}
+
+ )}
+
+
+
+
+
+
+ );
+ }
+
+ render() {
+ const { imageUrl, addImage, previewAlt, viewFullSize } = this.props;
+ const displayUrl = this.state.currentUrl || imageUrl;
+
+ return (
+ <>
+ {!displayUrl ? (
+ e.key === "Enter" && this.showUploadModal()}
+ role="button"
+ tabIndex={0}
+ aria-label={addImage}
+ >
+ {addImage}
+
+ ) : (
+
e.key === "Enter" && this.showPreviewModal()}
+ role="button"
+ tabIndex={0}
+ aria-label={viewFullSize}
+ />
+ )}
+ {this.renderPreviewModal()}
+ {this.renderUploadModal()}
+ >
+ );
+ }
+}
+
+const I18N_DEFAULTS = {
+ addImage: "Add image",
+ imagePreviewTitle: "Image Preview",
+ uploadImageTitle: "Upload Image",
+ close: "Close",
+ uploadSuccess: "Image uploaded successfully.",
+ uploading: "Uploading…",
+ dragDropHint: "Drag & drop image here",
+ pasteHint: "or paste with Ctrl+V",
+ dropZoneAriaLabel: "Drop image here or paste with Ctrl+V",
+ wrongFileType: "Please select an image file (e.g. PNG, JPEG, GIF).",
+ fullSizeAlt: "Full size preview",
+ previewAlt: "Preview",
+ viewFullSize: "Click to view full size"
+};
+
+ImageUpload.propTypes = {
+ imageUrl: PropTypes.string,
+ uploadUrl: PropTypes.string.isRequired,
+ formId: PropTypes.string,
+ fieldName: PropTypes.string,
+ fileFieldName: PropTypes.string,
+ useResetInfo: PropTypes.string,
+ addImage: PropTypes.string,
+ imagePreviewTitle: PropTypes.string,
+ uploadImageTitle: PropTypes.string,
+ close: PropTypes.string,
+ uploadSuccess: PropTypes.string,
+ uploading: PropTypes.string,
+ dragDropHint: PropTypes.string,
+ pasteHint: PropTypes.string,
+ dropZoneAriaLabel: PropTypes.string,
+ wrongFileType: PropTypes.string,
+ fullSizeAlt: PropTypes.string,
+ previewAlt: PropTypes.string,
+ viewFullSize: PropTypes.string
+};
+
+ImageUpload.defaultProps = I18N_DEFAULTS;
+
+export default ImageUpload;
diff --git a/app/javascript/components/admin/Initializer.jsx b/app/javascript/components/admin/Initializer.jsx
index 2320644..5ab1b99 100644
--- a/app/javascript/components/admin/Initializer.jsx
+++ b/app/javascript/components/admin/Initializer.jsx
@@ -1,5 +1,6 @@
import $ from 'jquery';
import CurriculumEditor from './curriculum/CurriculumEditor';
+import ImageUpload from './ImageUpload';
import ImportStatus from './ImportStatus';
import MultiSelectedOperation from './MultiSelectedOperation';
import React from 'react';
@@ -9,6 +10,7 @@ class Initializer {
static initialize() {
// Mount internal components
Initializer.#initializeCurriculumEditor();
+ Initializer.#initializeImageUpload();
Initializer.#InitializeImportStatus();
Initializer.#initializeMultiSelectedOperation();
@@ -25,6 +27,14 @@ class Initializer {
});
}
+ static #initializeImageUpload() {
+ document.querySelectorAll('[id="#lcms-engine-ImageUpload"]').forEach(e => {
+ const props = JSON.parse(e.dataset.content);
+ e.removeAttribute('data-content');
+ ReactDOM.render(, e);
+ });
+ }
+
static #InitializeImportStatus() {
document.querySelectorAll('[id="#lcms-engine-ImportStatus"]').forEach(e => {
const props = JSON.parse(e.dataset.content);
@@ -61,6 +71,7 @@ class Initializer {
$('.table input[type=checkbox][name="selected_ids[]"]').prop('checked', checked);
});
}
+
}
export default Initializer;
diff --git a/app/models/setting.rb b/app/models/setting.rb
new file mode 100644
index 0000000..413bc39
--- /dev/null
+++ b/app/models/setting.rb
@@ -0,0 +1,72 @@
+class Setting < ApplicationRecord
+ CACHE_PREFIX = "settings"
+
+ validates :key, presence: true
+ validates :value, presence: true
+
+ after_commit :expire_cache
+
+ class << self
+ def cache_key_for(key, include_defaults: false)
+ "#{CACHE_PREFIX}/#{key}#{"_with_defaults" if include_defaults}"
+ end
+
+ def get(key, include_defaults: false)
+ result = find_by(key: key)
+ db_settings = result&.value
+
+ if include_defaults
+ db_settings = merge_with_defaults(key, db_settings)
+ end
+
+ db_settings
+ end
+
+ def merge_with_defaults(key, settings)
+ symbolized = (settings || {})
+ .reject { |_k, v| v.blank? }
+ .transform_keys(&:to_sym)
+
+ SETTINGS_DEFAULTS[key.to_sym]&.merge(symbolized) || symbolized
+ end
+
+ def get_multiple(keys, include_defaults: false)
+ settings_rows = where(key: keys)
+ db_settings = settings_rows.each_with_object({}) do |row, hash|
+ hash[row.key.to_sym] = row.value
+ end
+
+ return db_settings unless include_defaults
+
+ keys.each do |key|
+ db_settings[key.to_sym] = merge_with_defaults(key, db_settings[key.to_sym])
+ end
+
+ db_settings
+ end
+
+ def set(key, value)
+ record = find_or_initialize_by(key: key)
+ record.update!(value: value)
+ end
+
+ def unset(key)
+ find_by(key: key)&.destroy
+ end
+
+ def unset_within(key, sub_key)
+ settings = get(key)
+ return unless settings
+
+ settings.delete(sub_key.to_s)
+ settings.blank? ? unset(key) : set(key, settings)
+ end
+ end
+
+ private
+
+ def expire_cache
+ Rails.cache.delete(self.class.cache_key_for(key))
+ Rails.cache.delete(self.class.cache_key_for(key, include_defaults: true))
+ end
+end
diff --git a/app/services/s3_service.rb b/app/services/s3_service.rb
index 406aa75..632e618 100644
--- a/app/services/s3_service.rb
+++ b/app/services/s3_service.rb
@@ -105,4 +105,14 @@ def upload_local(key, data)
def self.url_for(key)
create_object(key).public_url
end
+
+ # Deletes an object from S3. No-op when AWS_S3_BLOCK is true.
+ #
+ # @param key [String] The S3 object key (e.g. "uploads/settings/logo.png")
+ #
+ def self.delete_object(key)
+ return if AWS_S3_BLOCK
+
+ create_object(key).delete
+ end
end
diff --git a/app/uploaders/image_uploader.rb b/app/uploaders/image_uploader.rb
new file mode 100644
index 0000000..a323018
--- /dev/null
+++ b/app/uploaders/image_uploader.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class ImageUploader < CarrierWave::Uploader::Base
+ MIME_TO_EXTENSION = {
+ "image/png" => ".png",
+ "image/jpeg" => ".jpg",
+ "image/jpg" => ".jpg",
+ "image/gif" => ".gif",
+ "image/webp" => ".webp",
+ "image/svg+xml" => ".svg"
+ }.freeze
+
+ def store_dir
+ "uploads/settings"
+ end
+
+ def filename
+ ext = original_filename.present? ? File.extname(original_filename) : extension_fallback
+ @filename_cache ||= "#{SecureRandom.hex(8)}#{ext}"
+ end
+
+ def extension_allowlist
+ %w(jpg jpeg png gif webp svg)
+ end
+
+ private
+
+ def extension_fallback
+ return ".#{file.extension}" if file.present? && file.respond_to?(:extension) && file.extension.present?
+ return extension_from_mime_type if file.present? && file.respond_to?(:content_type) && file.content_type.present?
+
+ raise CarrierWave::IntegrityError, "Unable to determine file type"
+ end
+
+ def extension_from_mime_type
+ ext = MIME_TO_EXTENSION[file.content_type]
+ raise CarrierWave::IntegrityError, "Unsupported file type: #{file.content_type}" if ext.blank?
+
+ ext
+ end
+end
diff --git a/app/views/admin/settings/edit.html.erb b/app/views/admin/settings/edit.html.erb
new file mode 100644
index 0000000..cf439d1
--- /dev/null
+++ b/app/views/admin/settings/edit.html.erb
@@ -0,0 +1,15 @@
+
+
+
<%= t("admin.nav.settings") %>
+
+
+
+ <%= render partial: "admin/settings/edit/#{@setting_type.to_s.underscore}",
+ locals: {
+ setting_group: @setting_group,
+ setting_key: @setting_key,
+ setting_value: @setting_value
+ } %>
+
+
+
diff --git a/app/views/admin/settings/edit/_color.html.erb b/app/views/admin/settings/edit/_color.html.erb
new file mode 100644
index 0000000..64da0f5
--- /dev/null
+++ b/app/views/admin/settings/edit/_color.html.erb
@@ -0,0 +1,7 @@
+
diff --git a/app/views/admin/settings/edit/_image.html.erb b/app/views/admin/settings/edit/_image.html.erb
new file mode 100644
index 0000000..c62a1b2
--- /dev/null
+++ b/app/views/admin/settings/edit/_image.html.erb
@@ -0,0 +1,7 @@
+
diff --git a/app/views/admin/settings/index.html.erb b/app/views/admin/settings/index.html.erb
new file mode 100644
index 0000000..1c09b37
--- /dev/null
+++ b/app/views/admin/settings/index.html.erb
@@ -0,0 +1,74 @@
+
+
+
<%= t("admin.nav.settings") %>
+
+
+
+ <% SETTINGS.each do |group, types| %>
+
<%= t("admin.settings.groups.#{group}") %>
+
<%= t("admin.settings.descriptions.#{group}") %>
+
+
+ | <%= t("admin.settings.index.key") %> |
+ <%= t("admin.settings.index.value") %> |
+ <%= t("admin.settings.index.actions") %> |
+
+ <% types.each_key do |key| %>
+ <% value = @all_settings[group][key] %>
+
+ | <%= t("admin.settings.labels.#{group}.#{key}") %> |
+
+ <% if types[key].present? %>
+ <%= render partial: "admin/settings/show/#{types[key].to_s.underscore}", locals: { setting_key: key, setting_value: value, form_id: "settings-form" } %>
+ <% else %>
+ <%= value %>
+ <% end %>
+ |
+
+ <%= form_tag entry_admin_settings_path(key: key), method: :delete, data: { turbo: false }, class: "d-inline" do %>
+
+ <% end %>
+ |
+
+ <% end %>
+
+ <% end %>
+
+ <%= form_with url: admin_settings_path, method: :patch, id: "settings-form", data: { turbo: false } do |f| %>
+
+ <% end %>
+
+
+
+
diff --git a/app/views/admin/settings/show/_color.html.erb b/app/views/admin/settings/show/_color.html.erb
new file mode 100644
index 0000000..8bf327e
--- /dev/null
+++ b/app/views/admin/settings/show/_color.html.erb
@@ -0,0 +1,7 @@
+
diff --git a/app/views/admin/settings/show/_image.html.erb b/app/views/admin/settings/show/_image.html.erb
new file mode 100644
index 0000000..32d0872
--- /dev/null
+++ b/app/views/admin/settings/show/_image.html.erb
@@ -0,0 +1,23 @@
+
+
+ <%= render_component "ImageUpload", {
+ imageUrl: setting_value,
+ fieldName: setting_key,
+ uploadUrl: upload_image_admin_settings_path,
+ formId: form_id,
+ useResetInfo: t("admin.settings.image_preview.use_reset"),
+ addImage: t("admin.settings.image_upload.add_image"),
+ imagePreviewTitle: t("admin.settings.image_upload.image_preview_title"),
+ uploadImageTitle: t("admin.settings.image_upload.upload_image_title"),
+ close: t("admin.settings.image_upload.close"),
+ uploadSuccess: t("admin.settings.image_upload.upload_success"),
+ uploading: t("admin.settings.image_upload.uploading"),
+ dragDropHint: t("admin.settings.image_upload.drag_drop_hint"),
+ pasteHint: t("admin.settings.image_upload.paste_hint", shortcut: t("admin.settings.image_upload.paste_shortcut")),
+ dropZoneAriaLabel: t("admin.settings.image_upload.drop_zone_aria_label"),
+ wrongFileType: t("admin.settings.image_upload.wrong_file_type"),
+ fullSizeAlt: t("admin.settings.image_upload.full_size_alt"),
+ previewAlt: t("admin.settings.image_upload.preview_alt"),
+ viewFullSize: t("admin.settings.image_upload.view_full_size")
+ } %>
+
diff --git a/app/views/shared/_header.html.erb b/app/views/shared/_header.html.erb
index c8ecde2..dd9102f 100644
--- a/app/views/shared/_header.html.erb
+++ b/app/views/shared/_header.html.erb
@@ -1,6 +1,34 @@
-