refactor(web): improve Settings page maintainability and consistency (#5757)

Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
memoclaw 2026-03-21 15:05:48 +08:00 committed by GitHub
parent d5de325ff4
commit 9ded59a1aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 597 additions and 320 deletions

View File

@ -1,7 +1,9 @@
import type { Element } from "hast";
import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useNavigateTo from "@/hooks/useNavigateTo";
import { colorToHex } from "@/lib/color";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { useMemoViewContext } from "../MemoView/MemoViewContext";
@ -12,14 +14,28 @@ interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
children?: React.ReactNode;
}
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, ...props }) => {
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, style, ...props }) => {
const { parentPage } = useMemoViewContext();
const location = useLocation();
const navigateTo = useNavigateTo();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
const { tagsSetting } = useInstance();
const tag = dataTag || "";
// Custom color from admin tag metadata. Dynamic hex values must use inline styles
// because Tailwind can't scan dynamically constructed class names.
// Text uses a darkened variant (40% color + black) for contrast on light backgrounds.
const bgHex = colorToHex(tagsSetting.tags[tag]?.backgroundColor);
const tagStyle: React.CSSProperties | undefined = bgHex
? {
borderColor: bgHex,
color: `color-mix(in srgb, ${bgHex} 60%, black)`,
backgroundColor: `color-mix(in srgb, ${bgHex} 15%, transparent)`,
...style,
}
: style;
const handleTagClick = (e: React.MouseEvent) => {
e.stopPropagation();
@ -48,7 +64,12 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
return (
<span
className={cn("inline-block w-auto text-primary cursor-pointer transition-colors hover:text-primary/80", className)}
className={cn(
"inline-flex items-center align-middle px-1.5 py-px text-sm leading-snug rounded border cursor-pointer transition-opacity hover:opacity-75",
!bgHex && "border-primary text-primary bg-primary/15",
className,
)}
style={tagStyle}
data-tag={tag}
{...props}
onClick={handleTagClick}

View File

@ -12,6 +12,7 @@ import { handleError } from "@/lib/error";
import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
import SettingGroup from "./SettingGroup";
import SettingTable from "./SettingTable";
const listAccessTokens = async (parent: string) => {
@ -64,11 +65,7 @@ const AccessTokenSection = () => {
);
};
const handleCreateToken = () => {
createTokenDialog.open();
};
const handleDeleteAccessToken = async (token: PersonalAccessToken) => {
const handleDeleteAccessToken = (token: PersonalAccessToken) => {
setDeleteTarget(token);
};
@ -82,18 +79,7 @@ const AccessTokenSection = () => {
};
return (
<div className="w-full flex flex-col gap-2">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-muted-foreground">{t("setting.access-token.title")}</h4>
<p className="text-xs text-muted-foreground">{t("setting.access-token.description")}</p>
</div>
<Button onClick={handleCreateToken} size="sm">
<PlusIcon className="w-4 h-4 mr-1.5" />
{t("common.create")}
</Button>
</div>
<SettingGroup title={t("setting.access-token.title")} description={t("setting.access-token.description")}>
<SettingTable
columns={[
{
@ -125,10 +111,17 @@ const AccessTokenSection = () => {
},
]}
data={personalAccessTokens}
emptyMessage="No access tokens found"
emptyMessage={t("setting.access-token.no-tokens-found")}
getRowKey={(token) => token.name}
/>
<div className="flex justify-end">
<Button onClick={createTokenDialog.open} size="sm">
<PlusIcon className="w-4 h-4 mr-1.5" />
{t("common.create")}
</Button>
</div>
{/* Create Access Token Dialog */}
<CreateAccessTokenDialog
open={createTokenDialog.isOpen}
@ -145,7 +138,7 @@ const AccessTokenSection = () => {
onConfirm={confirmDeleteAccessToken}
confirmVariant="destructive"
/>
</div>
</SettingGroup>
);
};

View File

@ -31,13 +31,23 @@ const InstanceSection = () => {
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
useEffect(() => {
setInstanceGeneralSetting({ ...instanceGeneralSetting, customProfile: originalSetting.customProfile });
}, [originalSetting]);
setInstanceGeneralSetting((prev) =>
create(InstanceSetting_GeneralSettingSchema, {
...prev,
customProfile: originalSetting.customProfile,
}),
);
}, [originalSetting.customProfile]);
const handleUpdateCustomizedProfileButtonClick = () => {
customizeDialog.open();
const fetchIdentityProviderList = async () => {
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
setIdentityProviderList(identityProviders);
};
useEffect(() => {
fetchIdentityProviderList();
}, []);
const updatePartialSetting = (partial: Partial<InstanceSetting_GeneralSetting>) => {
setInstanceGeneralSetting(
create(InstanceSetting_GeneralSettingSchema, {
@ -68,20 +78,11 @@ const InstanceSection = () => {
toast.success(t("message.update-succeed"));
};
useEffect(() => {
fetchIdentityProviderList();
}, []);
const fetchIdentityProviderList = async () => {
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
setIdentityProviderList(identityProviders);
};
return (
<SettingSection>
<SettingSection title={t("setting.system.label")}>
<SettingGroup title={t("common.basic")}>
<SettingRow label={t("setting.system.server-name")} description={instanceGeneralSetting.customProfile?.title || "Memos"}>
<Button variant="outline" onClick={handleUpdateCustomizedProfileButtonClick}>
<Button variant="outline" onClick={customizeDialog.open}>
{t("common.edit")}
</Button>
</SettingRow>
@ -109,7 +110,7 @@ const InstanceSection = () => {
</SettingRow>
</SettingGroup>
<SettingGroup>
<SettingGroup showSeparator>
<SettingRow label={t("setting.instance.disallow-user-registration")}>
<Switch
disabled={profile.demo}
@ -169,8 +170,7 @@ const InstanceSection = () => {
open={customizeDialog.isOpen}
onOpenChange={customizeDialog.setOpen}
onSuccess={() => {
// Refresh instance settings if needed
toast.success("Profile updated successfully!");
toast.success(t("message.update-succeed"));
}}
/>
</SettingSection>

View File

@ -2,7 +2,7 @@ import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { sortBy } from "lodash-es";
import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
@ -10,6 +10,7 @@ import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { useDeleteUser, useListUsers } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { State } from "@/types/proto/api/v1/common_pb";
import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
@ -26,17 +27,11 @@ const MemberSection = () => {
const createDialog = useDialog();
const editDialog = useDialog();
const [editingUser, setEditingUser] = useState<User | undefined>();
const sortedUsers = sortBy(users, "id");
const sortedUsers = useMemo(() => sortBy(users, "id"), [users]);
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined);
const stringifyUserRole = (role: User_Role) => {
if (role === User_Role.ADMIN) {
return t("setting.member.admin");
} else {
return t("setting.member.user");
}
};
const stringifyUserRole = (role: User_Role) => (role === User_Role.ADMIN ? t("setting.member.admin") : t("setting.member.user"));
const handleCreateUser = () => {
setEditingUser(undefined);
@ -48,48 +43,63 @@ const MemberSection = () => {
editDialog.open();
};
const handleArchiveUserClick = async (user: User) => {
const handleArchiveUserClick = (user: User) => {
setArchiveTarget(user);
};
const confirmArchiveUser = async () => {
if (!archiveTarget) return;
const username = archiveTarget.username;
await userServiceClient.updateUser({
user: {
name: archiveTarget.name,
state: State.ARCHIVED,
},
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
});
try {
await userServiceClient.updateUser({
user: {
name: archiveTarget.name,
state: State.ARCHIVED,
},
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
});
toast.success(t("setting.member.archive-success", { username }));
await refetchUsers();
} catch (error: unknown) {
handleError(error, toast.error, { context: "Archive user" });
}
setArchiveTarget(undefined);
toast.success(t("setting.member.archive-success", { username }));
await refetchUsers();
};
const handleRestoreUserClick = async (user: User) => {
const { username } = user;
await userServiceClient.updateUser({
user: {
name: user.name,
state: State.NORMAL,
},
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
});
toast.success(t("setting.member.restore-success", { username }));
await refetchUsers();
try {
await userServiceClient.updateUser({
user: {
name: user.name,
state: State.NORMAL,
},
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
});
toast.success(t("setting.member.restore-success", { username }));
await refetchUsers();
} catch (error: unknown) {
handleError(error, toast.error, { context: "Restore user" });
}
};
const handleDeleteUserClick = async (user: User) => {
const handleDeleteUserClick = (user: User) => {
setDeleteTarget(user);
};
const confirmDeleteUser = async () => {
const confirmDeleteUser = () => {
if (!deleteTarget) return;
const { username, name } = deleteTarget;
deleteUserMutation.mutate(name);
setDeleteTarget(undefined);
toast.success(t("setting.member.delete-success", { username }));
deleteUserMutation.mutate(name, {
onSuccess: () => {
setDeleteTarget(undefined);
toast.success(t("setting.member.delete-success", { username }));
},
onError: (error) => {
setDeleteTarget(undefined);
handleError(error, toast.error, { context: "Delete user" });
},
});
};
return (
@ -110,7 +120,7 @@ const MemberSection = () => {
render: (_, user: User) => (
<span className="text-foreground">
{user.username}
{user.state === State.ARCHIVED && <span className="ml-2 italic text-muted-foreground">(Archived)</span>}
{user.state === State.ARCHIVED && <span className="ml-2 italic text-muted-foreground">({t("common.archived")})</span>}
</span>
),
},
@ -161,7 +171,7 @@ const MemberSection = () => {
},
]}
data={sortedUsers}
emptyMessage="No members found"
emptyMessage={t("setting.member.no-members-found")}
getRowKey={(user) => user.name}
/>

View File

@ -35,17 +35,18 @@ const MemoRelatedSettings = () => {
};
const upsertReaction = () => {
if (!editingReaction) {
const trimmed = editingReaction.trim();
if (!trimmed) {
return;
}
updatePartialSetting({ reactions: uniq([...memoRelatedSetting.reactions, editingReaction.trim()]) });
updatePartialSetting({ reactions: uniq([...memoRelatedSetting.reactions, trimmed]) });
setEditingReaction("");
};
const handleUpdateSetting = async () => {
if (memoRelatedSetting.reactions.length === 0) {
toast.error("Reactions must not be empty.");
toast.error(t("setting.memo.reactions-required"));
return;
}
@ -69,8 +70,8 @@ const MemoRelatedSettings = () => {
};
return (
<SettingSection>
<SettingGroup title={t("setting.memo.title")}>
<SettingSection title={t("setting.memo.label")}>
<SettingGroup title={t("common.basic")}>
<SettingRow label={t("setting.system.display-with-updated-time")}>
<Switch
checked={memoRelatedSetting.displayWithUpdateTime}
@ -89,8 +90,8 @@ const MemoRelatedSettings = () => {
<Input
className="w-24"
type="number"
defaultValue={memoRelatedSetting.contentLengthLimit}
onBlur={(event) => updatePartialSetting({ contentLengthLimit: Number(event.target.value) })}
value={memoRelatedSetting.contentLengthLimit}
onChange={(event) => updatePartialSetting({ contentLengthLimit: Number(event.target.value) })}
/>
</SettingRow>
</SettingGroup>
@ -113,7 +114,7 @@ const MemoRelatedSettings = () => {
className="w-32 h-8"
placeholder={t("common.input")}
value={editingReaction}
onChange={(event) => setEditingReaction(event.target.value.trim())}
onChange={(event) => setEditingReaction(event.target.value)}
onKeyDown={(e) => e.key === "Enter" && upsertReaction()}
/>
<Button variant="ghost" size="sm" onClick={upsertReaction} className="h-8 w-8 p-0">

View File

@ -17,16 +17,8 @@ const MyAccountSection = () => {
const accountDialog = useDialog();
const passwordDialog = useDialog();
const handleEditAccount = () => {
accountDialog.open();
};
const handleChangePassword = () => {
passwordDialog.open();
};
return (
<SettingSection>
<SettingSection title={t("setting.my-account.label")}>
<SettingGroup title={t("setting.account.title")}>
<div className="w-full flex flex-row justify-start items-center gap-3">
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} />
@ -38,7 +30,7 @@ const MyAccountSection = () => {
{user?.description && <p className="w-full text-sm text-muted-foreground truncate">{user?.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleEditAccount}>
<Button variant="outline" size="sm" onClick={accountDialog.open}>
<PenLineIcon className="w-4 h-4 mr-1.5" />
{t("common.edit")}
</Button>
@ -49,7 +41,7 @@ const MyAccountSection = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleChangePassword}>{t("setting.account.change-password")}</DropdownMenuItem>
<DropdownMenuItem onClick={passwordDialog.open}>{t("setting.account.change-password")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@ -13,14 +13,13 @@ import VisibilityIcon from "../VisibilityIcon";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
import WebhookSection from "./WebhookSection";
const PreferencesSection = () => {
const t = useTranslate();
const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth();
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
const handleLocaleSelectChange = async (locale: Locale) => {
const handleLocaleSelectChange = (locale: Locale) => {
// Apply locale immediately for instant UI feedback and persist to localStorage
loadLocale(locale);
// Persist to user settings
@ -45,7 +44,7 @@ const PreferencesSection = () => {
);
};
const handleThemeChange = async (theme: string) => {
const handleThemeChange = (theme: string) => {
// Apply theme immediately for instant UI feedback
loadTheme(theme);
// Persist to user settings
@ -69,7 +68,7 @@ const PreferencesSection = () => {
});
return (
<SettingSection>
<SettingSection title={t("setting.preference.label")}>
<SettingGroup title={t("common.basic")}>
<SettingRow label={t("common.language")}>
<LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} />
@ -80,7 +79,7 @@ const PreferencesSection = () => {
</SettingRow>
</SettingGroup>
<SettingGroup title={t("setting.preference.label")} showSeparator>
<SettingGroup title={t("common.memo")} showSeparator>
<SettingRow label={t("setting.preference.default-memo-visibility")}>
<Select value={setting.memoVisibility || "PRIVATE"} onValueChange={handleDefaultMemoVisibilityChanged}>
<SelectTrigger className="min-w-fit">
@ -101,10 +100,6 @@ const PreferencesSection = () => {
</Select>
</SettingRow>
</SettingGroup>
<SettingGroup showSeparator>
<WebhookSection />
</SettingGroup>
</SettingSection>
);
};

View File

@ -5,6 +5,7 @@ import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { identityProviderServiceClient } from "@/connect";
import { useDialog } from "@/hooks/useDialog";
import { handleError } from "@/lib/error";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
import { useTranslate } from "@/utils/i18n";
@ -16,20 +17,20 @@ import SettingTable from "./SettingTable";
const SSOSection = () => {
const t = useTranslate();
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
const [deleteTarget, setDeleteTarget] = useState<IdentityProvider | undefined>(undefined);
useEffect(() => {
fetchIdentityProviderList();
}, []);
const idpDialog = useDialog();
const fetchIdentityProviderList = async () => {
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
setIdentityProviderList(identityProviders);
};
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
useEffect(() => {
fetchIdentityProviderList();
}, []);
const handleDeleteIdentityProvider = (identityProvider: IdentityProvider) => {
setDeleteTarget(identityProvider);
};
@ -48,23 +49,22 @@ const SSOSection = () => {
const handleCreateIdentityProvider = () => {
setEditingIdentityProvider(undefined);
setIsCreateDialogOpen(true);
idpDialog.open();
};
const handleEditIdentityProvider = (identityProvider: IdentityProvider) => {
setEditingIdentityProvider(identityProvider);
setIsCreateDialogOpen(true);
idpDialog.open();
};
const handleDialogSuccess = async () => {
await fetchIdentityProviderList();
setIsCreateDialogOpen(false);
idpDialog.close();
setEditingIdentityProvider(undefined);
};
const handleDialogOpenChange = (open: boolean) => {
setIsCreateDialogOpen(open);
// Clear editing state when dialog is closed
idpDialog.setOpen(open);
if (!open) {
setEditingIdentityProvider(undefined);
}
@ -127,7 +127,7 @@ const SSOSection = () => {
/>
<CreateIdentityProviderDialog
open={isCreateDialogOpen}
open={idpDialog.isOpen}
onOpenChange={handleDialogOpenChange}
identityProvider={editingIdentityProvider}
onSuccess={handleDialogSuccess}

View File

@ -1,14 +1,14 @@
import { LucideIcon } from "lucide-react";
import React from "react";
interface SettingMenuItemProps {
interface SectionMenuItemProps {
text: string;
icon: LucideIcon;
isSelected: boolean;
onClick: () => void;
}
const SectionMenuItem: React.FC<SettingMenuItemProps> = ({ text, icon: IconComponent, isSelected, onClick }) => {
const SectionMenuItem: React.FC<SectionMenuItemProps> = ({ text, icon: IconComponent, isSelected, onClick }) => {
return (
<div
onClick={onClick}

View File

@ -55,78 +55,55 @@ const StorageSection = () => {
return !isEqual(originalSetting, instanceStorageSetting);
}, [instanceStorageSetting, originalSetting]);
const handleMaxUploadSizeChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
const handleMaxUploadSizeChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
let num = parseInt(event.target.value);
if (Number.isNaN(num)) {
num = 0;
}
const update = create(InstanceSetting_StorageSettingSchema, {
...instanceStorageSetting,
uploadSizeLimitMb: BigInt(num),
});
setInstanceStorageSetting(update);
setInstanceStorageSetting(
create(InstanceSetting_StorageSettingSchema, {
...instanceStorageSetting,
uploadSizeLimitMb: BigInt(num),
}),
);
};
const handleFilepathTemplateChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
const update = create(InstanceSetting_StorageSettingSchema, {
...instanceStorageSetting,
filepathTemplate: event.target.value,
});
setInstanceStorageSetting(update);
const handleFilepathTemplateChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
setInstanceStorageSetting(
create(InstanceSetting_StorageSettingSchema, {
...instanceStorageSetting,
filepathTemplate: event.target.value,
}),
);
};
const handlePartialS3ConfigChanged = async (s3Config: Partial<InstanceSetting_StorageSetting_S3Config>) => {
const existingS3Config = instanceStorageSetting.s3Config;
const s3ConfigInit = {
accessKeyId: existingS3Config?.accessKeyId ?? "",
accessKeySecret: existingS3Config?.accessKeySecret ?? "",
endpoint: existingS3Config?.endpoint ?? "",
region: existingS3Config?.region ?? "",
bucket: existingS3Config?.bucket ?? "",
usePathStyle: existingS3Config?.usePathStyle ?? false,
...s3Config,
};
const update = create(InstanceSetting_StorageSettingSchema, {
storageType: instanceStorageSetting.storageType,
filepathTemplate: instanceStorageSetting.filepathTemplate,
uploadSizeLimitMb: instanceStorageSetting.uploadSizeLimitMb,
s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, s3ConfigInit),
});
setInstanceStorageSetting(update);
const handleS3FieldChange = (field: keyof InstanceSetting_StorageSetting_S3Config, value: string | boolean) => {
const existing = instanceStorageSetting.s3Config;
setInstanceStorageSetting(
create(InstanceSetting_StorageSettingSchema, {
storageType: instanceStorageSetting.storageType,
filepathTemplate: instanceStorageSetting.filepathTemplate,
uploadSizeLimitMb: instanceStorageSetting.uploadSizeLimitMb,
s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, {
accessKeyId: existing?.accessKeyId ?? "",
accessKeySecret: existing?.accessKeySecret ?? "",
endpoint: existing?.endpoint ?? "",
region: existing?.region ?? "",
bucket: existing?.bucket ?? "",
usePathStyle: existing?.usePathStyle ?? false,
[field]: value,
}),
}),
);
};
const handleS3ConfigAccessKeyIdChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
handlePartialS3ConfigChanged({ accessKeyId: event.target.value });
};
const handleS3ConfigAccessKeySecretChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
handlePartialS3ConfigChanged({ accessKeySecret: event.target.value });
};
const handleS3ConfigEndpointChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
handlePartialS3ConfigChanged({ endpoint: event.target.value });
};
const handleS3ConfigRegionChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
handlePartialS3ConfigChanged({ region: event.target.value });
};
const handleS3ConfigBucketChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
handlePartialS3ConfigChanged({ bucket: event.target.value });
};
const handleS3ConfigUsePathStyleChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
handlePartialS3ConfigChanged({
usePathStyle: event.target.checked,
});
};
const handleStorageTypeChanged = async (storageType: InstanceSetting_StorageSetting_StorageType) => {
const update = create(InstanceSetting_StorageSettingSchema, {
...instanceStorageSetting,
storageType: storageType,
});
setInstanceStorageSetting(update);
const handleStorageTypeChanged = (storageType: InstanceSetting_StorageSetting_StorageType) => {
setInstanceStorageSetting(
create(InstanceSetting_StorageSettingSchema, {
...instanceStorageSetting,
storageType,
}),
);
};
const saveInstanceStorageSetting = async () => {
@ -141,7 +118,7 @@ const StorageSection = () => {
}),
);
await fetchSetting(InstanceSetting_Key.STORAGE);
toast.success("Updated");
toast.success(t("message.update-succeed"));
} catch (error: unknown) {
handleError(error, toast.error, {
context: "Update storage settings",
@ -150,7 +127,7 @@ const StorageSection = () => {
};
return (
<SettingSection>
<SettingSection title={t("setting.storage.label")}>
<SettingGroup title={t("setting.storage.current-storage")}>
<div className="w-full">
<RadioGroup
@ -197,39 +174,51 @@ const StorageSection = () => {
{instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3 && (
<SettingGroup title="S3 Configuration" showSeparator>
<SettingRow label="Access key id">
<Input className="w-64" value={instanceStorageSetting.s3Config?.accessKeyId} onChange={handleS3ConfigAccessKeyIdChanged} />
<SettingRow label={t("setting.storage.accesskey")}>
<Input
className="w-64"
value={instanceStorageSetting.s3Config?.accessKeyId}
onChange={(e) => handleS3FieldChange("accessKeyId", e.target.value)}
/>
</SettingRow>
<SettingRow label="Access key secret">
<SettingRow label={t("setting.storage.secretkey")}>
<Input
className="w-64"
type="password"
value={instanceStorageSetting.s3Config?.accessKeySecret}
onChange={handleS3ConfigAccessKeySecretChanged}
onChange={(e) => handleS3FieldChange("accessKeySecret", e.target.value)}
/>
</SettingRow>
<SettingRow label="Endpoint">
<Input className="w-64" value={instanceStorageSetting.s3Config?.endpoint} onChange={handleS3ConfigEndpointChanged} />
<SettingRow label={t("setting.storage.endpoint")}>
<Input
className="w-64"
value={instanceStorageSetting.s3Config?.endpoint}
onChange={(e) => handleS3FieldChange("endpoint", e.target.value)}
/>
</SettingRow>
<SettingRow label="Region">
<Input className="w-64" value={instanceStorageSetting.s3Config?.region} onChange={handleS3ConfigRegionChanged} />
<SettingRow label={t("setting.storage.region")}>
<Input
className="w-64"
value={instanceStorageSetting.s3Config?.region}
onChange={(e) => handleS3FieldChange("region", e.target.value)}
/>
</SettingRow>
<SettingRow label="Bucket">
<Input className="w-64" value={instanceStorageSetting.s3Config?.bucket} onChange={handleS3ConfigBucketChanged} />
<SettingRow label={t("setting.storage.bucket")}>
<Input
className="w-64"
value={instanceStorageSetting.s3Config?.bucket}
onChange={(e) => handleS3FieldChange("bucket", e.target.value)}
/>
</SettingRow>
<SettingRow label="Use Path Style">
<Switch
checked={instanceStorageSetting.s3Config?.usePathStyle}
onCheckedChange={(checked) =>
handleS3ConfigUsePathStyleChanged({ target: { checked } } as React.ChangeEvent<HTMLInputElement> & {
target: { checked: boolean };
})
}
onCheckedChange={(checked) => handleS3FieldChange("usePathStyle", checked)}
/>
</SettingRow>
</SettingGroup>

View File

@ -0,0 +1,203 @@
import { create } from "@bufbuild/protobuf";
import { isEqual } from "lodash-es";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useInstance } from "@/contexts/InstanceContext";
import { useTagCounts } from "@/hooks/useUserQueries";
import { colorToHex } from "@/lib/color";
import { handleError } from "@/lib/error";
import {
InstanceSetting_Key,
InstanceSetting_TagMetadataSchema,
InstanceSetting_TagsSettingSchema,
InstanceSettingSchema,
} from "@/types/proto/api/v1/instance_service_pb";
import { ColorSchema } from "@/types/proto/google/type/color_pb";
import { useTranslate } from "@/utils/i18n";
import SettingGroup from "./SettingGroup";
import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable";
// Fallback to white when no color is stored.
const tagColorToHex = (color?: { red?: number; green?: number; blue?: number }): string => colorToHex(color) ?? "#ffffff";
// Converts a CSS hex string to a google.type.Color message.
const hexToColor = (hex: string) =>
create(ColorSchema, {
red: parseInt(hex.slice(1, 3), 16) / 255,
green: parseInt(hex.slice(3, 5), 16) / 255,
blue: parseInt(hex.slice(5, 7), 16) / 255,
});
const TagsSection = () => {
const t = useTranslate();
const { tagsSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const { data: tagCounts = {} } = useTagCounts(false);
// Local state: map of tagName → hex color string for editing.
const [localTags, setLocalTags] = useState<Record<string, string>>(() =>
Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])),
);
const [newTagName, setNewTagName] = useState("");
const [newTagColor, setNewTagColor] = useState("#ffffff");
// Sync local state when the fetched setting arrives (the fetch is async and
// completes after mount, so localTags would be empty without this sync).
useEffect(() => {
setLocalTags(
Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])),
);
}, [originalSetting.tags]);
// All known tag names: union of saved entries and tags used in memos.
const allKnownTags = useMemo(
() => Array.from(new Set([...Object.keys(localTags), ...Object.keys(tagCounts)])).sort(),
[localTags, tagCounts],
);
// Only show rows for tags that have metadata configured.
const configuredEntries = useMemo(
() =>
Object.keys(localTags)
.sort()
.map((name) => ({ name })),
[localTags],
);
const originalHexMap = useMemo(
() => Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])),
[originalSetting.tags],
);
const hasChanges = !isEqual(localTags, originalHexMap);
const handleColorChange = (tagName: string, hex: string) => {
setLocalTags((prev) => ({ ...prev, [tagName]: hex }));
};
const handleRemoveTag = (tagName: string) => {
setLocalTags((prev) => {
const next = { ...prev };
delete next[tagName];
return next;
});
};
const handleAddTag = () => {
const name = newTagName.trim();
if (!name) return;
if (localTags[name] !== undefined) {
toast.error(t("setting.tags.tag-already-exists"));
return;
}
setLocalTags((prev) => ({ ...prev, [name]: newTagColor }));
setNewTagName("");
setNewTagColor("#ffffff");
};
const handleSave = async () => {
try {
const tags = Object.fromEntries(
Object.entries(localTags).map(([name, hex]) => [
name,
create(InstanceSetting_TagMetadataSchema, { backgroundColor: hexToColor(hex) }),
]),
);
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.TAGS]}`,
value: {
case: "tagsSetting",
value: create(InstanceSetting_TagsSettingSchema, { tags }),
},
}),
);
await fetchSetting(InstanceSetting_Key.TAGS);
toast.success(t("message.update-succeed"));
} catch (error: unknown) {
handleError(error, toast.error, { context: "Update tags setting" });
}
};
return (
<SettingSection title={t("setting.tags.label")}>
<SettingGroup title={t("setting.tags.title")} description={t("setting.tags.description")}>
<SettingTable
columns={[
{
key: "name",
header: t("setting.tags.tag-name"),
render: (_, row: { name: string }) => <span className="font-mono text-foreground">{row.name}</span>,
},
{
key: "color",
header: t("setting.tags.background-color"),
render: (_, row: { name: string }) => (
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded border border-border shrink-0" style={{ backgroundColor: localTags[row.name] }} />
<input
type="color"
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
value={localTags[row.name]}
onChange={(e) => handleColorChange(row.name, e.target.value)}
/>
</div>
),
},
{
key: "actions",
header: "",
className: "text-right",
render: (_, row: { name: string }) => (
<Button variant="ghost" size="sm" onClick={() => handleRemoveTag(row.name)}>
<TrashIcon className="w-4 h-4 text-destructive" />
</Button>
),
},
]}
data={configuredEntries}
emptyMessage={t("setting.tags.no-tags-configured")}
getRowKey={(row) => row.name}
/>
<div className="flex items-center gap-2 pt-1">
<Input
className="w-48"
placeholder={t("setting.tags.tag-name-placeholder")}
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddTag()}
list="known-tags"
/>
<datalist id="known-tags">
{allKnownTags
.filter((tag) => !localTags[tag])
.map((tag) => (
<option key={tag} value={tag} />
))}
</datalist>
<input
type="color"
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
value={newTagColor}
onChange={(e) => setNewTagColor(e.target.value)}
/>
<Button variant="outline" size="sm" onClick={handleAddTag} disabled={!newTagName.trim()}>
<PlusIcon className="w-4 h-4 mr-1.5" />
{t("common.add")}
</Button>
</div>
</SettingGroup>
<div className="w-full flex justify-end">
<Button disabled={!hasChanges} onClick={handleSave}>
{t("common.save")}
</Button>
</div>
</SettingSection>
);
};
export default TagsSection;

View File

@ -1,7 +1,6 @@
import { ExternalLinkIcon, PlusIcon, TrashIcon } from "lucide-react";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
@ -9,6 +8,8 @@ import useCurrentUser from "@/hooks/useCurrentUser";
import { UserWebhook } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import CreateWebhookDialog from "../CreateWebhookDialog";
import LearnMore from "../LearnMore";
import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable";
const WebhookSection = () => {
@ -18,7 +19,7 @@ const WebhookSection = () => {
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(undefined);
const listWebhooks = async () => {
const fetchWebhooks = async () => {
if (!currentUser) return [];
const { webhooks } = await userServiceClient.listUserWebhooks({
parent: currentUser.name,
@ -27,41 +28,45 @@ const WebhookSection = () => {
};
useEffect(() => {
listWebhooks().then((webhooks) => {
setWebhooks(webhooks);
});
fetchWebhooks().then(setWebhooks);
}, [currentUser]);
const handleCreateWebhookDialogConfirm = async () => {
const webhooks = await listWebhooks();
const webhooks = await fetchWebhooks();
const name = webhooks[webhooks.length - 1]?.displayName || "";
setWebhooks(webhooks);
setIsCreateWebhookDialogOpen(false);
toast.success(t("setting.webhook.create-dialog.create-webhook-success", { name }));
};
const handleDeleteWebhook = async (webhook: UserWebhook) => {
const handleDeleteWebhook = (webhook: UserWebhook) => {
setDeleteTarget(webhook);
};
const confirmDeleteWebhook = async () => {
if (!deleteTarget) return;
await userServiceClient.deleteUserWebhook({ name: deleteTarget.name });
setWebhooks(webhooks.filter((item) => item.name !== deleteTarget.name));
setWebhooks((prev) => prev.filter((item) => item.name !== deleteTarget.name));
const name = deleteTarget.displayName;
setDeleteTarget(undefined);
toast.success(t("setting.webhook.delete-dialog.delete-webhook-success", { name: deleteTarget.displayName }));
toast.success(t("setting.webhook.delete-dialog.delete-webhook-success", { name }));
};
return (
<div className="w-full flex flex-col gap-2">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<h4 className="text-sm font-medium text-muted-foreground">{t("setting.webhook.title")}</h4>
<Button onClick={() => setIsCreateWebhookDialogOpen(true)} size="sm">
<PlusIcon className="w-4 h-4 mr-1.5" />
<SettingSection
title={
<div className="flex items-center gap-2">
<span>{t("setting.webhook.title")}</span>
<LearnMore url="https://usememos.com/docs/integrations/webhooks" />
</div>
}
actions={
<Button onClick={() => setIsCreateWebhookDialogOpen(true)}>
<PlusIcon className="w-4 h-4 mr-2" />
{t("common.create")}
</Button>
</div>
}
>
<SettingTable
columns={[
{
@ -94,17 +99,6 @@ const WebhookSection = () => {
getRowKey={(webhook) => webhook.name}
/>
<div className="w-full">
<Link
className="text-muted-foreground text-sm inline-flex items-center hover:underline hover:text-primary"
to="https://usememos.com/docs/integrations/webhooks"
target="_blank"
>
{t("common.learn-more")}
<ExternalLinkIcon className="w-4 h-4 ml-1" />
</Link>
</div>
<CreateWebhookDialog
open={isCreateWebhookDialogOpen}
onOpenChange={setIsCreateWebhookDialogOpen}
@ -120,7 +114,7 @@ const WebhookSection = () => {
onConfirm={confirmDeleteWebhook}
confirmVariant="destructive"
/>
</div>
</SettingSection>
);
};

View File

@ -12,6 +12,8 @@ import {
InstanceSetting_MemoRelatedSettingSchema,
InstanceSetting_StorageSetting,
InstanceSetting_StorageSettingSchema,
InstanceSetting_TagsSetting,
InstanceSetting_TagsSettingSchema,
} from "@/types/proto/api/v1/instance_service_pb";
const instanceSettingNamePrefix = "instance/settings/";
@ -36,6 +38,7 @@ interface InstanceContextValue extends InstanceState {
generalSetting: InstanceSetting_GeneralSetting;
memoRelatedSetting: InstanceSetting_MemoRelatedSetting;
storageSetting: InstanceSetting_StorageSetting;
tagsSetting: InstanceSetting_TagsSetting;
initialize: () => Promise<void>;
fetchSetting: (key: InstanceSetting_Key) => Promise<void>;
updateSetting: (setting: InstanceSetting) => Promise<void>;
@ -77,19 +80,28 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
return create(InstanceSetting_StorageSettingSchema, {});
}, [state.settings]);
const tagsSetting = useMemo((): InstanceSetting_TagsSetting => {
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}TAGS`);
if (setting?.value.case === "tagsSetting") {
return setting.value.value;
}
return create(InstanceSetting_TagsSettingSchema, {});
}, [state.settings]);
const initialize = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
try {
const profile = await instanceServiceClient.getInstanceProfile({});
const [generalSetting, memoRelatedSettingResponse] = await Promise.all([
const [generalSetting, memoRelatedSettingResponse, tagsSettingResponse] = await Promise.all([
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.GENERAL) }),
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED) }),
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.TAGS) }),
]);
setState({
profile,
settings: [generalSetting, memoRelatedSettingResponse],
settings: [generalSetting, memoRelatedSettingResponse, tagsSettingResponse],
isInitialized: true,
isLoading: false,
profileLoaded: true,
@ -129,11 +141,12 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
generalSetting,
memoRelatedSetting,
storageSetting,
tagsSetting,
initialize,
fetchSetting,
updateSetting,
}),
[state, generalSetting, memoRelatedSetting, storageSetting, initialize, fetchSetting, updateSetting],
[state, generalSetting, memoRelatedSetting, storageSetting, tagsSetting, initialize, fetchSetting, updateSetting],
);
return <InstanceContext.Provider value={value}>{children}</InstanceContext.Provider>;

21
web/src/lib/color.ts Normal file
View File

@ -0,0 +1,21 @@
/**
* Converts a google.type.Color (r/g/b as 01 floats) to a CSS hex string (#rrggbb).
* Returns undefined when no color is provided.
*/
export const colorToHex = (color?: { red?: number; green?: number; blue?: number }): string | undefined => {
if (!color) return undefined;
const clamp = (val: number | undefined): number => {
const n = val ?? 0;
return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0;
};
const r = Math.round(clamp(color.red) * 255)
.toString(16)
.padStart(2, "0");
const g = Math.round(clamp(color.green) * 255)
.toString(16)
.padStart(2, "0");
const b = Math.round(clamp(color.blue) * 255)
.toString(16)
.padStart(2, "0");
return `#${r}${g}${b}`;
};

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "لم يتم العثور على Webhooks.",
"title": "Webhooks",
"url": "الرابط"
"url": "الرابط",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "No s'han trobat webhooks.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Žádné webhooky nenalezeny.",
"title": "Webhooky",
"url": "URL"
"url": "URL",
"label": "Webhooky"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Keine Webhooks gefunden.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -7,6 +7,9 @@
"customize-server": {
"title": "Customise Server"
}
},
"webhook": {
"label": "Webhooks"
}
},
"auth": {

View File

@ -297,7 +297,8 @@
},
"description": "A list of all access tokens for your account.",
"title": "Access Tokens",
"token": "Token"
"token": "Token",
"no-tokens-found": "No access tokens found"
},
"account": {
"change-password": "Change password",
@ -336,7 +337,8 @@
"label": "Member",
"list-title": "Member list",
"restore-success": "{{username}} restored successfully",
"user": "User"
"user": "User",
"no-members-found": "No members found"
},
"memo": {
"content-length-limit": "Content length limit (Byte)",
@ -345,7 +347,8 @@
"enable-memo-location": "Enable memo location",
"label": "Memo",
"reactions": "Reactions",
"title": "Memo related settings"
"title": "Memo related settings",
"reactions-required": "Reactions list must not be empty"
},
"my-account": {
"label": "My Account"
@ -465,7 +468,18 @@
},
"no-webhooks-found": "No webhooks found.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
},
"tags": {
"label": "Tags",
"title": "Tag metadata",
"description": "Assign display colors to tags instance-wide.",
"background-color": "Background color",
"no-tags-configured": "No tag metadata configured.",
"tag-name": "Tag name",
"tag-name-placeholder": "e.g. work",
"tag-already-exists": "Tag already exists."
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "No se encontraron webhooks.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "وب‌هوکی یافت نشد.",
"title": "وب‌هوک‌ها",
"url": "آدرس"
"url": "آدرس",
"label": "وب‌هوک‌ها"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Aucun webhook trouvé.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Non hai webhooks.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "कोई वेबहुक नहीं मिला।",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Nema pronađenih webhooks.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Nincs webhook.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Tidak ada webhook yang ditemukan.",
"title": "Webhook",
"url": "URL"
"url": "URL",
"label": "Webhook"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Nessun webhook trovato.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Webhookが見つかりません。",
"title": "Webhook",
"url": "URL"
"url": "URL",
"label": "Webhook"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Webhook-ები ვერ მოიძებნა.",
"title": "Webhook-ები",
"url": "URL"
"url": "URL",
"label": "Webhook-ები"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Webhook이 없습니다.",
"title": "Webhook",
"url": "URL"
"url": "URL",
"label": "Webhook"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "कोणतेही वेबहुक आढळले नाहीत.",
"title": "वेबहुक्स",
"url": "URL"
"url": "URL",
"label": "वेबहुक्स"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Ingen webhooks funnet.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Geen webhooks gevonden.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -435,7 +435,8 @@
},
"no-webhooks-found": "Nie znaleziono webhooków.",
"title": "Webhooki",
"url": "URL"
"url": "URL",
"label": "Webhooki"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Nenhum webhook encontrado.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Nenhum webhook encontrado.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Нет вебхуков",
"title": "Вебхуки",
"url": "Ссылка"
"url": "Ссылка",
"label": "Вебхуки"
}
},
"tag": {

View File

@ -435,7 +435,8 @@
},
"no-webhooks-found": "Ne najdem nobenega webhooka.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Inga webhooks hittades.",
"title": "Webhooks",
"url": "URL"
"url": "URL",
"label": "Webhooks"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "ไม่พบ webhook",
"title": "Webhook",
"url": "URL"
"url": "URL",
"label": "Webhook"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Webhook bulunamadı.",
"title": "Webhook'lar",
"url": "URL"
"url": "URL",
"label": "Webhook'lar"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Вебхуків не знайдено.",
"title": "Вебхуки",
"url": "Посилання"
"url": "Посилання",
"label": "Вебхуки"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "Không tìm thấy webhook nào.",
"title": "Webhook",
"url": "Url"
"url": "Url",
"label": "Webhook"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "没有 Webhook。",
"title": "Webhook",
"url": "链接"
"url": "链接",
"label": "Webhook"
}
},
"tag": {

View File

@ -434,7 +434,8 @@
},
"no-webhooks-found": "尚未建立任何 Webhook。",
"title": "Webhook",
"url": "網址"
"url": "網址",
"label": "Webhook"
}
},
"tag": {

View File

@ -1,5 +1,16 @@
import { CogIcon, DatabaseIcon, KeyIcon, LibraryIcon, LucideIcon, Settings2Icon, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CogIcon,
DatabaseIcon,
KeyIcon,
LibraryIcon,
LucideIcon,
Settings2Icon,
TagsIcon,
UserIcon,
UsersIcon,
WebhookIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import MobileHeader from "@/components/MobileHeader";
import InstanceSection from "@/components/Settings/InstanceSection";
@ -10,6 +21,8 @@ import PreferencesSection from "@/components/Settings/PreferencesSection";
import SectionMenuItem from "@/components/Settings/SectionMenuItem";
import SSOSection from "@/components/Settings/SSOSection";
import StorageSection from "@/components/Settings/StorageSection";
import TagsSection from "@/components/Settings/TagsSection";
import WebhookSection from "@/components/Settings/WebhookSection";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -18,70 +31,68 @@ import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
type SettingSection = "my-account" | "preference" | "member" | "system" | "memo" | "storage" | "sso";
type SettingSection = "my-account" | "preference" | "webhook" | "member" | "system" | "memo" | "storage" | "sso" | "tags";
interface State {
selectedSection: SettingSection;
}
const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference", "webhook"];
const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo", "storage", "tags", "sso"];
const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference"];
const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo", "storage", "sso"];
const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = {
"my-account": UserIcon,
preference: CogIcon,
webhook: WebhookIcon,
member: UsersIcon,
system: Settings2Icon,
memo: LibraryIcon,
storage: DatabaseIcon,
tags: TagsIcon,
sso: KeyIcon,
};
const SECTION_COMPONENT_MAP: Record<SettingSection, React.ComponentType> = {
"my-account": MyAccountSection,
preference: PreferencesSection,
webhook: WebhookSection,
member: MemberSection,
system: InstanceSection,
memo: MemoRelatedSettings,
storage: StorageSection,
tags: TagsSection,
sso: SSOSection,
};
const Setting = () => {
const t = useTranslate();
const sm = useMediaQuery("sm");
const location = useLocation();
const user = useCurrentUser();
const { profile, fetchSetting } = useInstance();
const [state, setState] = useState<State>({
selectedSection: "my-account",
});
const [selectedSection, setSelectedSection] = useState<SettingSection>("my-account");
const isHost = user?.role === User_Role.ADMIN;
const settingsSectionList = useMemo(() => {
let settingList = [...BASIC_SECTIONS];
if (isHost) {
settingList = settingList.concat(ADMIN_SECTIONS);
}
return settingList;
return isHost ? [...BASIC_SECTIONS, ...ADMIN_SECTIONS] : [...BASIC_SECTIONS];
}, [isHost]);
useEffect(() => {
let hash = location.hash.slice(1) as SettingSection;
// If the hash is not a valid section, redirect to the default section.
if (![...BASIC_SECTIONS, ...ADMIN_SECTIONS].includes(hash)) {
hash = "my-account";
}
setState({
selectedSection: hash,
});
}, [location.hash]);
const hash = location.hash.slice(1) as SettingSection;
const nextSection = settingsSectionList.includes(hash) ? hash : "my-account";
setSelectedSection(nextSection);
}, [location.hash, settingsSectionList]);
useEffect(() => {
if (!isHost) {
return;
}
// Initial fetch for instance settings.
(async () => {
[InstanceSetting_Key.MEMO_RELATED, InstanceSetting_Key.STORAGE].forEach(async (key) => {
await fetchSetting(key);
});
})();
// Fetch admin-only settings that are not eagerly loaded by InstanceContext.
fetchSetting(InstanceSetting_Key.STORAGE);
fetchSetting(InstanceSetting_Key.TAGS);
}, [isHost, fetchSetting]);
const handleSectionSelectorItemClick = useCallback((settingSection: SettingSection) => {
window.location.hash = settingSection;
}, []);
const handleSectionSelectorItemClick = (section: SettingSection) => {
window.location.hash = section;
};
const ActiveSection = SECTION_COMPONENT_MAP[selectedSection];
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
@ -97,12 +108,12 @@ const Setting = () => {
key={item}
text={t(`setting.${item}.label`)}
icon={SECTION_ICON_MAP[item]}
isSelected={state.selectedSection === item}
isSelected={selectedSection === item}
onClick={() => handleSectionSelectorItemClick(item)}
/>
))}
</div>
{isHost ? (
{isHost && (
<>
<span className="text-sm mt-4 pl-3 font-mono select-none text-muted-foreground">{t("common.admin")}</span>
<div className="w-full flex flex-col justify-start items-start mt-1">
@ -111,7 +122,7 @@ const Setting = () => {
key={item}
text={t(`setting.${item}.label`)}
icon={SECTION_ICON_MAP[item]}
isSelected={state.selectedSection === item}
isSelected={selectedSection === item}
onClick={() => handleSectionSelectorItemClick(item)}
/>
))}
@ -120,41 +131,27 @@ const Setting = () => {
</span>
</div>
</>
) : null}
)}
</div>
)}
<div className="w-full grow sm:pl-4 overflow-x-auto">
{!sm && (
<div className="w-auto inline-block my-2">
<Select value={state.selectedSection} onValueChange={(value) => handleSectionSelectorItemClick(value as SettingSection)}>
<Select value={selectedSection} onValueChange={(value) => handleSectionSelectorItemClick(value as SettingSection)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select section" />
</SelectTrigger>
<SelectContent>
{settingsSectionList.map((settingSection) => (
<SelectItem key={settingSection} value={settingSection}>
{t(`setting.${settingSection}.label`)}
{settingsSectionList.map((section) => (
<SelectItem key={section} value={section}>
{t(`setting.${section}.label`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{state.selectedSection === "my-account" ? (
<MyAccountSection />
) : state.selectedSection === "preference" ? (
<PreferencesSection />
) : state.selectedSection === "member" ? (
<MemberSection />
) : state.selectedSection === "system" ? (
<InstanceSection />
) : state.selectedSection === "memo" ? (
<MemoRelatedSettings />
) : state.selectedSection === "storage" ? (
<StorageSection />
) : state.selectedSection === "sso" ? (
<SSOSection />
) : null}
<ActiveSection />
</div>
</div>
</div>