From 9ded59a1aaf4c6ed970d05dde08bc1b3ebeb0bc2 Mon Sep 17 00:00:00 2001 From: memoclaw Date: Sat, 21 Mar 2026 15:05:48 +0800 Subject: [PATCH] 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> --- web/src/components/MemoContent/Tag.tsx | 25 ++- .../Settings/AccessTokenSection.tsx | 31 ++- .../components/Settings/InstanceSection.tsx | 36 ++-- web/src/components/Settings/MemberSection.tsx | 80 ++++--- .../Settings/MemoRelatedSettings.tsx | 17 +- .../components/Settings/MyAccountSection.tsx | 14 +- .../Settings/PreferencesSection.tsx | 13 +- web/src/components/Settings/SSOSection.tsx | 24 +-- .../components/Settings/SectionMenuItem.tsx | 4 +- .../components/Settings/StorageSection.tsx | 147 ++++++------- web/src/components/Settings/TagsSection.tsx | 203 ++++++++++++++++++ .../components/Settings/WebhookSection.tsx | 52 ++--- web/src/contexts/InstanceContext.tsx | 19 +- web/src/lib/color.ts | 21 ++ web/src/locales/ar.json | 3 +- web/src/locales/ca.json | 3 +- web/src/locales/cs.json | 3 +- web/src/locales/de.json | 3 +- web/src/locales/en-GB.json | 3 + web/src/locales/en.json | 22 +- web/src/locales/es.json | 3 +- web/src/locales/fa.json | 3 +- web/src/locales/fr.json | 3 +- web/src/locales/gl.json | 3 +- web/src/locales/hi.json | 3 +- web/src/locales/hr.json | 3 +- web/src/locales/hu.json | 3 +- web/src/locales/id.json | 3 +- web/src/locales/it.json | 3 +- web/src/locales/ja.json | 3 +- web/src/locales/ka-GE.json | 3 +- web/src/locales/ko.json | 3 +- web/src/locales/mr.json | 3 +- web/src/locales/nb.json | 3 +- web/src/locales/nl.json | 3 +- web/src/locales/pl.json | 3 +- web/src/locales/pt-BR.json | 3 +- web/src/locales/pt-PT.json | 3 +- web/src/locales/ru.json | 3 +- web/src/locales/sl.json | 3 +- web/src/locales/sv.json | 3 +- web/src/locales/th.json | 3 +- web/src/locales/tr.json | 3 +- web/src/locales/uk.json | 3 +- web/src/locales/vi.json | 3 +- web/src/locales/zh-Hans.json | 3 +- web/src/locales/zh-Hant.json | 3 +- web/src/pages/Setting.tsx | 113 +++++----- 48 files changed, 597 insertions(+), 320 deletions(-) create mode 100644 web/src/components/Settings/TagsSection.tsx create mode 100644 web/src/lib/color.ts diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx index 966a90418..a7a47c5b9 100644 --- a/web/src/components/MemoContent/Tag.tsx +++ b/web/src/components/MemoContent/Tag.tsx @@ -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 { children?: React.ReactNode; } -export const Tag: React.FC = ({ "data-tag": dataTag, children, className, ...props }) => { +export const Tag: React.FC = ({ "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 = ({ "data-tag": dataTag, children, classNa return ( { @@ -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 ( -
-
-
-

{t("setting.access-token.title")}

-

{t("setting.access-token.description")}

-
- -
- + { }, ]} data={personalAccessTokens} - emptyMessage="No access tokens found" + emptyMessage={t("setting.access-token.no-tokens-found")} getRowKey={(token) => token.name} /> +
+ +
+ {/* Create Access Token Dialog */} { onConfirm={confirmDeleteAccessToken} confirmVariant="destructive" /> -
+ ); }; diff --git a/web/src/components/Settings/InstanceSection.tsx b/web/src/components/Settings/InstanceSection.tsx index 9d23f82b1..2951e00e1 100644 --- a/web/src/components/Settings/InstanceSection.tsx +++ b/web/src/components/Settings/InstanceSection.tsx @@ -31,13 +31,23 @@ const InstanceSection = () => { const [identityProviderList, setIdentityProviderList] = useState([]); 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) => { 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 ( - + - @@ -109,7 +110,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")); }} /> diff --git a/web/src/components/Settings/MemberSection.tsx b/web/src/components/Settings/MemberSection.tsx index ab15e5fc2..720d36473 100644 --- a/web/src/components/Settings/MemberSection.tsx +++ b/web/src/components/Settings/MemberSection.tsx @@ -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(); - const sortedUsers = sortBy(users, "id"); + const sortedUsers = useMemo(() => sortBy(users, "id"), [users]); const [archiveTarget, setArchiveTarget] = useState(undefined); const [deleteTarget, setDeleteTarget] = useState(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) => ( {user.username} - {user.state === State.ARCHIVED && (Archived)} + {user.state === State.ARCHIVED && ({t("common.archived")})} ), }, @@ -161,7 +171,7 @@ const MemberSection = () => { }, ]} data={sortedUsers} - emptyMessage="No members found" + emptyMessage={t("setting.member.no-members-found")} getRowKey={(user) => user.name} /> diff --git a/web/src/components/Settings/MemoRelatedSettings.tsx b/web/src/components/Settings/MemoRelatedSettings.tsx index a1a15310e..8c7d2620f 100644 --- a/web/src/components/Settings/MemoRelatedSettings.tsx +++ b/web/src/components/Settings/MemoRelatedSettings.tsx @@ -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 ( - - + + { updatePartialSetting({ contentLengthLimit: Number(event.target.value) })} + value={memoRelatedSetting.contentLengthLimit} + onChange={(event) => updatePartialSetting({ contentLengthLimit: Number(event.target.value) })} /> @@ -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()} /> @@ -49,7 +41,7 @@ const MyAccountSection = () => { - {t("setting.account.change-password")} + {t("setting.account.change-password")} diff --git a/web/src/components/Settings/PreferencesSection.tsx b/web/src/components/Settings/PreferencesSection.tsx index 045336ded..d99a07ccf 100644 --- a/web/src/components/Settings/PreferencesSection.tsx +++ b/web/src/components/Settings/PreferencesSection.tsx @@ -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 ( - + @@ -80,7 +79,7 @@ const PreferencesSection = () => { - + - - - - ); }; diff --git a/web/src/components/Settings/SSOSection.tsx b/web/src/components/Settings/SSOSection.tsx index 1e7cfc8bc..baa71c77b 100644 --- a/web/src/components/Settings/SSOSection.tsx +++ b/web/src/components/Settings/SSOSection.tsx @@ -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([]); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [editingIdentityProvider, setEditingIdentityProvider] = useState(); const [deleteTarget, setDeleteTarget] = useState(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 = () => { /> void; } -const SectionMenuItem: React.FC = ({ text, icon: IconComponent, isSelected, onClick }) => { +const SectionMenuItem: React.FC = ({ text, icon: IconComponent, isSelected, onClick }) => { return (
{ return !isEqual(originalSetting, instanceStorageSetting); }, [instanceStorageSetting, originalSetting]); - const handleMaxUploadSizeChanged = async (event: React.FocusEvent) => { + const handleMaxUploadSizeChanged = (event: React.ChangeEvent) => { 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) => { - const update = create(InstanceSetting_StorageSettingSchema, { - ...instanceStorageSetting, - filepathTemplate: event.target.value, - }); - setInstanceStorageSetting(update); + const handleFilepathTemplateChanged = (event: React.ChangeEvent) => { + setInstanceStorageSetting( + create(InstanceSetting_StorageSettingSchema, { + ...instanceStorageSetting, + filepathTemplate: event.target.value, + }), + ); }; - const handlePartialS3ConfigChanged = async (s3Config: Partial) => { - 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) => { - handlePartialS3ConfigChanged({ accessKeyId: event.target.value }); - }; - - const handleS3ConfigAccessKeySecretChanged = async (event: React.FocusEvent) => { - handlePartialS3ConfigChanged({ accessKeySecret: event.target.value }); - }; - - const handleS3ConfigEndpointChanged = async (event: React.FocusEvent) => { - handlePartialS3ConfigChanged({ endpoint: event.target.value }); - }; - - const handleS3ConfigRegionChanged = async (event: React.FocusEvent) => { - handlePartialS3ConfigChanged({ region: event.target.value }); - }; - - const handleS3ConfigBucketChanged = async (event: React.FocusEvent) => { - handlePartialS3ConfigChanged({ bucket: event.target.value }); - }; - - const handleS3ConfigUsePathStyleChanged = (event: React.ChangeEvent) => { - 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 ( - +
{ {instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3 && ( - - + + handleS3FieldChange("accessKeyId", e.target.value)} + /> - + handleS3FieldChange("accessKeySecret", e.target.value)} /> - - + + handleS3FieldChange("endpoint", e.target.value)} + /> - - + + handleS3FieldChange("region", e.target.value)} + /> - - + + handleS3FieldChange("bucket", e.target.value)} + /> - handleS3ConfigUsePathStyleChanged({ target: { checked } } as React.ChangeEvent & { - target: { checked: boolean }; - }) - } + onCheckedChange={(checked) => handleS3FieldChange("usePathStyle", checked)} /> diff --git a/web/src/components/Settings/TagsSection.tsx b/web/src/components/Settings/TagsSection.tsx new file mode 100644 index 000000000..d695cb055 --- /dev/null +++ b/web/src/components/Settings/TagsSection.tsx @@ -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>(() => + 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 ( + + + {row.name}, + }, + { + key: "color", + header: t("setting.tags.background-color"), + render: (_, row: { name: string }) => ( +
+
+ handleColorChange(row.name, e.target.value)} + /> +
+ ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (_, row: { name: string }) => ( + + ), + }, + ]} + data={configuredEntries} + emptyMessage={t("setting.tags.no-tags-configured")} + getRowKey={(row) => row.name} + /> + +
+ setNewTagName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAddTag()} + list="known-tags" + /> + + {allKnownTags + .filter((tag) => !localTags[tag]) + .map((tag) => ( + + setNewTagColor(e.target.value)} + /> + +
+ + +
+ +
+ + ); +}; + +export default TagsSection; diff --git a/web/src/components/Settings/WebhookSection.tsx b/web/src/components/Settings/WebhookSection.tsx index e6f1a3eb0..9d0306bb8 100644 --- a/web/src/components/Settings/WebhookSection.tsx +++ b/web/src/components/Settings/WebhookSection.tsx @@ -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(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 ( -
-
-

{t("setting.webhook.title")}

-
+ } + actions={ + -
- + } + > { getRowKey={(webhook) => webhook.name} /> -
- - {t("common.learn-more")} - - -
- { onConfirm={confirmDeleteWebhook} confirmVariant="destructive" /> -
+
); }; diff --git a/web/src/contexts/InstanceContext.tsx b/web/src/contexts/InstanceContext.tsx index cf388bf72..96f0ad06e 100644 --- a/web/src/contexts/InstanceContext.tsx +++ b/web/src/contexts/InstanceContext.tsx @@ -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; fetchSetting: (key: InstanceSetting_Key) => Promise; updateSetting: (setting: InstanceSetting) => Promise; @@ -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 {children}; diff --git a/web/src/lib/color.ts b/web/src/lib/color.ts new file mode 100644 index 000000000..4219700ba --- /dev/null +++ b/web/src/lib/color.ts @@ -0,0 +1,21 @@ +/** + * Converts a google.type.Color (r/g/b as 0–1 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}`; +}; diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index df6ade6b1..64d7e0951 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "لم يتم العثور على Webhooks.", "title": "Webhooks", - "url": "الرابط" + "url": "الرابط", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/ca.json b/web/src/locales/ca.json index 1f4e150bc..c066ceae0 100644 --- a/web/src/locales/ca.json +++ b/web/src/locales/ca.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "No s'han trobat webhooks.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/cs.json b/web/src/locales/cs.json index cc1c998ec..8f00266a9 100644 --- a/web/src/locales/cs.json +++ b/web/src/locales/cs.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Žádné webhooky nenalezeny.", "title": "Webhooky", - "url": "URL" + "url": "URL", + "label": "Webhooky" } }, "tag": { diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 74baec44e..7d9f6c478 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Keine Webhooks gefunden.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/en-GB.json b/web/src/locales/en-GB.json index b2c7210c9..c4bc5e055 100644 --- a/web/src/locales/en-GB.json +++ b/web/src/locales/en-GB.json @@ -7,6 +7,9 @@ "customize-server": { "title": "Customise Server" } + }, + "webhook": { + "label": "Webhooks" } }, "auth": { diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 92a5b313a..f0f6d6dcd 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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": { diff --git a/web/src/locales/es.json b/web/src/locales/es.json index bc4fbb49c..0c5a436b0 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "No se encontraron webhooks.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/fa.json b/web/src/locales/fa.json index 51114faa4..cfada28a4 100644 --- a/web/src/locales/fa.json +++ b/web/src/locales/fa.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "وب‌هوکی یافت نشد.", "title": "وب‌هوک‌ها", - "url": "آدرس" + "url": "آدرس", + "label": "وب‌هوک‌ها" } }, "tag": { diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 82166a335..e04b74910 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Aucun webhook trouvé.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/gl.json b/web/src/locales/gl.json index 9683e1fa0..fd6aceb28 100644 --- a/web/src/locales/gl.json +++ b/web/src/locales/gl.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Non hai webhooks.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/hi.json b/web/src/locales/hi.json index d4be2093d..8f0abfc3b 100644 --- a/web/src/locales/hi.json +++ b/web/src/locales/hi.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "कोई वेबहुक नहीं मिला।", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/hr.json b/web/src/locales/hr.json index 45896eeb0..b32007cc3 100644 --- a/web/src/locales/hr.json +++ b/web/src/locales/hr.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Nema pronađenih webhooks.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/hu.json b/web/src/locales/hu.json index 630c86019..3b0e759ac 100644 --- a/web/src/locales/hu.json +++ b/web/src/locales/hu.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Nincs webhook.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/id.json b/web/src/locales/id.json index 1c3a22447..cc1976754 100644 --- a/web/src/locales/id.json +++ b/web/src/locales/id.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Tidak ada webhook yang ditemukan.", "title": "Webhook", - "url": "URL" + "url": "URL", + "label": "Webhook" } }, "tag": { diff --git a/web/src/locales/it.json b/web/src/locales/it.json index 44522d02d..d1fde1c9e 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Nessun webhook trovato.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 48ce04152..b7bb815cd 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Webhookが見つかりません。", "title": "Webhook", - "url": "URL" + "url": "URL", + "label": "Webhook" } }, "tag": { diff --git a/web/src/locales/ka-GE.json b/web/src/locales/ka-GE.json index ce93fee81..2b62f1e5b 100644 --- a/web/src/locales/ka-GE.json +++ b/web/src/locales/ka-GE.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Webhook-ები ვერ მოიძებნა.", "title": "Webhook-ები", - "url": "URL" + "url": "URL", + "label": "Webhook-ები" } }, "tag": { diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 2359f35a0..3429f5fdc 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Webhook이 없습니다.", "title": "Webhook", - "url": "URL" + "url": "URL", + "label": "Webhook" } }, "tag": { diff --git a/web/src/locales/mr.json b/web/src/locales/mr.json index 69d00319a..ed90e15b5 100644 --- a/web/src/locales/mr.json +++ b/web/src/locales/mr.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "कोणतेही वेबहुक आढळले नाहीत.", "title": "वेबहुक्स", - "url": "URL" + "url": "URL", + "label": "वेबहुक्स" } }, "tag": { diff --git a/web/src/locales/nb.json b/web/src/locales/nb.json index 49464ab54..769d93534 100644 --- a/web/src/locales/nb.json +++ b/web/src/locales/nb.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Ingen webhooks funnet.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index 88277fc9a..7520cc5c6 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Geen webhooks gevonden.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index 14c467cdf..1db440227 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -435,7 +435,8 @@ }, "no-webhooks-found": "Nie znaleziono webhooków.", "title": "Webhooki", - "url": "URL" + "url": "URL", + "label": "Webhooki" } }, "tag": { diff --git a/web/src/locales/pt-BR.json b/web/src/locales/pt-BR.json index 6307b5829..27c5c3c55 100644 --- a/web/src/locales/pt-BR.json +++ b/web/src/locales/pt-BR.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Nenhum webhook encontrado.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/pt-PT.json b/web/src/locales/pt-PT.json index 082f6d954..47e69152c 100644 --- a/web/src/locales/pt-PT.json +++ b/web/src/locales/pt-PT.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Nenhum webhook encontrado.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 639816a38..0e51c1b7a 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Нет вебхуков", "title": "Вебхуки", - "url": "Ссылка" + "url": "Ссылка", + "label": "Вебхуки" } }, "tag": { diff --git a/web/src/locales/sl.json b/web/src/locales/sl.json index 863d5a453..2f9ea373b 100644 --- a/web/src/locales/sl.json +++ b/web/src/locales/sl.json @@ -435,7 +435,8 @@ }, "no-webhooks-found": "Ne najdem nobenega webhooka.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index f16609e02..857981dc3 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Inga webhooks hittades.", "title": "Webhooks", - "url": "URL" + "url": "URL", + "label": "Webhooks" } }, "tag": { diff --git a/web/src/locales/th.json b/web/src/locales/th.json index 46b04bcc3..864c7fa8d 100644 --- a/web/src/locales/th.json +++ b/web/src/locales/th.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "ไม่พบ webhook", "title": "Webhook", - "url": "URL" + "url": "URL", + "label": "Webhook" } }, "tag": { diff --git a/web/src/locales/tr.json b/web/src/locales/tr.json index 4b65f9cf1..318d62fab 100644 --- a/web/src/locales/tr.json +++ b/web/src/locales/tr.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Webhook bulunamadı.", "title": "Webhook'lar", - "url": "URL" + "url": "URL", + "label": "Webhook'lar" } }, "tag": { diff --git a/web/src/locales/uk.json b/web/src/locales/uk.json index 6a084d2e3..ccaecd691 100644 --- a/web/src/locales/uk.json +++ b/web/src/locales/uk.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "Вебхуків не знайдено.", "title": "Вебхуки", - "url": "Посилання" + "url": "Посилання", + "label": "Вебхуки" } }, "tag": { diff --git a/web/src/locales/vi.json b/web/src/locales/vi.json index baeaec59f..273e76921 100644 --- a/web/src/locales/vi.json +++ b/web/src/locales/vi.json @@ -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": { diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index aa746180c..6e30fc0bd 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "没有 Webhook。", "title": "Webhook", - "url": "链接" + "url": "链接", + "label": "Webhook" } }, "tag": { diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index da626b6b0..97fe82c8a 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -434,7 +434,8 @@ }, "no-webhooks-found": "尚未建立任何 Webhook。", "title": "Webhook", - "url": "網址" + "url": "網址", + "label": "Webhook" } }, "tag": { diff --git a/web/src/pages/Setting.tsx b/web/src/pages/Setting.tsx index f9f4790aa..5cb82e153 100644 --- a/web/src/pages/Setting.tsx +++ b/web/src/pages/Setting.tsx @@ -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 = { "my-account": UserIcon, preference: CogIcon, + webhook: WebhookIcon, member: UsersIcon, system: Settings2Icon, memo: LibraryIcon, storage: DatabaseIcon, + tags: TagsIcon, sso: KeyIcon, }; +const SECTION_COMPONENT_MAP: Record = { + "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({ - selectedSection: "my-account", - }); + const [selectedSection, setSelectedSection] = useState("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 (
@@ -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)} /> ))}
- {isHost ? ( + {isHost && ( <> {t("common.admin")}
@@ -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 = () => {
- ) : null} + )}
)}
{!sm && (
- handleSectionSelectorItemClick(value as SettingSection)}> - {settingsSectionList.map((settingSection) => ( - - {t(`setting.${settingSection}.label`)} + {settingsSectionList.map((section) => ( + + {t(`setting.${section}.label`)} ))}
)} - {state.selectedSection === "my-account" ? ( - - ) : state.selectedSection === "preference" ? ( - - ) : state.selectedSection === "member" ? ( - - ) : state.selectedSection === "system" ? ( - - ) : state.selectedSection === "memo" ? ( - - ) : state.selectedSection === "storage" ? ( - - ) : state.selectedSection === "sso" ? ( - - ) : null} +