diff --git a/app/assets/stylesheets/components/SiteSettings/General/index.scss b/app/assets/stylesheets/components/SiteSettings/General/index.scss index aa968b3f..41e229b1 100644 --- a/app/assets/stylesheets/components/SiteSettings/General/index.scss +++ b/app/assets/stylesheets/components/SiteSettings/General/index.scss @@ -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; } } \ No newline at end of file diff --git a/app/controllers/tenants_controller.rb b/app/controllers/tenants_controller.rb index ce6fa135..0ab6e26e 100644 --- a/app/controllers/tenants_controller.rb +++ b/app/controllers/tenants_controller.rb @@ -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 diff --git a/app/javascript/actions/Tenant/updateTenant.ts b/app/javascript/actions/Tenant/updateTenant.ts index 9943922b..47759edd 100644 --- a/app/javascript/actions/Tenant/updateTenant.ts +++ b/app/javascript/actions/Tenant/updateTenant.ts @@ -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) diff --git a/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx b/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx index f2e03085..7636f1e1 100644 --- a/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx +++ b/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx @@ -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([]); + const [showSiteFaviconDropzone, setShowSiteFaviconDropzone] = React.useState([null, undefined, ''].includes(siteFaviconUrl)); + const [shouldDeleteSiteFavicon, setShouldDeleteSiteFavicon] = React.useState(false); + return ( <> @@ -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 = ({
- - {/* */} + + + { + siteFaviconUrl && +
+ {`${originForm.siteName} +
+ } + +
+ { + (siteFaviconUrl && !shouldDeleteSiteFavicon) && + (showSiteFaviconDropzone ? + setShowSiteFaviconDropzone(false)} + icon={} + > + {I18n.t('common.buttons.cancel')} + + : + setShowSiteFaviconDropzone(true)} + icon={} + > + {I18n.t('common.buttons.edit')} + ) + } + + { + (siteFaviconUrl && !showSiteFaviconDropzone) && + (shouldDeleteSiteFavicon ? + setShouldDeleteSiteFavicon(false)} + icon={} + > + {I18n.t('common.buttons.cancel')} + + : + { setShouldDeleteSiteFavicon(true); setValue('siteFavicon', getValues('siteFavicon'), { shouldDirty: true }) }} + icon={} + > + {I18n.t('common.buttons.delete')} + + ) + } + +
+ + { + showSiteFaviconDropzone && + ( + + )} + /> + }
diff --git a/app/javascript/components/SiteSettings/General/index.tsx b/app/javascript/components/SiteSettings/General/index.tsx index f96879da..575a13dd 100644 --- a/app/javascript/components/SiteSettings/General/index.tsx +++ b/app/javascript/components/SiteSettings/General/index.tsx @@ -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 { { + 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) => { diff --git a/app/javascript/containers/GeneralSiteSettings.tsx b/app/javascript/containers/GeneralSiteSettings.tsx index a51166bf..5e9d783a 100644 --- a/app/javascript/containers/GeneralSiteSettings.tsx +++ b/app/javascript/containers/GeneralSiteSettings.tsx @@ -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, diff --git a/app/models/tenant.rb b/app/models/tenant.rb index b00c05ea..347bd2ef 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 0fc9146d..434d3463 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/policies/tenant_policy.rb b/app/policies/tenant_policy.rb index aad0ec14..e1b3035c 100644 --- a/app/policies/tenant_policy.rb +++ b/app/policies/tenant_policy.rb @@ -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 diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index a87c837c..5fbd1546 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -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') %> diff --git a/app/views/site_settings/general.html.erb b/app/views/site_settings/general.html.erb index 1c9abc5b..a7a7b5c3 100644 --- a/app/views/site_settings/general.html.erb +++ b/app/views/site_settings/general.html.erb @@ -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 diff --git a/config/application.rb b/config/application.rb index 7c85c098..3dffae68 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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 diff --git a/config/locales/backend/backend.en.yml b/config/locales/backend/backend.en.yml index d646a92a..684604f8 100644 --- a/config/locales/backend/backend.en.yml +++ b/config/locales/backend/backend.en.yml @@ -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' diff --git a/config/locales/en.yml b/config/locales/en.yml index c0cb5dc6..d2ca2963 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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'