mirror of https://github.com/usememos/memos.git
refactor(web): improve Settings page maintainability and consistency (#5757)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
d5de325ff4
commit
9ded59a1aa
|
|
@ -1,7 +1,9 @@
|
|||
import type { Element } from "hast";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { colorToHex } from "@/lib/color";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Routes } from "@/router";
|
||||
import { useMemoViewContext } from "../MemoView/MemoViewContext";
|
||||
|
|
@ -12,14 +14,28 @@ interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, ...props }) => {
|
||||
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, style, ...props }) => {
|
||||
const { parentPage } = useMemoViewContext();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
|
||||
const { tagsSetting } = useInstance();
|
||||
|
||||
const tag = dataTag || "";
|
||||
|
||||
// Custom color from admin tag metadata. Dynamic hex values must use inline styles
|
||||
// because Tailwind can't scan dynamically constructed class names.
|
||||
// Text uses a darkened variant (40% color + black) for contrast on light backgrounds.
|
||||
const bgHex = colorToHex(tagsSetting.tags[tag]?.backgroundColor);
|
||||
const tagStyle: React.CSSProperties | undefined = bgHex
|
||||
? {
|
||||
borderColor: bgHex,
|
||||
color: `color-mix(in srgb, ${bgHex} 60%, black)`,
|
||||
backgroundColor: `color-mix(in srgb, ${bgHex} 15%, transparent)`,
|
||||
...style,
|
||||
}
|
||||
: style;
|
||||
|
||||
const handleTagClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -48,7 +64,12 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
|
|||
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-block w-auto text-primary cursor-pointer transition-colors hover:text-primary/80", className)}
|
||||
className={cn(
|
||||
"inline-flex items-center align-middle px-1.5 py-px text-sm leading-snug rounded border cursor-pointer transition-opacity hover:opacity-75",
|
||||
!bgHex && "border-primary text-primary bg-primary/15",
|
||||
className,
|
||||
)}
|
||||
style={tagStyle}
|
||||
data-tag={tag}
|
||||
{...props}
|
||||
onClick={handleTagClick}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { handleError } from "@/lib/error";
|
|||
import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
|
||||
import SettingGroup from "./SettingGroup";
|
||||
import SettingTable from "./SettingTable";
|
||||
|
||||
const listAccessTokens = async (parent: string) => {
|
||||
|
|
@ -64,11 +65,7 @@ const AccessTokenSection = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const handleCreateToken = () => {
|
||||
createTokenDialog.open();
|
||||
};
|
||||
|
||||
const handleDeleteAccessToken = async (token: PersonalAccessToken) => {
|
||||
const handleDeleteAccessToken = (token: PersonalAccessToken) => {
|
||||
setDeleteTarget(token);
|
||||
};
|
||||
|
||||
|
|
@ -82,18 +79,7 @@ const AccessTokenSection = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t("setting.access-token.title")}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t("setting.access-token.description")}</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateToken} size="sm">
|
||||
<PlusIcon className="w-4 h-4 mr-1.5" />
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingGroup title={t("setting.access-token.title")} description={t("setting.access-token.description")}>
|
||||
<SettingTable
|
||||
columns={[
|
||||
{
|
||||
|
|
@ -125,10 +111,17 @@ const AccessTokenSection = () => {
|
|||
},
|
||||
]}
|
||||
data={personalAccessTokens}
|
||||
emptyMessage="No access tokens found"
|
||||
emptyMessage={t("setting.access-token.no-tokens-found")}
|
||||
getRowKey={(token) => token.name}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={createTokenDialog.open} size="sm">
|
||||
<PlusIcon className="w-4 h-4 mr-1.5" />
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Access Token Dialog */}
|
||||
<CreateAccessTokenDialog
|
||||
open={createTokenDialog.isOpen}
|
||||
|
|
@ -145,7 +138,7 @@ const AccessTokenSection = () => {
|
|||
onConfirm={confirmDeleteAccessToken}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
</SettingGroup>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -31,13 +31,23 @@ const InstanceSection = () => {
|
|||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setInstanceGeneralSetting({ ...instanceGeneralSetting, customProfile: originalSetting.customProfile });
|
||||
}, [originalSetting]);
|
||||
setInstanceGeneralSetting((prev) =>
|
||||
create(InstanceSetting_GeneralSettingSchema, {
|
||||
...prev,
|
||||
customProfile: originalSetting.customProfile,
|
||||
}),
|
||||
);
|
||||
}, [originalSetting.customProfile]);
|
||||
|
||||
const handleUpdateCustomizedProfileButtonClick = () => {
|
||||
customizeDialog.open();
|
||||
const fetchIdentityProviderList = async () => {
|
||||
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
|
||||
setIdentityProviderList(identityProviders);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchIdentityProviderList();
|
||||
}, []);
|
||||
|
||||
const updatePartialSetting = (partial: Partial<InstanceSetting_GeneralSetting>) => {
|
||||
setInstanceGeneralSetting(
|
||||
create(InstanceSetting_GeneralSettingSchema, {
|
||||
|
|
@ -68,20 +78,11 @@ const InstanceSection = () => {
|
|||
toast.success(t("message.update-succeed"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchIdentityProviderList();
|
||||
}, []);
|
||||
|
||||
const fetchIdentityProviderList = async () => {
|
||||
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
|
||||
setIdentityProviderList(identityProviders);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingSection>
|
||||
<SettingSection title={t("setting.system.label")}>
|
||||
<SettingGroup title={t("common.basic")}>
|
||||
<SettingRow label={t("setting.system.server-name")} description={instanceGeneralSetting.customProfile?.title || "Memos"}>
|
||||
<Button variant="outline" onClick={handleUpdateCustomizedProfileButtonClick}>
|
||||
<Button variant="outline" onClick={customizeDialog.open}>
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
|
|
@ -109,7 +110,7 @@ const InstanceSection = () => {
|
|||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup>
|
||||
<SettingGroup showSeparator>
|
||||
<SettingRow label={t("setting.instance.disallow-user-registration")}>
|
||||
<Switch
|
||||
disabled={profile.demo}
|
||||
|
|
@ -169,8 +170,7 @@ const InstanceSection = () => {
|
|||
open={customizeDialog.isOpen}
|
||||
onOpenChange={customizeDialog.setOpen}
|
||||
onSuccess={() => {
|
||||
// Refresh instance settings if needed
|
||||
toast.success("Profile updated successfully!");
|
||||
toast.success(t("message.update-succeed"));
|
||||
}}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { create } from "@bufbuild/protobuf";
|
|||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { MoreVerticalIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -10,6 +10,7 @@ import { userServiceClient } from "@/connect";
|
|||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useDialog } from "@/hooks/useDialog";
|
||||
import { useDeleteUser, useListUsers } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -26,17 +27,11 @@ const MemberSection = () => {
|
|||
const createDialog = useDialog();
|
||||
const editDialog = useDialog();
|
||||
const [editingUser, setEditingUser] = useState<User | undefined>();
|
||||
const sortedUsers = sortBy(users, "id");
|
||||
const sortedUsers = useMemo(() => sortBy(users, "id"), [users]);
|
||||
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined);
|
||||
|
||||
const stringifyUserRole = (role: User_Role) => {
|
||||
if (role === User_Role.ADMIN) {
|
||||
return t("setting.member.admin");
|
||||
} else {
|
||||
return t("setting.member.user");
|
||||
}
|
||||
};
|
||||
const stringifyUserRole = (role: User_Role) => (role === User_Role.ADMIN ? t("setting.member.admin") : t("setting.member.user"));
|
||||
|
||||
const handleCreateUser = () => {
|
||||
setEditingUser(undefined);
|
||||
|
|
@ -48,48 +43,63 @@ const MemberSection = () => {
|
|||
editDialog.open();
|
||||
};
|
||||
|
||||
const handleArchiveUserClick = async (user: User) => {
|
||||
const handleArchiveUserClick = (user: User) => {
|
||||
setArchiveTarget(user);
|
||||
};
|
||||
|
||||
const confirmArchiveUser = async () => {
|
||||
if (!archiveTarget) return;
|
||||
const username = archiveTarget.username;
|
||||
await userServiceClient.updateUser({
|
||||
user: {
|
||||
name: archiveTarget.name,
|
||||
state: State.ARCHIVED,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
|
||||
});
|
||||
try {
|
||||
await userServiceClient.updateUser({
|
||||
user: {
|
||||
name: archiveTarget.name,
|
||||
state: State.ARCHIVED,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
|
||||
});
|
||||
toast.success(t("setting.member.archive-success", { username }));
|
||||
await refetchUsers();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Archive user" });
|
||||
}
|
||||
setArchiveTarget(undefined);
|
||||
toast.success(t("setting.member.archive-success", { username }));
|
||||
await refetchUsers();
|
||||
};
|
||||
|
||||
const handleRestoreUserClick = async (user: User) => {
|
||||
const { username } = user;
|
||||
await userServiceClient.updateUser({
|
||||
user: {
|
||||
name: user.name,
|
||||
state: State.NORMAL,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
|
||||
});
|
||||
toast.success(t("setting.member.restore-success", { username }));
|
||||
await refetchUsers();
|
||||
try {
|
||||
await userServiceClient.updateUser({
|
||||
user: {
|
||||
name: user.name,
|
||||
state: State.NORMAL,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
|
||||
});
|
||||
toast.success(t("setting.member.restore-success", { username }));
|
||||
await refetchUsers();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Restore user" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUserClick = async (user: User) => {
|
||||
const handleDeleteUserClick = (user: User) => {
|
||||
setDeleteTarget(user);
|
||||
};
|
||||
|
||||
const confirmDeleteUser = async () => {
|
||||
const confirmDeleteUser = () => {
|
||||
if (!deleteTarget) return;
|
||||
const { username, name } = deleteTarget;
|
||||
deleteUserMutation.mutate(name);
|
||||
setDeleteTarget(undefined);
|
||||
toast.success(t("setting.member.delete-success", { username }));
|
||||
deleteUserMutation.mutate(name, {
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(undefined);
|
||||
toast.success(t("setting.member.delete-success", { username }));
|
||||
},
|
||||
onError: (error) => {
|
||||
setDeleteTarget(undefined);
|
||||
handleError(error, toast.error, { context: "Delete user" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -110,7 +120,7 @@ const MemberSection = () => {
|
|||
render: (_, user: User) => (
|
||||
<span className="text-foreground">
|
||||
{user.username}
|
||||
{user.state === State.ARCHIVED && <span className="ml-2 italic text-muted-foreground">(Archived)</span>}
|
||||
{user.state === State.ARCHIVED && <span className="ml-2 italic text-muted-foreground">({t("common.archived")})</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
@ -161,7 +171,7 @@ const MemberSection = () => {
|
|||
},
|
||||
]}
|
||||
data={sortedUsers}
|
||||
emptyMessage="No members found"
|
||||
emptyMessage={t("setting.member.no-members-found")}
|
||||
getRowKey={(user) => user.name}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -35,17 +35,18 @@ const MemoRelatedSettings = () => {
|
|||
};
|
||||
|
||||
const upsertReaction = () => {
|
||||
if (!editingReaction) {
|
||||
const trimmed = editingReaction.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
updatePartialSetting({ reactions: uniq([...memoRelatedSetting.reactions, editingReaction.trim()]) });
|
||||
updatePartialSetting({ reactions: uniq([...memoRelatedSetting.reactions, trimmed]) });
|
||||
setEditingReaction("");
|
||||
};
|
||||
|
||||
const handleUpdateSetting = async () => {
|
||||
if (memoRelatedSetting.reactions.length === 0) {
|
||||
toast.error("Reactions must not be empty.");
|
||||
toast.error(t("setting.memo.reactions-required"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -69,8 +70,8 @@ const MemoRelatedSettings = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<SettingSection>
|
||||
<SettingGroup title={t("setting.memo.title")}>
|
||||
<SettingSection title={t("setting.memo.label")}>
|
||||
<SettingGroup title={t("common.basic")}>
|
||||
<SettingRow label={t("setting.system.display-with-updated-time")}>
|
||||
<Switch
|
||||
checked={memoRelatedSetting.displayWithUpdateTime}
|
||||
|
|
@ -89,8 +90,8 @@ const MemoRelatedSettings = () => {
|
|||
<Input
|
||||
className="w-24"
|
||||
type="number"
|
||||
defaultValue={memoRelatedSetting.contentLengthLimit}
|
||||
onBlur={(event) => updatePartialSetting({ contentLengthLimit: Number(event.target.value) })}
|
||||
value={memoRelatedSetting.contentLengthLimit}
|
||||
onChange={(event) => updatePartialSetting({ contentLengthLimit: Number(event.target.value) })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
|
@ -113,7 +114,7 @@ const MemoRelatedSettings = () => {
|
|||
className="w-32 h-8"
|
||||
placeholder={t("common.input")}
|
||||
value={editingReaction}
|
||||
onChange={(event) => setEditingReaction(event.target.value.trim())}
|
||||
onChange={(event) => setEditingReaction(event.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && upsertReaction()}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={upsertReaction} className="h-8 w-8 p-0">
|
||||
|
|
|
|||
|
|
@ -17,16 +17,8 @@ const MyAccountSection = () => {
|
|||
const accountDialog = useDialog();
|
||||
const passwordDialog = useDialog();
|
||||
|
||||
const handleEditAccount = () => {
|
||||
accountDialog.open();
|
||||
};
|
||||
|
||||
const handleChangePassword = () => {
|
||||
passwordDialog.open();
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingSection>
|
||||
<SettingSection title={t("setting.my-account.label")}>
|
||||
<SettingGroup title={t("setting.account.title")}>
|
||||
<div className="w-full flex flex-row justify-start items-center gap-3">
|
||||
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} />
|
||||
|
|
@ -38,7 +30,7 @@ const MyAccountSection = () => {
|
|||
{user?.description && <p className="w-full text-sm text-muted-foreground truncate">{user?.description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={handleEditAccount}>
|
||||
<Button variant="outline" size="sm" onClick={accountDialog.open}>
|
||||
<PenLineIcon className="w-4 h-4 mr-1.5" />
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
|
|
@ -49,7 +41,7 @@ const MyAccountSection = () => {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleChangePassword}>{t("setting.account.change-password")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={passwordDialog.open}>{t("setting.account.change-password")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,14 +13,13 @@ import VisibilityIcon from "../VisibilityIcon";
|
|||
import SettingGroup from "./SettingGroup";
|
||||
import SettingRow from "./SettingRow";
|
||||
import SettingSection from "./SettingSection";
|
||||
import WebhookSection from "./WebhookSection";
|
||||
|
||||
const PreferencesSection = () => {
|
||||
const t = useTranslate();
|
||||
const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth();
|
||||
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
|
||||
|
||||
const handleLocaleSelectChange = async (locale: Locale) => {
|
||||
const handleLocaleSelectChange = (locale: Locale) => {
|
||||
// Apply locale immediately for instant UI feedback and persist to localStorage
|
||||
loadLocale(locale);
|
||||
// Persist to user settings
|
||||
|
|
@ -45,7 +44,7 @@ const PreferencesSection = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const handleThemeChange = async (theme: string) => {
|
||||
const handleThemeChange = (theme: string) => {
|
||||
// Apply theme immediately for instant UI feedback
|
||||
loadTheme(theme);
|
||||
// Persist to user settings
|
||||
|
|
@ -69,7 +68,7 @@ const PreferencesSection = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<SettingSection>
|
||||
<SettingSection title={t("setting.preference.label")}>
|
||||
<SettingGroup title={t("common.basic")}>
|
||||
<SettingRow label={t("common.language")}>
|
||||
<LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} />
|
||||
|
|
@ -80,7 +79,7 @@ const PreferencesSection = () => {
|
|||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title={t("setting.preference.label")} showSeparator>
|
||||
<SettingGroup title={t("common.memo")} showSeparator>
|
||||
<SettingRow label={t("setting.preference.default-memo-visibility")}>
|
||||
<Select value={setting.memoVisibility || "PRIVATE"} onValueChange={handleDefaultMemoVisibilityChanged}>
|
||||
<SelectTrigger className="min-w-fit">
|
||||
|
|
@ -101,10 +100,6 @@ const PreferencesSection = () => {
|
|||
</Select>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup showSeparator>
|
||||
<WebhookSection />
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import ConfirmDialog from "@/components/ConfirmDialog";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { identityProviderServiceClient } from "@/connect";
|
||||
import { useDialog } from "@/hooks/useDialog";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -16,20 +17,20 @@ import SettingTable from "./SettingTable";
|
|||
const SSOSection = () => {
|
||||
const t = useTranslate();
|
||||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
|
||||
const [deleteTarget, setDeleteTarget] = useState<IdentityProvider | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIdentityProviderList();
|
||||
}, []);
|
||||
const idpDialog = useDialog();
|
||||
|
||||
const fetchIdentityProviderList = async () => {
|
||||
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
|
||||
setIdentityProviderList(identityProviders);
|
||||
};
|
||||
|
||||
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
|
||||
useEffect(() => {
|
||||
fetchIdentityProviderList();
|
||||
}, []);
|
||||
|
||||
const handleDeleteIdentityProvider = (identityProvider: IdentityProvider) => {
|
||||
setDeleteTarget(identityProvider);
|
||||
};
|
||||
|
||||
|
|
@ -48,23 +49,22 @@ const SSOSection = () => {
|
|||
|
||||
const handleCreateIdentityProvider = () => {
|
||||
setEditingIdentityProvider(undefined);
|
||||
setIsCreateDialogOpen(true);
|
||||
idpDialog.open();
|
||||
};
|
||||
|
||||
const handleEditIdentityProvider = (identityProvider: IdentityProvider) => {
|
||||
setEditingIdentityProvider(identityProvider);
|
||||
setIsCreateDialogOpen(true);
|
||||
idpDialog.open();
|
||||
};
|
||||
|
||||
const handleDialogSuccess = async () => {
|
||||
await fetchIdentityProviderList();
|
||||
setIsCreateDialogOpen(false);
|
||||
idpDialog.close();
|
||||
setEditingIdentityProvider(undefined);
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
setIsCreateDialogOpen(open);
|
||||
// Clear editing state when dialog is closed
|
||||
idpDialog.setOpen(open);
|
||||
if (!open) {
|
||||
setEditingIdentityProvider(undefined);
|
||||
}
|
||||
|
|
@ -127,7 +127,7 @@ const SSOSection = () => {
|
|||
/>
|
||||
|
||||
<CreateIdentityProviderDialog
|
||||
open={isCreateDialogOpen}
|
||||
open={idpDialog.isOpen}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
identityProvider={editingIdentityProvider}
|
||||
onSuccess={handleDialogSuccess}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { LucideIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
interface SettingMenuItemProps {
|
||||
interface SectionMenuItemProps {
|
||||
text: string;
|
||||
icon: LucideIcon;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const SectionMenuItem: React.FC<SettingMenuItemProps> = ({ text, icon: IconComponent, isSelected, onClick }) => {
|
||||
const SectionMenuItem: React.FC<SectionMenuItemProps> = ({ text, icon: IconComponent, isSelected, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
|
|
|
|||
|
|
@ -55,78 +55,55 @@ const StorageSection = () => {
|
|||
return !isEqual(originalSetting, instanceStorageSetting);
|
||||
}, [instanceStorageSetting, originalSetting]);
|
||||
|
||||
const handleMaxUploadSizeChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const handleMaxUploadSizeChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let num = parseInt(event.target.value);
|
||||
if (Number.isNaN(num)) {
|
||||
num = 0;
|
||||
}
|
||||
const update = create(InstanceSetting_StorageSettingSchema, {
|
||||
...instanceStorageSetting,
|
||||
uploadSizeLimitMb: BigInt(num),
|
||||
});
|
||||
setInstanceStorageSetting(update);
|
||||
setInstanceStorageSetting(
|
||||
create(InstanceSetting_StorageSettingSchema, {
|
||||
...instanceStorageSetting,
|
||||
uploadSizeLimitMb: BigInt(num),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleFilepathTemplateChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const update = create(InstanceSetting_StorageSettingSchema, {
|
||||
...instanceStorageSetting,
|
||||
filepathTemplate: event.target.value,
|
||||
});
|
||||
setInstanceStorageSetting(update);
|
||||
const handleFilepathTemplateChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInstanceStorageSetting(
|
||||
create(InstanceSetting_StorageSettingSchema, {
|
||||
...instanceStorageSetting,
|
||||
filepathTemplate: event.target.value,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handlePartialS3ConfigChanged = async (s3Config: Partial<InstanceSetting_StorageSetting_S3Config>) => {
|
||||
const existingS3Config = instanceStorageSetting.s3Config;
|
||||
const s3ConfigInit = {
|
||||
accessKeyId: existingS3Config?.accessKeyId ?? "",
|
||||
accessKeySecret: existingS3Config?.accessKeySecret ?? "",
|
||||
endpoint: existingS3Config?.endpoint ?? "",
|
||||
region: existingS3Config?.region ?? "",
|
||||
bucket: existingS3Config?.bucket ?? "",
|
||||
usePathStyle: existingS3Config?.usePathStyle ?? false,
|
||||
...s3Config,
|
||||
};
|
||||
const update = create(InstanceSetting_StorageSettingSchema, {
|
||||
storageType: instanceStorageSetting.storageType,
|
||||
filepathTemplate: instanceStorageSetting.filepathTemplate,
|
||||
uploadSizeLimitMb: instanceStorageSetting.uploadSizeLimitMb,
|
||||
s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, s3ConfigInit),
|
||||
});
|
||||
setInstanceStorageSetting(update);
|
||||
const handleS3FieldChange = (field: keyof InstanceSetting_StorageSetting_S3Config, value: string | boolean) => {
|
||||
const existing = instanceStorageSetting.s3Config;
|
||||
setInstanceStorageSetting(
|
||||
create(InstanceSetting_StorageSettingSchema, {
|
||||
storageType: instanceStorageSetting.storageType,
|
||||
filepathTemplate: instanceStorageSetting.filepathTemplate,
|
||||
uploadSizeLimitMb: instanceStorageSetting.uploadSizeLimitMb,
|
||||
s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, {
|
||||
accessKeyId: existing?.accessKeyId ?? "",
|
||||
accessKeySecret: existing?.accessKeySecret ?? "",
|
||||
endpoint: existing?.endpoint ?? "",
|
||||
region: existing?.region ?? "",
|
||||
bucket: existing?.bucket ?? "",
|
||||
usePathStyle: existing?.usePathStyle ?? false,
|
||||
[field]: value,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleS3ConfigAccessKeyIdChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
handlePartialS3ConfigChanged({ accessKeyId: event.target.value });
|
||||
};
|
||||
|
||||
const handleS3ConfigAccessKeySecretChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
handlePartialS3ConfigChanged({ accessKeySecret: event.target.value });
|
||||
};
|
||||
|
||||
const handleS3ConfigEndpointChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
handlePartialS3ConfigChanged({ endpoint: event.target.value });
|
||||
};
|
||||
|
||||
const handleS3ConfigRegionChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
handlePartialS3ConfigChanged({ region: event.target.value });
|
||||
};
|
||||
|
||||
const handleS3ConfigBucketChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
handlePartialS3ConfigChanged({ bucket: event.target.value });
|
||||
};
|
||||
|
||||
const handleS3ConfigUsePathStyleChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handlePartialS3ConfigChanged({
|
||||
usePathStyle: event.target.checked,
|
||||
});
|
||||
};
|
||||
|
||||
const handleStorageTypeChanged = async (storageType: InstanceSetting_StorageSetting_StorageType) => {
|
||||
const update = create(InstanceSetting_StorageSettingSchema, {
|
||||
...instanceStorageSetting,
|
||||
storageType: storageType,
|
||||
});
|
||||
setInstanceStorageSetting(update);
|
||||
const handleStorageTypeChanged = (storageType: InstanceSetting_StorageSetting_StorageType) => {
|
||||
setInstanceStorageSetting(
|
||||
create(InstanceSetting_StorageSettingSchema, {
|
||||
...instanceStorageSetting,
|
||||
storageType,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const saveInstanceStorageSetting = async () => {
|
||||
|
|
@ -141,7 +118,7 @@ const StorageSection = () => {
|
|||
}),
|
||||
);
|
||||
await fetchSetting(InstanceSetting_Key.STORAGE);
|
||||
toast.success("Updated");
|
||||
toast.success(t("message.update-succeed"));
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: "Update storage settings",
|
||||
|
|
@ -150,7 +127,7 @@ const StorageSection = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<SettingSection>
|
||||
<SettingSection title={t("setting.storage.label")}>
|
||||
<SettingGroup title={t("setting.storage.current-storage")}>
|
||||
<div className="w-full">
|
||||
<RadioGroup
|
||||
|
|
@ -197,39 +174,51 @@ const StorageSection = () => {
|
|||
|
||||
{instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3 && (
|
||||
<SettingGroup title="S3 Configuration" showSeparator>
|
||||
<SettingRow label="Access key id">
|
||||
<Input className="w-64" value={instanceStorageSetting.s3Config?.accessKeyId} onChange={handleS3ConfigAccessKeyIdChanged} />
|
||||
<SettingRow label={t("setting.storage.accesskey")}>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={instanceStorageSetting.s3Config?.accessKeyId}
|
||||
onChange={(e) => handleS3FieldChange("accessKeyId", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="Access key secret">
|
||||
<SettingRow label={t("setting.storage.secretkey")}>
|
||||
<Input
|
||||
className="w-64"
|
||||
type="password"
|
||||
value={instanceStorageSetting.s3Config?.accessKeySecret}
|
||||
onChange={handleS3ConfigAccessKeySecretChanged}
|
||||
onChange={(e) => handleS3FieldChange("accessKeySecret", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="Endpoint">
|
||||
<Input className="w-64" value={instanceStorageSetting.s3Config?.endpoint} onChange={handleS3ConfigEndpointChanged} />
|
||||
<SettingRow label={t("setting.storage.endpoint")}>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={instanceStorageSetting.s3Config?.endpoint}
|
||||
onChange={(e) => handleS3FieldChange("endpoint", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="Region">
|
||||
<Input className="w-64" value={instanceStorageSetting.s3Config?.region} onChange={handleS3ConfigRegionChanged} />
|
||||
<SettingRow label={t("setting.storage.region")}>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={instanceStorageSetting.s3Config?.region}
|
||||
onChange={(e) => handleS3FieldChange("region", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="Bucket">
|
||||
<Input className="w-64" value={instanceStorageSetting.s3Config?.bucket} onChange={handleS3ConfigBucketChanged} />
|
||||
<SettingRow label={t("setting.storage.bucket")}>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={instanceStorageSetting.s3Config?.bucket}
|
||||
onChange={(e) => handleS3FieldChange("bucket", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="Use Path Style">
|
||||
<Switch
|
||||
checked={instanceStorageSetting.s3Config?.usePathStyle}
|
||||
onCheckedChange={(checked) =>
|
||||
handleS3ConfigUsePathStyleChanged({ target: { checked } } as React.ChangeEvent<HTMLInputElement> & {
|
||||
target: { checked: boolean };
|
||||
})
|
||||
}
|
||||
onCheckedChange={(checked) => handleS3FieldChange("usePathStyle", checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useTagCounts } from "@/hooks/useUserQueries";
|
||||
import { colorToHex } from "@/lib/color";
|
||||
import { handleError } from "@/lib/error";
|
||||
import {
|
||||
InstanceSetting_Key,
|
||||
InstanceSetting_TagMetadataSchema,
|
||||
InstanceSetting_TagsSettingSchema,
|
||||
InstanceSettingSchema,
|
||||
} from "@/types/proto/api/v1/instance_service_pb";
|
||||
import { ColorSchema } from "@/types/proto/google/type/color_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import SettingGroup from "./SettingGroup";
|
||||
import SettingSection from "./SettingSection";
|
||||
import SettingTable from "./SettingTable";
|
||||
|
||||
// Fallback to white when no color is stored.
|
||||
const tagColorToHex = (color?: { red?: number; green?: number; blue?: number }): string => colorToHex(color) ?? "#ffffff";
|
||||
|
||||
// Converts a CSS hex string to a google.type.Color message.
|
||||
const hexToColor = (hex: string) =>
|
||||
create(ColorSchema, {
|
||||
red: parseInt(hex.slice(1, 3), 16) / 255,
|
||||
green: parseInt(hex.slice(3, 5), 16) / 255,
|
||||
blue: parseInt(hex.slice(5, 7), 16) / 255,
|
||||
});
|
||||
|
||||
const TagsSection = () => {
|
||||
const t = useTranslate();
|
||||
const { tagsSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
|
||||
const { data: tagCounts = {} } = useTagCounts(false);
|
||||
|
||||
// Local state: map of tagName → hex color string for editing.
|
||||
const [localTags, setLocalTags] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])),
|
||||
);
|
||||
const [newTagName, setNewTagName] = useState("");
|
||||
const [newTagColor, setNewTagColor] = useState("#ffffff");
|
||||
|
||||
// Sync local state when the fetched setting arrives (the fetch is async and
|
||||
// completes after mount, so localTags would be empty without this sync).
|
||||
useEffect(() => {
|
||||
setLocalTags(
|
||||
Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])),
|
||||
);
|
||||
}, [originalSetting.tags]);
|
||||
|
||||
// All known tag names: union of saved entries and tags used in memos.
|
||||
const allKnownTags = useMemo(
|
||||
() => Array.from(new Set([...Object.keys(localTags), ...Object.keys(tagCounts)])).sort(),
|
||||
[localTags, tagCounts],
|
||||
);
|
||||
|
||||
// Only show rows for tags that have metadata configured.
|
||||
const configuredEntries = useMemo(
|
||||
() =>
|
||||
Object.keys(localTags)
|
||||
.sort()
|
||||
.map((name) => ({ name })),
|
||||
[localTags],
|
||||
);
|
||||
|
||||
const originalHexMap = useMemo(
|
||||
() => Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])),
|
||||
[originalSetting.tags],
|
||||
);
|
||||
const hasChanges = !isEqual(localTags, originalHexMap);
|
||||
|
||||
const handleColorChange = (tagName: string, hex: string) => {
|
||||
setLocalTags((prev) => ({ ...prev, [tagName]: hex }));
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagName: string) => {
|
||||
setLocalTags((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[tagName];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
const name = newTagName.trim();
|
||||
if (!name) return;
|
||||
if (localTags[name] !== undefined) {
|
||||
toast.error(t("setting.tags.tag-already-exists"));
|
||||
return;
|
||||
}
|
||||
setLocalTags((prev) => ({ ...prev, [name]: newTagColor }));
|
||||
setNewTagName("");
|
||||
setNewTagColor("#ffffff");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const tags = Object.fromEntries(
|
||||
Object.entries(localTags).map(([name, hex]) => [
|
||||
name,
|
||||
create(InstanceSetting_TagMetadataSchema, { backgroundColor: hexToColor(hex) }),
|
||||
]),
|
||||
);
|
||||
await updateSetting(
|
||||
create(InstanceSettingSchema, {
|
||||
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.TAGS]}`,
|
||||
value: {
|
||||
case: "tagsSetting",
|
||||
value: create(InstanceSetting_TagsSettingSchema, { tags }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fetchSetting(InstanceSetting_Key.TAGS);
|
||||
toast.success(t("message.update-succeed"));
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Update tags setting" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingSection title={t("setting.tags.label")}>
|
||||
<SettingGroup title={t("setting.tags.title")} description={t("setting.tags.description")}>
|
||||
<SettingTable
|
||||
columns={[
|
||||
{
|
||||
key: "name",
|
||||
header: t("setting.tags.tag-name"),
|
||||
render: (_, row: { name: string }) => <span className="font-mono text-foreground">{row.name}</span>,
|
||||
},
|
||||
{
|
||||
key: "color",
|
||||
header: t("setting.tags.background-color"),
|
||||
render: (_, row: { name: string }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded border border-border shrink-0" style={{ backgroundColor: localTags[row.name] }} />
|
||||
<input
|
||||
type="color"
|
||||
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
|
||||
value={localTags[row.name]}
|
||||
onChange={(e) => handleColorChange(row.name, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
className: "text-right",
|
||||
render: (_, row: { name: string }) => (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRemoveTag(row.name)}>
|
||||
<TrashIcon className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={configuredEntries}
|
||||
emptyMessage={t("setting.tags.no-tags-configured")}
|
||||
getRowKey={(row) => row.name}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder={t("setting.tags.tag-name-placeholder")}
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddTag()}
|
||||
list="known-tags"
|
||||
/>
|
||||
<datalist id="known-tags">
|
||||
{allKnownTags
|
||||
.filter((tag) => !localTags[tag])
|
||||
.map((tag) => (
|
||||
<option key={tag} value={tag} />
|
||||
))}
|
||||
</datalist>
|
||||
<input
|
||||
type="color"
|
||||
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
|
||||
value={newTagColor}
|
||||
onChange={(e) => setNewTagColor(e.target.value)}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleAddTag} disabled={!newTagName.trim()}>
|
||||
<PlusIcon className="w-4 h-4 mr-1.5" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingGroup>
|
||||
|
||||
<div className="w-full flex justify-end">
|
||||
<Button disabled={!hasChanges} onClick={handleSave}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagsSection;
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { ExternalLinkIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { userServiceClient } from "@/connect";
|
||||
|
|
@ -9,6 +8,8 @@ import useCurrentUser from "@/hooks/useCurrentUser";
|
|||
import { UserWebhook } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import CreateWebhookDialog from "../CreateWebhookDialog";
|
||||
import LearnMore from "../LearnMore";
|
||||
import SettingSection from "./SettingSection";
|
||||
import SettingTable from "./SettingTable";
|
||||
|
||||
const WebhookSection = () => {
|
||||
|
|
@ -18,7 +19,7 @@ const WebhookSection = () => {
|
|||
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(undefined);
|
||||
|
||||
const listWebhooks = async () => {
|
||||
const fetchWebhooks = async () => {
|
||||
if (!currentUser) return [];
|
||||
const { webhooks } = await userServiceClient.listUserWebhooks({
|
||||
parent: currentUser.name,
|
||||
|
|
@ -27,41 +28,45 @@ const WebhookSection = () => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
listWebhooks().then((webhooks) => {
|
||||
setWebhooks(webhooks);
|
||||
});
|
||||
fetchWebhooks().then(setWebhooks);
|
||||
}, [currentUser]);
|
||||
|
||||
const handleCreateWebhookDialogConfirm = async () => {
|
||||
const webhooks = await listWebhooks();
|
||||
const webhooks = await fetchWebhooks();
|
||||
const name = webhooks[webhooks.length - 1]?.displayName || "";
|
||||
setWebhooks(webhooks);
|
||||
setIsCreateWebhookDialogOpen(false);
|
||||
toast.success(t("setting.webhook.create-dialog.create-webhook-success", { name }));
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = async (webhook: UserWebhook) => {
|
||||
const handleDeleteWebhook = (webhook: UserWebhook) => {
|
||||
setDeleteTarget(webhook);
|
||||
};
|
||||
|
||||
const confirmDeleteWebhook = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await userServiceClient.deleteUserWebhook({ name: deleteTarget.name });
|
||||
setWebhooks(webhooks.filter((item) => item.name !== deleteTarget.name));
|
||||
setWebhooks((prev) => prev.filter((item) => item.name !== deleteTarget.name));
|
||||
const name = deleteTarget.displayName;
|
||||
setDeleteTarget(undefined);
|
||||
toast.success(t("setting.webhook.delete-dialog.delete-webhook-success", { name: deleteTarget.displayName }));
|
||||
toast.success(t("setting.webhook.delete-dialog.delete-webhook-success", { name }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t("setting.webhook.title")}</h4>
|
||||
<Button onClick={() => setIsCreateWebhookDialogOpen(true)} size="sm">
|
||||
<PlusIcon className="w-4 h-4 mr-1.5" />
|
||||
<SettingSection
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("setting.webhook.title")}</span>
|
||||
<LearnMore url="https://usememos.com/docs/integrations/webhooks" />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<Button onClick={() => setIsCreateWebhookDialogOpen(true)}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
<SettingTable
|
||||
columns={[
|
||||
{
|
||||
|
|
@ -94,17 +99,6 @@ const WebhookSection = () => {
|
|||
getRowKey={(webhook) => webhook.name}
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<Link
|
||||
className="text-muted-foreground text-sm inline-flex items-center hover:underline hover:text-primary"
|
||||
to="https://usememos.com/docs/integrations/webhooks"
|
||||
target="_blank"
|
||||
>
|
||||
{t("common.learn-more")}
|
||||
<ExternalLinkIcon className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<CreateWebhookDialog
|
||||
open={isCreateWebhookDialogOpen}
|
||||
onOpenChange={setIsCreateWebhookDialogOpen}
|
||||
|
|
@ -120,7 +114,7 @@ const WebhookSection = () => {
|
|||
onConfirm={confirmDeleteWebhook}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
InstanceSetting_MemoRelatedSettingSchema,
|
||||
InstanceSetting_StorageSetting,
|
||||
InstanceSetting_StorageSettingSchema,
|
||||
InstanceSetting_TagsSetting,
|
||||
InstanceSetting_TagsSettingSchema,
|
||||
} from "@/types/proto/api/v1/instance_service_pb";
|
||||
|
||||
const instanceSettingNamePrefix = "instance/settings/";
|
||||
|
|
@ -36,6 +38,7 @@ interface InstanceContextValue extends InstanceState {
|
|||
generalSetting: InstanceSetting_GeneralSetting;
|
||||
memoRelatedSetting: InstanceSetting_MemoRelatedSetting;
|
||||
storageSetting: InstanceSetting_StorageSetting;
|
||||
tagsSetting: InstanceSetting_TagsSetting;
|
||||
initialize: () => Promise<void>;
|
||||
fetchSetting: (key: InstanceSetting_Key) => Promise<void>;
|
||||
updateSetting: (setting: InstanceSetting) => Promise<void>;
|
||||
|
|
@ -77,19 +80,28 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
|
|||
return create(InstanceSetting_StorageSettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const tagsSetting = useMemo((): InstanceSetting_TagsSetting => {
|
||||
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}TAGS`);
|
||||
if (setting?.value.case === "tagsSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return create(InstanceSetting_TagsSettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const initialize = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
try {
|
||||
const profile = await instanceServiceClient.getInstanceProfile({});
|
||||
|
||||
const [generalSetting, memoRelatedSettingResponse] = await Promise.all([
|
||||
const [generalSetting, memoRelatedSettingResponse, tagsSettingResponse] = await Promise.all([
|
||||
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.GENERAL) }),
|
||||
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED) }),
|
||||
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.TAGS) }),
|
||||
]);
|
||||
|
||||
setState({
|
||||
profile,
|
||||
settings: [generalSetting, memoRelatedSettingResponse],
|
||||
settings: [generalSetting, memoRelatedSettingResponse, tagsSettingResponse],
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
profileLoaded: true,
|
||||
|
|
@ -129,11 +141,12 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
|
|||
generalSetting,
|
||||
memoRelatedSetting,
|
||||
storageSetting,
|
||||
tagsSetting,
|
||||
initialize,
|
||||
fetchSetting,
|
||||
updateSetting,
|
||||
}),
|
||||
[state, generalSetting, memoRelatedSetting, storageSetting, initialize, fetchSetting, updateSetting],
|
||||
[state, generalSetting, memoRelatedSetting, storageSetting, tagsSetting, initialize, fetchSetting, updateSetting],
|
||||
);
|
||||
|
||||
return <InstanceContext.Provider value={value}>{children}</InstanceContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
};
|
||||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "لم يتم العثور على Webhooks.",
|
||||
"title": "Webhooks",
|
||||
"url": "الرابط"
|
||||
"url": "الرابط",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "No s'han trobat webhooks.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Žádné webhooky nenalezeny.",
|
||||
"title": "Webhooky",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooky"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Keine Webhooks gefunden.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
"customize-server": {
|
||||
"title": "Customise Server"
|
||||
}
|
||||
},
|
||||
"webhook": {
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "No se encontraron webhooks.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "وبهوکی یافت نشد.",
|
||||
"title": "وبهوکها",
|
||||
"url": "آدرس"
|
||||
"url": "آدرس",
|
||||
"label": "وبهوکها"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Aucun webhook trouvé.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Non hai webhooks.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "कोई वेबहुक नहीं मिला।",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Nema pronađenih webhooks.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Nincs webhook.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Tidak ada webhook yang ditemukan.",
|
||||
"title": "Webhook",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhook"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Nessun webhook trovato.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Webhookが見つかりません。",
|
||||
"title": "Webhook",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhook"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Webhook-ები ვერ მოიძებნა.",
|
||||
"title": "Webhook-ები",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhook-ები"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Webhook이 없습니다.",
|
||||
"title": "Webhook",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhook"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "कोणतेही वेबहुक आढळले नाहीत.",
|
||||
"title": "वेबहुक्स",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "वेबहुक्स"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Ingen webhooks funnet.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Geen webhooks gevonden.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -435,7 +435,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Nie znaleziono webhooków.",
|
||||
"title": "Webhooki",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooki"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Nenhum webhook encontrado.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Nenhum webhook encontrado.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Нет вебхуков",
|
||||
"title": "Вебхуки",
|
||||
"url": "Ссылка"
|
||||
"url": "Ссылка",
|
||||
"label": "Вебхуки"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -435,7 +435,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Ne najdem nobenega webhooka.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Inga webhooks hittades.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhooks"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "ไม่พบ webhook",
|
||||
"title": "Webhook",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhook"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Webhook bulunamadı.",
|
||||
"title": "Webhook'lar",
|
||||
"url": "URL"
|
||||
"url": "URL",
|
||||
"label": "Webhook'lar"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "Вебхуків не знайдено.",
|
||||
"title": "Вебхуки",
|
||||
"url": "Посилання"
|
||||
"url": "Посилання",
|
||||
"label": "Вебхуки"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "没有 Webhook。",
|
||||
"title": "Webhook",
|
||||
"url": "链接"
|
||||
"url": "链接",
|
||||
"label": "Webhook"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -434,7 +434,8 @@
|
|||
},
|
||||
"no-webhooks-found": "尚未建立任何 Webhook。",
|
||||
"title": "Webhook",
|
||||
"url": "網址"
|
||||
"url": "網址",
|
||||
"label": "Webhook"
|
||||
}
|
||||
},
|
||||
"tag": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import { CogIcon, DatabaseIcon, KeyIcon, LibraryIcon, LucideIcon, Settings2Icon, UserIcon, UsersIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CogIcon,
|
||||
DatabaseIcon,
|
||||
KeyIcon,
|
||||
LibraryIcon,
|
||||
LucideIcon,
|
||||
Settings2Icon,
|
||||
TagsIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
WebhookIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
import InstanceSection from "@/components/Settings/InstanceSection";
|
||||
|
|
@ -10,6 +21,8 @@ import PreferencesSection from "@/components/Settings/PreferencesSection";
|
|||
import SectionMenuItem from "@/components/Settings/SectionMenuItem";
|
||||
import SSOSection from "@/components/Settings/SSOSection";
|
||||
import StorageSection from "@/components/Settings/StorageSection";
|
||||
import TagsSection from "@/components/Settings/TagsSection";
|
||||
import WebhookSection from "@/components/Settings/WebhookSection";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
|
|
@ -18,70 +31,68 @@ import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
|
|||
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
type SettingSection = "my-account" | "preference" | "member" | "system" | "memo" | "storage" | "sso";
|
||||
type SettingSection = "my-account" | "preference" | "webhook" | "member" | "system" | "memo" | "storage" | "sso" | "tags";
|
||||
|
||||
interface State {
|
||||
selectedSection: SettingSection;
|
||||
}
|
||||
const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference", "webhook"];
|
||||
const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo", "storage", "tags", "sso"];
|
||||
|
||||
const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference"];
|
||||
const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo", "storage", "sso"];
|
||||
const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = {
|
||||
"my-account": UserIcon,
|
||||
preference: CogIcon,
|
||||
webhook: WebhookIcon,
|
||||
member: UsersIcon,
|
||||
system: Settings2Icon,
|
||||
memo: LibraryIcon,
|
||||
storage: DatabaseIcon,
|
||||
tags: TagsIcon,
|
||||
sso: KeyIcon,
|
||||
};
|
||||
|
||||
const SECTION_COMPONENT_MAP: Record<SettingSection, React.ComponentType> = {
|
||||
"my-account": MyAccountSection,
|
||||
preference: PreferencesSection,
|
||||
webhook: WebhookSection,
|
||||
member: MemberSection,
|
||||
system: InstanceSection,
|
||||
memo: MemoRelatedSettings,
|
||||
storage: StorageSection,
|
||||
tags: TagsSection,
|
||||
sso: SSOSection,
|
||||
};
|
||||
|
||||
const Setting = () => {
|
||||
const t = useTranslate();
|
||||
const sm = useMediaQuery("sm");
|
||||
const location = useLocation();
|
||||
const user = useCurrentUser();
|
||||
const { profile, fetchSetting } = useInstance();
|
||||
const [state, setState] = useState<State>({
|
||||
selectedSection: "my-account",
|
||||
});
|
||||
const [selectedSection, setSelectedSection] = useState<SettingSection>("my-account");
|
||||
const isHost = user?.role === User_Role.ADMIN;
|
||||
|
||||
const settingsSectionList = useMemo(() => {
|
||||
let settingList = [...BASIC_SECTIONS];
|
||||
if (isHost) {
|
||||
settingList = settingList.concat(ADMIN_SECTIONS);
|
||||
}
|
||||
return settingList;
|
||||
return isHost ? [...BASIC_SECTIONS, ...ADMIN_SECTIONS] : [...BASIC_SECTIONS];
|
||||
}, [isHost]);
|
||||
|
||||
useEffect(() => {
|
||||
let hash = location.hash.slice(1) as SettingSection;
|
||||
// If the hash is not a valid section, redirect to the default section.
|
||||
if (![...BASIC_SECTIONS, ...ADMIN_SECTIONS].includes(hash)) {
|
||||
hash = "my-account";
|
||||
}
|
||||
setState({
|
||||
selectedSection: hash,
|
||||
});
|
||||
}, [location.hash]);
|
||||
const hash = location.hash.slice(1) as SettingSection;
|
||||
const nextSection = settingsSectionList.includes(hash) ? hash : "my-account";
|
||||
setSelectedSection(nextSection);
|
||||
}, [location.hash, settingsSectionList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial fetch for instance settings.
|
||||
(async () => {
|
||||
[InstanceSetting_Key.MEMO_RELATED, InstanceSetting_Key.STORAGE].forEach(async (key) => {
|
||||
await fetchSetting(key);
|
||||
});
|
||||
})();
|
||||
// Fetch admin-only settings that are not eagerly loaded by InstanceContext.
|
||||
fetchSetting(InstanceSetting_Key.STORAGE);
|
||||
fetchSetting(InstanceSetting_Key.TAGS);
|
||||
}, [isHost, fetchSetting]);
|
||||
|
||||
const handleSectionSelectorItemClick = useCallback((settingSection: SettingSection) => {
|
||||
window.location.hash = settingSection;
|
||||
}, []);
|
||||
const handleSectionSelectorItemClick = (section: SettingSection) => {
|
||||
window.location.hash = section;
|
||||
};
|
||||
|
||||
const ActiveSection = SECTION_COMPONENT_MAP[selectedSection];
|
||||
|
||||
return (
|
||||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
|
||||
|
|
@ -97,12 +108,12 @@ const Setting = () => {
|
|||
key={item}
|
||||
text={t(`setting.${item}.label`)}
|
||||
icon={SECTION_ICON_MAP[item]}
|
||||
isSelected={state.selectedSection === item}
|
||||
isSelected={selectedSection === item}
|
||||
onClick={() => handleSectionSelectorItemClick(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isHost ? (
|
||||
{isHost && (
|
||||
<>
|
||||
<span className="text-sm mt-4 pl-3 font-mono select-none text-muted-foreground">{t("common.admin")}</span>
|
||||
<div className="w-full flex flex-col justify-start items-start mt-1">
|
||||
|
|
@ -111,7 +122,7 @@ const Setting = () => {
|
|||
key={item}
|
||||
text={t(`setting.${item}.label`)}
|
||||
icon={SECTION_ICON_MAP[item]}
|
||||
isSelected={state.selectedSection === item}
|
||||
isSelected={selectedSection === item}
|
||||
onClick={() => handleSectionSelectorItemClick(item)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -120,41 +131,27 @@ const Setting = () => {
|
|||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full grow sm:pl-4 overflow-x-auto">
|
||||
{!sm && (
|
||||
<div className="w-auto inline-block my-2">
|
||||
<Select value={state.selectedSection} onValueChange={(value) => handleSectionSelectorItemClick(value as SettingSection)}>
|
||||
<Select value={selectedSection} onValueChange={(value) => handleSectionSelectorItemClick(value as SettingSection)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select section" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{settingsSectionList.map((settingSection) => (
|
||||
<SelectItem key={settingSection} value={settingSection}>
|
||||
{t(`setting.${settingSection}.label`)}
|
||||
{settingsSectionList.map((section) => (
|
||||
<SelectItem key={section} value={section}>
|
||||
{t(`setting.${section}.label`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{state.selectedSection === "my-account" ? (
|
||||
<MyAccountSection />
|
||||
) : state.selectedSection === "preference" ? (
|
||||
<PreferencesSection />
|
||||
) : state.selectedSection === "member" ? (
|
||||
<MemberSection />
|
||||
) : state.selectedSection === "system" ? (
|
||||
<InstanceSection />
|
||||
) : state.selectedSection === "memo" ? (
|
||||
<MemoRelatedSettings />
|
||||
) : state.selectedSection === "storage" ? (
|
||||
<StorageSection />
|
||||
) : state.selectedSection === "sso" ? (
|
||||
<SSOSection />
|
||||
) : null}
|
||||
<ActiveSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue