feat: implement custom branding

This commit is contained in:
Steven 2024-07-29 23:05:30 +08:00
parent a113d82e9b
commit b6271938b3
6 changed files with 75 additions and 6 deletions

View File

@ -8,6 +8,7 @@ import { PlanType } from "@/types/proto/api/v1/subscription_service";
import { Role } from "@/types/proto/api/v1/user_service"; import { Role } from "@/types/proto/api/v1/user_service";
import AboutDialog from "./AboutDialog"; import AboutDialog from "./AboutDialog";
import Icon from "./Icon"; import Icon from "./Icon";
import Logo from "./Logo";
import Dropdown from "./common/Dropdown"; import Dropdown from "./common/Dropdown";
const Header: React.FC = () => { const Header: React.FC = () => {
@ -32,7 +33,7 @@ const Header: React.FC = () => {
<div className="w-full max-w-8xl mx-auto px-4 sm:px-6 md:px-12 py-3 flex flex-row justify-between items-center"> <div className="w-full max-w-8xl mx-auto px-4 sm:px-6 md:px-12 py-3 flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center shrink mr-2"> <div className="flex flex-row justify-start items-center shrink mr-2">
<Link to="/" className="cursor-pointer flex flex-row justify-start items-center dark:text-gray-400" unstable_viewTransition> <Link to="/" className="cursor-pointer flex flex-row justify-start items-center dark:text-gray-400" unstable_viewTransition>
<Icon.CircleSlash className="w-7 h-auto dark:text-gray-500 mr-2" strokeWidth={1.5} /> <Logo className="mr-2" />
Slash Slash
</Link> </Link>
{profile.plan === PlanType.PRO && ( {profile.plan === PlanType.PRO && (

View File

@ -0,0 +1,23 @@
import classNames from "classnames";
import { useWorkspaceStore } from "@/stores";
import Icon from "./Icon";
interface Props {
className?: string;
}
const Logo = ({ className }: Props) => {
const workspaceStore = useWorkspaceStore();
const branding = workspaceStore.setting.branding ? new TextDecoder().decode(workspaceStore.setting.branding) : "";
return (
<div className={classNames("w-8 h-auto dark:text-gray-500 rounded-lg overflow-hidden", className)}>
{branding ? (
<img src={branding} alt="branding" className="max-w-full max-h-full" />
) : (
<Icon.CircleSlash className="w-full h-auto" strokeWidth={1.5} />
)}
</div>
);
};
export default Logo;

View File

@ -1,5 +1,5 @@
import { Button, Option, Select, Textarea } from "@mui/joy"; import { Button, Option, Select, Textarea } from "@mui/joy";
import { isEqual } from "lodash-es"; import { head, isEqual } from "lodash-es";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -7,6 +7,7 @@ import { workspaceServiceClient } from "@/grpcweb";
import { useWorkspaceStore } from "@/stores"; import { useWorkspaceStore } from "@/stores";
import { Visibility } from "@/types/proto/api/v1/common"; import { Visibility } from "@/types/proto/api/v1/common";
import { WorkspaceSetting } from "@/types/proto/api/v1/workspace_service"; import { WorkspaceSetting } from "@/types/proto/api/v1/workspace_service";
import Icon from "../Icon";
const getDefaultVisibility = (visibility?: Visibility) => { const getDefaultVisibility = (visibility?: Visibility) => {
if (!visibility || [Visibility.VISIBILITY_UNSPECIFIED, Visibility.UNRECOGNIZED].includes(visibility)) { if (!visibility || [Visibility.VISIBILITY_UNSPECIFIED, Visibility.UNRECOGNIZED].includes(visibility)) {
@ -16,12 +17,32 @@ const getDefaultVisibility = (visibility?: Visibility) => {
return visibility; return visibility;
}; };
const convertFileToBase64 = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
const WorkspaceSection = () => { const WorkspaceSection = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const workspaceStore = useWorkspaceStore(); const workspaceStore = useWorkspaceStore();
const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(workspaceStore.setting); const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(workspaceStore.setting);
const originalWorkspaceSetting = useRef<WorkspaceSetting>(workspaceStore.setting); const originalWorkspaceSetting = useRef<WorkspaceSetting>(workspaceStore.setting);
const allowSave = !isEqual(originalWorkspaceSetting.current, workspaceSetting); const allowSave = !isEqual(originalWorkspaceSetting.current, workspaceSetting);
const branding = workspaceSetting.branding ? new TextDecoder().decode(workspaceSetting.branding) : "";
const onBrandingChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files: File[] = Array.from(event.target.files || []);
const file = head(files);
if (!file) {
return;
}
const base64 = await convertFileToBase64(file);
setWorkspaceSetting({ ...workspaceSetting, branding: new TextEncoder().encode(base64) });
};
const handleCustomStyleChange = async (value: string) => { const handleCustomStyleChange = async (value: string) => {
setWorkspaceSetting({ setWorkspaceSetting({
@ -39,6 +60,9 @@ const WorkspaceSection = () => {
const handleSaveWorkspaceSetting = async () => { const handleSaveWorkspaceSetting = async () => {
const updateMask: string[] = []; const updateMask: string[] = [];
if (!isEqual(originalWorkspaceSetting.current.branding, workspaceSetting.branding)) {
updateMask.push("branding");
}
if (!isEqual(originalWorkspaceSetting.current.customStyle, workspaceSetting.customStyle)) { if (!isEqual(originalWorkspaceSetting.current.customStyle, workspaceSetting.customStyle)) {
updateMask.push("custom_style"); updateMask.push("custom_style");
} }
@ -70,6 +94,26 @@ const WorkspaceSection = () => {
<div className="w-full flex flex-col sm:flex-row justify-start items-start gap-4 sm:gap-x-16"> <div className="w-full flex flex-col sm:flex-row justify-start items-start gap-4 sm:gap-x-16">
<p className="sm:w-1/4 text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p> <p className="sm:w-1/4 text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p>
<div className="w-full sm:w-auto grow flex flex-col justify-start items-start gap-4"> <div className="w-full sm:w-auto grow flex flex-col justify-start items-start gap-4">
<div className="w-full flex flex-row justify-between items-center">
<div className="w-full flex flex-col justify-start items-start">
<p className="font-medium dark:text-gray-400">Custom branding</p>
<p className="text-sm text-gray-500 leading-tight">Recommand logo ratio: 1:1</p>
</div>
<div className="relative shrink-0 hover:opacity-80">
{branding ? (
<div className="relative w-16 h-16">
<img src={branding} alt="branding" className="max-w-full max-h-full" />
<Icon.X
className="w-4 h-auto -top-2 -right-2 absolute z-1 border rounded-full bg-white opacity-80"
onClick={() => setWorkspaceSetting({ ...workspaceSetting, branding: new TextEncoder().encode("") })}
/>
</div>
) : (
<Icon.CircleSlash className="w-7 h-auto dark:text-gray-500 mr-2" strokeWidth={1.5} />
)}
<input className="absolute inset-0 z-1 opacity-0" type="file" accept=".jpg,.jpeg,.png,.svg,.webp" onChange={onBrandingChange} />
</div>
</div>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full flex flex-col justify-start items-start">
<p className="font-medium dark:text-gray-400">{t("settings.workspace.default-visibility")}</p> <p className="font-medium dark:text-gray-400">{t("settings.workspace.default-visibility")}</p>

View File

@ -3,7 +3,7 @@ import React, { FormEvent, useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Icon from "@/components/Icon"; import Logo from "@/components/Logo";
import { authServiceClient } from "@/grpcweb"; import { authServiceClient } from "@/grpcweb";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
@ -64,7 +64,7 @@ const SignIn: React.FC = () => {
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center"> <div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center"> <div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4"> <div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
<Icon.CircleSlash className="w-10 h-auto dark:text-gray-500 mr-2" strokeWidth={1.5} /> <Logo className="mr-2" />
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span> <span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
</div> </div>
<form className="w-full mt-6" onSubmit={handleSigninBtnClick}> <form className="w-full mt-6" onSubmit={handleSigninBtnClick}>

View File

@ -3,7 +3,7 @@ import React, { FormEvent, useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Icon from "@/components/Icon"; import Logo from "@/components/Logo";
import { authServiceClient } from "@/grpcweb"; import { authServiceClient } from "@/grpcweb";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
@ -75,7 +75,7 @@ const SignUp: React.FC = () => {
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center"> <div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center"> <div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4"> <div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
<Icon.CircleSlash className="w-10 h-auto dark:text-gray-500 mr-2" strokeWidth={1.5} /> <Logo className="mr-2" />
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span> <span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
</div> </div>
<p className="w-full text-2xl mt-6 dark:text-gray-500">{t("auth.create-your-account")}</p> <p className="w-full text-2xl mt-6 dark:text-gray-500">{t("auth.create-your-account")}</p>

View File

@ -61,6 +61,7 @@ func (s *APIV1Service) GetWorkspaceSetting(ctx context.Context, _ *v1pb.GetWorks
for _, v := range workspaceSettings { for _, v := range workspaceSettings {
if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_GENERAL { if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_GENERAL {
generalSetting := v.GetGeneral() generalSetting := v.GetGeneral()
workspaceSetting.Branding = generalSetting.GetBranding()
workspaceSetting.CustomStyle = generalSetting.GetCustomStyle() workspaceSetting.CustomStyle = generalSetting.GetCustomStyle()
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_SHORTCUT_RELATED { } else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_SHORTCUT_RELATED {
shortcutRelatedSetting := v.GetShortcutRelated() shortcutRelatedSetting := v.GetShortcutRelated()