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

View File

@@ -96,14 +96,22 @@ class TenantsController < ApplicationController
# to avoid unique constraint violation # to avoid unique constraint violation
params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank? params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank?
# Handle site logo attachment
if params[:tenant][:should_delete_site_logo] == "true" if params[:tenant][:should_delete_site_logo] == "true"
@tenant.site_logo.purge if @tenant.site_logo.attached? @tenant.site_logo.purge if @tenant.site_logo.attached?
elsif params[:tenant][:site_logo].present? 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.purge if @tenant.site_logo.attached?
@tenant.site_logo.attach(params[:tenant][:site_logo]) @tenant.site_logo.attach(params[:tenant][:site_logo])
end 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) if @tenant.update(tenant_update_params)
render json: @tenant render json: @tenant
else else
@@ -141,7 +149,7 @@ class TenantsController < ApplicationController
.permitted_attributes_for_update .permitted_attributes_for_update
.concat([{ .concat([{
tenant_setting_attributes: policy(@tenant.tenant_setting).permitted_attributes_for_update, 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 }]) # in order to permit nested attributes for tenant_setting
) )
end end

View File

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

View File

@@ -28,6 +28,7 @@ export interface ISiteSettingsGeneralForm {
siteName: string; siteName: string;
siteLogo: File; siteLogo: File;
oldSiteLogo: string; oldSiteLogo: string;
siteFavicon: File;
brandDisplaySetting: string; brandDisplaySetting: string;
locale: string; locale: string;
useBrowserLocale: boolean; useBrowserLocale: boolean;
@@ -49,6 +50,7 @@ export interface ISiteSettingsGeneralForm {
interface Props { interface Props {
originForm: ISiteSettingsGeneralForm; originForm: ISiteSettingsGeneralForm;
siteLogoUrl?: string; siteLogoUrl?: string;
siteFaviconUrl?: string;
boards: IBoardJSON[]; boards: IBoardJSON[];
isMultiTenant: boolean; isMultiTenant: boolean;
authenticityToken: string; authenticityToken: string;
@@ -61,6 +63,8 @@ interface Props {
siteLogo: File, siteLogo: File,
shouldDeleteSiteLogo: boolean, shouldDeleteSiteLogo: boolean,
oldSiteLogo: string, oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
brandDisplaySetting: string, brandDisplaySetting: string,
locale: string, locale: string,
useBrowserLocale: boolean, useBrowserLocale: boolean,
@@ -84,6 +88,7 @@ interface Props {
const GeneralSiteSettingsP = ({ const GeneralSiteSettingsP = ({
originForm, originForm,
siteLogoUrl, siteLogoUrl,
siteFaviconUrl,
boards, boards,
isMultiTenant, isMultiTenant,
authenticityToken, authenticityToken,
@@ -132,6 +137,8 @@ const GeneralSiteSettingsP = ({
'siteLogo' in data && data.siteLogo ? data.siteLogo[0] : null, 'siteLogo' in data && data.siteLogo ? data.siteLogo[0] : null,
shouldDeleteSiteLogo, shouldDeleteSiteLogo,
data.oldSiteLogo, data.oldSiteLogo,
'siteFavicon' in data && data.siteFavicon ? data.siteFavicon[0] : null,
shouldDeleteSiteFavicon,
data.brandDisplaySetting, data.brandDisplaySetting,
data.locale, data.locale,
data.useBrowserLocale, data.useBrowserLocale,
@@ -182,6 +189,11 @@ const GeneralSiteSettingsP = ({
const [showSiteLogoDropzone, setShowSiteLogoDropzone] = React.useState([null, undefined, ''].includes(siteLogoUrl)); const [showSiteLogoDropzone, setShowSiteLogoDropzone] = React.useState([null, undefined, ''].includes(siteLogoUrl));
const [shouldDeleteSiteLogo, setShouldDeleteSiteLogo] = React.useState(false); 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 ( return (
<> <>
<Box customClass="generalSiteSettingsContainer"> <Box customClass="generalSiteSettingsContainer">
@@ -293,7 +305,7 @@ const GeneralSiteSettingsP = ({
files={siteLogoFile} files={siteLogoFile}
setFiles={setSiteLogoFile} setFiles={setSiteLogoFile}
onDrop={field.onChange} onDrop={field.onChange}
maxSizeKB={512} maxSizeKB={256}
maxFiles={1} maxFiles={1}
/> />
)} )}
@@ -302,8 +314,72 @@ const GeneralSiteSettingsP = ({
</div> </div>
<div className="formGroup col-6"> <div className="formGroup col-6">
<label htmlFor="siteFavicon">{ getLabel('tenant', 'site_logo') }</label> <label htmlFor="siteFavicon">{ getLabel('tenant', 'site_favicon') }</label>
{/* <Dropzone /> */}
{
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>
</div> </div>

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ const mapDispatchToProps = (dispatch: any) => ({
siteLogo: File, siteLogo: File,
shouldDeleteSiteLogo: boolean, shouldDeleteSiteLogo: boolean,
oldSiteLogo: string, oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
brandDisplaySetting: TenantSettingBrandDisplay, brandDisplaySetting: TenantSettingBrandDisplay,
locale: string, locale: string,
useBrowserLocale: boolean, useBrowserLocale: boolean,
@@ -44,6 +46,8 @@ const mapDispatchToProps = (dispatch: any) => ({
siteLogo, siteLogo,
shouldDeleteSiteLogo, shouldDeleteSiteLogo,
oldSiteLogo, oldSiteLogo,
siteFavicon,
shouldDeleteSiteFavicon,
tenantSetting: { tenantSetting: {
brand_display: brandDisplaySetting, brand_display: brandDisplaySetting,
use_browser_locale: useBrowserLocale, 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_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_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] enum status: [:active, :pending, :blocked]
@@ -23,6 +24,12 @@ class Tenant < ApplicationRecord
validates :subdomain, presence: true, uniqueness: true validates :subdomain, presence: true, uniqueness: true
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } 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 :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 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, allow_blank: true, length: { in: 6..128 }
validates :password, presence: true, on: :create validates :password, presence: true, on: :create
validates :avatar, validates :avatar,
content_type: ['image/png', 'image/jpg', 'image/jpeg'], content_type: Rails.application.accepted_image_types,
size: { less_than: 100.kilobytes } size: { less_than: 128.kilobytes }
def set_default_role def set_default_role
self.role ||= :user self.role ||= :user

View File

@@ -5,7 +5,7 @@ class TenantPolicy < ApplicationPolicy
def permitted_attributes_for_update def permitted_attributes_for_update
if user.admin? 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 else
[] []
end end

View File

@@ -14,7 +14,7 @@
<%= javascript_include_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "application", "data-turbo-track": "reload" %>
<%= stylesheet_link_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> </head>
<body> <body>

View File

@@ -26,6 +26,7 @@
useBrowserLocale: @tenant_setting.use_browser_locale, useBrowserLocale: @tenant_setting.use_browser_locale,
}, },
siteLogoUrl: @tenant.site_logo.attached? ? url_for(@tenant.site_logo) : nil, 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), boards: @tenant.boards.order(order: :asc),
isMultiTenant: Rails.application.multi_tenancy?, isMultiTenant: Rails.application.multi_tenancy?,
authenticityToken: form_authenticity_token authenticityToken: form_authenticity_token

View File

@@ -40,6 +40,10 @@ module App
15 15
end 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 def trial_period_days
ENV.key?("TRIAL_PERIOD_DAYS") ? ENV["TRIAL_PERIOD_DAYS"].to_i.days : 7.days ENV.key?("TRIAL_PERIOD_DAYS") ? ENV["TRIAL_PERIOD_DAYS"].to_i.days : 7.days
end end

View File

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

View File

@@ -75,7 +75,7 @@ en:
user_staff: 'Staff' user_staff: 'Staff'
language_supported: '%{language} supported' language_supported: '%{language} supported'
powered_by: 'Powered by' 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: buttons:
new: 'New' new: 'New'
edit: 'Edit' edit: 'Edit'