Add site favicon attachment

This commit is contained in:
riggraz
2025-01-22 14:04:08 +01:00
parent 61948f40fe
commit 4dd897061a
15 changed files with 134 additions and 23 deletions

View File

@@ -9,7 +9,7 @@
@extend .mb-4;
}
.siteLogoPreview {
.siteLogoPreview, .siteFaviconPreview {
@extend .d-block, .my-2;
position: relative;
@@ -18,21 +18,19 @@
height: fit-content; /* Adjusts height to match the image */
}
.siteLogoPreview .siteLogoPreviewImg {
.siteLogoPreview .siteLogoPreviewImg, .siteFaviconPreview .siteFaviconPreviewImg {
display: block;
height: 50px; /* Fixed height for the image */
width: auto; /* Maintain the aspect ratio */
}
.siteLogoPreview.siteLogoPreviewShouldDelete {
.siteLogoPreview.siteLogoPreviewShouldDelete, .siteFaviconPreview.siteFaviconPreviewShouldDelete {
border: 2px solid red;
.siteLogoPreviewImg {
.siteFaviconPreviewImg {
filter: grayscale(100%);
}
}
.siteLogoActions {
@extend .d-flex;
}
.siteLogoActions, .siteFaviconActions { @extend .d-flex; }
}

View File

@@ -96,14 +96,22 @@ class TenantsController < ApplicationController
# to avoid unique constraint violation
params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank?
# Handle site logo attachment
if params[:tenant][:should_delete_site_logo] == "true"
@tenant.site_logo.purge if @tenant.site_logo.attached?
elsif params[:tenant][:site_logo].present?
# If site_logo is provided, remove the old one if it exists and attach the new one
@tenant.site_logo.purge if @tenant.site_logo.attached?
@tenant.site_logo.attach(params[:tenant][:site_logo])
end
# Handle site favicon attachment
if params[:tenant][:should_delete_site_favicon] == "true"
@tenant.site_favicon.purge if @tenant.site_favicon.attached?
elsif params[:tenant][:site_favicon].present?
@tenant.site_favicon.purge if @tenant.site_favicon.attached?
@tenant.site_favicon.attach(params[:tenant][:site_favicon])
end
if @tenant.update(tenant_update_params)
render json: @tenant
else
@@ -141,7 +149,7 @@ class TenantsController < ApplicationController
.permitted_attributes_for_update
.concat([{
tenant_setting_attributes: policy(@tenant.tenant_setting).permitted_attributes_for_update,
additional_params: [:should_delete_site_logo]
additional_params: [:should_delete_site_logo, :should_delete_site_favicon]
}]) # in order to permit nested attributes for tenant_setting
)
end

View File

@@ -2,7 +2,6 @@ import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ITenantSetting from "../../interfaces/ITenantSetting";
import ITenantJSON from "../../interfaces/json/ITenant";
import { State } from "../../reducers/rootReducer";
@@ -50,6 +49,8 @@ interface UpdateTenantParams {
siteLogo?: File;
shouldDeleteSiteLogo?: boolean;
oldSiteLogo?: string;
siteFavicon?: File;
shouldDeleteSiteFavicon?: boolean;
tenantSetting?: ITenantSetting;
locale?: string;
customDomain?: string;
@@ -61,6 +62,8 @@ export const updateTenant = ({
siteLogo = null,
shouldDeleteSiteLogo = null,
oldSiteLogo = null,
siteFavicon = null,
shouldDeleteSiteFavicon = null,
tenantSetting = null,
locale = null,
customDomain = null,
@@ -79,6 +82,10 @@ export const updateTenant = ({
body.append('tenant[should_delete_site_logo]', shouldDeleteSiteLogo.toString());
if (oldSiteLogo)
body.append('tenant[old_site_logo]', oldSiteLogo);
if (siteFavicon)
body.append('tenant[site_favicon]', siteFavicon);
if (shouldDeleteSiteFavicon)
body.append('tenant[should_delete_site_favicon]', shouldDeleteSiteFavicon.toString());
if (locale)
body.append('tenant[locale]', locale);
if (customDomain)

View File

@@ -28,6 +28,7 @@ export interface ISiteSettingsGeneralForm {
siteName: string;
siteLogo: File;
oldSiteLogo: string;
siteFavicon: File;
brandDisplaySetting: string;
locale: string;
useBrowserLocale: boolean;
@@ -49,6 +50,7 @@ export interface ISiteSettingsGeneralForm {
interface Props {
originForm: ISiteSettingsGeneralForm;
siteLogoUrl?: string;
siteFaviconUrl?: string;
boards: IBoardJSON[];
isMultiTenant: boolean;
authenticityToken: string;
@@ -61,6 +63,8 @@ interface Props {
siteLogo: File,
shouldDeleteSiteLogo: boolean,
oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
brandDisplaySetting: string,
locale: string,
useBrowserLocale: boolean,
@@ -84,6 +88,7 @@ interface Props {
const GeneralSiteSettingsP = ({
originForm,
siteLogoUrl,
siteFaviconUrl,
boards,
isMultiTenant,
authenticityToken,
@@ -132,6 +137,8 @@ const GeneralSiteSettingsP = ({
'siteLogo' in data && data.siteLogo ? data.siteLogo[0] : null,
shouldDeleteSiteLogo,
data.oldSiteLogo,
'siteFavicon' in data && data.siteFavicon ? data.siteFavicon[0] : null,
shouldDeleteSiteFavicon,
data.brandDisplaySetting,
data.locale,
data.useBrowserLocale,
@@ -182,6 +189,11 @@ const GeneralSiteSettingsP = ({
const [showSiteLogoDropzone, setShowSiteLogoDropzone] = React.useState([null, undefined, ''].includes(siteLogoUrl));
const [shouldDeleteSiteLogo, setShouldDeleteSiteLogo] = React.useState(false);
// Site favicon
const [siteFaviconFile, setSiteFaviconFile] = React.useState<any>([]);
const [showSiteFaviconDropzone, setShowSiteFaviconDropzone] = React.useState([null, undefined, ''].includes(siteFaviconUrl));
const [shouldDeleteSiteFavicon, setShouldDeleteSiteFavicon] = React.useState(false);
return (
<>
<Box customClass="generalSiteSettingsContainer">
@@ -293,7 +305,7 @@ const GeneralSiteSettingsP = ({
files={siteLogoFile}
setFiles={setSiteLogoFile}
onDrop={field.onChange}
maxSizeKB={512}
maxSizeKB={256}
maxFiles={1}
/>
)}
@@ -302,8 +314,72 @@ const GeneralSiteSettingsP = ({
</div>
<div className="formGroup col-6">
<label htmlFor="siteFavicon">{ getLabel('tenant', 'site_logo') }</label>
{/* <Dropzone /> */}
<label htmlFor="siteFavicon">{ getLabel('tenant', 'site_favicon') }</label>
{
siteFaviconUrl &&
<div className={`siteFaviconPreview${shouldDeleteSiteFavicon ? ' siteFaviconPreviewShouldDelete' : ''}`}>
<img src={siteFaviconUrl} alt={`${originForm.siteName} favicon`} className="siteFaviconPreviewImg" />
</div>
}
<div className="siteFaviconActions">
{
(siteFaviconUrl && !shouldDeleteSiteFavicon) &&
(showSiteFaviconDropzone ?
<ActionLink
onClick={() => setShowSiteFaviconDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowSiteFaviconDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(siteFaviconUrl && !showSiteFaviconDropzone) &&
(shouldDeleteSiteFavicon ?
<ActionLink
onClick={() => setShouldDeleteSiteFavicon(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => { setShouldDeleteSiteFavicon(true); setValue('siteFavicon', getValues('siteFavicon'), { shouldDirty: true }) }}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)
}
</div>
{
showSiteFaviconDropzone &&
<Controller
name="siteFavicon"
control={control}
render={({ field }) => (
<Dropzone
files={siteFaviconFile}
setFiles={setSiteFaviconFile}
onDrop={field.onChange}
maxSizeKB={64}
maxFiles={1}
accept={['image/x-icon', 'image/icon', 'image/png', 'image/jpeg', 'image/jpg']}
/>
)}
/>
}
</div>
</div>

View File

@@ -11,6 +11,7 @@ import { ISiteSettingsGeneralForm } from './GeneralSiteSettingsP';
interface Props {
originForm: ISiteSettingsGeneralForm;
siteLogoUrl?: string;
siteFaviconUrl?: string;
boards: IBoardJSON[];
isMultiTenant: boolean;
authenticityToken: string;
@@ -31,6 +32,7 @@ class GeneralSiteSettingsRoot extends React.Component<Props> {
<GeneralSiteSettings
originForm={this.props.originForm}
siteLogoUrl={this.props.siteLogoUrl}
siteFaviconUrl={this.props.siteFaviconUrl}
boards={this.props.boards}
isMultiTenant={this.props.isMultiTenant}
authenticityToken={this.props.authenticityToken}

View File

@@ -9,6 +9,7 @@ interface Props {
onDrop?: any;
maxSizeKB?: number;
maxFiles?: number;
accept?: string[];
}
const Dropzone = ({
@@ -17,18 +18,20 @@ const Dropzone = ({
onDrop,
maxSizeKB = 256,
maxFiles = 1,
accept = ['image/png', 'image/jpeg', 'image/jpg', 'image/x-icon', 'image/icon', 'image/svg+xml', 'image/svg', 'image/webp'],
}: Props) => {
const acceptDict = accept.reduce((acc, type) => {
acc[type] = [];
return acc;
}, {});
const {
getRootProps,
getInputProps,
isDragAccept,
isDragReject
} = useDropzone({
accept: {
'image/png': [],
'image/jpeg': [],
'image/jpg': [],
},
accept: acceptDict,
maxSize: maxSizeKB * 1024,
maxFiles: maxFiles,
onDrop: (acceptedFiles, fileRejections) => {

View File

@@ -21,6 +21,8 @@ const mapDispatchToProps = (dispatch: any) => ({
siteLogo: File,
shouldDeleteSiteLogo: boolean,
oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
brandDisplaySetting: TenantSettingBrandDisplay,
locale: string,
useBrowserLocale: boolean,
@@ -44,6 +46,8 @@ const mapDispatchToProps = (dispatch: any) => ({
siteLogo,
shouldDeleteSiteLogo,
oldSiteLogo,
siteFavicon,
shouldDeleteSiteFavicon,
tenantSetting: {
brand_display: brandDisplaySetting,
use_browser_locale: useBrowserLocale,

View File

@@ -13,6 +13,7 @@ class Tenant < ApplicationRecord
has_many :default_o_auths, -> { where tenant_id: nil, is_enabled: true }, through: :tenant_default_o_auths, source: :o_auth
has_one_attached :site_logo, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
has_one_attached :site_favicon, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
enum status: [:active, :pending, :blocked]
@@ -23,6 +24,12 @@ class Tenant < ApplicationRecord
validates :subdomain, presence: true, uniqueness: true
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :custom_domain, format: { with: /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}\z/ }, uniqueness: true, allow_blank: true, allow_nil: true
validates :site_logo,
content_type: Rails.application.accepted_image_types,
size: { less_than: 256.kilobytes }
validates :site_favicon,
content_type: Rails.application.accepted_image_types,
size: { less_than: 64.kilobytes }
accepts_nested_attributes_for :tenant_setting, update_only: true

View File

@@ -30,8 +30,8 @@ class User < ApplicationRecord
validates :password, allow_blank: true, length: { in: 6..128 }
validates :password, presence: true, on: :create
validates :avatar,
content_type: ['image/png', 'image/jpg', 'image/jpeg'],
size: { less_than: 100.kilobytes }
content_type: Rails.application.accepted_image_types,
size: { less_than: 128.kilobytes }
def set_default_role
self.role ||= :user

View File

@@ -5,7 +5,7 @@ class TenantPolicy < ApplicationPolicy
def permitted_attributes_for_update
if user.admin?
[:site_name, :site_logo, :old_site_logo, :locale, :custom_domain]
[:site_name, :site_logo, :old_site_logo, :site_favicon, :locale, :custom_domain]
else
[]
end

View File

@@ -14,7 +14,7 @@
<%= javascript_include_tag "application", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= favicon_link_tag asset_path('favicon.png') %>
<%= favicon_link_tag @tenant.site_favicon.attached? ? url_for(@tenant.site_favicon) : asset_path('favicon.png') %>
</head>
<body>

View File

@@ -26,6 +26,7 @@
useBrowserLocale: @tenant_setting.use_browser_locale,
},
siteLogoUrl: @tenant.site_logo.attached? ? url_for(@tenant.site_logo) : nil,
siteFaviconUrl: @tenant.site_favicon.attached? ? url_for(@tenant.site_favicon) : nil,
boards: @tenant.boards.order(order: :asc),
isMultiTenant: Rails.application.multi_tenancy?,
authenticityToken: form_authenticity_token

View File

@@ -40,6 +40,10 @@ module App
15
end
def accepted_image_types
%w[image/png image/jpg image/jpeg image/x-icon image/icon image/svg+xml image/svg image/webp]
end
def trial_period_days
ENV.key?("TRIAL_PERIOD_DAYS") ? ENV["TRIAL_PERIOD_DAYS"].to_i.days : 7.days
end

View File

@@ -128,6 +128,7 @@ en:
tenant:
site_name: 'Site name'
site_logo: 'Site logo'
site_favicon: 'Site favicon'
subdomain: 'Subdomain'
locale: 'Language'
custom_domain: 'Custom domain'

View File

@@ -75,7 +75,7 @@ en:
user_staff: 'Staff'
language_supported: '%{language} supported'
powered_by: 'Powered by'
drag_and_drop: 'Drag and drop or click to upload images (only .png or .jpg, max %{maxCount} files, max %{maxSize}KB each)'
drag_and_drop: 'Drag and drop or click to upload images (max %{maxCount} files, max %{maxSize}KB each)'
buttons:
new: 'New'
edit: 'Edit'