chore(settings): improve storage tags and memo panels

pull/5912/head
boojack 4 weeks ago
parent ee65e90a39
commit 14480bfc46

@ -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")}
<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>
{!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>
)}
</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…
Cancel
Save