Unverified Commit 9ded59a1 authored by memoclaw's avatar memoclaw Committed by GitHub

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

Co-authored-by: 's avatarmemoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: 's avatarCopilot <223556219+Copilot@users.noreply.github.com>
parent d5de325f
import type { Element } from "hast"; import type { Element } from "hast";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { colorToHex } from "@/lib/color";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { useMemoViewContext } from "../MemoView/MemoViewContext"; import { useMemoViewContext } from "../MemoView/MemoViewContext";
...@@ -12,14 +14,28 @@ interface TagProps extends React.HTMLAttributes<HTMLSpanElement> { ...@@ -12,14 +14,28 @@ interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
children?: React.ReactNode; 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 { parentPage } = useMemoViewContext();
const location = useLocation(); const location = useLocation();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext(); const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
const { tagsSetting } = useInstance();
const tag = dataTag || ""; 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) => { const handleTagClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
...@@ -48,7 +64,12 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa ...@@ -48,7 +64,12 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
return ( return (
<span <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} data-tag={tag}
{...props} {...props}
onClick={handleTagClick} onClick={handleTagClick}
......
...@@ -12,6 +12,7 @@ import { handleError } from "@/lib/error"; ...@@ -12,6 +12,7 @@ import { handleError } from "@/lib/error";
import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/proto/api/v1/user_service_pb"; import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import CreateAccessTokenDialog from "../CreateAccessTokenDialog"; import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
import SettingGroup from "./SettingGroup";
import SettingTable from "./SettingTable"; import SettingTable from "./SettingTable";
const listAccessTokens = async (parent: string) => { const listAccessTokens = async (parent: string) => {
...@@ -64,11 +65,7 @@ const AccessTokenSection = () => { ...@@ -64,11 +65,7 @@ const AccessTokenSection = () => {
); );
}; };
const handleCreateToken = () => { const handleDeleteAccessToken = (token: PersonalAccessToken) => {
createTokenDialog.open();
};
const handleDeleteAccessToken = async (token: PersonalAccessToken) => {
setDeleteTarget(token); setDeleteTarget(token);
}; };
...@@ -82,18 +79,7 @@ const AccessTokenSection = () => { ...@@ -82,18 +79,7 @@ const AccessTokenSection = () => {
}; };
return ( return (
<div className="w-full flex flex-col gap-2"> <SettingGroup title={t("setting.access-token.title")} description={t("setting.access-token.description")}>
<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>
<SettingTable <SettingTable
columns={[ columns={[
{ {
...@@ -125,10 +111,17 @@ const AccessTokenSection = () => { ...@@ -125,10 +111,17 @@ const AccessTokenSection = () => {
}, },
]} ]}
data={personalAccessTokens} data={personalAccessTokens}
emptyMessage="No access tokens found" emptyMessage={t("setting.access-token.no-tokens-found")}
getRowKey={(token) => token.name} 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 */} {/* Create Access Token Dialog */}
<CreateAccessTokenDialog <CreateAccessTokenDialog
open={createTokenDialog.isOpen} open={createTokenDialog.isOpen}
...@@ -145,7 +138,7 @@ const AccessTokenSection = () => { ...@@ -145,7 +138,7 @@ const AccessTokenSection = () => {
onConfirm={confirmDeleteAccessToken} onConfirm={confirmDeleteAccessToken}
confirmVariant="destructive" confirmVariant="destructive"
/> />
</div> </SettingGroup>
); );
}; };
......
...@@ -31,13 +31,23 @@ const InstanceSection = () => { ...@@ -31,13 +31,23 @@ const InstanceSection = () => {
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]); const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
useEffect(() => { useEffect(() => {
setInstanceGeneralSetting({ ...instanceGeneralSetting, customProfile: originalSetting.customProfile }); setInstanceGeneralSetting((prev) =>
}, [originalSetting]); create(InstanceSetting_GeneralSettingSchema, {
...prev,
customProfile: originalSetting.customProfile,
}),
);
}, [originalSetting.customProfile]);
const handleUpdateCustomizedProfileButtonClick = () => { const fetchIdentityProviderList = async () => {
customizeDialog.open(); const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
setIdentityProviderList(identityProviders);
}; };
useEffect(() => {
fetchIdentityProviderList();
}, []);
const updatePartialSetting = (partial: Partial<InstanceSetting_GeneralSetting>) => { const updatePartialSetting = (partial: Partial<InstanceSetting_GeneralSetting>) => {
setInstanceGeneralSetting( setInstanceGeneralSetting(
create(InstanceSetting_GeneralSettingSchema, { create(InstanceSetting_GeneralSettingSchema, {
...@@ -68,20 +78,11 @@ const InstanceSection = () => { ...@@ -68,20 +78,11 @@ const InstanceSection = () => {
toast.success(t("message.update-succeed")); toast.success(t("message.update-succeed"));
}; };
useEffect(() => {
fetchIdentityProviderList();
}, []);
const fetchIdentityProviderList = async () => {
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
setIdentityProviderList(identityProviders);
};
return ( return (
<SettingSection> <SettingSection title={t("setting.system.label")}>
<SettingGroup title={t("common.basic")}> <SettingGroup title={t("common.basic")}>
<SettingRow label={t("setting.system.server-name")} description={instanceGeneralSetting.customProfile?.title || "Memos"}> <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")} {t("common.edit")}
</Button> </Button>
</SettingRow> </SettingRow>
...@@ -109,7 +110,7 @@ const InstanceSection = () => { ...@@ -109,7 +110,7 @@ const InstanceSection = () => {
</SettingRow> </SettingRow>
</SettingGroup> </SettingGroup>
<SettingGroup> <SettingGroup showSeparator>
<SettingRow label={t("setting.instance.disallow-user-registration")}> <SettingRow label={t("setting.instance.disallow-user-registration")}>
<Switch <Switch
disabled={profile.demo} disabled={profile.demo}
...@@ -169,8 +170,7 @@ const InstanceSection = () => { ...@@ -169,8 +170,7 @@ const InstanceSection = () => {
open={customizeDialog.isOpen} open={customizeDialog.isOpen}
onOpenChange={customizeDialog.setOpen} onOpenChange={customizeDialog.setOpen}
onSuccess={() => { onSuccess={() => {
// Refresh instance settings if needed toast.success(t("message.update-succeed"));
toast.success("Profile updated successfully!");
}} }}
/> />
</SettingSection> </SettingSection>
......
...@@ -2,7 +2,7 @@ import { create } from "@bufbuild/protobuf"; ...@@ -2,7 +2,7 @@ import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import { MoreVerticalIcon, PlusIcon } from "lucide-react"; import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { useState } from "react"; import { useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -10,6 +10,7 @@ import { userServiceClient } from "@/connect"; ...@@ -10,6 +10,7 @@ import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog"; import { useDialog } from "@/hooks/useDialog";
import { useDeleteUser, useListUsers } from "@/hooks/useUserQueries"; import { useDeleteUser, useListUsers } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
import { User, User_Role } from "@/types/proto/api/v1/user_service_pb"; import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -26,17 +27,11 @@ const MemberSection = () => { ...@@ -26,17 +27,11 @@ const MemberSection = () => {
const createDialog = useDialog(); const createDialog = useDialog();
const editDialog = useDialog(); const editDialog = useDialog();
const [editingUser, setEditingUser] = useState<User | undefined>(); 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 [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined); const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined);
const stringifyUserRole = (role: User_Role) => { const stringifyUserRole = (role: User_Role) => (role === User_Role.ADMIN ? t("setting.member.admin") : t("setting.member.user"));
if (role === User_Role.ADMIN) {
return t("setting.member.admin");
} else {
return t("setting.member.user");
}
};
const handleCreateUser = () => { const handleCreateUser = () => {
setEditingUser(undefined); setEditingUser(undefined);
...@@ -48,48 +43,63 @@ const MemberSection = () => { ...@@ -48,48 +43,63 @@ const MemberSection = () => {
editDialog.open(); editDialog.open();
}; };
const handleArchiveUserClick = async (user: User) => { const handleArchiveUserClick = (user: User) => {
setArchiveTarget(user); setArchiveTarget(user);
}; };
const confirmArchiveUser = async () => { const confirmArchiveUser = async () => {
if (!archiveTarget) return; if (!archiveTarget) return;
const username = archiveTarget.username; const username = archiveTarget.username;
await userServiceClient.updateUser({ try {
user: { await userServiceClient.updateUser({
name: archiveTarget.name, user: {
state: State.ARCHIVED, name: archiveTarget.name,
}, state: State.ARCHIVED,
updateMask: create(FieldMaskSchema, { paths: ["state"] }), },
}); 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); setArchiveTarget(undefined);
toast.success(t("setting.member.archive-success", { username }));
await refetchUsers();
}; };
const handleRestoreUserClick = async (user: User) => { const handleRestoreUserClick = async (user: User) => {
const { username } = user; const { username } = user;
await userServiceClient.updateUser({ try {
user: { await userServiceClient.updateUser({
name: user.name, user: {
state: State.NORMAL, name: user.name,
}, state: State.NORMAL,
updateMask: create(FieldMaskSchema, { paths: ["state"] }), },
}); updateMask: create(FieldMaskSchema, { paths: ["state"] }),
toast.success(t("setting.member.restore-success", { username })); });
await refetchUsers(); 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); setDeleteTarget(user);
}; };
const confirmDeleteUser = async () => { const confirmDeleteUser = () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const { username, name } = deleteTarget; const { username, name } = deleteTarget;
deleteUserMutation.mutate(name); deleteUserMutation.mutate(name, {
setDeleteTarget(undefined); onSuccess: () => {
toast.success(t("setting.member.delete-success", { username })); setDeleteTarget(undefined);
toast.success(t("setting.member.delete-success", { username }));
},
onError: (error) => {
setDeleteTarget(undefined);
handleError(error, toast.error, { context: "Delete user" });
},
});
}; };
return ( return (
...@@ -110,7 +120,7 @@ const MemberSection = () => { ...@@ -110,7 +120,7 @@ const MemberSection = () => {
render: (_, user: User) => ( render: (_, user: User) => (
<span className="text-foreground"> <span className="text-foreground">
{user.username} {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> </span>
), ),
}, },
...@@ -161,7 +171,7 @@ const MemberSection = () => { ...@@ -161,7 +171,7 @@ const MemberSection = () => {
}, },
]} ]}
data={sortedUsers} data={sortedUsers}
emptyMessage="No members found" emptyMessage={t("setting.member.no-members-found")}
getRowKey={(user) => user.name} getRowKey={(user) => user.name}
/> />
......
...@@ -35,17 +35,18 @@ const MemoRelatedSettings = () => { ...@@ -35,17 +35,18 @@ const MemoRelatedSettings = () => {
}; };
const upsertReaction = () => { const upsertReaction = () => {
if (!editingReaction) { const trimmed = editingReaction.trim();
if (!trimmed) {
return; return;
} }
updatePartialSetting({ reactions: uniq([...memoRelatedSetting.reactions, editingReaction.trim()]) }); updatePartialSetting({ reactions: uniq([...memoRelatedSetting.reactions, trimmed]) });
setEditingReaction(""); setEditingReaction("");
}; };
const handleUpdateSetting = async () => { const handleUpdateSetting = async () => {
if (memoRelatedSetting.reactions.length === 0) { if (memoRelatedSetting.reactions.length === 0) {
toast.error("Reactions must not be empty."); toast.error(t("setting.memo.reactions-required"));
return; return;
} }
...@@ -69,8 +70,8 @@ const MemoRelatedSettings = () => { ...@@ -69,8 +70,8 @@ const MemoRelatedSettings = () => {
}; };
return ( return (
<SettingSection> <SettingSection title={t("setting.memo.label")}>
<SettingGroup title={t("setting.memo.title")}> <SettingGroup title={t("common.basic")}>
<SettingRow label={t("setting.system.display-with-updated-time")}> <SettingRow label={t("setting.system.display-with-updated-time")}>
<Switch <Switch
checked={memoRelatedSetting.displayWithUpdateTime} checked={memoRelatedSetting.displayWithUpdateTime}
...@@ -89,8 +90,8 @@ const MemoRelatedSettings = () => { ...@@ -89,8 +90,8 @@ const MemoRelatedSettings = () => {
<Input <Input
className="w-24" className="w-24"
type="number" type="number"
defaultValue={memoRelatedSetting.contentLengthLimit} value={memoRelatedSetting.contentLengthLimit}
onBlur={(event) => updatePartialSetting({ contentLengthLimit: Number(event.target.value) })} onChange={(event) => updatePartialSetting({ contentLengthLimit: Number(event.target.value) })}
/> />
</SettingRow> </SettingRow>
</SettingGroup> </SettingGroup>
...@@ -113,7 +114,7 @@ const MemoRelatedSettings = () => { ...@@ -113,7 +114,7 @@ const MemoRelatedSettings = () => {
className="w-32 h-8" className="w-32 h-8"
placeholder={t("common.input")} placeholder={t("common.input")}
value={editingReaction} value={editingReaction}
onChange={(event) => setEditingReaction(event.target.value.trim())} onChange={(event) => setEditingReaction(event.target.value)}
onKeyDown={(e) => e.key === "Enter" && upsertReaction()} onKeyDown={(e) => e.key === "Enter" && upsertReaction()}
/> />
<Button variant="ghost" size="sm" onClick={upsertReaction} className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" onClick={upsertReaction} className="h-8 w-8 p-0">
......
...@@ -17,16 +17,8 @@ const MyAccountSection = () => { ...@@ -17,16 +17,8 @@ const MyAccountSection = () => {
const accountDialog = useDialog(); const accountDialog = useDialog();
const passwordDialog = useDialog(); const passwordDialog = useDialog();
const handleEditAccount = () => {
accountDialog.open();
};
const handleChangePassword = () => {
passwordDialog.open();
};
return ( return (
<SettingSection> <SettingSection title={t("setting.my-account.label")}>
<SettingGroup title={t("setting.account.title")}> <SettingGroup title={t("setting.account.title")}>
<div className="w-full flex flex-row justify-start items-center gap-3"> <div className="w-full flex flex-row justify-start items-center gap-3">
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} /> <UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} />
...@@ -38,7 +30,7 @@ const MyAccountSection = () => { ...@@ -38,7 +30,7 @@ const MyAccountSection = () => {
{user?.description && <p className="w-full text-sm text-muted-foreground truncate">{user?.description}</p>} {user?.description && <p className="w-full text-sm text-muted-foreground truncate">{user?.description}</p>}
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <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" /> <PenLineIcon className="w-4 h-4 mr-1.5" />
{t("common.edit")} {t("common.edit")}
</Button> </Button>
...@@ -49,7 +41,7 @@ const MyAccountSection = () => { ...@@ -49,7 +41,7 @@ const MyAccountSection = () => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleChangePassword}>{t("setting.account.change-password")}</DropdownMenuItem> <DropdownMenuItem onClick={passwordDialog.open}>{t("setting.account.change-password")}</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
......
...@@ -13,14 +13,13 @@ import VisibilityIcon from "../VisibilityIcon"; ...@@ -13,14 +13,13 @@ import VisibilityIcon from "../VisibilityIcon";
import SettingGroup from "./SettingGroup"; import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow"; import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection"; import SettingSection from "./SettingSection";
import WebhookSection from "./WebhookSection";
const PreferencesSection = () => { const PreferencesSection = () => {
const t = useTranslate(); const t = useTranslate();
const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth(); const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth();
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name); 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 // Apply locale immediately for instant UI feedback and persist to localStorage
loadLocale(locale); loadLocale(locale);
// Persist to user settings // Persist to user settings
...@@ -45,7 +44,7 @@ const PreferencesSection = () => { ...@@ -45,7 +44,7 @@ const PreferencesSection = () => {
); );
}; };
const handleThemeChange = async (theme: string) => { const handleThemeChange = (theme: string) => {
// Apply theme immediately for instant UI feedback // Apply theme immediately for instant UI feedback
loadTheme(theme); loadTheme(theme);
// Persist to user settings // Persist to user settings
...@@ -69,7 +68,7 @@ const PreferencesSection = () => { ...@@ -69,7 +68,7 @@ const PreferencesSection = () => {
}); });
return ( return (
<SettingSection> <SettingSection title={t("setting.preference.label")}>
<SettingGroup title={t("common.basic")}> <SettingGroup title={t("common.basic")}>
<SettingRow label={t("common.language")}> <SettingRow label={t("common.language")}>
<LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} /> <LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} />
...@@ -80,7 +79,7 @@ const PreferencesSection = () => { ...@@ -80,7 +79,7 @@ const PreferencesSection = () => {
</SettingRow> </SettingRow>
</SettingGroup> </SettingGroup>
<SettingGroup title={t("setting.preference.label")} showSeparator> <SettingGroup title={t("common.memo")} showSeparator>
<SettingRow label={t("setting.preference.default-memo-visibility")}> <SettingRow label={t("setting.preference.default-memo-visibility")}>
<Select value={setting.memoVisibility || "PRIVATE"} onValueChange={handleDefaultMemoVisibilityChanged}> <Select value={setting.memoVisibility || "PRIVATE"} onValueChange={handleDefaultMemoVisibilityChanged}>
<SelectTrigger className="min-w-fit"> <SelectTrigger className="min-w-fit">
...@@ -101,10 +100,6 @@ const PreferencesSection = () => { ...@@ -101,10 +100,6 @@ const PreferencesSection = () => {
</Select> </Select>
</SettingRow> </SettingRow>
</SettingGroup> </SettingGroup>
<SettingGroup showSeparator>
<WebhookSection />
</SettingGroup>
</SettingSection> </SettingSection>
); );
}; };
......
...@@ -5,6 +5,7 @@ import ConfirmDialog from "@/components/ConfirmDialog"; ...@@ -5,6 +5,7 @@ import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { identityProviderServiceClient } from "@/connect"; import { identityProviderServiceClient } from "@/connect";
import { useDialog } from "@/hooks/useDialog";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb"; import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -16,20 +17,20 @@ import SettingTable from "./SettingTable"; ...@@ -16,20 +17,20 @@ import SettingTable from "./SettingTable";
const SSOSection = () => { const SSOSection = () => {
const t = useTranslate(); const t = useTranslate();
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]); const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>(); const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
const [deleteTarget, setDeleteTarget] = useState<IdentityProvider | undefined>(undefined); const [deleteTarget, setDeleteTarget] = useState<IdentityProvider | undefined>(undefined);
const idpDialog = useDialog();
useEffect(() => {
fetchIdentityProviderList();
}, []);
const fetchIdentityProviderList = async () => { const fetchIdentityProviderList = async () => {
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({}); const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
setIdentityProviderList(identityProviders); setIdentityProviderList(identityProviders);
}; };
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => { useEffect(() => {
fetchIdentityProviderList();
}, []);
const handleDeleteIdentityProvider = (identityProvider: IdentityProvider) => {
setDeleteTarget(identityProvider); setDeleteTarget(identityProvider);
}; };
...@@ -48,23 +49,22 @@ const SSOSection = () => { ...@@ -48,23 +49,22 @@ const SSOSection = () => {
const handleCreateIdentityProvider = () => { const handleCreateIdentityProvider = () => {
setEditingIdentityProvider(undefined); setEditingIdentityProvider(undefined);
setIsCreateDialogOpen(true); idpDialog.open();
}; };
const handleEditIdentityProvider = (identityProvider: IdentityProvider) => { const handleEditIdentityProvider = (identityProvider: IdentityProvider) => {
setEditingIdentityProvider(identityProvider); setEditingIdentityProvider(identityProvider);
setIsCreateDialogOpen(true); idpDialog.open();
}; };
const handleDialogSuccess = async () => { const handleDialogSuccess = async () => {
await fetchIdentityProviderList(); await fetchIdentityProviderList();
setIsCreateDialogOpen(false); idpDialog.close();
setEditingIdentityProvider(undefined); setEditingIdentityProvider(undefined);
}; };
const handleDialogOpenChange = (open: boolean) => { const handleDialogOpenChange = (open: boolean) => {
setIsCreateDialogOpen(open); idpDialog.setOpen(open);
// Clear editing state when dialog is closed
if (!open) { if (!open) {
setEditingIdentityProvider(undefined); setEditingIdentityProvider(undefined);
} }
...@@ -127,7 +127,7 @@ const SSOSection = () => { ...@@ -127,7 +127,7 @@ const SSOSection = () => {
/> />
<CreateIdentityProviderDialog <CreateIdentityProviderDialog
open={isCreateDialogOpen} open={idpDialog.isOpen}
onOpenChange={handleDialogOpenChange} onOpenChange={handleDialogOpenChange}
identityProvider={editingIdentityProvider} identityProvider={editingIdentityProvider}
onSuccess={handleDialogSuccess} onSuccess={handleDialogSuccess}
......
import { LucideIcon } from "lucide-react"; import { LucideIcon } from "lucide-react";
import React from "react"; import React from "react";
interface SettingMenuItemProps { interface SectionMenuItemProps {
text: string; text: string;
icon: LucideIcon; icon: LucideIcon;
isSelected: boolean; isSelected: boolean;
onClick: () => void; onClick: () => void;
} }
const SectionMenuItem: React.FC<SettingMenuItemProps> = ({ text, icon: IconComponent, isSelected, onClick }) => { const SectionMenuItem: React.FC<SectionMenuItemProps> = ({ text, icon: IconComponent, isSelected, onClick }) => {
return ( return (
<div <div
onClick={onClick} onClick={onClick}
......
...@@ -55,78 +55,55 @@ const StorageSection = () => { ...@@ -55,78 +55,55 @@ const StorageSection = () => {
return !isEqual(originalSetting, instanceStorageSetting); return !isEqual(originalSetting, instanceStorageSetting);
}, [instanceStorageSetting, originalSetting]); }, [instanceStorageSetting, originalSetting]);
const handleMaxUploadSizeChanged = async (event: React.FocusEvent<HTMLInputElement>) => { const handleMaxUploadSizeChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
let num = parseInt(event.target.value); let num = parseInt(event.target.value);
if (Number.isNaN(num)) { if (Number.isNaN(num)) {
num = 0; num = 0;
} }
const update = create(InstanceSetting_StorageSettingSchema, { setInstanceStorageSetting(
...instanceStorageSetting, create(InstanceSetting_StorageSettingSchema, {
uploadSizeLimitMb: BigInt(num), ...instanceStorageSetting,
}); uploadSizeLimitMb: BigInt(num),
setInstanceStorageSetting(update); }),
);
}; };
const handleFilepathTemplateChanged = async (event: React.FocusEvent<HTMLInputElement>) => { const handleFilepathTemplateChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
const update = create(InstanceSetting_StorageSettingSchema, { setInstanceStorageSetting(
...instanceStorageSetting, create(InstanceSetting_StorageSettingSchema, {
filepathTemplate: event.target.value, ...instanceStorageSetting,
}); filepathTemplate: event.target.value,
setInstanceStorageSetting(update); }),
);
}; };
const handlePartialS3ConfigChanged = async (s3Config: Partial<InstanceSetting_StorageSetting_S3Config>) => { const handleS3FieldChange = (field: keyof InstanceSetting_StorageSetting_S3Config, value: string | boolean) => {
const existingS3Config = instanceStorageSetting.s3Config; const existing = instanceStorageSetting.s3Config;
const s3ConfigInit = { setInstanceStorageSetting(
accessKeyId: existingS3Config?.accessKeyId ?? "", create(InstanceSetting_StorageSettingSchema, {
accessKeySecret: existingS3Config?.accessKeySecret ?? "", storageType: instanceStorageSetting.storageType,
endpoint: existingS3Config?.endpoint ?? "", filepathTemplate: instanceStorageSetting.filepathTemplate,
region: existingS3Config?.region ?? "", uploadSizeLimitMb: instanceStorageSetting.uploadSizeLimitMb,
bucket: existingS3Config?.bucket ?? "", s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, {
usePathStyle: existingS3Config?.usePathStyle ?? false, accessKeyId: existing?.accessKeyId ?? "",
...s3Config, accessKeySecret: existing?.accessKeySecret ?? "",
}; endpoint: existing?.endpoint ?? "",
const update = create(InstanceSetting_StorageSettingSchema, { region: existing?.region ?? "",
storageType: instanceStorageSetting.storageType, bucket: existing?.bucket ?? "",
filepathTemplate: instanceStorageSetting.filepathTemplate, usePathStyle: existing?.usePathStyle ?? false,
uploadSizeLimitMb: instanceStorageSetting.uploadSizeLimitMb, [field]: value,
s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, s3ConfigInit), }),
}); }),
setInstanceStorageSetting(update); );
};
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 handleStorageTypeChanged = (storageType: InstanceSetting_StorageSetting_StorageType) => {
const update = create(InstanceSetting_StorageSettingSchema, { setInstanceStorageSetting(
...instanceStorageSetting, create(InstanceSetting_StorageSettingSchema, {
storageType: storageType, ...instanceStorageSetting,
}); storageType,
setInstanceStorageSetting(update); }),
);
}; };
const saveInstanceStorageSetting = async () => { const saveInstanceStorageSetting = async () => {
...@@ -141,7 +118,7 @@ const StorageSection = () => { ...@@ -141,7 +118,7 @@ const StorageSection = () => {
}), }),
); );
await fetchSetting(InstanceSetting_Key.STORAGE); await fetchSetting(InstanceSetting_Key.STORAGE);
toast.success("Updated"); toast.success(t("message.update-succeed"));
} catch (error: unknown) { } catch (error: unknown) {
handleError(error, toast.error, { handleError(error, toast.error, {
context: "Update storage settings", context: "Update storage settings",
...@@ -150,7 +127,7 @@ const StorageSection = () => { ...@@ -150,7 +127,7 @@ const StorageSection = () => {
}; };
return ( return (
<SettingSection> <SettingSection title={t("setting.storage.label")}>
<SettingGroup title={t("setting.storage.current-storage")}> <SettingGroup title={t("setting.storage.current-storage")}>
<div className="w-full"> <div className="w-full">
<RadioGroup <RadioGroup
...@@ -197,39 +174,51 @@ const StorageSection = () => { ...@@ -197,39 +174,51 @@ const StorageSection = () => {
{instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3 && ( {instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3 && (
<SettingGroup title="S3 Configuration" showSeparator> <SettingGroup title="S3 Configuration" showSeparator>
<SettingRow label="Access key id"> <SettingRow label={t("setting.storage.accesskey")}>
<Input className="w-64" value={instanceStorageSetting.s3Config?.accessKeyId} onChange={handleS3ConfigAccessKeyIdChanged} /> <Input
className="w-64"
value={instanceStorageSetting.s3Config?.accessKeyId}
onChange={(e) => handleS3FieldChange("accessKeyId", e.target.value)}
/>
</SettingRow> </SettingRow>
<SettingRow label="Access key secret"> <SettingRow label={t("setting.storage.secretkey")}>
<Input <Input
className="w-64" className="w-64"
type="password" type="password"
value={instanceStorageSetting.s3Config?.accessKeySecret} value={instanceStorageSetting.s3Config?.accessKeySecret}
onChange={handleS3ConfigAccessKeySecretChanged} onChange={(e) => handleS3FieldChange("accessKeySecret", e.target.value)}
/> />
</SettingRow> </SettingRow>
<SettingRow label="Endpoint"> <SettingRow label={t("setting.storage.endpoint")}>
<Input className="w-64" value={instanceStorageSetting.s3Config?.endpoint} onChange={handleS3ConfigEndpointChanged} /> <Input
className="w-64"
value={instanceStorageSetting.s3Config?.endpoint}
onChange={(e) => handleS3FieldChange("endpoint", e.target.value)}
/>
</SettingRow> </SettingRow>
<SettingRow label="Region"> <SettingRow label={t("setting.storage.region")}>
<Input className="w-64" value={instanceStorageSetting.s3Config?.region} onChange={handleS3ConfigRegionChanged} /> <Input
className="w-64"
value={instanceStorageSetting.s3Config?.region}
onChange={(e) => handleS3FieldChange("region", e.target.value)}
/>
</SettingRow> </SettingRow>
<SettingRow label="Bucket"> <SettingRow label={t("setting.storage.bucket")}>
<Input className="w-64" value={instanceStorageSetting.s3Config?.bucket} onChange={handleS3ConfigBucketChanged} /> <Input
className="w-64"
value={instanceStorageSetting.s3Config?.bucket}
onChange={(e) => handleS3FieldChange("bucket", e.target.value)}
/>
</SettingRow> </SettingRow>
<SettingRow label="Use Path Style"> <SettingRow label="Use Path Style">
<Switch <Switch
checked={instanceStorageSetting.s3Config?.usePathStyle} checked={instanceStorageSetting.s3Config?.usePathStyle}
onCheckedChange={(checked) => onCheckedChange={(checked) => handleS3FieldChange("usePathStyle", checked)}
handleS3ConfigUsePathStyleChanged({ target: { checked } } as React.ChangeEvent<HTMLInputElement> & {
target: { checked: boolean };
})
}
/> />
</SettingRow> </SettingRow>
</SettingGroup> </SettingGroup>
......
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;
import { ExternalLinkIcon, PlusIcon, TrashIcon } from "lucide-react"; import { PlusIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect"; import { userServiceClient } from "@/connect";
...@@ -9,6 +8,8 @@ import useCurrentUser from "@/hooks/useCurrentUser"; ...@@ -9,6 +8,8 @@ import useCurrentUser from "@/hooks/useCurrentUser";
import { UserWebhook } from "@/types/proto/api/v1/user_service_pb"; import { UserWebhook } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import CreateWebhookDialog from "../CreateWebhookDialog"; import CreateWebhookDialog from "../CreateWebhookDialog";
import LearnMore from "../LearnMore";
import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable"; import SettingTable from "./SettingTable";
const WebhookSection = () => { const WebhookSection = () => {
...@@ -18,7 +19,7 @@ const WebhookSection = () => { ...@@ -18,7 +19,7 @@ const WebhookSection = () => {
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false); const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(undefined); const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(undefined);
const listWebhooks = async () => { const fetchWebhooks = async () => {
if (!currentUser) return []; if (!currentUser) return [];
const { webhooks } = await userServiceClient.listUserWebhooks({ const { webhooks } = await userServiceClient.listUserWebhooks({
parent: currentUser.name, parent: currentUser.name,
...@@ -27,41 +28,45 @@ const WebhookSection = () => { ...@@ -27,41 +28,45 @@ const WebhookSection = () => {
}; };
useEffect(() => { useEffect(() => {
listWebhooks().then((webhooks) => { fetchWebhooks().then(setWebhooks);
setWebhooks(webhooks);
});
}, [currentUser]); }, [currentUser]);
const handleCreateWebhookDialogConfirm = async () => { const handleCreateWebhookDialogConfirm = async () => {
const webhooks = await listWebhooks(); const webhooks = await fetchWebhooks();
const name = webhooks[webhooks.length - 1]?.displayName || ""; const name = webhooks[webhooks.length - 1]?.displayName || "";
setWebhooks(webhooks); setWebhooks(webhooks);
setIsCreateWebhookDialogOpen(false); setIsCreateWebhookDialogOpen(false);
toast.success(t("setting.webhook.create-dialog.create-webhook-success", { name })); toast.success(t("setting.webhook.create-dialog.create-webhook-success", { name }));
}; };
const handleDeleteWebhook = async (webhook: UserWebhook) => { const handleDeleteWebhook = (webhook: UserWebhook) => {
setDeleteTarget(webhook); setDeleteTarget(webhook);
}; };
const confirmDeleteWebhook = async () => { const confirmDeleteWebhook = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
await userServiceClient.deleteUserWebhook({ name: deleteTarget.name }); 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); 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 ( return (
<div className="w-full flex flex-col gap-2"> <SettingSection
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2"> title={
<h4 className="text-sm font-medium text-muted-foreground">{t("setting.webhook.title")}</h4> <div className="flex items-center gap-2">
<Button onClick={() => setIsCreateWebhookDialogOpen(true)} size="sm"> <span>{t("setting.webhook.title")}</span>
<PlusIcon className="w-4 h-4 mr-1.5" /> <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")} {t("common.create")}
</Button> </Button>
</div> }
>
<SettingTable <SettingTable
columns={[ columns={[
{ {
...@@ -94,17 +99,6 @@ const WebhookSection = () => { ...@@ -94,17 +99,6 @@ const WebhookSection = () => {
getRowKey={(webhook) => webhook.name} 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 <CreateWebhookDialog
open={isCreateWebhookDialogOpen} open={isCreateWebhookDialogOpen}
onOpenChange={setIsCreateWebhookDialogOpen} onOpenChange={setIsCreateWebhookDialogOpen}
...@@ -120,7 +114,7 @@ const WebhookSection = () => { ...@@ -120,7 +114,7 @@ const WebhookSection = () => {
onConfirm={confirmDeleteWebhook} onConfirm={confirmDeleteWebhook}
confirmVariant="destructive" confirmVariant="destructive"
/> />
</div> </SettingSection>
); );
}; };
......
...@@ -12,6 +12,8 @@ import { ...@@ -12,6 +12,8 @@ import {
InstanceSetting_MemoRelatedSettingSchema, InstanceSetting_MemoRelatedSettingSchema,
InstanceSetting_StorageSetting, InstanceSetting_StorageSetting,
InstanceSetting_StorageSettingSchema, InstanceSetting_StorageSettingSchema,
InstanceSetting_TagsSetting,
InstanceSetting_TagsSettingSchema,
} from "@/types/proto/api/v1/instance_service_pb"; } from "@/types/proto/api/v1/instance_service_pb";
const instanceSettingNamePrefix = "instance/settings/"; const instanceSettingNamePrefix = "instance/settings/";
...@@ -36,6 +38,7 @@ interface InstanceContextValue extends InstanceState { ...@@ -36,6 +38,7 @@ interface InstanceContextValue extends InstanceState {
generalSetting: InstanceSetting_GeneralSetting; generalSetting: InstanceSetting_GeneralSetting;
memoRelatedSetting: InstanceSetting_MemoRelatedSetting; memoRelatedSetting: InstanceSetting_MemoRelatedSetting;
storageSetting: InstanceSetting_StorageSetting; storageSetting: InstanceSetting_StorageSetting;
tagsSetting: InstanceSetting_TagsSetting;
initialize: () => Promise<void>; initialize: () => Promise<void>;
fetchSetting: (key: InstanceSetting_Key) => Promise<void>; fetchSetting: (key: InstanceSetting_Key) => Promise<void>;
updateSetting: (setting: InstanceSetting) => Promise<void>; updateSetting: (setting: InstanceSetting) => Promise<void>;
...@@ -77,19 +80,28 @@ export function InstanceProvider({ children }: { children: ReactNode }) { ...@@ -77,19 +80,28 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
return create(InstanceSetting_StorageSettingSchema, {}); return create(InstanceSetting_StorageSettingSchema, {});
}, [state.settings]); }, [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 () => { const initialize = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true })); setState((prev) => ({ ...prev, isLoading: true }));
try { try {
const profile = await instanceServiceClient.getInstanceProfile({}); 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.GENERAL) }),
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED) }), instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED) }),
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.TAGS) }),
]); ]);
setState({ setState({
profile, profile,
settings: [generalSetting, memoRelatedSettingResponse], settings: [generalSetting, memoRelatedSettingResponse, tagsSettingResponse],
isInitialized: true, isInitialized: true,
isLoading: false, isLoading: false,
profileLoaded: true, profileLoaded: true,
...@@ -129,11 +141,12 @@ export function InstanceProvider({ children }: { children: ReactNode }) { ...@@ -129,11 +141,12 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
generalSetting, generalSetting,
memoRelatedSetting, memoRelatedSetting,
storageSetting, storageSetting,
tagsSetting,
initialize, initialize,
fetchSetting, fetchSetting,
updateSetting, updateSetting,
}), }),
[state, generalSetting, memoRelatedSetting, storageSetting, initialize, fetchSetting, updateSetting], [state, generalSetting, memoRelatedSetting, storageSetting, tagsSetting, initialize, fetchSetting, updateSetting],
); );
return <InstanceContext.Provider value={value}>{children}</InstanceContext.Provider>; return <InstanceContext.Provider value={value}>{children}</InstanceContext.Provider>;
......
/**
* 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}`;
};
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "لم يتم العثور على Webhooks.", "no-webhooks-found": "لم يتم العثور على Webhooks.",
"title": "Webhooks", "title": "Webhooks",
"url": "الرابط" "url": "الرابط",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "No s'han trobat webhooks.", "no-webhooks-found": "No s'han trobat webhooks.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Žádné webhooky nenalezeny.", "no-webhooks-found": "Žádné webhooky nenalezeny.",
"title": "Webhooky", "title": "Webhooky",
"url": "URL" "url": "URL",
"label": "Webhooky"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Keine Webhooks gefunden.", "no-webhooks-found": "Keine Webhooks gefunden.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -7,6 +7,9 @@ ...@@ -7,6 +7,9 @@
"customize-server": { "customize-server": {
"title": "Customise Server" "title": "Customise Server"
} }
},
"webhook": {
"label": "Webhooks"
} }
}, },
"auth": { "auth": {
......
...@@ -297,7 +297,8 @@ ...@@ -297,7 +297,8 @@
}, },
"description": "A list of all access tokens for your account.", "description": "A list of all access tokens for your account.",
"title": "Access Tokens", "title": "Access Tokens",
"token": "Token" "token": "Token",
"no-tokens-found": "No access tokens found"
}, },
"account": { "account": {
"change-password": "Change password", "change-password": "Change password",
...@@ -336,7 +337,8 @@ ...@@ -336,7 +337,8 @@
"label": "Member", "label": "Member",
"list-title": "Member list", "list-title": "Member list",
"restore-success": "{{username}} restored successfully", "restore-success": "{{username}} restored successfully",
"user": "User" "user": "User",
"no-members-found": "No members found"
}, },
"memo": { "memo": {
"content-length-limit": "Content length limit (Byte)", "content-length-limit": "Content length limit (Byte)",
...@@ -345,7 +347,8 @@ ...@@ -345,7 +347,8 @@
"enable-memo-location": "Enable memo location", "enable-memo-location": "Enable memo location",
"label": "Memo", "label": "Memo",
"reactions": "Reactions", "reactions": "Reactions",
"title": "Memo related settings" "title": "Memo related settings",
"reactions-required": "Reactions list must not be empty"
}, },
"my-account": { "my-account": {
"label": "My Account" "label": "My Account"
...@@ -465,7 +468,18 @@ ...@@ -465,7 +468,18 @@
}, },
"no-webhooks-found": "No webhooks found.", "no-webhooks-found": "No webhooks found.",
"title": "Webhooks", "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": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "No se encontraron webhooks.", "no-webhooks-found": "No se encontraron webhooks.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "وب‌هوکی یافت نشد.", "no-webhooks-found": "وب‌هوکی یافت نشد.",
"title": "وب‌هوک‌ها", "title": "وب‌هوک‌ها",
"url": "آدرس" "url": "آدرس",
"label": "وب‌هوک‌ها"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Aucun webhook trouvé.", "no-webhooks-found": "Aucun webhook trouvé.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Non hai webhooks.", "no-webhooks-found": "Non hai webhooks.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "कोई वेबहुक नहीं मिला।", "no-webhooks-found": "कोई वेबहुक नहीं मिला।",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Nema pronađenih webhooks.", "no-webhooks-found": "Nema pronađenih webhooks.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Nincs webhook.", "no-webhooks-found": "Nincs webhook.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Tidak ada webhook yang ditemukan.", "no-webhooks-found": "Tidak ada webhook yang ditemukan.",
"title": "Webhook", "title": "Webhook",
"url": "URL" "url": "URL",
"label": "Webhook"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Nessun webhook trovato.", "no-webhooks-found": "Nessun webhook trovato.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Webhookが見つかりません。", "no-webhooks-found": "Webhookが見つかりません。",
"title": "Webhook", "title": "Webhook",
"url": "URL" "url": "URL",
"label": "Webhook"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Webhook-ები ვერ მოიძებნა.", "no-webhooks-found": "Webhook-ები ვერ მოიძებნა.",
"title": "Webhook-ები", "title": "Webhook-ები",
"url": "URL" "url": "URL",
"label": "Webhook-ები"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Webhook이 없습니다.", "no-webhooks-found": "Webhook이 없습니다.",
"title": "Webhook", "title": "Webhook",
"url": "URL" "url": "URL",
"label": "Webhook"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "कोणतेही वेबहुक आढळले नाहीत.", "no-webhooks-found": "कोणतेही वेबहुक आढळले नाहीत.",
"title": "वेबहुक्स", "title": "वेबहुक्स",
"url": "URL" "url": "URL",
"label": "वेबहुक्स"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Ingen webhooks funnet.", "no-webhooks-found": "Ingen webhooks funnet.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Geen webhooks gevonden.", "no-webhooks-found": "Geen webhooks gevonden.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -435,7 +435,8 @@ ...@@ -435,7 +435,8 @@
}, },
"no-webhooks-found": "Nie znaleziono webhooków.", "no-webhooks-found": "Nie znaleziono webhooków.",
"title": "Webhooki", "title": "Webhooki",
"url": "URL" "url": "URL",
"label": "Webhooki"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Nenhum webhook encontrado.", "no-webhooks-found": "Nenhum webhook encontrado.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Nenhum webhook encontrado.", "no-webhooks-found": "Nenhum webhook encontrado.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Нет вебхуков", "no-webhooks-found": "Нет вебхуков",
"title": "Вебхуки", "title": "Вебхуки",
"url": "Ссылка" "url": "Ссылка",
"label": "Вебхуки"
} }
}, },
"tag": { "tag": {
......
...@@ -435,7 +435,8 @@ ...@@ -435,7 +435,8 @@
}, },
"no-webhooks-found": "Ne najdem nobenega webhooka.", "no-webhooks-found": "Ne najdem nobenega webhooka.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Inga webhooks hittades.", "no-webhooks-found": "Inga webhooks hittades.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL",
"label": "Webhooks"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "ไม่พบ webhook", "no-webhooks-found": "ไม่พบ webhook",
"title": "Webhook", "title": "Webhook",
"url": "URL" "url": "URL",
"label": "Webhook"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Webhook bulunamadı.", "no-webhooks-found": "Webhook bulunamadı.",
"title": "Webhook'lar", "title": "Webhook'lar",
"url": "URL" "url": "URL",
"label": "Webhook'lar"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Вебхуків не знайдено.", "no-webhooks-found": "Вебхуків не знайдено.",
"title": "Вебхуки", "title": "Вебхуки",
"url": "Посилання" "url": "Посилання",
"label": "Вебхуки"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "Không tìm thấy webhook nào.", "no-webhooks-found": "Không tìm thấy webhook nào.",
"title": "Webhook", "title": "Webhook",
"url": "Url" "url": "Url",
"label": "Webhook"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "没有 Webhook。", "no-webhooks-found": "没有 Webhook。",
"title": "Webhook", "title": "Webhook",
"url": "链接" "url": "链接",
"label": "Webhook"
} }
}, },
"tag": { "tag": {
......
...@@ -434,7 +434,8 @@ ...@@ -434,7 +434,8 @@
}, },
"no-webhooks-found": "尚未建立任何 Webhook。", "no-webhooks-found": "尚未建立任何 Webhook。",
"title": "Webhook", "title": "Webhook",
"url": "網址" "url": "網址",
"label": "Webhook"
} }
}, },
"tag": { "tag": {
......
import { CogIcon, DatabaseIcon, KeyIcon, LibraryIcon, LucideIcon, Settings2Icon, UserIcon, UsersIcon } from "lucide-react"; import {
import { useCallback, useEffect, useMemo, useState } from "react"; 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 { useLocation } from "react-router-dom";
import MobileHeader from "@/components/MobileHeader"; import MobileHeader from "@/components/MobileHeader";
import InstanceSection from "@/components/Settings/InstanceSection"; import InstanceSection from "@/components/Settings/InstanceSection";
...@@ -10,6 +21,8 @@ import PreferencesSection from "@/components/Settings/PreferencesSection"; ...@@ -10,6 +21,8 @@ import PreferencesSection from "@/components/Settings/PreferencesSection";
import SectionMenuItem from "@/components/Settings/SectionMenuItem"; import SectionMenuItem from "@/components/Settings/SectionMenuItem";
import SSOSection from "@/components/Settings/SSOSection"; import SSOSection from "@/components/Settings/SSOSection";
import StorageSection from "@/components/Settings/StorageSection"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
...@@ -18,70 +31,68 @@ import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb"; ...@@ -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 { User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; 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 { const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference", "webhook"];
selectedSection: SettingSection; 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> = { const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = {
"my-account": UserIcon, "my-account": UserIcon,
preference: CogIcon, preference: CogIcon,
webhook: WebhookIcon,
member: UsersIcon, member: UsersIcon,
system: Settings2Icon, system: Settings2Icon,
memo: LibraryIcon, memo: LibraryIcon,
storage: DatabaseIcon, storage: DatabaseIcon,
tags: TagsIcon,
sso: KeyIcon, 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 Setting = () => {
const t = useTranslate(); const t = useTranslate();
const sm = useMediaQuery("sm"); const sm = useMediaQuery("sm");
const location = useLocation(); const location = useLocation();
const user = useCurrentUser(); const user = useCurrentUser();
const { profile, fetchSetting } = useInstance(); const { profile, fetchSetting } = useInstance();
const [state, setState] = useState<State>({ const [selectedSection, setSelectedSection] = useState<SettingSection>("my-account");
selectedSection: "my-account",
});
const isHost = user?.role === User_Role.ADMIN; const isHost = user?.role === User_Role.ADMIN;
const settingsSectionList = useMemo(() => { const settingsSectionList = useMemo(() => {
let settingList = [...BASIC_SECTIONS]; return isHost ? [...BASIC_SECTIONS, ...ADMIN_SECTIONS] : [...BASIC_SECTIONS];
if (isHost) {
settingList = settingList.concat(ADMIN_SECTIONS);
}
return settingList;
}, [isHost]); }, [isHost]);
useEffect(() => { useEffect(() => {
let hash = location.hash.slice(1) as SettingSection; const hash = location.hash.slice(1) as SettingSection;
// If the hash is not a valid section, redirect to the default section. const nextSection = settingsSectionList.includes(hash) ? hash : "my-account";
if (![...BASIC_SECTIONS, ...ADMIN_SECTIONS].includes(hash)) { setSelectedSection(nextSection);
hash = "my-account"; }, [location.hash, settingsSectionList]);
}
setState({
selectedSection: hash,
});
}, [location.hash]);
useEffect(() => { useEffect(() => {
if (!isHost) { if (!isHost) {
return; return;
} }
// Fetch admin-only settings that are not eagerly loaded by InstanceContext.
// Initial fetch for instance settings. fetchSetting(InstanceSetting_Key.STORAGE);
(async () => { fetchSetting(InstanceSetting_Key.TAGS);
[InstanceSetting_Key.MEMO_RELATED, InstanceSetting_Key.STORAGE].forEach(async (key) => {
await fetchSetting(key);
});
})();
}, [isHost, fetchSetting]); }, [isHost, fetchSetting]);
const handleSectionSelectorItemClick = useCallback((settingSection: SettingSection) => { const handleSectionSelectorItemClick = (section: SettingSection) => {
window.location.hash = settingSection; window.location.hash = section;
}, []); };
const ActiveSection = SECTION_COMPONENT_MAP[selectedSection];
return ( 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"> <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 = () => { ...@@ -97,12 +108,12 @@ const Setting = () => {
key={item} key={item}
text={t(`setting.${item}.label`)} text={t(`setting.${item}.label`)}
icon={SECTION_ICON_MAP[item]} icon={SECTION_ICON_MAP[item]}
isSelected={state.selectedSection === item} isSelected={selectedSection === item}
onClick={() => handleSectionSelectorItemClick(item)} onClick={() => handleSectionSelectorItemClick(item)}
/> />
))} ))}
</div> </div>
{isHost ? ( {isHost && (
<> <>
<span className="text-sm mt-4 pl-3 font-mono select-none text-muted-foreground">{t("common.admin")}</span> <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"> <div className="w-full flex flex-col justify-start items-start mt-1">
...@@ -111,7 +122,7 @@ const Setting = () => { ...@@ -111,7 +122,7 @@ const Setting = () => {
key={item} key={item}
text={t(`setting.${item}.label`)} text={t(`setting.${item}.label`)}
icon={SECTION_ICON_MAP[item]} icon={SECTION_ICON_MAP[item]}
isSelected={state.selectedSection === item} isSelected={selectedSection === item}
onClick={() => handleSectionSelectorItemClick(item)} onClick={() => handleSectionSelectorItemClick(item)}
/> />
))} ))}
...@@ -120,41 +131,27 @@ const Setting = () => { ...@@ -120,41 +131,27 @@ const Setting = () => {
</span> </span>
</div> </div>
</> </>
) : null} )}
</div> </div>
)} )}
<div className="w-full grow sm:pl-4 overflow-x-auto"> <div className="w-full grow sm:pl-4 overflow-x-auto">
{!sm && ( {!sm && (
<div className="w-auto inline-block my-2"> <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]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select section" /> <SelectValue placeholder="Select section" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{settingsSectionList.map((settingSection) => ( {settingsSectionList.map((section) => (
<SelectItem key={settingSection} value={settingSection}> <SelectItem key={section} value={section}>
{t(`setting.${settingSection}.label`)} {t(`setting.${section}.label`)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)} )}
{state.selectedSection === "my-account" ? ( <ActiveSection />
<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}
</div> </div>
</div> </div>
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment