diff --git a/web/src/components/Settings/MemoRelatedSettings.tsx b/web/src/components/Settings/MemoRelatedSettings.tsx index 6bd27961c..c077c933e 100644 --- a/web/src/components/Settings/MemoRelatedSettings.tsx +++ b/web/src/components/Settings/MemoRelatedSettings.tsx @@ -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(originalSetting); const [editingReaction, setEditingReaction] = useState(""); + useEffect(() => { + setMemoRelatedSetting(originalSetting); + }, [originalSetting]); + const updatePartialSetting = (partial: Partial) => { const newInstanceMemoRelatedSetting = create(InstanceSetting_MemoRelatedSettingSchema, { ...memoRelatedSetting, @@ -71,47 +74,74 @@ const MemoRelatedSettings = () => { return ( - - - updatePartialSetting({ enableDoubleClickEdit: checked })} - /> - + +
+
+
+
{t("setting.system.enable-double-click-to-edit")}
+

{t("setting.memo.double-click-edit-description")}

+
+ updatePartialSetting({ enableDoubleClickEdit: checked })} + /> +
- - updatePartialSetting({ contentLengthLimit: Number(event.target.value) })} - /> - +
+
+
{t("setting.memo.content-length-limit")}
+

{t("setting.memo.content-length-limit-description")}

+
+
+ updatePartialSetting({ contentLengthLimit: Number(event.target.value) })} + /> + {t("setting.memo.bytes-unit")} +
+
+
- -
- {memoRelatedSetting.reactions.map((reactionType) => ( - - {reactionType} - updatePartialSetting({ reactions: memoRelatedSetting.reactions.filter((r) => r !== reactionType) })} - > - - + +
+
+ {t("setting.memo.configured-reactions")} + + {memoRelatedSetting.reactions.length} - ))} -
+
+ +
+ {memoRelatedSetting.reactions.map((reactionType) => ( + + {reactionType} + + + ))} +
+ +
setEditingReaction(event.target.value)} onKeyDown={(e) => e.key === "Enter" && upsertReaction()} /> -
diff --git a/web/src/components/Settings/StorageSection.tsx b/web/src/components/Settings/StorageSection.tsx index 9bf702a0b..7864d4c21 100644 --- a/web/src/components/Settings/StorageSection.tsx +++ b/web/src/components/Settings/StorageSection.tsx @@ -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(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 ( - -
- { - handleStorageTypeChanged(Number(value) as InstanceSetting_StorageSetting_StorageType); - }} - className="flex flex-row gap-4" - > -
- - -
-
- - -
-
- - -
-
+ + { + 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 ( +
+ {selected &&
} +
+ + +
+
+ ); + })} + + +
+
+ + {t("setting.storage.selected-backend")} + {t(selectedStorageOption.titleKey)} +
+
    + {selectedStorageOption.noteKeys.map((note) => ( +
  • + + {t(note)} +
  • + ))} +
@@ -161,11 +264,15 @@ const StorageSection = () => { {instanceStorageSetting.storageType !== InstanceSetting_StorageSetting_StorageType.DATABASE && ( - + @@ -173,51 +280,60 @@ const StorageSection = () => { {instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3 && ( - - + + handleS3FieldChange("accessKeyId", e.target.value)} /> - + handleS3FieldChange("accessKeySecret", e.target.value)} /> - + handleS3FieldChange("endpoint", e.target.value)} /> - + handleS3FieldChange("region", e.target.value)} /> - + handleS3FieldChange("bucket", e.target.value)} /> - + handleS3FieldChange("usePathStyle", checked)} /> diff --git a/web/src/components/Settings/TagsSection.tsx b/web/src/components/Settings/TagsSection.tsx index ab368fedf..ceaa164ea 100644 --- a/web/src/components/Settings/TagsSection.tsx +++ b/web/src/components/Settings/TagsSection.tsx @@ -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 ( - {row.name}, - }, - { - key: "color", - header: t("setting.tags.background-color"), - render: (_, row: { name: string }) => ( -
- handleColorChange(row.name, e.target.value)} - /> - + + + +
+
+ {t("setting.tags.tag-pattern-hint")} +
+
+ +
+

{t("setting.tags.configured-rules")}

+ + {configuredEntries.length} + +
+ +
+ {configuredEntries.length === 0 ? ( +
+ +

{t("setting.tags.no-tags-configured")}

+
+ ) : ( +
+ {configuredEntries.map((row) => ( +
+
+
+ + {row.name} +
+
+ {t("setting.tags.matching-rule")} + / + {t("setting.tags.used-count", { count: row.count })} +
+
+ +
+ + handleColorChange(row.name, e.target.value)} + aria-label={t("setting.tags.background-color")} + /> + +
+ + + + - {!localTags[row.name].color && ( - {t("setting.tags.using-default-color")} - )}
- ), - }, - { - key: "blur", - header: t("setting.tags.blur-content"), - render: (_, row: { name: string }) => ( - handleBlurChange(row.name, e.target.checked)} - /> - ), - }, - { - key: "actions", - header: "", - className: "text-right", - render: (_, row: { name: string }) => ( - - ), - }, - ]} - data={configuredEntries} - emptyMessage={t("setting.tags.no-tags-configured")} - getRowKey={(row) => row.name} - /> - -
- setNewTagName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAddTag()} - list="known-tags" - /> - - {allKnownTags - .filter((tag) => !localTags[tag]) - .map((tag) => ( - - setNewTagColor(e.target.value)} - /> - - - +
+ )}
-

{t("setting.tags.tag-pattern-hint")}

- {!newTagColor &&

{t("setting.tags.using-default-color")}

}
diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 54facf362..37b3ded97 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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." } }, diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index b27def4fc..917a38489 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -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": "使用默认颜色。" } },