mirror of
https://github.com/aykhans/slash-e.git
synced 2025-04-18 13:15:27 +00:00
feat: implement custom branding
This commit is contained in:
parent
a113d82e9b
commit
b6271938b3
@ -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 && (
|
||||||
|
23
frontend/web/src/components/Logo.tsx
Normal file
23
frontend/web/src/components/Logo.tsx
Normal 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;
|
@ -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>
|
||||||
|
@ -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}>
|
||||||
|
@ -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>
|
||||||
|
@ -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()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user