chore(settings): improve storage tags and memo panels
This commit is contained in:
parent
ee65e90a39
commit
14480bfc46
5 changed files with 424 additions and 178 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { isEqual, uniq } from "lodash-es";
|
||||
import { CheckIcon, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -17,7 +17,6 @@ import {
|
|||
} from "@/types/proto/api/v1/instance_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import SettingGroup from "./SettingGroup";
|
||||
import SettingRow from "./SettingRow";
|
||||
import SettingSection from "./SettingSection";
|
||||
|
||||
const MemoRelatedSettings = () => {
|
||||
|
|
@ -26,6 +25,10 @@ const MemoRelatedSettings = () => {
|
|||
const [memoRelatedSetting, setMemoRelatedSetting] = useState<InstanceSetting_MemoRelatedSetting>(originalSetting);
|
||||
const [editingReaction, setEditingReaction] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setMemoRelatedSetting(originalSetting);
|
||||
}, [originalSetting]);
|
||||
|
||||
const updatePartialSetting = (partial: Partial<InstanceSetting_MemoRelatedSetting>) => {
|
||||
const newInstanceMemoRelatedSetting = create(InstanceSetting_MemoRelatedSettingSchema, {
|
||||
...memoRelatedSetting,
|
||||
|
|
@ -71,47 +74,74 @@ const MemoRelatedSettings = () => {
|
|||
|
||||
return (
|
||||
<SettingSection title={t("setting.memo.label")}>
|
||||
<SettingGroup title={t("common.basic")}>
|
||||
<SettingRow label={t("setting.system.enable-double-click-to-edit")}>
|
||||
<Switch
|
||||
checked={memoRelatedSetting.enableDoubleClickEdit}
|
||||
onCheckedChange={(checked) => updatePartialSetting({ enableDoubleClickEdit: checked })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<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>
|
||||
<Switch
|
||||
checked={memoRelatedSetting.enableDoubleClickEdit}
|
||||
onCheckedChange={(checked) => updatePartialSetting({ enableDoubleClickEdit: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingRow label={t("setting.memo.content-length-limit")}>
|
||||
<Input
|
||||
className="w-24"
|
||||
type="number"
|
||||
value={memoRelatedSetting.contentLengthLimit}
|
||||
onChange={(event) => updatePartialSetting({ contentLengthLimit: Number(event.target.value) })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-28 font-mono"
|
||||
type="number"
|
||||
min={0}
|
||||
value={memoRelatedSetting.contentLengthLimit}
|
||||
onChange={(event) => updatePartialSetting({ contentLengthLimit: Number(event.target.value) })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t("setting.memo.bytes-unit")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title={t("setting.memo.reactions")} showSeparator>
|
||||
<div className="w-full flex flex-row flex-wrap gap-2">
|
||||
{memoRelatedSetting.reactions.map((reactionType) => (
|
||||
<Badge key={reactionType} variant="outline" className="flex items-center gap-1.5 h-8 px-3">
|
||||
{reactionType}
|
||||
<span
|
||||
className="cursor-pointer text-muted-foreground hover:text-destructive"
|
||||
onClick={() => updatePartialSetting({ reactions: memoRelatedSetting.reactions.filter((r) => r !== reactionType) })}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<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 className="flex items-center gap-1.5">
|
||||
</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">
|
||||
<span>{reactionType}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground transition-colors hover:text-destructive"
|
||||
onClick={() => updatePartialSetting({ reactions: memoRelatedSetting.reactions.filter((r) => r !== reactionType) })}
|
||||
aria-label={t("setting.memo.remove-reaction")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</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="w-32 h-8"
|
||||
placeholder={t("common.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="ghost" size="sm" onClick={upsertReaction} className="h-8 w-8 p-0">
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
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";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -9,6 +11,7 @@ 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,
|
||||
InstanceSetting_StorageSetting,
|
||||
|
|
@ -23,11 +26,60 @@ import SettingGroup from "./SettingGroup";
|
|||
import SettingRow from "./SettingRow";
|
||||
import SettingSection from "./SettingSection";
|
||||
|
||||
const DEFAULT_FILEPATH_TEMPLATE = "assets/{timestamp}_{uuid}_{filename}";
|
||||
|
||||
type StorageTypeOption = {
|
||||
storageType: InstanceSetting_StorageSetting_StorageType;
|
||||
id: string;
|
||||
titleKey: "setting.storage.type-database" | "setting.storage.type-local" | "setting.storage.type-s3";
|
||||
descriptionKey: "setting.storage.database-description" | "setting.storage.local-description" | "setting.storage.s3-description";
|
||||
noteKeys: readonly [
|
||||
"setting.storage.database-note-backup" | "setting.storage.local-note-path" | "setting.storage.s3-note-scale",
|
||||
"setting.storage.database-note-size" | "setting.storage.local-note-backup" | "setting.storage.s3-note-config",
|
||||
];
|
||||
icon: LucideIcon;
|
||||
badges?: readonly ("setting.storage.badge-default" | "setting.storage.badge-recommended")[];
|
||||
};
|
||||
|
||||
const storageTypeOptions: StorageTypeOption[] = [
|
||||
{
|
||||
storageType: InstanceSetting_StorageSetting_StorageType.LOCAL,
|
||||
id: "storage-type-local",
|
||||
titleKey: "setting.storage.type-local",
|
||||
descriptionKey: "setting.storage.local-description",
|
||||
noteKeys: ["setting.storage.local-note-path", "setting.storage.local-note-backup"],
|
||||
icon: FolderIcon,
|
||||
badges: ["setting.storage.badge-default", "setting.storage.badge-recommended"],
|
||||
},
|
||||
{
|
||||
storageType: InstanceSetting_StorageSetting_StorageType.DATABASE,
|
||||
id: "storage-type-database",
|
||||
titleKey: "setting.storage.type-database",
|
||||
descriptionKey: "setting.storage.database-description",
|
||||
noteKeys: ["setting.storage.database-note-backup", "setting.storage.database-note-size"],
|
||||
icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
storageType: InstanceSetting_StorageSetting_StorageType.S3,
|
||||
id: "storage-type-s3",
|
||||
titleKey: "setting.storage.type-s3",
|
||||
descriptionKey: "setting.storage.s3-description",
|
||||
noteKeys: ["setting.storage.s3-note-scale", "setting.storage.s3-note-config"],
|
||||
icon: CloudIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const StorageSection = () => {
|
||||
const t = useTranslate();
|
||||
const { storageSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
|
||||
const [instanceStorageSetting, setInstanceStorageSetting] = useState<InstanceSetting_StorageSetting>(originalSetting);
|
||||
|
||||
const selectedStorageOption = useMemo(
|
||||
() => storageTypeOptions.find((option) => option.storageType === instanceStorageSetting.storageType) ?? storageTypeOptions[0],
|
||||
[instanceStorageSetting.storageType],
|
||||
);
|
||||
const SelectedStorageIcon = selectedStorageOption.icon;
|
||||
|
||||
useEffect(() => {
|
||||
setInstanceStorageSetting(originalSetting);
|
||||
}, [originalSetting]);
|
||||
|
|
@ -42,12 +94,14 @@ const StorageSection = () => {
|
|||
return false;
|
||||
}
|
||||
} else if (instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3) {
|
||||
const hasExistingS3Config = originalSetting.s3Config !== undefined;
|
||||
if (
|
||||
instanceStorageSetting.s3Config?.accessKeyId.length === 0 ||
|
||||
instanceStorageSetting.s3Config?.accessKeySecret.length === 0 ||
|
||||
instanceStorageSetting.s3Config?.endpoint.length === 0 ||
|
||||
instanceStorageSetting.s3Config?.region.length === 0 ||
|
||||
instanceStorageSetting.s3Config?.bucket.length === 0
|
||||
!instanceStorageSetting.filepathTemplate ||
|
||||
!instanceStorageSetting.s3Config?.accessKeyId ||
|
||||
(!hasExistingS3Config && !instanceStorageSetting.s3Config?.accessKeySecret) ||
|
||||
!instanceStorageSetting.s3Config?.endpoint ||
|
||||
!instanceStorageSetting.s3Config?.region ||
|
||||
!instanceStorageSetting.s3Config?.bucket
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -102,6 +156,7 @@ const StorageSection = () => {
|
|||
create(InstanceSetting_StorageSettingSchema, {
|
||||
...instanceStorageSetting,
|
||||
storageType,
|
||||
filepathTemplate: instanceStorageSetting.filepathTemplate || DEFAULT_FILEPATH_TEMPLATE,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -128,28 +183,76 @@ const StorageSection = () => {
|
|||
|
||||
return (
|
||||
<SettingSection title={t("setting.storage.label")}>
|
||||
<SettingGroup title={t("setting.storage.current-storage")}>
|
||||
<div className="w-full">
|
||||
<RadioGroup
|
||||
value={String(instanceStorageSetting.storageType)}
|
||||
onValueChange={(value) => {
|
||||
handleStorageTypeChanged(Number(value) as InstanceSetting_StorageSetting_StorageType);
|
||||
}}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={String(InstanceSetting_StorageSetting_StorageType.DATABASE)} id="database" />
|
||||
<Label htmlFor="database">{t("setting.storage.type-database")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={String(InstanceSetting_StorageSetting_StorageType.LOCAL)} id="local" />
|
||||
<Label htmlFor="local">{t("setting.storage.type-local")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={String(InstanceSetting_StorageSetting_StorageType.S3)} id="s3" />
|
||||
<Label htmlFor="s3">S3</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<SettingGroup title={t("setting.storage.current-storage")} description={t("setting.storage.current-storage-description")}>
|
||||
<RadioGroup
|
||||
value={String(instanceStorageSetting.storageType)}
|
||||
onValueChange={(value) => {
|
||||
handleStorageTypeChanged(Number(value) as InstanceSetting_StorageSetting_StorageType);
|
||||
}}
|
||||
className="overflow-hidden rounded-lg border border-border bg-background divide-y divide-border"
|
||||
>
|
||||
{storageTypeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const selected = instanceStorageSetting.storageType === option.storageType;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"relative flex border-border bg-background px-3 py-3 transition-colors first:rounded-t-lg last:rounded-b-lg",
|
||||
selected ? "bg-muted/50" : "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{selected && <div className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-primary" aria-hidden />}
|
||||
<div className="flex w-full items-start gap-3">
|
||||
<RadioGroupItem value={String(option.storageType)} id={option.id} className="mt-0.5" />
|
||||
<Label
|
||||
htmlFor={option.id}
|
||||
className="grid min-w-0 flex-1 cursor-pointer gap-2 sm:grid-cols-[minmax(12rem,16rem)_1fr] sm:gap-5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className={cn("size-4 shrink-0", selected ? "text-foreground" : "text-muted-foreground")} />
|
||||
<span className="truncate text-sm font-medium text-foreground">{t(option.titleKey)}</span>
|
||||
</div>
|
||||
{option.badges && (
|
||||
<div className="flex flex-wrap gap-1.5 pl-6">
|
||||
{option.badges.map((badge) => (
|
||||
<Badge
|
||||
key={badge}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-md px-1.5 py-0 text-[10px] font-normal",
|
||||
badge === "setting.storage.badge-recommended" && "border-primary/30 bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{t(badge)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs font-normal leading-5 text-muted-foreground">{t(option.descriptionKey)}</p>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
|
||||
<div className="rounded-md border border-border/70 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">
|
||||
{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 />
|
||||
<span>{t(note)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<SettingRow label={t("setting.system.max-upload-size")} tooltip={t("setting.system.max-upload-size-hint")}>
|
||||
|
|
@ -161,11 +264,15 @@ const StorageSection = () => {
|
|||
</SettingRow>
|
||||
|
||||
{instanceStorageSetting.storageType !== InstanceSetting_StorageSetting_StorageType.DATABASE && (
|
||||
<SettingRow label={t("setting.storage.filepath-template")}>
|
||||
<SettingRow
|
||||
label={t("setting.storage.filepath-template")}
|
||||
description={t("setting.storage.filepath-template-description")}
|
||||
vertical
|
||||
>
|
||||
<Input
|
||||
className="w-64"
|
||||
className="w-full max-w-lg font-mono"
|
||||
value={instanceStorageSetting.filepathTemplate}
|
||||
placeholder="assets/{timestamp}_{filename}"
|
||||
placeholder={DEFAULT_FILEPATH_TEMPLATE}
|
||||
onChange={handleFilepathTemplateChanged}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
|
@ -173,51 +280,60 @@ const StorageSection = () => {
|
|||
</SettingGroup>
|
||||
|
||||
{instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3 && (
|
||||
<SettingGroup title="S3 Configuration" showSeparator>
|
||||
<SettingRow label={t("setting.storage.accesskey")}>
|
||||
<SettingGroup
|
||||
title={t("setting.storage.s3-configuration")}
|
||||
description={t("setting.storage.s3-configuration-description")}
|
||||
showSeparator
|
||||
>
|
||||
<SettingRow label={t("setting.storage.accesskey")} description={t("setting.storage.accesskey-description")}>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={instanceStorageSetting.s3Config?.accessKeyId}
|
||||
value={instanceStorageSetting.s3Config?.accessKeyId ?? ""}
|
||||
onChange={(e) => handleS3FieldChange("accessKeyId", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label={t("setting.storage.secretkey")}>
|
||||
<SettingRow
|
||||
label={t("setting.storage.secretkey")}
|
||||
description={
|
||||
originalSetting.s3Config ? t("setting.storage.secretkey-preserve-description") : t("setting.storage.secretkey-description")
|
||||
}
|
||||
>
|
||||
<Input
|
||||
className="w-64"
|
||||
type="password"
|
||||
value={instanceStorageSetting.s3Config?.accessKeySecret}
|
||||
value={instanceStorageSetting.s3Config?.accessKeySecret ?? ""}
|
||||
onChange={(e) => handleS3FieldChange("accessKeySecret", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label={t("setting.storage.endpoint")}>
|
||||
<SettingRow label={t("setting.storage.endpoint")} description={t("setting.storage.endpoint-description")}>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={instanceStorageSetting.s3Config?.endpoint}
|
||||
value={instanceStorageSetting.s3Config?.endpoint ?? ""}
|
||||
onChange={(e) => handleS3FieldChange("endpoint", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label={t("setting.storage.region")}>
|
||||
<SettingRow label={t("setting.storage.region")} description={t("setting.storage.region-description")}>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={instanceStorageSetting.s3Config?.region}
|
||||
value={instanceStorageSetting.s3Config?.region ?? ""}
|
||||
onChange={(e) => handleS3FieldChange("region", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label={t("setting.storage.bucket")}>
|
||||
<SettingRow label={t("setting.storage.bucket")} description={t("setting.storage.bucket-description")}>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={instanceStorageSetting.s3Config?.bucket}
|
||||
value={instanceStorageSetting.s3Config?.bucket ?? ""}
|
||||
onChange={(e) => handleS3FieldChange("bucket", e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="Use Path Style">
|
||||
<SettingRow label={t("setting.storage.use-path-style")} description={t("setting.storage.use-path-style-description")}>
|
||||
<Switch
|
||||
checked={instanceStorageSetting.s3Config?.usePathStyle}
|
||||
checked={instanceStorageSetting.s3Config?.usePathStyle ?? false}
|
||||
onCheckedChange={(checked) => handleS3FieldChange("usePathStyle", checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { EyeOffIcon, PaletteIcon, PlusIcon, TagIcon, TrashIcon } from "lucide-react";
|
||||
import { 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";
|
||||
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 {
|
||||
InstanceSetting_Key,
|
||||
InstanceSetting_TagMetadataSchema,
|
||||
|
|
@ -20,7 +23,6 @@ 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";
|
||||
|
||||
const DEFAULT_TAG_COLOR = "#ffffff";
|
||||
|
||||
|
|
@ -75,8 +77,8 @@ const TagsSection = () => {
|
|||
() =>
|
||||
Object.keys(localTags)
|
||||
.sort()
|
||||
.map((name) => ({ name })),
|
||||
[localTags],
|
||||
.map((name) => ({ name, count: tagCounts[name] ?? 0 })),
|
||||
[localTags, tagCounts],
|
||||
);
|
||||
|
||||
const originalMetaMap = useMemo(
|
||||
|
|
@ -152,102 +154,120 @@ const TagsSection = () => {
|
|||
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">
|
||||
<input
|
||||
type="color"
|
||||
className="w-8 h-8 cursor-pointer rounded border border-border bg-transparent p-0.5"
|
||||
value={localTags[row.name].color ?? DEFAULT_TAG_COLOR}
|
||||
onChange={(e) => handleColorChange(row.name, e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleClearColor(row.name)} disabled={!localTags[row.name].color}>
|
||||
{t("common.clear")}
|
||||
</Button>
|
||||
{!localTags[row.name].color && (
|
||||
<span className="text-xs text-muted-foreground">{t("setting.tags.using-default-color")}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "blur",
|
||||
header: t("setting.tags.blur-content"),
|
||||
render: (_, row: { name: string }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
checked={localTags[row.name].blur}
|
||||
onChange={(e) => handleBlurChange(row.name, e.target.checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 ?? DEFAULT_TAG_COLOR}
|
||||
onChange={(e) => setNewTagColor(e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => setNewTagColor(undefined)} disabled={!newTagColor}>
|
||||
{t("common.clear")}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
checked={newTagBlur}
|
||||
onChange={(e) => setNewTagBlur(e.target.checked)}
|
||||
/>
|
||||
{t("setting.tags.blur-content")}
|
||||
</label>
|
||||
<Button variant="outline" onClick={handleAddTag} disabled={!newTagName.trim()}>
|
||||
<PlusIcon className="w-4 h-4 mr-1.5" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<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")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t("setting.tags.configured-rules")}</h4>
|
||||
<Badge variant="outline" className="rounded-md px-2 py-0 text-xs font-normal">
|
||||
{configuredEntries.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-background">
|
||||
{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">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<TagIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono text-sm text-foreground">{row.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 pl-6 text-xs text-muted-foreground">
|
||||
<span>{t("setting.tags.matching-rule")}</span>
|
||||
<span className="text-border">/</span>
|
||||
<span>{t("setting.tags.used-count", { count: row.count })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span
|
||||
className={cn("size-5 rounded-md border border-border", !localTags[row.name].color && "bg-background")}
|
||||
style={{ backgroundColor: localTags[row.name].color ?? DEFAULT_TAG_COLOR }}
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
className="size-8 cursor-pointer rounded-md border border-border bg-transparent p-0.5"
|
||||
value={localTags[row.name].color ?? DEFAULT_TAG_COLOR}
|
||||
onChange={(e) => handleColorChange(row.name, e.target.value)}
|
||||
aria-label={t("setting.tags.background-color")}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleClearColor(row.name)} disabled={!localTags[row.name].color}>
|
||||
{localTags[row.name].color ?? t("setting.tags.default-color")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<EyeOffIcon className="size-4" />
|
||||
{t("setting.tags.blur-content")}
|
||||
<Switch checked={localTags[row.name].blur} onCheckedChange={(checked) => handleBlurChange(row.name, checked)} />
|
||||
</label>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRemoveTag(row.name)} aria-label={t("common.delete")}>
|
||||
<TrashIcon className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("setting.tags.tag-pattern-hint")}</p>
|
||||
{!newTagColor && <p className="text-xs text-muted-foreground">{t("setting.tags.using-default-color")}</p>}
|
||||
</SettingGroup>
|
||||
|
||||
<div className="w-full flex justify-end">
|
||||
|
|
|
|||
|
|
@ -466,11 +466,21 @@
|
|||
"no-members-found": "No members found"
|
||||
},
|
||||
"memo": {
|
||||
"add-reaction": "Add reaction",
|
||||
"bytes-unit": "bytes",
|
||||
"configured-reactions": "Configured reactions",
|
||||
"content-length-limit": "Content length limit (Byte)",
|
||||
"content-length-limit-description": "Maximum memo body size accepted by the server.",
|
||||
"double-click-edit-description": "Allow users to open memo editing by double-clicking a memo.",
|
||||
"editing-description": "Control memo editing behavior and server-side content limits.",
|
||||
"editing-title": "Editing",
|
||||
"enable-blur-sensitive-content": "Enable sensitive content blurring",
|
||||
"enable-memo-comments": "Enable memo comments",
|
||||
"enable-memo-location": "Enable memo location",
|
||||
"label": "Memo",
|
||||
"reaction-placeholder": "e.g. 👍",
|
||||
"reactions-description": "Define the reaction options users can apply to memos.",
|
||||
"remove-reaction": "Remove reaction",
|
||||
"reactions": "Reactions",
|
||||
"title": "Memo related settings",
|
||||
"reactions-required": "Reactions list must not be empty"
|
||||
|
|
@ -555,29 +565,52 @@
|
|||
},
|
||||
"storage": {
|
||||
"accesskey": "Access key",
|
||||
"accesskey-description": "Access key ID for the bucket or S3-compatible service.",
|
||||
"accesskey-placeholder": "Access key / Access ID",
|
||||
"badge-default": "Default",
|
||||
"badge-recommended": "Recommended",
|
||||
"bucket": "Bucket",
|
||||
"bucket-description": "Bucket where new attachment objects will be stored.",
|
||||
"bucket-placeholder": "Bucket name",
|
||||
"create-a-service": "Create a service",
|
||||
"create-storage": "Create Storage",
|
||||
"current-storage": "Current object storage",
|
||||
"current-storage": "Attachment storage",
|
||||
"current-storage-description": "Choose where new attachments are stored. Existing attachments remain in their current storage location.",
|
||||
"database-description": "Store attachment blobs directly in the application database.",
|
||||
"database-note-backup": "Simple to back up together with notes on small instances.",
|
||||
"database-note-size": "The database can grow quickly with many or large attachments.",
|
||||
"delete-storage": "Delete Storage",
|
||||
"endpoint": "Endpoint",
|
||||
"endpoint-description": "Service endpoint, such as an AWS S3, Cloudflare R2, MinIO, or other compatible URL.",
|
||||
"filepath-template": "Filepath template",
|
||||
"filepath-template-description": "Used by local and S3 storage. Supports {timestamp}, {uuid}, and {filename}. Default: assets/{timestamp}_{uuid}_{filename}.",
|
||||
"label": "Storage",
|
||||
"local-description": "Store new attachments on the server file system. This is the default for new instances.",
|
||||
"local-note-backup": "Persist and back up the attachment directory, especially in Docker or container deployments.",
|
||||
"local-note-path": "Relative paths are written under the instance profile data directory.",
|
||||
"local-storage-path": "Local storage path",
|
||||
"path": "Storage Path",
|
||||
"path-description": "You can use the same dynamic variables from local storage, like {filename}",
|
||||
"path-placeholder": "custom/path",
|
||||
"presign-placeholder": "Pre-sign URL, optional",
|
||||
"region": "Region",
|
||||
"region-description": "Region required by the provider. For some compatible services, any provider-accepted region string is enough.",
|
||||
"region-placeholder": "Region name",
|
||||
"s3-configuration": "S3 configuration",
|
||||
"s3-configuration-description": "Configure an S3-compatible bucket for new attachments.",
|
||||
"s3-description": "Store new attachments in an S3-compatible object storage service.",
|
||||
"s3-note-config": "Requires a valid endpoint, region, bucket, credentials, permissions, and server network access.",
|
||||
"s3-note-scale": "Best for large attachment libraries, cloud deployments, or multiple application instances.",
|
||||
"s3-compatible-url": "S3 Compatible URL",
|
||||
"secretkey": "Secret key",
|
||||
"secretkey-description": "Secret access key for the bucket or S3-compatible service.",
|
||||
"secretkey-preserve-description": "Leave blank to keep the existing secret key.",
|
||||
"secretkey-placeholder": "Secret key / Access Key",
|
||||
"selected-backend": "Selected",
|
||||
"storage-services": "Storage services",
|
||||
"type-database": "Database",
|
||||
"type-local": "Local file system",
|
||||
"type-s3": "S3-compatible storage",
|
||||
"update-a-service": "Update a service",
|
||||
"update-local-path": "Update Local Storage Path",
|
||||
"update-local-path-description": "Local storage path is a relative path to your database file",
|
||||
|
|
@ -586,6 +619,8 @@
|
|||
"url-prefix-placeholder": "Custom URL prefix, optional",
|
||||
"url-suffix": "URL suffix",
|
||||
"url-suffix-placeholder": "Custom URL suffix, optional",
|
||||
"use-path-style": "Use path-style URLs",
|
||||
"use-path-style-description": "Enable this for providers such as MinIO or some S3-compatible services that do not support virtual-hosted-style buckets.",
|
||||
"warning-text": "Are you sure you want to delete storage service `{{name}}`? THIS ACTION IS IRREVERSIBLE"
|
||||
},
|
||||
"system": {
|
||||
|
|
@ -640,14 +675,19 @@
|
|||
"label": "Tags",
|
||||
"title": "Tag metadata",
|
||||
"description": "Assign optional display colors to tags instance-wide, or blur matching memo content. Tag names are treated as anchored regex patterns.",
|
||||
"add-rule": "Add rule",
|
||||
"background-color": "Background color",
|
||||
"blur-content": "Blur content",
|
||||
"configured-rules": "Configured rules",
|
||||
"default-color": "Default",
|
||||
"matching-rule": "Anchored regex",
|
||||
"no-tags-configured": "No tag metadata configured.",
|
||||
"tag-name": "Tag name",
|
||||
"tag-name-placeholder": "e.g. work or project/.*",
|
||||
"tag-already-exists": "Tag already exists.",
|
||||
"tag-pattern-hint": "Tag name or regex pattern (e.g. project/.* matches all project/ tags)",
|
||||
"invalid-regex": "Invalid or unsafe regex pattern.",
|
||||
"used-count": "{{count}} memos",
|
||||
"using-default-color": "Using default color."
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -459,28 +459,51 @@
|
|||
},
|
||||
"storage": {
|
||||
"accesskey": "访问密钥(Access key)",
|
||||
"accesskey-description": "存储桶或 S3 兼容服务的访问密钥 ID。",
|
||||
"accesskey-placeholder": "访问密钥 / 访问 ID",
|
||||
"badge-default": "默认",
|
||||
"badge-recommended": "推荐",
|
||||
"bucket": "储存桶(Bucket)",
|
||||
"bucket-description": "新附件对象将写入这个存储桶。",
|
||||
"bucket-placeholder": "储存桶名",
|
||||
"create-a-service": "新建服务",
|
||||
"create-storage": "创建存储",
|
||||
"current-storage": "当前对象存储",
|
||||
"current-storage": "附件存储",
|
||||
"current-storage-description": "选择新附件的保存位置。已有附件仍保留在当前存储位置。",
|
||||
"database-description": "将附件二进制内容直接存入应用数据库。",
|
||||
"database-note-backup": "小型实例可以和备忘录一起备份,操作简单。",
|
||||
"database-note-size": "大量或大文件附件会让数据库体积快速增长。",
|
||||
"delete-storage": "删除存储",
|
||||
"endpoint": "端点(Endpoint)",
|
||||
"endpoint-description": "服务端点,例如 AWS S3、Cloudflare R2、MinIO 或其它兼容服务链接。",
|
||||
"filepath-template": "文件路径模板",
|
||||
"filepath-template-description": "本地文件系统和 S3 都会使用该模板。支持 {timestamp}、{uuid} 和 {filename}。默认值:assets/{timestamp}_{uuid}_{filename}。",
|
||||
"local-storage-path": "本地存储路径",
|
||||
"local-description": "将新附件保存在服务器文件系统中。新实例默认使用这种方式。",
|
||||
"local-note-backup": "请持久化并备份附件目录,Docker 或容器化部署尤其需要注意。",
|
||||
"local-note-path": "相对路径会写入实例 profile data 目录。",
|
||||
"path": "存储路径",
|
||||
"path-description": "您可以使用本地存储中的相同动态变量,例如 {filename}",
|
||||
"path-placeholder": "自定义路径",
|
||||
"presign-placeholder": "预签名链接(可选)",
|
||||
"region": "地区",
|
||||
"region-description": "服务商要求的区域。部分兼容服务只需要填写服务商接受的区域字符串。",
|
||||
"region-placeholder": "区域名称",
|
||||
"s3-configuration": "S3 配置",
|
||||
"s3-configuration-description": "为新附件配置一个 S3 兼容存储桶。",
|
||||
"s3-description": "将新附件保存在 S3 兼容对象存储服务中。",
|
||||
"s3-note-config": "需要正确的 endpoint、region、bucket、凭据、权限和服务器网络访问。",
|
||||
"s3-note-scale": "适合大附件库、云部署或多个应用实例。",
|
||||
"s3-compatible-url": "S3 兼容链接",
|
||||
"secretkey": "私有密钥",
|
||||
"secretkey-description": "存储桶或 S3 兼容服务的私有访问密钥。",
|
||||
"secretkey-preserve-description": "留空则保留现有私有密钥。",
|
||||
"secretkey-placeholder": "私有密钥 / 访问密钥",
|
||||
"selected-backend": "当前选择",
|
||||
"storage-services": "存储服务列表",
|
||||
"type-database": "数据库",
|
||||
"type-local": "本地文件系统",
|
||||
"type-s3": "S3 兼容存储",
|
||||
"update-a-service": "更新服务",
|
||||
"update-local-path": "更新本地存储路径",
|
||||
"update-local-path-description": "本地存储路径是数据库文件的相对路径",
|
||||
|
|
@ -489,6 +512,8 @@
|
|||
"url-prefix-placeholder": "自定义链接前缀,可选",
|
||||
"url-suffix": "链接后缀",
|
||||
"url-suffix-placeholder": "自定义链接后缀,可选",
|
||||
"use-path-style": "使用路径样式链接",
|
||||
"use-path-style-description": "MinIO 或部分不支持虚拟主机样式 bucket 的 S3 兼容服务通常需要开启。",
|
||||
"warning-text": "您确定要删除存储服务“{{name}}”吗?(此操作不可逆)",
|
||||
"label": "存储"
|
||||
},
|
||||
|
|
@ -570,11 +595,21 @@
|
|||
"week-start-day": "周开始日"
|
||||
},
|
||||
"memo": {
|
||||
"add-reaction": "添加表态",
|
||||
"bytes-unit": "字节",
|
||||
"configured-reactions": "已配置表态",
|
||||
"content-length-limit": "内容长度限制(字节)",
|
||||
"content-length-limit-description": "服务端接受的备忘录正文最大长度。",
|
||||
"double-click-edit-description": "允许用户双击备忘录进入编辑。",
|
||||
"editing-description": "控制备忘录编辑行为和服务端内容长度限制。",
|
||||
"editing-title": "编辑",
|
||||
"enable-blur-sensitive-content": "启用 NSFW 内容模糊处理(在下方添加 NSFW 标签)",
|
||||
"enable-memo-comments": "启用备忘录评论",
|
||||
"enable-memo-location": "启用备忘录定位",
|
||||
"reactions": "表态",
|
||||
"reaction-placeholder": "例如 👍",
|
||||
"reactions-description": "定义用户可以添加到备忘录上的表态选项。",
|
||||
"remove-reaction": "移除表态",
|
||||
"title": "备忘录相关设置",
|
||||
"label": "备忘录",
|
||||
"reactions-required": "反应列表不能为空"
|
||||
|
|
@ -603,14 +638,19 @@
|
|||
"label": "标签",
|
||||
"title": "标签元数据",
|
||||
"description": "将可选的显示颜色分配给实例范围内的标签,或模糊匹配的备忘录内容。标签名称被视为锚定的正则表达式模式。",
|
||||
"add-rule": "添加规则",
|
||||
"background-color": "背景颜色",
|
||||
"blur-content": "模糊内容",
|
||||
"configured-rules": "已配置规则",
|
||||
"default-color": "默认",
|
||||
"matching-rule": "锚定正则",
|
||||
"no-tags-configured": "未配置标签元数据。",
|
||||
"tag-name": "标签名称",
|
||||
"tag-name-placeholder": "例如工作或project/.*",
|
||||
"tag-already-exists": "标签已经存在。",
|
||||
"tag-pattern-hint": "标签名称或正则表达式模式(例如 project/.* 匹配所有 project/ 标签)",
|
||||
"invalid-regex": "无效或不安全的正则表达式模式。",
|
||||
"used-count": "{{count}} 条备忘录",
|
||||
"using-default-color": "使用默认颜色。"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue