chore: refactor Settings UI structure (#5912)

Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
pull/5915/head
memoclaw 1 month ago committed by GitHub
parent 14480bfc46
commit 267f90a3ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -11,7 +11,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useInstance } from "@/contexts/InstanceContext";
import { handleError } from "@/lib/error";
import {
InstanceSetting_AIProviderConfig,
InstanceSetting_AIProviderConfigSchema,
@ -22,8 +21,10 @@ import {
} from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
import SettingGroup from "./SettingGroup";
import { SettingPanel } from "./SettingList";
import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable";
import useInstanceSettingUpdater, { buildInstanceSettingName } from "./useInstanceSettingUpdater";
type LocalAIProvider = {
id: string;
@ -81,7 +82,8 @@ const toProviderConfig = (provider: LocalAIProvider) =>
const AISection = () => {
const t = useTranslate();
const { aiSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const saveInstanceSetting = useInstanceSettingUpdater();
const { aiSetting: originalSetting } = useInstance();
const [providers, setProviders] = useState<LocalAIProvider[]>(() => originalSetting.providers.map(toLocalProvider));
const [editingProvider, setEditingProvider] = useState<LocalAIProvider | undefined>();
const [deleteTarget, setDeleteTarget] = useState<LocalAIProvider | undefined>();
@ -136,25 +138,19 @@ const AISection = () => {
};
const handleSaveSetting = async () => {
try {
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.AI]}`,
value: {
case: "aiSetting",
value: create(InstanceSetting_AISettingSchema, {
providers: providers.map(toProviderConfig),
}),
},
}),
);
await fetchSetting(InstanceSetting_Key.AI);
toast.success(t("message.update-succeed"));
} catch (error: unknown) {
handleError(error, toast.error, {
context: "Update AI providers",
});
}
await saveInstanceSetting({
key: InstanceSetting_Key.AI,
setting: create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.AI),
value: {
case: "aiSetting",
value: create(InstanceSetting_AISettingSchema, {
providers: providers.map(toProviderConfig),
}),
},
}),
errorContext: "Update AI providers",
});
};
return (
@ -167,7 +163,7 @@ const AISection = () => {
</Button>
}
>
<section className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<SettingPanel className="bg-muted/30 px-4 py-3">
<div className="flex max-w-3xl flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground">
@ -185,7 +181,7 @@ const AISection = () => {
))}
</ul>
</div>
</section>
</SettingPanel>
<SettingGroup title={t("setting.ai.providers")} description={t("setting.ai.description")}>
<SettingTable

@ -5,11 +5,9 @@ import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { identityProviderServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext";
import useDialog from "@/hooks/useDialog";
import { handleError } from "@/lib/error";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
import {
InstanceSetting_GeneralSetting,
@ -20,24 +18,21 @@ import {
import { useTranslate } from "@/utils/i18n";
import UpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import { SettingCodeEditor, SettingList, SettingListItem } from "./SettingList";
import SettingSection from "./SettingSection";
import useInstanceSettingUpdater, { buildInstanceSettingName } from "./useInstanceSettingUpdater";
const InstanceSection = () => {
const t = useTranslate();
const customizeDialog = useDialog();
const { generalSetting: originalSetting, profile, updateSetting, fetchSetting } = useInstance();
const saveInstanceSetting = useInstanceSettingUpdater();
const { generalSetting: originalSetting, profile } = useInstance();
const [instanceGeneralSetting, setInstanceGeneralSetting] = useState<InstanceSetting_GeneralSetting>(originalSetting);
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
useEffect(() => {
setInstanceGeneralSetting((prev) =>
create(InstanceSetting_GeneralSettingSchema, {
...prev,
customProfile: originalSetting.customProfile,
}),
);
}, [originalSetting.customProfile]);
setInstanceGeneralSetting(originalSetting);
}, [originalSetting]);
const fetchIdentityProviderList = async () => {
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
@ -58,106 +53,111 @@ const InstanceSection = () => {
};
const handleSaveGeneralSetting = async () => {
try {
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.GENERAL]}`,
value: {
case: "generalSetting",
value: instanceGeneralSetting,
},
}),
);
await fetchSetting(InstanceSetting_Key.GENERAL);
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Update general settings",
});
return;
}
toast.success(t("message.update-succeed"));
await saveInstanceSetting({
key: InstanceSetting_Key.GENERAL,
setting: create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.GENERAL),
value: {
case: "generalSetting",
value: instanceGeneralSetting,
},
}),
errorContext: "Update general settings",
});
};
return (
<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={customizeDialog.open}>
{t("common.edit")}
</Button>
</SettingRow>
<SettingGroup title={t("common.basic")} description={t("setting.system.basic-description")}>
<SettingList>
<SettingListItem label={t("setting.system.server-name")} description={instanceGeneralSetting.customProfile?.title || "Memos"}>
<Button variant="outline" onClick={customizeDialog.open}>
{t("common.edit")}
</Button>
</SettingListItem>
</SettingList>
</SettingGroup>
<SettingGroup title={t("setting.system.title")} showSeparator>
<SettingRow label={t("setting.system.additional-style")} vertical>
<Textarea
className="font-mono w-full"
rows={3}
placeholder={t("setting.system.additional-style-placeholder")}
value={instanceGeneralSetting.additionalStyle}
onChange={(event) => updatePartialSetting({ additionalStyle: event.target.value })}
/>
</SettingRow>
<SettingRow label={t("setting.system.additional-script")} vertical>
<Textarea
className="font-mono w-full"
rows={3}
placeholder={t("setting.system.additional-script-placeholder")}
value={instanceGeneralSetting.additionalScript}
onChange={(event) => updatePartialSetting({ additionalScript: event.target.value })}
/>
</SettingRow>
<SettingGroup title={t("setting.system.custom-code-title")} description={t("setting.system.custom-code-description")} showSeparator>
<SettingCodeEditor
label={t("setting.system.additional-style")}
description={t("setting.system.additional-style-description")}
placeholder={t("setting.system.additional-style-placeholder")}
value={instanceGeneralSetting.additionalStyle}
onChange={(additionalStyle) => updatePartialSetting({ additionalStyle })}
/>
<SettingCodeEditor
label={t("setting.system.additional-script")}
description={t("setting.system.additional-script-description")}
placeholder={t("setting.system.additional-script-placeholder")}
value={instanceGeneralSetting.additionalScript}
onChange={(additionalScript) => updatePartialSetting({ additionalScript })}
/>
</SettingGroup>
<SettingGroup showSeparator>
<SettingRow label={t("setting.instance.disallow-user-registration")}>
<Switch
disabled={profile.demo}
checked={instanceGeneralSetting.disallowUserRegistration}
onCheckedChange={(checked) => updatePartialSetting({ disallowUserRegistration: checked })}
/>
</SettingRow>
<SettingRow label={t("setting.instance.disallow-password-auth")}>
<Switch
disabled={profile.demo || (identityProviderList.length === 0 && !instanceGeneralSetting.disallowPasswordAuth)}
checked={instanceGeneralSetting.disallowPasswordAuth}
onCheckedChange={(checked) => updatePartialSetting({ disallowPasswordAuth: checked })}
/>
</SettingRow>
<SettingRow label={t("setting.instance.disallow-change-username")}>
<Switch
checked={instanceGeneralSetting.disallowChangeUsername}
onCheckedChange={(checked) => updatePartialSetting({ disallowChangeUsername: checked })}
/>
</SettingRow>
<SettingRow label={t("setting.instance.disallow-change-nickname")}>
<Switch
checked={instanceGeneralSetting.disallowChangeNickname}
onCheckedChange={(checked) => updatePartialSetting({ disallowChangeNickname: checked })}
/>
</SettingRow>
<SettingRow label={t("setting.instance.week-start-day")}>
<Select
value={instanceGeneralSetting.weekStartDayOffset.toString()}
onValueChange={(value) => {
updatePartialSetting({ weekStartDayOffset: parseInt(value) || 0 });
}}
<SettingGroup title={t("setting.instance.access-title")} description={t("setting.instance.access-description")} showSeparator>
<SettingList>
<SettingListItem
label={t("setting.instance.disallow-user-registration")}
description={t("setting.instance.disallow-user-registration-description")}
>
<Switch
disabled={profile.demo}
checked={instanceGeneralSetting.disallowUserRegistration}
onCheckedChange={(checked) => updatePartialSetting({ disallowUserRegistration: checked })}
/>
</SettingListItem>
<SettingListItem
label={t("setting.instance.disallow-password-auth")}
description={t("setting.instance.disallow-password-auth-description")}
>
<Switch
disabled={profile.demo || (identityProviderList.length === 0 && !instanceGeneralSetting.disallowPasswordAuth)}
checked={instanceGeneralSetting.disallowPasswordAuth}
onCheckedChange={(checked) => updatePartialSetting({ disallowPasswordAuth: checked })}
/>
</SettingListItem>
<SettingListItem
label={t("setting.instance.disallow-change-username")}
description={t("setting.instance.disallow-change-username-description")}
>
<Switch
checked={instanceGeneralSetting.disallowChangeUsername}
onCheckedChange={(checked) => updatePartialSetting({ disallowChangeUsername: checked })}
/>
</SettingListItem>
<SettingListItem
label={t("setting.instance.disallow-change-nickname")}
description={t("setting.instance.disallow-change-nickname-description")}
>
<SelectTrigger className="min-w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="-1">{t("setting.instance.saturday")}</SelectItem>
<SelectItem value="0">{t("setting.instance.sunday")}</SelectItem>
<SelectItem value="1">{t("setting.instance.monday")}</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<Switch
checked={instanceGeneralSetting.disallowChangeNickname}
onCheckedChange={(checked) => updatePartialSetting({ disallowChangeNickname: checked })}
/>
</SettingListItem>
<SettingListItem label={t("setting.instance.week-start-day")} description={t("setting.instance.week-start-day-description")}>
<Select
value={instanceGeneralSetting.weekStartDayOffset.toString()}
onValueChange={(value) => {
updatePartialSetting({ weekStartDayOffset: parseInt(value) || 0 });
}}
>
<SelectTrigger className="min-w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="-1">{t("setting.instance.saturday")}</SelectItem>
<SelectItem value="0">{t("setting.instance.sunday")}</SelectItem>
<SelectItem value="1">{t("setting.instance.monday")}</SelectItem>
</SelectContent>
</Select>
</SettingListItem>
</SettingList>
</SettingGroup>
<div className="w-full flex justify-end">

@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useInstance } from "@/contexts/InstanceContext";
import { handleError } from "@/lib/error";
import {
InstanceSetting_Key,
InstanceSetting_MemoRelatedSetting,
@ -17,11 +16,14 @@ import {
} from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
import SettingGroup from "./SettingGroup";
import { SettingList, SettingListItem, SettingPanel } from "./SettingList";
import SettingSection from "./SettingSection";
import useInstanceSettingUpdater, { buildInstanceSettingName } from "./useInstanceSettingUpdater";
const MemoRelatedSettings = () => {
const t = useTranslate();
const { memoRelatedSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const saveInstanceSetting = useInstanceSettingUpdater();
const { memoRelatedSetting: originalSetting } = useInstance();
const [memoRelatedSetting, setMemoRelatedSetting] = useState<InstanceSetting_MemoRelatedSetting>(originalSetting);
const [editingReaction, setEditingReaction] = useState<string>("");
@ -53,45 +55,34 @@ const MemoRelatedSettings = () => {
return;
}
try {
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.MEMO_RELATED]}`,
value: {
case: "memoRelatedSetting",
value: memoRelatedSetting,
},
}),
);
await fetchSetting(InstanceSetting_Key.MEMO_RELATED);
toast.success(t("message.update-succeed"));
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Update memo-related settings",
});
}
await saveInstanceSetting({
key: InstanceSetting_Key.MEMO_RELATED,
setting: create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED),
value: {
case: "memoRelatedSetting",
value: memoRelatedSetting,
},
}),
errorContext: "Update memo-related settings",
});
};
return (
<SettingSection title={t("setting.memo.label")}>
<SettingGroup title={t("setting.memo.editing-title")} description={t("setting.memo.editing-description")}>
<div className="overflow-hidden rounded-lg border border-border bg-background divide-y divide-border">
<div className="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{t("setting.system.enable-double-click-to-edit")}</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t("setting.memo.double-click-edit-description")}</p>
</div>
<SettingList>
<SettingListItem
label={t("setting.system.enable-double-click-to-edit")}
description={t("setting.memo.double-click-edit-description")}
>
<Switch
checked={memoRelatedSetting.enableDoubleClickEdit}
onCheckedChange={(checked) => updatePartialSetting({ enableDoubleClickEdit: checked })}
/>
</div>
</SettingListItem>
<div className="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{t("setting.memo.content-length-limit")}</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t("setting.memo.content-length-limit-description")}</p>
</div>
<SettingListItem label={t("setting.memo.content-length-limit")} description={t("setting.memo.content-length-limit-description")}>
<div className="flex items-center gap-2">
<Input
className="w-28 font-mono"
@ -102,19 +93,36 @@ const MemoRelatedSettings = () => {
/>
<span className="text-xs text-muted-foreground">{t("setting.memo.bytes-unit")}</span>
</div>
</div>
</div>
</SettingListItem>
</SettingList>
</SettingGroup>
<SettingGroup title={t("setting.memo.reactions")} description={t("setting.memo.reactions-description")} showSeparator>
<div className="overflow-hidden rounded-lg border border-border bg-background">
<div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
<span className="text-sm font-medium text-muted-foreground">{t("setting.memo.configured-reactions")}</span>
<Badge variant="outline" className="rounded-md px-2 py-0 text-xs font-normal">
{memoRelatedSetting.reactions.length}
</Badge>
</div>
<SettingPanel
header={
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-muted-foreground">{t("setting.memo.configured-reactions")}</span>
<Badge variant="outline" className="rounded-md px-2 py-0 text-xs font-normal">
{memoRelatedSetting.reactions.length}
</Badge>
</div>
}
footer={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
className="h-8 max-w-48 font-mono"
placeholder={t("setting.memo.reaction-placeholder")}
value={editingReaction}
onChange={(event) => setEditingReaction(event.target.value)}
onKeyDown={(e) => e.key === "Enter" && upsertReaction()}
/>
<Button variant="outline" size="sm" onClick={upsertReaction} disabled={!editingReaction.trim()}>
<CheckIcon className="w-4 h-4 mr-1.5" />
{t("setting.memo.add-reaction")}
</Button>
</div>
}
>
<div className="flex min-h-16 flex-wrap gap-2 px-3 py-3">
{memoRelatedSetting.reactions.map((reactionType) => (
<Badge key={reactionType} variant="outline" className="flex h-8 items-center gap-2 rounded-md px-2.5 font-normal">
@ -130,21 +138,7 @@ const MemoRelatedSettings = () => {
</Badge>
))}
</div>
<div className="flex flex-col gap-2 border-t border-border bg-muted/20 px-3 py-3 sm:flex-row sm:items-center">
<Input
className="h-8 max-w-48 font-mono"
placeholder={t("setting.memo.reaction-placeholder")}
value={editingReaction}
onChange={(event) => setEditingReaction(event.target.value)}
onKeyDown={(e) => e.key === "Enter" && upsertReaction()}
/>
<Button variant="outline" size="sm" onClick={upsertReaction} disabled={!editingReaction.trim()}>
<CheckIcon className="w-4 h-4 mr-1.5" />
{t("setting.memo.add-reaction")}
</Button>
</div>
</div>
</SettingPanel>
</SettingGroup>
<div className="w-full flex justify-end">

@ -11,7 +11,7 @@ import LocaleSelect from "../LocaleSelect";
import ThemeSelect from "../ThemeSelect";
import VisibilityIcon from "../VisibilityIcon";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import { SettingList, SettingListItem } from "./SettingList";
import SettingSection from "./SettingSection";
const PreferencesSection = () => {
@ -69,36 +69,47 @@ const PreferencesSection = () => {
return (
<SettingSection title={t("setting.preference.label")}>
<SettingGroup title={t("common.basic")}>
<SettingRow label={t("common.language")}>
<LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} />
</SettingRow>
<SettingGroup title={t("setting.preference.appearance-title")} description={t("setting.preference.appearance-description")}>
<SettingList>
<SettingListItem label={t("common.language")} description={t("setting.preference.language-description")}>
<LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} />
</SettingListItem>
<SettingRow label={t("setting.preference.theme")}>
<ThemeSelect value={setting.theme} onValueChange={handleThemeChange} />
</SettingRow>
<SettingListItem label={t("setting.preference.theme")} description={t("setting.preference.theme-description")}>
<ThemeSelect value={setting.theme} onValueChange={handleThemeChange} />
</SettingListItem>
</SettingList>
</SettingGroup>
<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">
<div className="flex items-center gap-2">
<VisibilityIcon visibility={convertVisibilityFromString(setting.memoVisibility)} />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{[Visibility.PRIVATE, Visibility.PROTECTED, Visibility.PUBLIC]
.map((v) => convertVisibilityToString(v))
.map((item) => (
<SelectItem key={item} value={item} className="whitespace-nowrap">
{t(`memo.visibility.${item.toLowerCase() as Lowercase<typeof item>}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingGroup
title={t("setting.preference.memo-defaults-title")}
description={t("setting.preference.memo-defaults-description")}
showSeparator
>
<SettingList>
<SettingListItem
label={t("setting.preference.default-memo-visibility")}
description={t("setting.preference.default-memo-visibility-description")}
>
<Select value={setting.memoVisibility || "PRIVATE"} onValueChange={handleDefaultMemoVisibilityChanged}>
<SelectTrigger className="min-w-fit">
<div className="flex items-center gap-2">
<VisibilityIcon visibility={convertVisibilityFromString(setting.memoVisibility)} />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{[Visibility.PRIVATE, Visibility.PROTECTED, Visibility.PUBLIC]
.map((v) => convertVisibilityToString(v))
.map((item) => (
<SelectItem key={item} value={item} className="whitespace-nowrap">
{t(`memo.visibility.${item.toLowerCase() as Lowercase<typeof item>}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingListItem>
</SettingList>
</SettingGroup>
</SettingSection>
);

@ -14,20 +14,20 @@ interface SettingGroupProps {
const SettingGroup: React.FC<SettingGroupProps> = ({ title, description, children, className, showSeparator = false, actions }) => {
return (
<>
{showSeparator && <Separator className="my-2" />}
<div className={cn("flex flex-col gap-3", className)}>
{showSeparator && <Separator className="my-0" />}
<div className={cn("flex min-w-0 flex-col gap-3", className)}>
{(title || description || actions) && (
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
{(title || description) && (
<div className="flex min-w-0 flex-1 flex-col gap-1">
{title && <h4 className="text-sm font-medium text-muted-foreground">{title}</h4>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
{title && <h4 className="text-sm font-medium text-foreground">{title}</h4>}
{description && <p className="max-w-2xl text-xs leading-5 text-muted-foreground">{description}</p>}
</div>
)}
{actions ? <div className="ml-auto shrink-0">{actions}</div> : null}
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2 sm:ml-auto">{actions}</div> : null}
</div>
)}
<div className="flex flex-col gap-3">{children}</div>
<div className="flex min-w-0 flex-col gap-3">{children}</div>
</div>
</>
);

@ -0,0 +1,95 @@
import { type ReactNode } from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
interface SettingListProps {
children: ReactNode;
className?: string;
}
export const SettingList = ({ children, className }: SettingListProps) => {
return (
<div className={cn("overflow-hidden rounded-lg border border-border bg-background divide-y divide-border", className)}>{children}</div>
);
};
interface SettingListItemProps {
label: ReactNode;
description?: ReactNode;
children?: ReactNode;
icon?: ReactNode;
className?: string;
contentClassName?: string;
controlClassName?: string;
vertical?: boolean;
}
export const SettingListItem = ({
label,
description,
children,
icon,
className,
contentClassName,
controlClassName,
vertical = false,
}: SettingListItemProps) => {
return (
<div className={cn("flex min-w-0 flex-col gap-3 px-3 py-3", !vertical && "sm:flex-row sm:items-center sm:justify-between", className)}>
<div className={cn("flex min-w-0 gap-2", contentClassName)}>
{icon && <div className="mt-0.5 shrink-0 text-muted-foreground">{icon}</div>}
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{label}</div>
{description && <div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</div>}
</div>
</div>
{children && <div className={cn("flex min-w-0 items-center", !vertical && "sm:shrink-0", controlClassName)}>{children}</div>}
</div>
);
};
interface SettingPanelProps {
children: ReactNode;
className?: string;
header?: ReactNode;
footer?: ReactNode;
}
export const SettingPanel = ({ children, className, header, footer }: SettingPanelProps) => {
return (
<div className={cn("overflow-hidden rounded-lg border border-border bg-background", className)}>
{header && <div className="border-b border-border px-3 py-2">{header}</div>}
{children}
{footer && <div className="border-t border-border bg-muted/20 px-3 py-2">{footer}</div>}
</div>
);
};
interface SettingCodeEditorProps {
label: string;
description: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
}
export const SettingCodeEditor = ({ label, description, value, placeholder, onChange }: SettingCodeEditorProps) => {
return (
<SettingPanel
header={
<>
<div className="text-sm font-medium text-foreground">{label}</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>
</>
}
>
<Textarea
className="min-h-24 rounded-none border-0 font-mono shadow-none focus-visible:ring-0"
rows={4}
placeholder={placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</SettingPanel>
);
};

@ -14,8 +14,14 @@ interface SettingRowProps {
const SettingRow: React.FC<SettingRowProps> = ({ label, description, tooltip, children, className, vertical = false }) => {
return (
<div className={cn("w-full flex gap-3", vertical ? "flex-col" : "flex-row justify-between items-center", className)}>
<div className={cn("flex flex-col gap-1", vertical ? "w-full" : "flex-1 min-w-0")}>
<div
className={cn(
"flex w-full min-w-0 gap-3",
vertical ? "flex-col" : "flex-col sm:flex-row sm:items-center sm:justify-between",
className,
)}
>
<div className={cn("flex min-w-0 flex-col gap-1", vertical ? "w-full" : "flex-1")}>
<div className="flex items-center gap-1.5">
<span className={cn("text-sm", vertical ? "font-medium" : "")}>{label}</span>
{tooltip && (
@ -33,7 +39,7 @@ const SettingRow: React.FC<SettingRowProps> = ({ label, description, tooltip, ch
</div>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
<div className={cn("flex items-center", vertical ? "w-full" : "shrink-0")}>{children}</div>
<div className={cn("flex min-w-0 items-center", vertical ? "w-full" : "w-full sm:w-auto sm:shrink-0")}>{children}</div>
</div>
);
};

@ -11,19 +11,21 @@ interface SettingSectionProps {
const SettingSection: React.FC<SettingSectionProps> = ({ title, description, children, className, actions }) => {
return (
<div className={cn("w-full flex flex-col gap-4 pt-2 pb-4", className)}>
<div className={cn("mx-auto flex w-full max-w-4xl min-w-0 flex-col gap-5 py-1 sm:py-2", className)}>
{(title || description || actions) && (
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div className="flex-1">
<div className="flex min-w-0 flex-col gap-3 border-b border-border/70 pb-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 flex-1">
{title && (
<div className="text-base font-semibold text-foreground mb-1">{typeof title === "string" ? <h3>{title}</h3> : title}</div>
<div className="mb-1 text-lg font-semibold tracking-tight text-foreground">
{typeof title === "string" ? <h3>{title}</h3> : title}
</div>
)}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{description && <p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</div>
)}
<div className="flex flex-col gap-4">{children}</div>
<div className="flex min-w-0 flex-col gap-5">{children}</div>
</div>
);
};

@ -2,7 +2,6 @@ import { create } from "@bufbuild/protobuf";
import { isEqual } from "lodash-es";
import { CloudIcon, DatabaseIcon, FolderIcon, LucideIcon } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -10,7 +9,6 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { useInstance } from "@/contexts/InstanceContext";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import {
InstanceSetting_Key,
@ -23,8 +21,10 @@ import {
} from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
import SettingGroup from "./SettingGroup";
import { SettingPanel } from "./SettingList";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
import useInstanceSettingUpdater, { buildInstanceSettingName } from "./useInstanceSettingUpdater";
const DEFAULT_FILEPATH_TEMPLATE = "assets/{timestamp}_{uuid}_{filename}";
@ -71,7 +71,8 @@ const storageTypeOptions: StorageTypeOption[] = [
const StorageSection = () => {
const t = useTranslate();
const { storageSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const saveInstanceSetting = useInstanceSettingUpdater();
const { storageSetting: originalSetting } = useInstance();
const [instanceStorageSetting, setInstanceStorageSetting] = useState<InstanceSetting_StorageSetting>(originalSetting);
const selectedStorageOption = useMemo(
@ -162,23 +163,17 @@ const StorageSection = () => {
};
const saveInstanceStorageSetting = async () => {
try {
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.STORAGE]}`,
value: {
case: "storageSetting",
value: instanceStorageSetting,
},
}),
);
await fetchSetting(InstanceSetting_Key.STORAGE);
toast.success(t("message.update-succeed"));
} catch (error: unknown) {
handleError(error, toast.error, {
context: "Update storage settings",
});
}
await saveInstanceSetting({
key: InstanceSetting_Key.STORAGE,
setting: create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.STORAGE),
value: {
case: "storageSetting",
value: instanceStorageSetting,
},
}),
errorContext: "Update storage settings",
});
};
return (
@ -189,7 +184,7 @@ const StorageSection = () => {
onValueChange={(value) => {
handleStorageTypeChanged(Number(value) as InstanceSetting_StorageSetting_StorageType);
}}
className="overflow-hidden rounded-lg border border-border bg-background divide-y divide-border"
className="overflow-hidden rounded-lg border border-border bg-background divide-y divide-border gap-0"
>
{storageTypeOptions.map((option) => {
const Icon = option.icon;
@ -239,13 +234,13 @@ const StorageSection = () => {
})}
</RadioGroup>
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
<SettingPanel className="rounded-md bg-muted/20 px-3 py-2.5">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<SelectedStorageIcon className="size-3.5" />
<span>{t("setting.storage.selected-backend")}</span>
<span className="text-foreground">{t(selectedStorageOption.titleKey)}</span>
</div>
<ul className="grid gap-1.5 text-xs leading-5 text-muted-foreground sm:grid-cols-2">
<ul className="flex flex-col gap-1.5 text-xs leading-5 text-muted-foreground">
{selectedStorageOption.noteKeys.map((note) => (
<li key={note} className="flex gap-2">
<span className="mt-2 size-1 rounded-full bg-muted-foreground/60" aria-hidden />
@ -253,7 +248,7 @@ const StorageSection = () => {
</li>
))}
</ul>
</div>
</SettingPanel>
<SettingRow label={t("setting.system.max-upload-size")} tooltip={t("setting.system.max-upload-size-hint")}>
<Input

@ -10,7 +10,6 @@ import { Switch } from "@/components/ui/switch";
import { useInstance } from "@/contexts/InstanceContext";
import { useTagCounts } from "@/hooks/useUserQueries";
import { colorToHex } from "@/lib/color";
import { handleError } from "@/lib/error";
import { isValidTagPattern } from "@/lib/tag";
import { cn } from "@/lib/utils";
import {
@ -22,7 +21,9 @@ import {
import { ColorSchema } from "@/types/proto/google/type/color_pb";
import { useTranslate } from "@/utils/i18n";
import SettingGroup from "./SettingGroup";
import { SettingList, SettingPanel } from "./SettingList";
import SettingSection from "./SettingSection";
import useInstanceSettingUpdater, { buildInstanceSettingName } from "./useInstanceSettingUpdater";
const DEFAULT_TAG_COLOR = "#ffffff";
@ -49,7 +50,8 @@ const toLocalTagMeta = (meta: {
const TagsSection = () => {
const t = useTranslate();
const { tagsSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const saveInstanceSetting = useInstanceSettingUpdater();
const { tagsSetting: originalSetting } = useInstance();
const { data: tagCounts = {} } = useTagCounts(false);
// Local state: map of tagName → { color, blur } for editing.
@ -125,87 +127,87 @@ const TagsSection = () => {
};
const handleSave = async () => {
try {
const tags = Object.fromEntries(
Object.entries(localTags).map(([name, meta]) => [
name,
create(InstanceSetting_TagMetadataSchema, {
blurContent: meta.blur,
...(meta.color ? { backgroundColor: hexToColor(meta.color) } : {}),
}),
]),
);
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.TAGS]}`,
value: {
case: "tagsSetting",
value: create(InstanceSetting_TagsSettingSchema, { tags }),
},
const tags = Object.fromEntries(
Object.entries(localTags).map(([name, meta]) => [
name,
create(InstanceSetting_TagMetadataSchema, {
blurContent: meta.blur,
...(meta.color ? { backgroundColor: hexToColor(meta.color) } : {}),
}),
);
await fetchSetting(InstanceSetting_Key.TAGS);
toast.success(t("message.update-succeed"));
} catch (error: unknown) {
handleError(error, toast.error, { context: "Update tags setting" });
}
]),
);
await saveInstanceSetting({
key: InstanceSetting_Key.TAGS,
setting: create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.TAGS),
value: {
case: "tagsSetting",
value: create(InstanceSetting_TagsSettingSchema, { tags }),
},
}),
errorContext: "Update tags setting",
});
};
return (
<SettingSection title={t("setting.tags.label")}>
<SettingGroup title={t("setting.tags.title")} description={t("setting.tags.description")}>
<div className="rounded-lg border border-border bg-background">
<div className="grid gap-3 p-3 lg:grid-cols-[minmax(12rem,1fr)_auto_auto_auto] lg:items-center">
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<SettingPanel footer={<span className="text-xs text-muted-foreground">{t("setting.tags.tag-pattern-hint")}</span>}>
<div className="flex flex-col gap-3 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<PlusIcon className="size-3.5" />
<span>{t("setting.tags.add-rule")}</span>
</div>
<Input
className="font-mono"
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>
<Button variant="outline" onClick={handleAddTag} disabled={!newTagName.trim()}>
<PlusIcon className="w-4 h-4 mr-1.5" />
{t("common.add")}
</Button>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_auto_auto] lg:items-center">
<div className="min-w-0">
<Input
className="font-mono"
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>
</div>
<div className="flex h-8 items-center gap-2 rounded-md border border-border bg-background px-2 text-sm text-muted-foreground">
<PaletteIcon className="size-4" />
{t("setting.tags.background-color")}
</span>
<input
type="color"
className="size-8 cursor-pointer rounded-md border border-border bg-transparent p-0.5"
value={newTagColor ?? DEFAULT_TAG_COLOR}
onChange={(e) => setNewTagColor(e.target.value)}
/>
<Button variant="ghost" size="sm" onClick={() => setNewTagColor(undefined)} disabled={!newTagColor}>
{t("common.clear")}
</Button>
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<EyeOffIcon className="size-4" />
{t("setting.tags.blur-content")}
<Switch checked={newTagBlur} onCheckedChange={setNewTagBlur} />
</label>
<Button variant="outline" onClick={handleAddTag} disabled={!newTagName.trim()}>
<PlusIcon className="w-4 h-4 mr-1.5" />
{t("common.add")}
</Button>
</div>
<div className="border-t border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{t("setting.tags.tag-pattern-hint")}
<span>{t("setting.tags.background-color")}</span>
<input
type="color"
className="size-6 cursor-pointer rounded border border-border bg-transparent p-0.5"
value={newTagColor ?? DEFAULT_TAG_COLOR}
onChange={(e) => setNewTagColor(e.target.value)}
aria-label={t("setting.tags.background-color")}
/>
<Button variant="ghost" size="sm" onClick={() => setNewTagColor(undefined)} disabled={!newTagColor} className="h-6 px-1.5">
{t("common.clear")}
</Button>
</div>
<label className="flex h-8 items-center gap-2 rounded-md border border-border bg-background px-2 text-sm text-muted-foreground">
<EyeOffIcon className="size-4" />
<span>{t("setting.tags.blur-content")}</span>
<Switch checked={newTagBlur} onCheckedChange={setNewTagBlur} />
</label>
</div>
</div>
</div>
</SettingPanel>
<div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-medium text-muted-foreground">{t("setting.tags.configured-rules")}</h4>
@ -214,14 +216,14 @@ const TagsSection = () => {
</Badge>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-background">
<SettingList>
{configuredEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center">
<TagIcon className="size-5 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("setting.tags.no-tags-configured")}</p>
</div>
) : (
<div className="divide-y divide-border">
<>
{configuredEntries.map((row) => (
<div key={row.name} className="grid gap-3 px-3 py-3 lg:grid-cols-[minmax(12rem,1fr)_auto_auto_auto] lg:items-center">
<div className="min-w-0">
@ -265,9 +267,9 @@ const TagsSection = () => {
</Button>
</div>
))}
</div>
</>
)}
</div>
</SettingList>
</SettingGroup>
<div className="w-full flex justify-end">

@ -0,0 +1,120 @@
import {
CogIcon,
DatabaseIcon,
HeartHandshakeIcon,
KeyIcon,
LibraryIcon,
type LucideIcon,
Settings2Icon,
TagsIcon,
UserIcon,
UsersIcon,
WebhookIcon,
} from "lucide-react";
import { type ComponentType } from "react";
import AISection from "@/components/Settings/AISection";
import InstanceSection from "@/components/Settings/InstanceSection";
import MemberSection from "@/components/Settings/MemberSection";
import MemoRelatedSettings from "@/components/Settings/MemoRelatedSettings";
import MyAccountSection from "@/components/Settings/MyAccountSection";
import PreferencesSection from "@/components/Settings/PreferencesSection";
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 { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
export type SettingSectionKey = "my-account" | "preference" | "webhook" | "member" | "system" | "memo" | "storage" | "sso" | "tags" | "ai";
type SettingSectionScope = "basic" | "admin";
export interface SettingSectionDefinition {
key: SettingSectionKey;
scope: SettingSectionScope;
labelKey: `setting.${SettingSectionKey}.label`;
icon: LucideIcon;
component: ComponentType;
preloadSettingKeys?: InstanceSetting_Key[];
}
export const SETTINGS_SECTIONS: SettingSectionDefinition[] = [
{
key: "my-account",
scope: "basic",
labelKey: "setting.my-account.label",
icon: UserIcon,
component: MyAccountSection,
},
{
key: "preference",
scope: "basic",
labelKey: "setting.preference.label",
icon: CogIcon,
component: PreferencesSection,
},
{
key: "webhook",
scope: "basic",
labelKey: "setting.webhook.label",
icon: WebhookIcon,
component: WebhookSection,
},
{
key: "member",
scope: "admin",
labelKey: "setting.member.label",
icon: UsersIcon,
component: MemberSection,
},
{
key: "system",
scope: "admin",
labelKey: "setting.system.label",
icon: Settings2Icon,
component: InstanceSection,
},
{
key: "memo",
scope: "admin",
labelKey: "setting.memo.label",
icon: LibraryIcon,
component: MemoRelatedSettings,
},
{
key: "tags",
scope: "admin",
labelKey: "setting.tags.label",
icon: TagsIcon,
component: TagsSection,
preloadSettingKeys: [InstanceSetting_Key.TAGS],
},
{
key: "storage",
scope: "admin",
labelKey: "setting.storage.label",
icon: DatabaseIcon,
component: StorageSection,
preloadSettingKeys: [InstanceSetting_Key.STORAGE],
},
{
key: "sso",
scope: "admin",
labelKey: "setting.sso.label",
icon: KeyIcon,
component: SSOSection,
},
{
key: "ai",
scope: "admin",
labelKey: "setting.ai.label",
icon: HeartHandshakeIcon,
component: AISection,
preloadSettingKeys: [InstanceSetting_Key.AI],
},
];
export const DEFAULT_SETTING_SECTION: SettingSectionKey = "my-account";
export const isSettingSectionKey = (value: string): value is SettingSectionKey => {
return SETTINGS_SECTIONS.some((section) => section.key === value);
};

@ -0,0 +1,36 @@
import { useCallback } from "react";
import { toast } from "react-hot-toast";
import { useInstance } from "@/contexts/InstanceContext";
import { handleError } from "@/lib/error";
import { InstanceSetting, InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
interface SaveInstanceSettingOptions {
key: InstanceSetting_Key;
setting: InstanceSetting;
errorContext: string;
}
export const buildInstanceSettingName = (key: InstanceSetting_Key) => `instance/settings/${InstanceSetting_Key[key]}`;
const useInstanceSettingUpdater = () => {
const t = useTranslate();
const { updateSetting, fetchSetting } = useInstance();
return useCallback(
async ({ key, setting, errorContext }: SaveInstanceSettingOptions) => {
try {
await updateSetting(setting);
await fetchSetting(key);
toast.success(t("message.update-succeed"));
return true;
} catch (error: unknown) {
await handleError(error, toast.error, { context: errorContext });
return false;
}
},
[fetchSetting, t, updateSetting],
);
};
export default useInstanceSettingUpdater;

@ -435,13 +435,20 @@
"providers": "Providers"
},
"instance": {
"access-description": "Control sign-up, authentication, profile editing, and calendar defaults for this instance.",
"access-title": "Access and policies",
"disallow-change-nickname-description": "Prevent users from changing their display name.",
"disallow-change-nickname": "Disallow changing nickname",
"disallow-change-username-description": "Prevent users from changing their login username.",
"disallow-change-username": "Disallow changing username",
"disallow-password-auth-description": "Require users to sign in through an identity provider instead of password login.",
"disallow-password-auth": "Disallow password auth",
"disallow-user-registration-description": "Prevent new users from creating accounts from the sign-up page.",
"disallow-user-registration": "Disallow user registration",
"monday": "Monday",
"saturday": "Saturday",
"sunday": "Sunday",
"week-start-day-description": "Controls the first day shown in calendar-style views.",
"week-start-day": "Week start day"
},
"member": {
@ -489,9 +496,16 @@
"label": "My Account"
},
"preference": {
"appearance-description": "Choose how the app looks and which language it uses for your account.",
"appearance-title": "Appearance",
"default-memo-sort-option": "Memo display time",
"default-memo-visibility": "Default memo visibility",
"default-memo-visibility-description": "Visibility applied to newly created memos unless changed in the editor.",
"label": "Preferences",
"language-description": "Updates the interface language immediately and saves it to your account.",
"memo-defaults-description": "Set the defaults used when composing new memos.",
"memo-defaults-title": "Memo defaults",
"theme-description": "Applies the selected theme immediately on this device.",
"theme": "Theme"
},
"shortcut": {
@ -625,16 +639,21 @@
},
"system": {
"additional-script": "Additional script",
"additional-script-description": "Inject JavaScript into every web page. Use only trusted code.",
"additional-script-placeholder": "Additional JavaScript code",
"additional-style": "Additional style",
"additional-style-description": "Inject CSS into the web app to adjust instance-wide presentation.",
"additional-style-placeholder": "Additional CSS code",
"allow-user-signup": "Allow user signup",
"basic-description": "Configure the public identity shown by this instance.",
"customize-server": {
"description": "Description",
"icon-url": "Icon URL",
"locale": "Server Locale",
"title": "Customize Server"
},
"custom-code-description": "Optional instance-wide CSS and JavaScript applied to the frontend.",
"custom-code-title": "Custom code",
"disable-password-login": "Disable password login",
"disable-password-login-final-warning": "Please type `CONFIRM` if you know what you are doing.",
"disable-password-login-warning": "This will disable password login for all users. It is not possible to log in without reverting this setting in the database if your configured identity providers fail. You'll also have to be extra careful when removing an identity provider",
@ -650,6 +669,7 @@
"server-name": "Server Name",
"title": "General"
},
"select-section": "Select section",
"version": "Version",
"webhook": {
"create-dialog": {

@ -382,8 +382,15 @@
"providers": "Providers"
},
"preference": {
"appearance-description": "选择当前账号使用的界面语言和外观主题。",
"appearance-title": "外观",
"default-memo-sort-option": "备忘录显示时间",
"default-memo-visibility": "默认备忘录可见性",
"default-memo-visibility-description": "创建新备忘录时默认使用的可见性,仍可在编辑器中调整。",
"language-description": "立即更新界面语言,并保存到当前账号。",
"memo-defaults-description": "设置创建新备忘录时使用的默认值。",
"memo-defaults-title": "备忘录默认值",
"theme-description": "立即在当前设备上应用所选主题。",
"theme": "主题",
"label": "偏好设置"
},
@ -519,16 +526,21 @@
},
"system": {
"additional-script": "自定义脚本",
"additional-script-description": "向所有 Web 页面注入 JavaScript。请只使用可信代码。",
"additional-script-placeholder": "自定义 JavaScript 代码",
"additional-style": "自定义样式",
"additional-style-description": "向前端应用注入 CSS用于调整实例范围内的显示效果。",
"additional-style-placeholder": "自定义 CSS 代码",
"allow-user-signup": "允许用户注册",
"basic-description": "配置该实例对外展示的身份信息。",
"customize-server": {
"description": "描述",
"icon-url": "图标链接",
"locale": "服务器语言环境",
"title": "自定义服务器"
},
"custom-code-description": "应用到前端的实例范围 CSS 和 JavaScript可选。",
"custom-code-title": "自定义代码",
"disable-password-login": "禁用密码登录",
"disable-password-login-final-warning": "如果您知道自己在做什么,请输入 \"CONFIRM\"。",
"disable-password-login-warning": "所有用户将无法使用密码登录。如果配置的身份提供程序失效,不在数据库中恢复此设置将无法登录。删除身份提供程序时也要格外小心",
@ -544,6 +556,7 @@
"title": "一般设置",
"label": "系统"
},
"select-section": "选择设置项",
"version": "版本",
"access-token": {
"access-token-copied-to-clipboard": "访问令牌已复制到剪贴板",
@ -585,13 +598,20 @@
"username-note": "用于登录"
},
"instance": {
"access-description": "控制该实例的注册、登录、资料编辑和日历默认行为。",
"access-title": "访问和策略",
"disallow-change-nickname-description": "阻止用户修改显示昵称。",
"disallow-change-nickname": "禁止修改用户昵称",
"disallow-change-username-description": "阻止用户修改登录用户名。",
"disallow-change-username": "禁止修改用户名",
"disallow-password-auth-description": "要求用户通过身份提供商登录,而不是使用密码登录。",
"disallow-password-auth": "禁用密码登录",
"disallow-user-registration-description": "阻止新用户从注册页面创建账号。",
"disallow-user-registration": "禁用用户注册",
"monday": "周一",
"saturday": "周六",
"sunday": "周日",
"week-start-day-description": "控制日历类视图显示的每周第一天。",
"week-start-day": "周开始日"
},
"memo": {

@ -1,107 +1,82 @@
import {
CogIcon,
DatabaseIcon,
HeartHandshakeIcon,
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 AISection from "@/components/Settings/AISection";
import InstanceSection from "@/components/Settings/InstanceSection";
import MemberSection from "@/components/Settings/MemberSection";
import MemoRelatedSettings from "@/components/Settings/MemoRelatedSettings";
import MyAccountSection from "@/components/Settings/MyAccountSection";
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 {
DEFAULT_SETTING_SECTION,
isSettingSectionKey,
SETTINGS_SECTIONS,
type SettingSectionDefinition,
type SettingSectionKey,
} from "@/components/Settings/settingSections";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery";
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" | "webhook" | "member" | "system" | "memo" | "storage" | "sso" | "tags" | "ai";
const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference", "webhook"];
const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo", "tags", "storage", "sso", "ai"];
const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/";
const isCommitSha = (commit: string) => /^[0-9a-f]{7,40}$/i.test(commit);
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,
ai: HeartHandshakeIcon,
};
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,
ai: AISection,
};
const Setting = () => {
const t = useTranslate();
const sm = useMediaQuery("sm");
const location = useLocation();
const user = useCurrentUser();
const { profile, fetchSetting } = useInstance();
const [selectedSection, setSelectedSection] = useState<SettingSection>("my-account");
const [selectedSection, setSelectedSection] = useState<SettingSectionKey>(DEFAULT_SETTING_SECTION);
const isHost = user?.role === User_Role.ADMIN;
const commitUrl = isCommitSha(profile.commit) ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : "";
const settingsSectionList = useMemo(() => {
return isHost ? [...BASIC_SECTIONS, ...ADMIN_SECTIONS] : [...BASIC_SECTIONS];
const sectionGroups = useMemo(() => {
const visibleSections = SETTINGS_SECTIONS.filter((section) => section.scope === "basic" || isHost);
return {
basic: visibleSections.filter((section) => section.scope === "basic"),
admin: visibleSections.filter((section) => section.scope === "admin"),
all: visibleSections,
};
}, [isHost]);
const visibleSectionKeys = useMemo(() => new Set(sectionGroups.all.map((section) => section.key)), [sectionGroups.all]);
useEffect(() => {
const hash = location.hash.slice(1) as SettingSection;
const nextSection = settingsSectionList.includes(hash) ? hash : "my-account";
const hash = location.hash.slice(1);
const nextSection = isSettingSectionKey(hash) && visibleSectionKeys.has(hash) ? hash : DEFAULT_SETTING_SECTION;
setSelectedSection(nextSection);
}, [location.hash, settingsSectionList]);
}, [location.hash, visibleSectionKeys]);
useEffect(() => {
if (!isHost) {
return;
}
// Fetch admin-only settings that are not eagerly loaded by InstanceContext.
fetchSetting(InstanceSetting_Key.STORAGE);
fetchSetting(InstanceSetting_Key.TAGS);
fetchSetting(InstanceSetting_Key.AI);
}, [isHost, fetchSetting]);
const preloadSettingKeys = new Set(sectionGroups.admin.flatMap((section) => section.preloadSettingKeys ?? []));
for (const key of preloadSettingKeys) {
fetchSetting(key);
}
}, [fetchSetting, isHost, sectionGroups.admin]);
const handleSectionSelectorItemClick = (section: SettingSection) => {
const handleSectionSelectorItemClick = (section: SettingSectionKey) => {
window.location.hash = section;
};
const ActiveSection = SECTION_COMPONENT_MAP[selectedSection];
const selectedSectionDefinition =
sectionGroups.all.find((section) => section.key === selectedSection) ??
SETTINGS_SECTIONS.find((section) => section.key === DEFAULT_SETTING_SECTION) ??
SETTINGS_SECTIONS[0];
const ActiveSection = selectedSectionDefinition.component;
const renderSectionMenuItems = (sections: SettingSectionDefinition[]) =>
sections.map((section) => (
<SectionMenuItem
key={section.key}
text={t(section.labelKey)}
icon={section.icon}
isSelected={selectedSection === section.key}
onClick={() => handleSectionSelectorItemClick(section.key)}
/>
));
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">
@ -111,30 +86,12 @@ const Setting = () => {
{sm && (
<div className="flex flex-col justify-start items-start w-40 h-auto shrink-0 py-2">
<span className="text-sm mt-0.5 pl-3 font-mono select-none text-muted-foreground">{t("common.basic")}</span>
<div className="w-full flex flex-col justify-start items-start mt-1">
{BASIC_SECTIONS.map((item) => (
<SectionMenuItem
key={item}
text={t(`setting.${item}.label`)}
icon={SECTION_ICON_MAP[item]}
isSelected={selectedSection === item}
onClick={() => handleSectionSelectorItemClick(item)}
/>
))}
</div>
<div className="w-full flex flex-col justify-start items-start mt-1">{renderSectionMenuItems(sectionGroups.basic)}</div>
{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">
{ADMIN_SECTIONS.map((item) => (
<SectionMenuItem
key={item}
text={t(`setting.${item}.label`)}
icon={SECTION_ICON_MAP[item]}
isSelected={selectedSection === item}
onClick={() => handleSectionSelectorItemClick(item)}
/>
))}
{renderSectionMenuItems(sectionGroups.admin)}
<div className="px-3 mt-2 opacity-70 text-sm leading-5">
{t("setting.version")}: {profile.version}
{profile.commit && (
@ -158,14 +115,14 @@ const Setting = () => {
<div className="w-full grow sm:pl-4 overflow-x-auto">
{!sm && (
<div className="w-auto inline-block my-2">
<Select value={selectedSection} onValueChange={(value) => handleSectionSelectorItemClick(value as SettingSection)}>
<Select value={selectedSection} onValueChange={(value) => handleSectionSelectorItemClick(value as SettingSectionKey)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select section" />
<SelectValue placeholder={t("setting.select-section")} />
</SelectTrigger>
<SelectContent>
{settingsSectionList.map((section) => (
<SelectItem key={section} value={section}>
{t(`setting.${section}.label`)}
{sectionGroups.all.map((section) => (
<SelectItem key={section.key} value={section.key}>
{t(section.labelKey)}
</SelectItem>
))}
</SelectContent>

Loading…
Cancel
Save