From ada49fd21d159563b21d29d7d3499a45c5ab1503 Mon Sep 17 00:00:00 2001 From: Aaron Liu Date: Sat, 12 Jul 2025 11:15:31 +0800 Subject: [PATCH] feat(fs): custom properties for files (#2407) --- package.json | 1 + public/locales/en-US/application.json | 10 + public/locales/en-US/dashboard.json | 31 ++- public/locales/ja-JP/application.json | 10 + public/locales/ja-JP/dashboard.json | 31 ++- public/locales/zh-CN/application.json | 10 + public/locales/zh-CN/dashboard.json | 31 ++- public/locales/zh-TW/application.json | 10 + public/locales/zh-TW/dashboard.json | 31 ++- src/api/explorer.ts | 22 ++ src/api/site.ts | 3 +- src/component/Admin/AdminBundle.tsx | 2 + .../CustomProps/CustomPropsSetting.tsx | 214 +++++++++++++++++ .../CustomProps/DraggableCustomPropsRow.tsx | 209 +++++++++++++++++ .../CustomProps/EditPropsDialog.tsx | 179 +++++++++++++++ src/component/Admin/FileSystem/FileSyste.tsx | 68 ++++++ src/component/Common/StyledComponents.tsx | 29 +++ .../FileManager/ContextMenu/CascadingMenu.tsx | 1 + .../ContextMenu/useActionDisplayOpt.ts | 2 + .../Explorer/ListView/AddColumn.tsx | 23 +- .../FileManager/Explorer/ListView/Cell.tsx | 33 ++- .../FileManager/Explorer/ListView/Column.tsx | 27 ++- .../Explorer/ListView/ColumnSetting.tsx | 31 ++- .../Explorer/ListView/ListView.tsx | 10 +- .../Search/AdvanceSearch/AddCondition.tsx | 54 ++++- .../Search/AdvanceSearch/AdvanceSearch.tsx | 41 ++-- .../Search/AdvanceSearch/ConditionBox.tsx | 116 ++++++---- .../AdvanceSearch/CustomPropsConditon.tsx | 36 +++ .../FileManager/Search/SearchIndicator.tsx | 13 +- .../Sidebar/CustomProps/AddButton.tsx | 109 +++++++++ .../CustomProps/BooleanPropsContent.tsx | 31 +++ .../Sidebar/CustomProps/CustomProps.tsx | 126 ++++++++++ .../Sidebar/CustomProps/CustomPropsItem.tsx | 216 ++++++++++++++++++ .../Sidebar/CustomProps/LinkPropsContent.tsx | 115 ++++++++++ .../CustomProps/MultiSelectPropsContent.tsx | 123 ++++++++++ .../CustomProps/NumberPropsContent.tsx | 49 ++++ .../Sidebar/CustomProps/RatingPropsItem.tsx | 23 ++ .../CustomProps/SelectPropsContent.tsx | 78 +++++++ .../Sidebar/CustomProps/TextPropsContent.tsx | 48 ++++ src/component/FileManager/Sidebar/Details.tsx | 21 +- .../FileManager/Sidebar/MediaMetaCard.tsx | 16 +- src/component/FileManager/Sidebar/Sidebar.tsx | 12 +- .../FileManager/Sidebar/SidebarContent.tsx | 12 +- .../FileManager/Sidebar/SidebarDialog.tsx | 18 +- src/component/Frame/NavBar/PageNavigation.tsx | 7 + src/component/Icons/CheckboxChecked.tsx | 9 + src/component/Icons/CubeTreeFilled.tsx | 9 + src/component/Icons/DataBarVerticalStar.tsx | 9 + src/component/Icons/SlideText.tsx | 9 + src/component/Icons/TaskListRegular.tsx | 9 + .../Icons/TextBulletListSquareEdiFilled.tsx | 9 + .../Icons/TextBulletListSquareEdit.tsx | 9 + src/component/Icons/TextIndentIncrease.tsx | 9 + .../Viewers/MusicPlayer/PlayerPopup.tsx | 1 - src/redux/thunks/filemanager.ts | 27 ++- src/router/index.tsx | 8 + src/util/uri.ts | 15 +- yarn.lock | 12 + 58 files changed, 2280 insertions(+), 137 deletions(-) create mode 100644 src/component/Admin/FileSystem/CustomProps/CustomPropsSetting.tsx create mode 100644 src/component/Admin/FileSystem/CustomProps/DraggableCustomPropsRow.tsx create mode 100644 src/component/Admin/FileSystem/CustomProps/EditPropsDialog.tsx create mode 100644 src/component/Admin/FileSystem/FileSyste.tsx create mode 100644 src/component/FileManager/Search/AdvanceSearch/CustomPropsConditon.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/AddButton.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/BooleanPropsContent.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/CustomProps.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/CustomPropsItem.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/LinkPropsContent.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/MultiSelectPropsContent.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/NumberPropsContent.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/RatingPropsItem.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/SelectPropsContent.tsx create mode 100644 src/component/FileManager/Sidebar/CustomProps/TextPropsContent.tsx create mode 100644 src/component/Icons/CheckboxChecked.tsx create mode 100644 src/component/Icons/CubeTreeFilled.tsx create mode 100644 src/component/Icons/DataBarVerticalStar.tsx create mode 100644 src/component/Icons/SlideText.tsx create mode 100644 src/component/Icons/TaskListRegular.tsx create mode 100644 src/component/Icons/TextBulletListSquareEdiFilled.tsx create mode 100644 src/component/Icons/TextBulletListSquareEdit.tsx create mode 100644 src/component/Icons/TextIndentIncrease.tsx diff --git a/package.json b/package.json index 191d56f..579768e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@excalidraw/excalidraw": "^0.18.0", "@fontsource/roboto": "^5.0.8", "@giscus/react": "^3.1.0", + "@iconify/react": "^6.0.0", "@marsidev/react-turnstile": "^1.1.0", "@mdxeditor/editor": "^3.39.0", "@mui/icons-material": "^6.0.0", diff --git a/public/locales/en-US/application.json b/public/locales/en-US/application.json index 765a648..8bc11ac 100644 --- a/public/locales/en-US/application.json +++ b/public/locales/en-US/application.json @@ -112,6 +112,16 @@ "dashboard": "Dashboard" }, "fileManager": { + "customProps": "Custom properties", + "rating": "Rating", + "description": "Description", + "add": "Add", + "clickToEdit": "Click to edit...", + "clickToEditSelect": "Click to select...", + "enterUrl": "Enter URL...", + "searchUser": "Search user...", + "typeToSearch": "Enter name or email...", + "searchProperty": "Search files with the same property", "permissions": "Permissions", "quality": "Quality", "audioTrack": "Audio", diff --git a/public/locales/en-US/dashboard.json b/public/locales/en-US/dashboard.json index 52459cb..5a5c691 100644 --- a/public/locales/en-US/dashboard.json +++ b/public/locales/en-US/dashboard.json @@ -40,7 +40,8 @@ "mediaProcessing": "Media processing", "queue": "Queue", "events": "Events", - "server": "Server" + "server": "Server", + "customProps": "Custom properties" }, "summary": { "generatedAt": "Generated at <0>", @@ -1376,6 +1377,34 @@ "status": "Status", "deleteXPayments": "Delete {{num}} payments" }, + "customProps": { + "add": "Add", + "type": "Type", + "default": "Default value", + "actions": "Actions", + "text": "Text", + "number": "Number", + "boolean": "Checkbox", + "select": "Single select", + "multiSelect": "Multi select", + "user": "User", + "link": "Link", + "rating": "Rating", + "addProp": "Add property", + "editProp": "Edit property", + "icon": "Icon", + "iconDes": "<0>Iconify Icon name, leave blank to hide the icon.", + "id": "ID", + "idDes": "Property ID, ensure it is unique across all properties.", + "idPatternDes": "Only letters, numbers, underscores, and hyphens are allowed.", + "minLength": "Minimum length", + "maxLength": "Maximum length", + "emptyLimit": "Leave blank to not limit.", + "minValue": "Minimum value", + "maxValue": "Maximum value", + "options": "Options", + "optionsDes": "One option per line." + }, "vas": { "disableSubAddressEmail": "Disable sub-address email", "disableSubAddressEmailDes": "After enabled, email addresses containing <0>+ cannot be used for sign-up.", diff --git a/public/locales/ja-JP/application.json b/public/locales/ja-JP/application.json index b6fd69f..d65a8d2 100644 --- a/public/locales/ja-JP/application.json +++ b/public/locales/ja-JP/application.json @@ -113,6 +113,16 @@ "dashboard": "管理パネル" }, "fileManager": { + "customProps": "カスタム属性", + "rating": "評価", + "description": "説明", + "add": "追加", + "clickToEdit": "編集...", + "clickToEditSelect": "選択...", + "enterUrl": "URLを入力...", + "searchUser": "ユーザーを検索...", + "typeToSearch": "名前またはメールアドレスを入力...", + "searchProperty": "同じ属性のファイルを検索", "permissions": "権限", "quality": "解像度", "audioTrack": "音声トラック", diff --git a/public/locales/ja-JP/dashboard.json b/public/locales/ja-JP/dashboard.json index 1a4706a..d3dcd26 100644 --- a/public/locales/ja-JP/dashboard.json +++ b/public/locales/ja-JP/dashboard.json @@ -40,7 +40,8 @@ "mediaProcessing": "メディア処理", "queue": "キュー", "events": "イベント", - "server": "サーバー" + "server": "サーバー", + "customProps": "カスタムプロパティ" }, "summary": { "generatedAt": "生成日 <0>", @@ -1363,6 +1364,34 @@ "status": "ステータス", "deleteXPayments": "{{num}}件の注文を削除" }, + "customProps": { + "add": "追加", + "type": "種類", + "default": "デフォルト値", + "actions": "操作", + "text": "テキスト", + "number": "数値", + "boolean": "チェックボックス", + "select": "単一選択", + "multiSelect": "複数選択", + "user": "ユーザー", + "link": "リンク", + "rating": "評価", + "addProp": "プロパティを追加", + "editProp": "プロパティを編集", + "icon": "アイコン", + "iconDes": "<0>Iconify アイコン名を入力してください。空欄の場合はアイコンを表示しません。", + "id": "ID", + "idDes": "プロパティのIDです。他のプロパティと重複しないようにしてください。", + "idPatternDes": "半角英数字、アンダースコア、ハイフンのみ使用できます。", + "minLength": "最小文字数", + "maxLength": "最大文字数", + "emptyLimit": "制限なしの場合は空欄にしてください。", + "minValue": "最小値", + "maxValue": "最大値", + "options": "選択肢", + "optionsDes": "1行につき1つの選択肢を入力してください。" + }, "vas": { "disableSubAddressEmail": "サブアドレスメールの無効化", "disableSubAddressEmailDes": "有効化後、<0>+を含むメールアドレスは登録不可になります。", diff --git a/public/locales/zh-CN/application.json b/public/locales/zh-CN/application.json index 398b56b..b11a7ed 100644 --- a/public/locales/zh-CN/application.json +++ b/public/locales/zh-CN/application.json @@ -113,6 +113,16 @@ "dashboard": "管理面板" }, "fileManager": { + "customProps": "自定义属性", + "rating": "评级", + "description": "描述", + "add": "添加", + "clickToEdit": "点击编辑...", + "clickToEditSelect": "点击选择...", + "enterUrl": "输入 URL...", + "searchUser": "搜索用户...", + "typeToSearch": "输入昵称或邮箱...", + "searchProperty": "搜索相同属性的文件", "permissions": "权限", "quality": "清晰度", "audioTrack": "音轨", diff --git a/public/locales/zh-CN/dashboard.json b/public/locales/zh-CN/dashboard.json index 7d66631..6b5ecf1 100644 --- a/public/locales/zh-CN/dashboard.json +++ b/public/locales/zh-CN/dashboard.json @@ -40,7 +40,8 @@ "mediaProcessing": "媒体处理", "queue": "队列", "events": "事件", - "server": "服务器" + "server": "服务器", + "customProps": "自定义属性" }, "summary": { "generatedAt": "生成于 <0>", @@ -1362,6 +1363,34 @@ "status": "状态", "deleteXPayments": "删除 {{num}} 个订单" }, + "customProps": { + "add": "添加", + "type": "类型", + "default": "默认值", + "actions": "操作", + "text": "文本", + "number": "数字", + "boolean": "勾选", + "select": "单选", + "multiSelect": "多选", + "user": "用户", + "link": "链接", + "rating": "评分", + "addProp": "添加属性", + "editProp": "编辑属性", + "icon": "图标", + "iconDes": "<0>Iconify 图标名称,留空表示不展示图标。", + "id": "标识", + "idDes": "属性标识,需要确保在所有属性中唯一。", + "idPatternDes": "只能包含字母、数字、下划线和中划线。", + "minLength": "最小长度", + "maxLength": "最大长度", + "emptyLimit": "留空表示不限制。", + "minValue": "最小值", + "maxValue": "最大值", + "options": "选项", + "optionsDes": "每行一个选项。" + }, "vas": { "disableSubAddressEmail": "禁用子地址邮箱", "disableSubAddressEmailDes": "开启后,包含加号 <0>+ 的邮箱地址无法注册账户。", diff --git a/public/locales/zh-TW/application.json b/public/locales/zh-TW/application.json index 95ed8ca..b787d40 100644 --- a/public/locales/zh-TW/application.json +++ b/public/locales/zh-TW/application.json @@ -113,6 +113,16 @@ "dashboard": "管理面板" }, "fileManager": { + "customProps": "自定義屬性", + "rating": "評級", + "description": "描述", + "add": "新增", + "clickToEdit": "點擊編輯...", + "clickToEditSelect": "點擊選擇...", + "enterUrl": "輸入 URL...", + "searchUser": "搜尋用戶...", + "typeToSearch": "輸入暱稱或郵箱...", + "searchProperty": "搜尋相同屬性的檔案", "permissions": "權限", "quality": "清晰度", "audioTrack": "音軌", diff --git a/public/locales/zh-TW/dashboard.json b/public/locales/zh-TW/dashboard.json index 239578d..18a2856 100644 --- a/public/locales/zh-TW/dashboard.json +++ b/public/locales/zh-TW/dashboard.json @@ -40,7 +40,8 @@ "mediaProcessing": "媒體處理", "queue": "佇列", "events": "事件", - "server": "伺服器" + "server": "伺服器", + "customProps": "自定義屬性" }, "summary": { "generatedAt": "生成於 <0>", @@ -1359,6 +1360,34 @@ "status": "狀態", "deleteXPayments": "刪除 {{num}} 個訂單" }, + "customProps": { + "add": "新增", + "type": "類型", + "default": "預設值", + "actions": "操作", + "text": "文本", + "number": "數字", + "boolean": "勾選", + "select": "單選", + "multiSelect": "多選", + "user": "使用者", + "link": "連結", + "rating": "評分", + "addProp": "新增屬性", + "editProp": "編輯屬性", + "icon": "圖標", + "iconDes": "<0>Iconify 圖標名稱,留空表示不展示圖標。", + "id": "標識", + "idDes": "屬性標識,需要確保在所有屬性中唯一。", + "idPatternDes": "只能包含字母、數字、下劃線和中劃線。", + "minLength": "最小長度", + "maxLength": "最大長度", + "emptyLimit": "留空表示不限制。", + "minValue": "最小值", + "maxValue": "最大值", + "options": "選項", + "optionsDes": "每行一個選項。" + }, "vas": { "disableSubAddressEmail": "禁用子地址郵箱", "disableSubAddressEmailDes": "開啟後,包含加號 <0>+ 的郵箱地址無法註冊賬戶。", diff --git a/src/api/explorer.ts b/src/api/explorer.ts index d63c214..3ae20e5 100644 --- a/src/api/explorer.ts +++ b/src/api/explorer.ts @@ -519,3 +519,25 @@ export interface PatchViewSyncService { uri: string; view?: ExplorerView; } + +export interface CustomProps { + id: string; + name: string; + type: CustomPropsType; + max?: number; + min?: number; + default?: string; + options?: string[]; + icon?: string; +} + +export enum CustomPropsType { + text = "text", + number = "number", + boolean = "boolean", + select = "select", + multi_select = "multi_select", + user = "user", + link = "link", + rating = "rating", +} diff --git a/src/api/site.ts b/src/api/site.ts index 52cf4db..9b86f49 100644 --- a/src/api/site.ts +++ b/src/api/site.ts @@ -1,4 +1,4 @@ -import { ViewerGroup } from "./explorer.ts"; +import { CustomProps, ViewerGroup } from "./explorer.ts"; import { User } from "./user.ts"; export enum CaptchaType { @@ -41,6 +41,7 @@ export interface SiteConfig { app_promotion?: boolean; thumbnail_width?: number; thumbnail_height?: number; + custom_props?: CustomProps[]; } export interface CaptchaResponse { diff --git a/src/component/Admin/AdminBundle.tsx b/src/component/Admin/AdminBundle.tsx index a292c4f..2d9d40f 100644 --- a/src/component/Admin/AdminBundle.tsx +++ b/src/component/Admin/AdminBundle.tsx @@ -12,9 +12,11 @@ import OauthCallback from "./StoragePolicy/OauthCallback"; import StoragePolicySetting from "./StoragePolicy/StoragePolicySetting"; import TaskList from "./Task/TaskList"; import UserSetting from "./User/UserSetting"; +import FileSystem from "./FileSystem/FileSyste"; export { EditGroup, + FileSystem, EditNode, EditStoragePolicy, EntitySetting, diff --git a/src/component/Admin/FileSystem/CustomProps/CustomPropsSetting.tsx b/src/component/Admin/FileSystem/CustomProps/CustomPropsSetting.tsx new file mode 100644 index 0000000..56db79b --- /dev/null +++ b/src/component/Admin/FileSystem/CustomProps/CustomPropsSetting.tsx @@ -0,0 +1,214 @@ +import { + Box, + ListItemIcon, + Menu, + Stack, + Table, + TableBody, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; +import { createRef, useCallback, useContext, useEffect, useState } from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { useTranslation } from "react-i18next"; +import { CustomProps, CustomPropsType } from "../../../../api/explorer"; +import { NoWrapCell, SecondaryButton, StyledTableContainerPaper } from "../../../Common/StyledComponents"; +import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu"; +import Add from "../../../Icons/Add"; +import { ProChip } from "../../../Pages/Setting/SettingForm"; +import ProDialog from "../../Common/ProDialog"; +import { SettingContext } from "../../Settings/SettingWrapper"; +import { SettingSection } from "../../Settings/Settings"; +import DraggableCustomPropsRow, { FieldTypes } from "./DraggableCustomPropsRow"; +import EditPropsDialog from "./EditPropsDialog"; + +const CustomPropsSetting = () => { + const { t } = useTranslation("dashboard"); + const { formRef, setSettings, values } = useContext(SettingContext); + const [customProps, setCustomProps] = useState([]); + const [open, setOpen] = useState(false); + const [isNew, setIsNew] = useState(false); + const [editProps, setEditProps] = useState(undefined); + const [proOpen, setProOpen] = useState(false); + const newPropsPopupState = usePopupState({ + variant: "popover", + popupId: "newProp", + }); + const { onClose, ...menuProps } = bindMenu(newPropsPopupState); + + useEffect(() => { + try { + setCustomProps(JSON.parse(values.custom_props || "[]")); + } catch { + setCustomProps([]); + } + }, [values.custom_props]); + + const onChange = useCallback( + (customProps: CustomProps[]) => { + setSettings({ + custom_props: JSON.stringify(customProps), + }); + }, + [setSettings], + ); + + const handleDeleteProduct = useCallback( + (id: string) => { + const newCustomProps = customProps.filter((p) => p.id !== id); + setCustomProps(newCustomProps); + onChange(newCustomProps); + }, + [customProps, onChange], + ); + + const handleSave = useCallback( + (props: CustomProps) => { + const existingIndex = customProps.findIndex((p) => p.id === props.id); + let newCustomProps: CustomProps[]; + if (existingIndex >= 0) { + newCustomProps = [...customProps]; + newCustomProps[existingIndex] = props; + } else { + newCustomProps = [...customProps, props]; + } + setCustomProps(newCustomProps); + onChange(newCustomProps); + }, + [customProps, onChange], + ); + + const moveRow = useCallback( + (from: number, to: number) => { + if (from === to) return; + const updated = [...customProps]; + const [moved] = updated.splice(from, 1); + updated.splice(to, 0, moved); + setCustomProps(updated); + onChange(updated); + }, + [customProps, onChange], + ); + + const handleMoveUp = (idx: number) => { + if (idx <= 0) return; + moveRow(idx, idx - 1); + }; + const handleMoveDown = (idx: number) => { + if (idx >= customProps.length - 1) return; + moveRow(idx, idx + 1); + }; + + const onNewProp = (type: CustomPropsType) => { + setEditProps({ + type, + id: "", + name: "", + default: "", + }); + setIsNew(true); + setOpen(true); + onClose(); + }; + + return ( + e.preventDefault()}> + + setProOpen(false)} /> + + + } {...bindTrigger(newPropsPopupState)}> + {t("customProps.add")} + + + {(Object.keys(FieldTypes) as CustomPropsType[]).map((type, index) => { + const fieldType = FieldTypes[type]; + const Icon = fieldType.icon; + return ( + (fieldType.pro ? setProOpen(true) : onNewProp(type))} + > + + + + {t(fieldType.title)} + {fieldType.pro && } + + ); + })} + + + + + + + + {t("settings.displayName")} + {t("customProps.type")} + {t("customProps.default")} + {t("settings.actions")} + + + + + {customProps.map((prop, idx) => { + const rowRef = createRef(); + return ( + { + setEditProps(props); + setIsNew(false); + setOpen(true); + }} + onDelete={handleDeleteProduct} + onMoveUp={() => handleMoveUp(idx)} + onMoveDown={() => handleMoveDown(idx)} + isFirst={idx === 0} + isLast={idx === customProps.length - 1} + t={t} + /> + ); + })} + {customProps.length === 0 && ( + + + + {t("application:setting.listEmpty")} + + + + )} + +
+
+
+
+
+ setOpen(false)} onSave={handleSave} isNew={isNew} props={editProps} /> +
+ ); +}; + +export default CustomPropsSetting; diff --git a/src/component/Admin/FileSystem/CustomProps/DraggableCustomPropsRow.tsx b/src/component/Admin/FileSystem/CustomProps/DraggableCustomPropsRow.tsx new file mode 100644 index 0000000..ec67324 --- /dev/null +++ b/src/component/Admin/FileSystem/CustomProps/DraggableCustomPropsRow.tsx @@ -0,0 +1,209 @@ +import { Icon } from "@iconify/react/dist/iconify.js"; +import { Box, IconButton, SvgIconProps, TableRow, useTheme } from "@mui/material"; +import React from "react"; +import { useDrag, useDrop } from "react-dnd"; +import { CustomProps, CustomPropsType } from "../../../../api/explorer"; +import { NoWrapCell } from "../../../Common/StyledComponents"; +import { getPropsContent } from "../../../FileManager/Sidebar/CustomProps/CustomPropsItem"; +import ArrowDown from "../../../Icons/ArrowDown"; +import CheckboxChecked from "../../../Icons/CheckboxChecked"; +import DataBarVerticalStar from "../../../Icons/DataBarVerticalStar"; +import Dismiss from "../../../Icons/Dismiss"; +import Edit from "../../../Icons/Edit"; +import LinkOutlined from "../../../Icons/LinkOutlined"; +import Numbers from "../../../Icons/Numbers"; +import PersonOutlined from "../../../Icons/PersonOutlined"; +import SlideText from "../../../Icons/SlideText"; +import TaskListRegular from "../../../Icons/TaskListRegular"; +import TextIndentIncrease from "../../../Icons/TextIndentIncrease"; + +const DND_TYPE = "storage-product-row"; + +// 拖拽item类型 +type DragItem = { index: number }; + +export interface DraggableCustomPropsRowProps { + customProps: CustomProps; + index: number; + moveRow: (from: number, to: number) => void; + onEdit: (customProps: CustomProps) => void; + onDelete: (id: string) => void; + onMoveUp: () => void; + onMoveDown: () => void; + isFirst: boolean; + isLast: boolean; + t: any; + style?: React.CSSProperties; +} + +export interface FieldTypeProps { + title: string; + icon: (props: SvgIconProps) => JSX.Element; + minTitle?: string; + minDes?: string; + maxTitle?: string; + maxDes?: string; + maxRequired?: boolean; + showOptions?: boolean; + pro?: boolean; +} + +export const FieldTypes: Record = { + [CustomPropsType.text]: { + title: "customProps.text", + icon: SlideText, + minTitle: "customProps.minLength", + minDes: "customProps.emptyLimit", + maxTitle: "customProps.maxLength", + maxDes: "customProps.emptyLimit", + }, + [CustomPropsType.number]: { + title: "customProps.number", + icon: Numbers, + minTitle: "customProps.minValue", + minDes: "customProps.emptyLimit", + maxTitle: "customProps.maxValue", + maxDes: "customProps.emptyLimit", + }, + [CustomPropsType.boolean]: { + title: "customProps.boolean", + icon: CheckboxChecked, + }, + [CustomPropsType.select]: { + title: "customProps.select", + icon: TextIndentIncrease, + showOptions: true, + }, + [CustomPropsType.multi_select]: { + title: "customProps.multiSelect", + icon: TaskListRegular, + showOptions: true, + }, + [CustomPropsType.user]: { + title: "customProps.user", + icon: PersonOutlined, + pro: true, + }, + [CustomPropsType.link]: { + title: "customProps.link", + icon: LinkOutlined, + minTitle: "customProps.minLength", + minDes: "customProps.emptyLimit", + maxTitle: "customProps.maxLength", + maxDes: "customProps.emptyLimit", + }, + [CustomPropsType.rating]: { + title: "customProps.rating", + icon: DataBarVerticalStar, + maxRequired: true, + maxTitle: "customProps.maxValue", + }, +}; + +const DraggableCustomPropsRow = React.memo( + React.forwardRef( + ( + { customProps, index, moveRow, onEdit, onDelete, onMoveUp, onMoveDown, isFirst, isLast, t, style }, + ref, + ): JSX.Element => { + const theme = useTheme(); + const [, drop] = useDrop({ + accept: DND_TYPE, + hover(item, monitor) { + if (!(ref && typeof ref !== "function" && ref.current)) return; + const dragIndex = item.index; + const hoverIndex = index; + if (dragIndex === hoverIndex) return; + const hoverBoundingRect = ref.current.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) return; + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return; + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return; + moveRow(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + }); + const [{ isDragging }, drag] = useDrag({ + type: DND_TYPE, + item: { index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + // 兼容ref为function和对象 + const setRowRef = (node: HTMLTableRowElement | null) => { + if (typeof ref === "function") { + ref(node); + } else if (ref) { + (ref as React.MutableRefObject).current = node; + } + drag(drop(node)); + }; + + const fieldType = FieldTypes[customProps.type]; + const TypeIcon = fieldType.icon; + + return ( + + + + {customProps.icon && ( + + )} + {t(customProps.name, { ns: "application" })} + + + + + + {t(fieldType.title)} + + + + {getPropsContent( + { + props: customProps, + id: customProps.id, + value: customProps.default ?? "", + }, + () => {}, + false, + true, + )} + + + onEdit(customProps)}> + + + onDelete(customProps.id)}> + + + + + + + + + + + + + ); + }, + ), +); + +export default DraggableCustomPropsRow; diff --git a/src/component/Admin/FileSystem/CustomProps/EditPropsDialog.tsx b/src/component/Admin/FileSystem/CustomProps/EditPropsDialog.tsx new file mode 100644 index 0000000..48e644d --- /dev/null +++ b/src/component/Admin/FileSystem/CustomProps/EditPropsDialog.tsx @@ -0,0 +1,179 @@ +import { Box, DialogContent, FormControl, Grid2, Link } from "@mui/material"; +import { useEffect, useRef, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { CustomProps } from "../../../../api/explorer"; +import { DenseFilledTextField } from "../../../Common/StyledComponents"; +import DraggableDialog from "../../../Dialogs/DraggableDialog"; +import { getPropsContent } from "../../../FileManager/Sidebar/CustomProps/CustomPropsItem"; +import SettingForm from "../../../Pages/Setting/SettingForm"; +import { NoMarginHelperText } from "../../Settings/Settings"; +import { FieldTypes } from "./DraggableCustomPropsRow"; + +interface EditPropsDialogProps { + open: boolean; + onClose: () => void; + onSave: (props: CustomProps) => void; + isNew: boolean; + props?: CustomProps; +} + +const EditPropsDialog = ({ open, onClose, onSave, isNew, props }: EditPropsDialogProps) => { + const { t } = useTranslation("dashboard"); + const formRef = useRef(null); + const [editProps, setEditProps] = useState(props); + + const handleSave = () => { + if (!formRef.current?.checkValidity()) { + formRef.current?.reportValidity(); + return; + } + + onSave({ ...editProps } as CustomProps); + onClose(); + }; + + useEffect(() => { + if (props) { + setEditProps({ ...props }); + } + + if (!open) { + setTimeout(() => { + setEditProps(undefined); + }, 100); + } + }, [open, props]); + + if (!editProps || !editProps.type) return null; + + const fieldType = FieldTypes[editProps?.type]; + return ( + + + + + + setEditProps({ ...editProps, id: e.target.value } as CustomProps)} + /> + {t("customProps.idDes")} + + + + + + setEditProps({ + ...editProps, + name: e.target.value, + } as CustomProps) + } + required + /> + {t("settings.displayNameDes")} + + + + + setEditProps({ ...editProps, icon: e.target.value } as CustomProps)} + /> + + { + ]} + /> + } + + + + {(fieldType.minTitle || fieldType.maxTitle) && ( + + {fieldType.minTitle && ( + + + setEditProps({ ...editProps, min: parseInt(e.target.value) } as CustomProps)} + /> + {fieldType.minDes && {t(fieldType.minDes)}} + + + )} + {fieldType.maxTitle && ( + + + setEditProps({ ...editProps, max: parseInt(e.target.value) } as CustomProps)} + /> + {fieldType.maxDes && {t(fieldType.maxDes)}} + + + )} + + )} + {fieldType.showOptions && ( + + + setEditProps({ ...editProps, options: e.target.value.split("\n") } as CustomProps)} + /> + {t("customProps.optionsDes")} + + + )} + + + {getPropsContent( + { + props: editProps, + id: editProps.id, + value: editProps.default ?? "", + }, + (value) => { + setEditProps({ ...editProps, default: value } as CustomProps); + }, + false, + false, + true, + )} + + + + + + ); +}; + +export default EditPropsDialog; diff --git a/src/component/Admin/FileSystem/FileSyste.tsx b/src/component/Admin/FileSystem/FileSyste.tsx new file mode 100644 index 0000000..a22e142 --- /dev/null +++ b/src/component/Admin/FileSystem/FileSyste.tsx @@ -0,0 +1,68 @@ +import { Box, Container } from "@mui/material"; +import { useQueryState } from "nuqs"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { CSSTransition, SwitchTransition } from "react-transition-group"; +import ResponsiveTabs, { Tab } from "../../Common/ResponsiveTabs.tsx"; +import TextBulletListSquareEdit from "../../Icons/TextBulletListSquareEdit.tsx"; +import PageContainer from "../../Pages/PageContainer.tsx"; +import PageHeader, { PageTabQuery } from "../../Pages/PageHeader.tsx"; +import SettingsWrapper from "../Settings/SettingWrapper.tsx"; +import CustomPropsSetting from "./CustomProps/CustomPropsSetting.tsx"; + +export enum SettingsPageTab { + Parameters = "parameters", + CustomProps = "customProps", + Icon = "icon", + FileApp = "fileApp", +} + +const FileSystem = () => { + const { t } = useTranslation("dashboard"); + const [tab, setTab] = useQueryState(PageTabQuery); + + const tabs: Tab[] = useMemo(() => { + const res = []; + res.push( + ...[ + { + label: t("nav.customProps"), + value: SettingsPageTab.CustomProps, + icon: , + }, + ], + ); + return res; + }, [t]); + + return ( + + + + setTab(newValue)} + tabs={tabs} + /> + + node.addEventListener("transitionend", done, false)} + classNames="fade" + key={`${tab}`} + > + + {(!tab || tab === SettingsPageTab.Parameters) &&
} + {tab === SettingsPageTab.CustomProps && ( + + + + )} +
+
+
+
+
+ ); +}; + +export default FileSystem; diff --git a/src/component/Common/StyledComponents.tsx b/src/component/Common/StyledComponents.tsx index 435302d..f5c2689 100644 --- a/src/component/Common/StyledComponents.tsx +++ b/src/component/Common/StyledComponents.tsx @@ -73,6 +73,35 @@ export const DenseFilledTextField = styled(FilledTextField)(({ theme }) => ({ }, })); +export const NoLabelFilledTextField = styled(FilledTextField)<{ backgroundColor?: string }>( + ({ theme, backgroundColor }) => ({ + "& .MuiInputBase-root": { + ...(backgroundColor && { + backgroundColor: backgroundColor, + }), + paddingTop: 0, + paddingBottom: 0, + "&.Mui-disabled": { + borderBottomStyle: "none", + "&::before": { + borderBottomStyle: "none !important", + }, + }, + }, + "& .MuiFilledInput-input": { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + fontSize: theme.typography.body2.fontSize, + "&.Mui-disabled": { + borderBottomStyle: "none", + "&::before": { + borderBottomStyle: "none !important", + }, + }, + }, + }), +); + export const DenseAutocomplete = styled(Autocomplete)(({ theme }) => ({ "& .MuiOutlinedInput-root": { paddingTop: "6px", diff --git a/src/component/FileManager/ContextMenu/CascadingMenu.tsx b/src/component/FileManager/ContextMenu/CascadingMenu.tsx index 2f31808..22de6c7 100644 --- a/src/component/FileManager/ContextMenu/CascadingMenu.tsx +++ b/src/component/FileManager/ContextMenu/CascadingMenu.tsx @@ -87,6 +87,7 @@ export function CascadingSubmenu({ title, popupId, menuItemProps, icon, ...props return ( <> { const { t } = useTranslation(); + const customPropsOptions = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props); const conditionPopupState = usePopupState({ variant: "popover", popupId: "columns", @@ -97,6 +100,24 @@ const AddColumn = (props: AddColumnProps) => { ), )} + {customPropsOptions && customPropsOptions.length > 0 && ( + + {customPropsOptions.map((option, index) => ( + onConditionAdd(ColumType.custom_props, { custom_props_id: option.id })} + > + {option.icon && ( + + + + )} + {t(option.name)} + + ))} + + )} ); diff --git a/src/component/FileManager/Explorer/ListView/Cell.tsx b/src/component/FileManager/Explorer/ListView/Cell.tsx index 0ed1bb7..7785cfb 100644 --- a/src/component/FileManager/Explorer/ListView/Cell.tsx +++ b/src/component/FileManager/Explorer/ListView/Cell.tsx @@ -1,5 +1,5 @@ import { Box, Fade, PopoverProps, Tooltip, Typography, useMediaQuery, useTheme } from "@mui/material"; -import { memo, useCallback, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { sizeToString } from "../../../../util"; import CrUri, { SearchParam } from "../../../../util/uri.ts"; import FileSmallIcon from "../FileSmallIcon.tsx"; @@ -15,13 +15,15 @@ import { useTranslation } from "react-i18next"; import { TransitionGroup } from "react-transition-group"; import { FileType, Metadata } from "../../../../api/explorer.ts"; import { bindDelayedHover } from "../../../../hooks/delayedHover.tsx"; -import { useAppDispatch } from "../../../../redux/hooks.ts"; +import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; import { loadFileThumb } from "../../../../redux/thunks/file.ts"; import AutoHeight from "../../../Common/AutoHeight.tsx"; import { NoWrapBox } from "../../../Common/StyledComponents.tsx"; import TimeBadge from "../../../Common/TimeBadge.tsx"; import Info from "../../../Icons/Info.tsx"; import FileBadge from "../../FileBadge.tsx"; +import { CustomPropsItem, customPropsMetadataPrefix } from "../../Sidebar/CustomProps/CustomProps.tsx"; +import { getPropsContent } from "../../Sidebar/CustomProps/CustomPropsItem.tsx"; import { getAlbum, getAperture, @@ -270,7 +272,27 @@ const MediaElementsCell = memo(({ element }: { element?: MediaMetaElements | str const Cell = memo((props: CellProps) => { const { t } = useTranslation(); - const { file, column, uploading, showLock, fileTag, search, isSelected } = props; + const customProps = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props); + const customProp = useMemo(() => { + if (!props.column.props?.custom_props_id || props.column.type !== ColumType.custom_props) { + return undefined; + } + const customProp = customProps?.find((p) => p.id === props.column.props?.custom_props_id); + if (!customProp) { + return undefined; + } + const value = props.file.metadata?.[`${customPropsMetadataPrefix}${customProp.id}`]; + if (value === undefined) { + return undefined; + } + return { + id: customProp.id, + props: customProp, + value: value ?? "", + } as CustomPropsItem; + }, [customProps, props.column.props?.custom_props_id, props.column.type, props.file.metadata]); + + const { file, column, uploading, fileTag, search, isSelected } = props; switch (column.type) { case ColumType.name: return ; @@ -330,6 +352,11 @@ const Cell = memo((props: CellProps) => { return ; case ColumType.duration: return ; + case ColumType.custom_props: + if (customProp) { + return getPropsContent(customProp, () => {}, false, true); + } + return ; } }); diff --git a/src/component/FileManager/Explorer/ListView/Column.tsx b/src/component/FileManager/Explorer/ListView/Column.tsx index 27daf1e..5d91fd4 100644 --- a/src/component/FileManager/Explorer/ListView/Column.tsx +++ b/src/component/FileManager/Explorer/ListView/Column.tsx @@ -1,10 +1,11 @@ -import { useTranslation } from "react-i18next"; import { Box, Fade, IconButton, styled } from "@mui/material"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { CustomProps } from "../../../../api/explorer.ts"; +import { NoWrapTypography } from "../../../Common/StyledComponents.tsx"; +import ArrowSortDownFilled from "../../../Icons/ArrowSortDownFilled.tsx"; import Divider from "../../../Icons/Divider.tsx"; import { ResizeProps } from "./ListHeader.tsx"; -import ArrowSortDownFilled from "../../../Icons/ArrowSortDownFilled.tsx"; -import { useCallback, useState } from "react"; -import { NoWrapTypography } from "../../../Common/StyledComponents.tsx"; export interface ListViewColumn { type: ColumType; @@ -21,6 +22,7 @@ export interface ListViewColumnSetting { export interface ColumTypeProps { metadata_key?: string; + custom_props_id?: string; } export enum ColumType { @@ -52,6 +54,9 @@ export enum ColumType { artist = 23, album = 24, duration = 25, + + // Custom props + custom_props = 26, } export interface ColumTypeDefaults { @@ -176,7 +181,11 @@ export const ColumnTypeDefaults: { [key: number]: ColumTypeDefaults } = { }, }; -export const getColumnTypeDefaults = (c: ListViewColumnSetting, isMobile?: boolean): ColumTypeDefaults => { +export const getColumnTypeDefaults = ( + c: ListViewColumnSetting, + isMobile?: boolean, + customProps?: CustomProps[], +): ColumTypeDefaults => { if (ColumnTypeDefaults[c.type]) { return { ...ColumnTypeDefaults[c.type], @@ -187,6 +196,14 @@ export const getColumnTypeDefaults = (c: ListViewColumnSetting, isMobile?: boole }; } + if (c.type === ColumType.custom_props) { + const customProp = customProps?.find((p) => p.id === c.props?.custom_props_id); + return { + title: customProp?.name ?? "application:fileManager.customProps", + width: 100, + }; + } + return { title: "application:fileManager.metadataColumn", width: 100, diff --git a/src/component/FileManager/Explorer/ListView/ColumnSetting.tsx b/src/component/FileManager/Explorer/ListView/ColumnSetting.tsx index bc2dc27..55557e6 100644 --- a/src/component/FileManager/Explorer/ListView/ColumnSetting.tsx +++ b/src/component/FileManager/Explorer/ListView/ColumnSetting.tsx @@ -10,7 +10,10 @@ import { TableRow, } from "@mui/material"; import { useSnackbar } from "notistack"; -import { useCallback, useEffect, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import React, { useCallback, useEffect, useState } from "react"; +import { DndProvider, useDrag, useDrop } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; import { useTranslation } from "react-i18next"; import { setListViewColumnSettingDialog } from "../../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; @@ -19,15 +22,11 @@ import AutoHeight from "../../../Common/AutoHeight.tsx"; import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx"; import { StyledTableContainerPaper } from "../../../Common/StyledComponents.tsx"; import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx"; +import ArrowDown from "../../../Icons/ArrowDown.tsx"; import Dismiss from "../../../Icons/Dismiss.tsx"; import { FileManagerIndex } from "../../FileManager.tsx"; import AddColumn from "./AddColumn.tsx"; import { getColumnTypeDefaults, ListViewColumnSetting } from "./Column.tsx"; -import ArrowDown from "../../../Icons/ArrowDown.tsx"; -import { DndProvider, useDrag, useDrop } from "react-dnd"; -import { HTML5Backend } from "react-dnd-html5-backend"; -import React from "react"; -import type { Dispatch, SetStateAction } from "react"; const DND_TYPE = "column-row"; @@ -43,8 +42,18 @@ interface DraggableColumnRowProps { isLast: boolean; } -const DraggableColumnRow: React.FC = ({ column, index, moveRow, columns, t, onDelete, isFirst, isLast }) => { +const DraggableColumnRow: React.FC = ({ + column, + index, + moveRow, + columns, + t, + onDelete, + isFirst, + isLast, +}) => { const ref = React.useRef(null); + const customProps = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props); const [, drop] = useDrop({ accept: DND_TYPE, hover(item: any, monitor) { @@ -84,7 +93,7 @@ const DraggableColumnRow: React.FC = ({ column, index, sx={{ "&:last-child td, &:last-child th": { border: 0 }, opacity: isDragging ? 0.5 : 1, cursor: "move" }} > - {t(getColumnTypeDefaults(column).title)} + {t(getColumnTypeDefaults(column, false, customProps).title)} @@ -143,7 +152,11 @@ const ColumnSetting = () => { const onColumnAdded = useCallback( (column: ListViewColumnSetting) => { const existed = columns.find((c) => c.type === column.type); - if (!existed || existed.props?.metadata_key != column.props?.metadata_key) { + if ( + !existed || + existed.props?.metadata_key != column.props?.metadata_key || + existed.props?.custom_props_id != column.props?.custom_props_id + ) { setColumns((prev) => [...prev, column]); } else { enqueueSnackbar(t("application:fileManager.columnExisted"), { diff --git a/src/component/FileManager/Explorer/ListView/ListView.tsx b/src/component/FileManager/Explorer/ListView/ListView.tsx index ea847b2..0b83473 100644 --- a/src/component/FileManager/Explorer/ListView/ListView.tsx +++ b/src/component/FileManager/Explorer/ListView/ListView.tsx @@ -25,13 +25,15 @@ const ListView = React.forwardRef( const fmIndex = useContext(FmIndexContext); const recursion_limit_reached = useAppSelector((state) => state.fileManager[fmIndex].list?.recursion_limit_reached); const columnSetting = useAppSelector((state) => state.fileManager[fmIndex].listViewColumns); + const customProps = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props); const [columns, setColumns] = useState( columnSetting.map( (c): ListViewColumn => ({ type: c.type, width: c.width, - defaults: getColumnTypeDefaults(c, isMobile), + props: c.props, + defaults: getColumnTypeDefaults(c, isMobile, customProps), }), ), ); @@ -42,11 +44,12 @@ const ListView = React.forwardRef( (c): ListViewColumn => ({ type: c.type, width: c.width, - defaults: getColumnTypeDefaults(c, isMobile), + props: c.props, + defaults: getColumnTypeDefaults(c, isMobile, customProps), }), ), ); - }, [columnSetting]); + }, [columnSetting, customProps]); const totalWidth = useMemo(() => { return columns.reduce((acc, column) => acc + (column.width ?? column.defaults.width), 0); @@ -59,6 +62,7 @@ const ListView = React.forwardRef( ...prev.map((c) => ({ type: c.type, width: c.width, + props: c.props, })), ]; return prev; diff --git a/src/component/FileManager/Search/AdvanceSearch/AddCondition.tsx b/src/component/FileManager/Search/AdvanceSearch/AddCondition.tsx index 01f1918..0abe525 100644 --- a/src/component/FileManager/Search/AdvanceSearch/AddCondition.tsx +++ b/src/component/FileManager/Search/AdvanceSearch/AddCondition.tsx @@ -1,22 +1,25 @@ import { useTranslation } from "react-i18next"; +import { Icon } from "@iconify/react/dist/iconify.js"; +import { ListItemIcon, ListItemText, Menu } from "@mui/material"; +import dayjs from "dayjs"; +import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; +import { FileType, Metadata } from "../../../../api/explorer.ts"; +import { useAppSelector } from "../../../../redux/hooks.ts"; import { SecondaryButton } from "../../../Common/StyledComponents.tsx"; import Add from "../../../Icons/Add.tsx"; -import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; -import { ListItemIcon, ListItemText, Menu } from "@mui/material"; -import { Condition, ConditionType } from "./ConditionBox.tsx"; -import React from "react"; -import TextCaseTitle from "../../../Icons/TextCaseTitle.tsx"; +import CalendarClock from "../../../Icons/CalendarClock.tsx"; import FolderOutlined from "../../../Icons/FolderOutlined.tsx"; -import { SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx"; -import { FileType, Metadata } from "../../../../api/explorer.ts"; +import HardDriveOutlined from "../../../Icons/HardDriveOutlined.tsx"; +import Info from "../../../Icons/Info.tsx"; import Numbers from "../../../Icons/Numbers.tsx"; import Tag from "../../../Icons/Tag.tsx"; +import TextBulletListSquareEdit from "../../../Icons/TextBulletListSquareEdit.tsx"; +import TextCaseTitle from "../../../Icons/TextCaseTitle.tsx"; import { CascadingSubmenu } from "../../ContextMenu/CascadingMenu.tsx"; -import Info from "../../../Icons/Info.tsx"; -import HardDriveOutlined from "../../../Icons/HardDriveOutlined.tsx"; -import dayjs from "dayjs"; -import CalendarClock from "../../../Icons/CalendarClock.tsx"; +import { SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx"; +import { customPropsMetadataPrefix } from "../../Sidebar/CustomProps/CustomProps.tsx"; +import { Condition, ConditionType } from "./ConditionBox.tsx"; export interface AddConditionProps { onConditionAdd: (condition: Condition) => void; @@ -142,6 +145,7 @@ const mediaMetaOptions: ConditionOption[] = [ const AddCondition = (props: AddConditionProps) => { const { t } = useTranslation(); + const customPropsOptions = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props); const conditionPopupState = usePopupState({ variant: "popover", popupId: "conditions", @@ -194,6 +198,34 @@ const AddCondition = (props: AddConditionProps) => { ))} + {customPropsOptions && customPropsOptions.length > 0 && ( + } + popupId={"customProps"} + title={t("application:fileManager.customProps")} + > + {customPropsOptions.map((option, index) => ( + + onConditionAdd({ + type: ConditionType.metadata, + id: customPropsMetadataPrefix + option.id, + metadata_key: customPropsMetadataPrefix + option.id, + }) + } + > + {option.icon && ( + + + + )} + {t(option.name)} + + ))} + + )} ); diff --git a/src/component/FileManager/Search/AdvanceSearch/AdvanceSearch.tsx b/src/component/FileManager/Search/AdvanceSearch/AdvanceSearch.tsx index a0b5c8d..d173140 100644 --- a/src/component/FileManager/Search/AdvanceSearch/AdvanceSearch.tsx +++ b/src/component/FileManager/Search/AdvanceSearch/AdvanceSearch.tsx @@ -1,19 +1,19 @@ -import { useTranslation } from "react-i18next"; -import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; -import { useCallback, useEffect, useState } from "react"; -import { closeAdvanceSearch } from "../../../../redux/globalStateSlice.ts"; -import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx"; import { Collapse, DialogContent } from "@mui/material"; -import ConditionBox, { Condition, ConditionType } from "./ConditionBox.tsx"; -import { SearchParam } from "../../../../util/uri.ts"; -import { TransitionGroup } from "react-transition-group"; -import AddCondition from "./AddCondition.tsx"; import { useSnackbar } from "notistack"; -import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx"; -import { FileManagerIndex } from "../../FileManager.tsx"; -import { defaultPath } from "../../../../hooks/useNavigation.tsx"; -import { advancedSearch } from "../../../../redux/thunks/filemanager.ts"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { TransitionGroup } from "react-transition-group"; import { Metadata } from "../../../../api/explorer.ts"; +import { defaultPath } from "../../../../hooks/useNavigation.tsx"; +import { closeAdvanceSearch } from "../../../../redux/globalStateSlice.ts"; +import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; +import { advancedSearch } from "../../../../redux/thunks/filemanager.ts"; +import { SearchParam } from "../../../../util/uri.ts"; +import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx"; +import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx"; +import { FileManagerIndex } from "../../FileManager.tsx"; +import AddCondition from "./AddCondition.tsx"; +import ConditionBox, { Condition, ConditionType } from "./ConditionBox.tsx"; const searchParamToConditions = (search_params: SearchParam, base: string): Condition[] => { const applied: Condition[] = [ @@ -72,11 +72,24 @@ const searchParamToConditions = (search_params: SearchParam, base: string): Cond type: ConditionType.metadata, metadata_key: key, metadata_value: value, + id: key, }); } }); } + if (search_params.metadata_strong_match) { + Object.entries(search_params.metadata_strong_match).forEach(([key, value]) => { + applied.push({ + type: ConditionType.metadata, + metadata_key: key, + metadata_value: value, + id: key, + metadata_strong_match: true, + }); + }); + } + if (tags.length > 0) { applied.push({ type: ConditionType.tag, @@ -84,6 +97,8 @@ const searchParamToConditions = (search_params: SearchParam, base: string): Cond }); } + console.log(search_params); + return applied; }; diff --git a/src/component/FileManager/Search/AdvanceSearch/ConditionBox.tsx b/src/component/FileManager/Search/AdvanceSearch/ConditionBox.tsx index 01bf13b..f051d86 100644 --- a/src/component/FileManager/Search/AdvanceSearch/ConditionBox.tsx +++ b/src/component/FileManager/Search/AdvanceSearch/ConditionBox.tsx @@ -1,21 +1,25 @@ +import { Icon } from "@iconify/react/dist/iconify.js"; import { Box, Grow, IconButton, Typography } from "@mui/material"; import { forwardRef, useCallback, useMemo, useState } from "react"; -import TextCaseTitle from "../../../Icons/TextCaseTitle.tsx"; import { useTranslation } from "react-i18next"; +import { useAppSelector } from "../../../../redux/hooks.ts"; +import CalendarClock from "../../../Icons/CalendarClock.tsx"; import Dismiss from "../../../Icons/Dismiss.tsx"; import FolderOutlined from "../../../Icons/FolderOutlined.tsx"; -import Search from "../../../Icons/Search.tsx"; -import { SearchBaseCondition } from "./SearchBaseCondition.tsx"; -import { FileTypeCondition } from "./FileTypeCondition.tsx"; -import { FileNameCondition, StyledBox } from "./FileNameCondition.tsx"; -import Tag from "../../../Icons/Tag.tsx"; -import { TagCondition } from "./TagCondition.tsx"; -import Numbers from "../../../Icons/Numbers.tsx"; -import { MetadataCondition } from "./MetadataCondition.tsx"; import HardDriveOutlined from "../../../Icons/HardDriveOutlined.tsx"; -import { SizeCondition } from "./SizeCondition.tsx"; -import CalendarClock from "../../../Icons/CalendarClock.tsx"; +import Numbers from "../../../Icons/Numbers.tsx"; +import Search from "../../../Icons/Search.tsx"; +import Tag from "../../../Icons/Tag.tsx"; +import TextCaseTitle from "../../../Icons/TextCaseTitle.tsx"; +import { customPropsMetadataPrefix } from "../../Sidebar/CustomProps/CustomProps.tsx"; +import { CustomPropsConditon } from "./CustomPropsConditon.tsx"; import { DateTimeCondition } from "./DateTimeCondition.tsx"; +import { FileNameCondition, StyledBox } from "./FileNameCondition.tsx"; +import { FileTypeCondition } from "./FileTypeCondition.tsx"; +import { MetadataCondition } from "./MetadataCondition.tsx"; +import { SearchBaseCondition } from "./SearchBaseCondition.tsx"; +import { SizeCondition } from "./SizeCondition.tsx"; +import { TagCondition } from "./TagCondition.tsx"; export interface Condition { type: ConditionType; @@ -28,6 +32,7 @@ export interface Condition { time?: number; metadata_key?: string; metadata_value?: string; + metadata_strong_match?: boolean; base_uri?: string; tags?: string[]; id?: string; @@ -58,29 +63,32 @@ export interface ConditionProps { const ConditionBox = forwardRef((props: ConditionProps, ref) => { const { condition, index, onRemove, onChange } = props; + const customPropsOptions = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props); const { t } = useTranslation(); const [hovered, setHovered] = useState(false); - const Icon = useMemo(() => { - switch (condition.type) { - case ConditionType.base: - return Search; - case ConditionType.type: - return FolderOutlined; - case ConditionType.tag: - return Tag; - case ConditionType.metadata: - return Numbers; - case ConditionType.size: - return HardDriveOutlined; - case ConditionType.modified: - case ConditionType.created: - return CalendarClock; + const onNameConditionAdded = useCallback( + (_e: any, newValue: string[]) => { + onChange({ + ...condition, + names: newValue, + }); + }, + [onChange], + ); - default: - return TextCaseTitle; + const customPropsOption = useMemo(() => { + if ( + condition.type !== ConditionType.metadata || + !condition.metadata_key || + !condition.metadata_key.startsWith(customPropsMetadataPrefix) + ) { + return undefined; } - }, [condition.type]); + return customPropsOptions?.find( + (option) => option.id === condition?.metadata_key?.slice(customPropsMetadataPrefix.length), + ); + }, [customPropsOptions, condition.type, condition.metadata_key]); const title = useMemo(() => { switch (condition.type) { @@ -93,6 +101,9 @@ const ConditionBox = forwardRef((props: ConditionProps, ref) => { case ConditionType.tag: return t("application:fileManager.tags"); case ConditionType.metadata: + if (customPropsOption) { + return t(customPropsOption.name); + } return t("application:fileManager.metadata"); case ConditionType.size: return t("application:navbar.fileSize"); @@ -103,17 +114,31 @@ const ConditionBox = forwardRef((props: ConditionProps, ref) => { default: return "Unknown"; } - }, [t, condition]); + }, [t, condition, customPropsOption]); - const onNameConditionAdded = useCallback( - (_e: any, newValue: string[]) => { - onChange({ - ...condition, - names: newValue, - }); - }, - [onChange], - ); + const ConditionIcon = useMemo(() => { + switch (condition.type) { + case ConditionType.base: + return Search; + case ConditionType.type: + return FolderOutlined; + case ConditionType.tag: + return Tag; + case ConditionType.metadata: + if (customPropsOption?.icon) { + return customPropsOption?.icon; + } + return Numbers; + case ConditionType.size: + return HardDriveOutlined; + case ConditionType.modified: + case ConditionType.created: + return CalendarClock; + + default: + return TextCaseTitle; + } + }, [condition.type, customPropsOption]); return ( { variant={"body2"} fontWeight={600} > - + {typeof ConditionIcon !== "string" ? ( + + ) : ( + + )} {title} onRemove(condition) : undefined}> @@ -149,7 +178,12 @@ const ConditionBox = forwardRef((props: ConditionProps, ref) => { {condition.type == ConditionType.type && } {condition.type == ConditionType.base && } {condition.type == ConditionType.tag && } - {condition.type == ConditionType.metadata && } + {condition.type == ConditionType.metadata && !customPropsOption && ( + + )} + {condition.type == ConditionType.metadata && customPropsOption && ( + + )} {condition.type == ConditionType.size && } {condition.type == ConditionType.created && ( diff --git a/src/component/FileManager/Search/AdvanceSearch/CustomPropsConditon.tsx b/src/component/FileManager/Search/AdvanceSearch/CustomPropsConditon.tsx new file mode 100644 index 0000000..d95d0cf --- /dev/null +++ b/src/component/FileManager/Search/AdvanceSearch/CustomPropsConditon.tsx @@ -0,0 +1,36 @@ +import { Box } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { CustomProps } from "../../../../api/explorer.ts"; +import { getPropsContent } from "../../Sidebar/CustomProps/CustomPropsItem.tsx"; +import { Condition } from "./ConditionBox.tsx"; + +export const CustomPropsConditon = ({ + condition, + onChange, + option, +}: { + onChange: (condition: Condition) => void; + condition: Condition; + option: CustomProps; +}) => { + const { t } = useTranslation(); + return ( + + {getPropsContent( + { + props: option, + id: option.id, + value: condition.metadata_value ?? "", + }, + (value) => onChange({ ...condition, metadata_value: value }), + false, + false, + true, + )} + + ); +}; diff --git a/src/component/FileManager/Search/SearchIndicator.tsx b/src/component/FileManager/Search/SearchIndicator.tsx index 2a1a86a..7838352 100644 --- a/src/component/FileManager/Search/SearchIndicator.tsx +++ b/src/component/FileManager/Search/SearchIndicator.tsx @@ -1,12 +1,12 @@ +import { alpha, Button, ButtonGroup, Grow, styled, useMediaQuery, useTheme } from "@mui/material"; +import { useContext, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; -import { useContext, useMemo } from "react"; -import { FmIndexContext } from "../FmIndexContext.tsx"; -import { alpha, Button, ButtonGroup, Grow, styled, useMediaQuery, useTheme } from "@mui/material"; -import Search from "../../Icons/Search.tsx"; -import Dismiss from "../../Icons/Dismiss.tsx"; import { clearSearch, openAdvancedSearch } from "../../../redux/thunks/filemanager.ts"; +import Dismiss from "../../Icons/Dismiss.tsx"; +import Search from "../../Icons/Search.tsx"; import { FileManagerIndex } from "../FileManager.tsx"; +import { FmIndexContext } from "../FmIndexContext.tsx"; export const StyledButtonGroup = styled(ButtonGroup)(({ theme }) => ({ "& .MuiButtonGroup-firstButton, .MuiButtonGroup-lastButton": { @@ -59,6 +59,9 @@ export const SearchIndicator = () => { if (search_params.updated_at_gte || search_params.updated_at_lte) { count++; } + if (search_params.metadata_strong_match) { + count += Object.keys(search_params.metadata_strong_match).length; + } return count; }, [search_params]); diff --git a/src/component/FileManager/Sidebar/CustomProps/AddButton.tsx b/src/component/FileManager/Sidebar/CustomProps/AddButton.tsx new file mode 100644 index 0000000..42e8a6b --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/AddButton.tsx @@ -0,0 +1,109 @@ +import { Icon } from "@iconify/react"; +import { Box, ListItemIcon, ListItemText, Menu, styled, Typography } from "@mui/material"; +import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { CustomProps } from "../../../../api/explorer.ts"; +import Add from "../../../Icons/Add.tsx"; +import { SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx"; +import { CustomPropsItem } from "./CustomProps.tsx"; + +const BorderedCard = styled(Box)(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(2), + backgroundColor: theme.palette.background.paper, +})); + +const BorderedCardClickable = styled(BorderedCard)<{ disabled?: boolean }>(({ theme, disabled }) => ({ + cursor: "pointer", + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + transition: "background-color 0.3s ease", + height: "100%", + borderStyle: "dashed", + display: "flex", + alignItems: "center", + gap: 8, + justifyContent: "center", + color: theme.palette.text.secondary, + opacity: disabled ? 0.5 : 1, + pointerEvents: disabled ? "none" : "auto", +})); + +export interface AddButtonProps { + options: CustomProps[]; + existingPropIds: string[]; + onPropAdd: (prop: CustomPropsItem) => void; + loading?: boolean; + disabled?: boolean; +} + +const AddButton = ({ options, existingPropIds, onPropAdd, disabled }: AddButtonProps) => { + const { t } = useTranslation(); + const propPopupState = usePopupState({ + variant: "popover", + popupId: "customProps", + }); + const { onClose, ...menuProps } = bindMenu(propPopupState); + + const unSelectedOptions = useMemo(() => { + return options?.filter((option) => !existingPropIds.includes(option.id)) ?? []; + }, [options, existingPropIds]); + + const handlePropAdd = (prop: CustomProps) => { + onPropAdd({ + props: prop, + id: prop.id, + value: prop.default ?? "", + }); + onClose(); + }; + + if (unSelectedOptions.length === 0) { + return undefined; + } + + return ( + <> + + + + {t("fileManager.add")} + + + + {unSelectedOptions.map((option) => ( + handlePropAdd(option)}> + {option.icon && ( + + + + )} + + {t(option.name)} + + + ))} + + + ); +}; + +export default AddButton; diff --git a/src/component/FileManager/Sidebar/CustomProps/BooleanPropsContent.tsx b/src/component/FileManager/Sidebar/CustomProps/BooleanPropsContent.tsx new file mode 100644 index 0000000..9258a73 --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/BooleanPropsContent.tsx @@ -0,0 +1,31 @@ +import { Box, FormControlLabel } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { isTrueVal } from "../../../../session/utils.ts"; +import { StyledCheckbox } from "../../../Common/StyledComponents.tsx"; +import { PropsContentProps } from "./CustomPropsItem.tsx"; + +const BooleanPropsItem = ({ prop, onChange, loading, readOnly, fullSize }: PropsContentProps) => { + const { t } = useTranslation(); + + const handleChange = (_: any, checked: boolean) => { + onChange(checked.toString()); + }; + + return ( + + } + label={fullSize ? t(prop.props.name) : undefined} + disabled={readOnly || loading} + /> + + ); +}; + +export default BooleanPropsItem; diff --git a/src/component/FileManager/Sidebar/CustomProps/CustomProps.tsx b/src/component/FileManager/Sidebar/CustomProps/CustomProps.tsx new file mode 100644 index 0000000..5883e0f --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/CustomProps.tsx @@ -0,0 +1,126 @@ +import { Collapse, Stack, Typography } from "@mui/material"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { TransitionGroup } from "react-transition-group"; +import { sendMetadataPatch } from "../../../../api/api.ts"; +import { CustomProps as CustomPropsType, FileResponse } from "../../../../api/explorer.ts"; +import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; +import { DisplayOption } from "../../ContextMenu/useActionDisplayOpt.ts"; +import AddButton from "./AddButton.tsx"; +import CustomPropsCard from "./CustomPropsItem.tsx"; + +export interface CustomPropsProps { + file: FileResponse; + setTarget: (target: FileResponse | undefined | null) => void; + targetDisplayOptions?: DisplayOption; +} + +export interface CustomPropsItem { + props: CustomPropsType; + id: string; + value: string; +} + +export const customPropsMetadataPrefix = "props:"; + +const CustomProps = ({ file, setTarget, targetDisplayOptions }: CustomPropsProps) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const dispatch = useAppDispatch(); + const custom_props = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props); + + const existingProps = useMemo(() => { + if (!file.metadata) { + return []; + } + return Object.keys(file.metadata) + .filter((key) => key.startsWith(customPropsMetadataPrefix)) + .map((key) => { + const propId = key.slice(customPropsMetadataPrefix.length); + return { + id: propId, + props: custom_props?.find((prop) => prop.id === propId), + value: file.metadata?.[key] ?? "", + } as CustomPropsItem; + }); + }, [file.metadata]); + + const existingPropIds = useMemo(() => { + return existingProps?.map((prop) => prop.id) ?? []; + }, [existingProps]); + + const handlePropPatch = (remove?: boolean) => (props: CustomPropsItem[]) => { + setLoading(true); + dispatch( + sendMetadataPatch({ + uris: [file.path], + patches: props.map((prop) => ({ + key: customPropsMetadataPrefix + prop.id, + value: prop.value, + remove, + })), + }), + ) + .then(() => { + if (remove) { + const newMetadata = { ...file.metadata }; + props.forEach((prop) => { + delete newMetadata[customPropsMetadataPrefix + prop.id]; + }); + setTarget({ ...file, metadata: newMetadata }); + } else { + setTarget({ + ...file, + metadata: { + ...file.metadata, + ...Object.assign({}, ...props.map((prop) => ({ [customPropsMetadataPrefix + prop.id]: prop.value }))), + }, + }); + } + }) + .finally(() => { + setLoading(false); + }); + }; + + if (existingProps.length === 0 && (!custom_props || custom_props.length === 0)) { + return undefined; + } + + return ( + + + {t("fileManager.customProps")} + + { + handlePropPatch(false)([prop]); + }} + /> + + {existingProps.map((prop, index) => ( + + { + handlePropPatch(false)([prop]); + }} + onPropDelete={(prop) => { + handlePropPatch(true)([prop]); + }} + readOnly={!targetDisplayOptions?.showCustomProps} + /> + + ))} + + + ); +}; + +export default CustomProps; diff --git a/src/component/FileManager/Sidebar/CustomProps/CustomPropsItem.tsx b/src/component/FileManager/Sidebar/CustomProps/CustomPropsItem.tsx new file mode 100644 index 0000000..210250e --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/CustomPropsItem.tsx @@ -0,0 +1,216 @@ +import { Icon } from "@iconify/react/dist/iconify.js"; +import { + alpha, + Box, + Grow, + IconButton, + ListItemIcon, + ListItemText, + Menu, + styled, + Typography, + useTheme, +} from "@mui/material"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { CustomProps, CustomPropsType } from "../../../../api/explorer.ts"; +import { useAppDispatch } from "../../../../redux/hooks.ts"; +import { searchMetadata } from "../../../../redux/thunks/filemanager.ts"; +import { copyToClipboard } from "../../../../util"; +import Clipboard from "../../../Icons/Clipboard.tsx"; +import DeleteOutlined from "../../../Icons/DeleteOutlined.tsx"; +import MoreVertical from "../../../Icons/MoreVertical.tsx"; +import Search from "../../../Icons/Search.tsx"; +import { SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx"; +import { FileManagerIndex } from "../../FileManager.tsx"; +import { StyledButtonBase } from "../MediaMetaCard.tsx"; +import BooleanPropsItem from "./BooleanPropsContent.tsx"; +import { CustomPropsItem } from "./CustomProps.tsx"; +import LinkPropsContent from "./LinkPropsContent.tsx"; +import MultiSelectPropsContent from "./MultiSelectPropsContent.tsx"; +import NumberPropsContent from "./NumberPropsContent.tsx"; +import RatingPropsItem from "./RatingPropsItem.tsx"; +import SelectPropsContent from "./SelectPropsContent.tsx"; +import TextPropsContent from "./TextPropsContent.tsx"; +import UserPropsContent from "./UserPropsContent.tsx"; + +export interface CustomPropsCardProps { + prop: CustomPropsItem; + loading?: boolean; + onPropPatch: (prop: CustomPropsItem) => void; + onPropDelete?: (prop: CustomPropsItem) => void; + readOnly?: boolean; +} + +export interface PropsContentProps { + prop: CustomPropsItem; + onChange: (value: string) => void; + loading?: boolean; + readOnly?: boolean; + backgroundColor?: string; + fullSize?: boolean; +} + +const PropsCard = styled(StyledButtonBase)(({ theme }) => ({ + flexDirection: "column", + alignItems: "flex-start", + gap: 9, +})); + +export const getPropsContent = ( + prop: CustomPropsItem, + onChange: (value: string) => void, + loading?: boolean, + readOnly?: boolean, + fullSize?: boolean, +) => { + switch (prop.props.type) { + case CustomPropsType.text: + return ( + + ); + case CustomPropsType.rating: + return ( + + ); + case CustomPropsType.number: + return ( + + ); + case CustomPropsType.boolean: + return ( + + ); + case CustomPropsType.select: + return ( + + ); + case CustomPropsType.multi_select: + return ( + + ); + case CustomPropsType.link: + return ( + + ); + default: + return null; + } +}; + +export const isCustomPropStrongMatch = (prop: CustomProps) => { + return prop.type === CustomPropsType.rating || prop.type === CustomPropsType.number; +}; + +const CustomPropsCard = ({ prop, loading, onPropPatch, onPropDelete, readOnly }: CustomPropsCardProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const dispatch = useAppDispatch(); + const [mouseOver, setMouseOver] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + const handleCopy = () => { + const value = prop.value || ""; + copyToClipboard(value); + handleMenuClose(); + }; + + const handleSearch = () => { + if (prop.value) { + dispatch( + searchMetadata( + FileManagerIndex.main, + `props:${prop.props.id}`, + prop.value, + false, + isCustomPropStrongMatch(prop.props), + ), + ); + } + handleMenuClose(); + }; + + const handleDelete = () => { + if (onPropDelete) { + onPropDelete(prop); + } + handleMenuClose(); + }; + + const Content = useMemo(() => { + return getPropsContent(prop, (value) => onPropPatch({ ...prop, value }), loading, readOnly, true); + }, [prop, loading, onPropPatch, readOnly]); + + return ( + setMouseOver(true)} onMouseLeave={() => setMouseOver(false)}> + + + + setAnchorEl(e.currentTarget)}> + + + + + {prop.props.icon && } + + {prop.props.type === CustomPropsType.boolean ? Content : t(prop.props.name)} + + + + {prop.props.type !== CustomPropsType.boolean && ( + + {Content} + + )} + + + {prop.value && ( + <> + + + + + {t("application:fileManager.copyToClipboard")} + + + + + + {t("application:fileManager.searchProperty")} + + + )} + + + + + + {t("application:fileManager.delete")} + + + + ); +}; + +export default CustomPropsCard; diff --git a/src/component/FileManager/Sidebar/CustomProps/LinkPropsContent.tsx b/src/component/FileManager/Sidebar/CustomProps/LinkPropsContent.tsx new file mode 100644 index 0000000..98ff3a0 --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/LinkPropsContent.tsx @@ -0,0 +1,115 @@ +import { Box, IconButton, Link, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { NoLabelFilledTextField } from "../../../Common/StyledComponents.tsx"; +import Edit from "../../../Icons/Edit.tsx"; +import { PropsContentProps } from "./CustomPropsItem.tsx"; + +const LinkPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => { + const { t } = useTranslation(); + const [value, setValue] = useState(prop.value); + const [isEditing, setIsEditing] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + useEffect(() => { + setValue(prop.value); + }, [prop.value]); + + const handleEditClick = () => { + setIsEditing(true); + }; + + const handleBlur = () => { + setIsEditing(false); + if (value !== prop.value) { + onChange(value); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleBlur(); + } + if (e.key === "Escape") { + setValue(prop.value); + setIsEditing(false); + } + }; + + if (readOnly) { + if (!value) { + return null; + } + return ( + + {value} + + ); + } + + if (isEditing) { + return ( + e.stopPropagation()} + onChange={(e) => setValue(e.target.value)} + value={value ?? ""} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + /> + ); + } + + if (!value) { + return ( + + {t("application:fileManager.clickToEdit")} + + ); + } + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {value} + + {isHovered && ( + { + e.stopPropagation(); + handleEditClick(); + }} + > + + + )} + + ); +}; + +export default LinkPropsContent; diff --git a/src/component/FileManager/Sidebar/CustomProps/MultiSelectPropsContent.tsx b/src/component/FileManager/Sidebar/CustomProps/MultiSelectPropsContent.tsx new file mode 100644 index 0000000..9b9b1e7 --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/MultiSelectPropsContent.tsx @@ -0,0 +1,123 @@ +import { Box, Chip, MenuItem, Select, styled, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { PropsContentProps } from "./CustomPropsItem.tsx"; + +const NoLabelFilledSelect = styled(Select)(({ theme }) => ({ + "& .MuiSelect-select": { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + fontSize: theme.typography.body2.fontSize, + "&.Mui-disabled": { + borderBottomStyle: "none", + "&::before": { + borderBottomStyle: "none !important", + }, + }, + }, + "&.MuiInputBase-root.MuiFilledInput-root.MuiSelect-root": { + "&.Mui-disabled": { + borderBottomStyle: "none", + "&::before": { + borderBottomStyle: "none !important", + }, + }, + }, +})); + +const MultiSelectPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => { + const { t } = useTranslation(); + const [values, setValues] = useState(() => { + if (prop.value) { + try { + return JSON.parse(prop.value); + } catch { + return []; + } + } + return []; + }); + + useEffect(() => { + if (prop.value) { + try { + setValues(JSON.parse(prop.value)); + } catch { + setValues([]); + } + } else { + setValues([]); + } + }, [prop.value]); + + const handleChange = (selectedValues: string[]) => { + setValues(selectedValues); + const newValue = JSON.stringify(selectedValues); + if (newValue !== prop.value) { + onChange(newValue); + } + }; + + const handleDelete = (valueToDelete: string) => { + const newValues = values.filter((value) => value !== valueToDelete); + handleChange(newValues); + }; + + if (readOnly) { + return ( + + {values.length > 0 + ? values.map((value, index) => ) + : ""} + + ); + } + + const options = prop.props.options || []; + + return ( + + handleChange(e.target.value as string[])} + onClick={(e) => e.stopPropagation()} + multiple + displayEmpty + renderValue={(selected) => { + const selectedArray = Array.isArray(selected) ? selected : []; + if (selectedArray.length === 0) { + return ( + + {t("application:fileManager.clickToEditSelect")} + + ); + } + return ( + + {selectedArray.map((value, index) => ( + handleDelete(value)} + onMouseDown={(e) => e.stopPropagation()} + /> + ))} + + ); + }} + > + {options.map((option) => ( + + {option} + + ))} + + + ); +}; + +export default MultiSelectPropsContent; diff --git a/src/component/FileManager/Sidebar/CustomProps/NumberPropsContent.tsx b/src/component/FileManager/Sidebar/CustomProps/NumberPropsContent.tsx new file mode 100644 index 0000000..801edb8 --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/NumberPropsContent.tsx @@ -0,0 +1,49 @@ +import { Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { NoLabelFilledTextField } from "../../../Common/StyledComponents.tsx"; +import { PropsContentProps } from "./CustomPropsItem.tsx"; + +const NumberPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => { + const { t } = useTranslation(); + const [value, setValue] = useState(prop.value); + + useEffect(() => { + setValue(prop.value); + }, [prop.value]); + + const onBlur = () => { + if (value !== prop.value) { + onChange(value); + } + }; + + if (readOnly) { + return {value}; + } + + return ( + e.stopPropagation()} + onChange={(e) => setValue(e.target.value)} + value={value ?? ""} + onBlur={onBlur} + required + /> + ); +}; + +export default NumberPropsContent; diff --git a/src/component/FileManager/Sidebar/CustomProps/RatingPropsItem.tsx b/src/component/FileManager/Sidebar/CustomProps/RatingPropsItem.tsx new file mode 100644 index 0000000..74ccae6 --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/RatingPropsItem.tsx @@ -0,0 +1,23 @@ +import { Rating } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { PropsContentProps } from "./CustomPropsItem.tsx"; + +const RatingPropsItem = ({ prop, onChange, loading, readOnly }: PropsContentProps) => { + const { t } = useTranslation(); + + const handleChange = (_: any, value: number | null) => { + onChange(value?.toString() ?? ""); + }; + + return ( + + ); +}; + +export default RatingPropsItem; diff --git a/src/component/FileManager/Sidebar/CustomProps/SelectPropsContent.tsx b/src/component/FileManager/Sidebar/CustomProps/SelectPropsContent.tsx new file mode 100644 index 0000000..8bf083a --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/SelectPropsContent.tsx @@ -0,0 +1,78 @@ +import { MenuItem, Select, styled, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { PropsContentProps } from "./CustomPropsItem.tsx"; + +const NoLabelFilledSelect = styled(Select)(({ theme }) => ({ + "& .MuiSelect-select": { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + fontSize: theme.typography.body2.fontSize, + "&.Mui-disabled": { + borderBottomStyle: "none", + "&::before": { + borderBottomStyle: "none !important", + }, + }, + }, + "&.MuiInputBase-root.MuiFilledInput-root.MuiSelect-root": { + "&.Mui-disabled": { + borderBottomStyle: "none", + "&::before": { + borderBottomStyle: "none !important", + }, + }, + }, +})); + +const SelectPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => { + const { t } = useTranslation(); + const [value, setValue] = useState(prop.value || ""); + + useEffect(() => { + setValue(prop.value || ""); + }, [prop.value]); + + const handleChange = (selectedValue: string) => { + setValue(selectedValue); + if (selectedValue !== prop.value) { + onChange(selectedValue); + } + }; + + if (readOnly) { + return {value}; + } + + const options = prop.props.options || []; + + return ( + handleChange(e.target.value as string)} + onClick={(e) => e.stopPropagation()} + displayEmpty + renderValue={(selected) => { + if (!selected) { + return ( + + {t("application:fileManager.clickToEditSelect")} + + ); + } + return selected as string; + }} + > + {options.map((option) => ( + + {option} + + ))} + + ); +}; + +export default SelectPropsContent; diff --git a/src/component/FileManager/Sidebar/CustomProps/TextPropsContent.tsx b/src/component/FileManager/Sidebar/CustomProps/TextPropsContent.tsx new file mode 100644 index 0000000..15d1277 --- /dev/null +++ b/src/component/FileManager/Sidebar/CustomProps/TextPropsContent.tsx @@ -0,0 +1,48 @@ +import { Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { NoLabelFilledTextField } from "../../../Common/StyledComponents.tsx"; +import { PropsContentProps } from "./CustomPropsItem.tsx"; + +const TextPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => { + const { t } = useTranslation(); + const [value, setValue] = useState(prop.value); + + useEffect(() => { + setValue(prop.value); + }, [prop.value]); + + const onBlur = () => { + if (value !== prop.value) { + onChange(value); + } + }; + + if (readOnly) { + return {value}; + } + + return ( + e.stopPropagation()} + onChange={(e) => setValue(e.target.value)} + value={value ?? ""} + onBlur={onBlur} + required + multiline + /> + ); +}; + +export default TextPropsContent; diff --git a/src/component/FileManager/Sidebar/Details.tsx b/src/component/FileManager/Sidebar/Details.tsx index e1f38af..1d1df2d 100644 --- a/src/component/FileManager/Sidebar/Details.tsx +++ b/src/component/FileManager/Sidebar/Details.tsx @@ -1,17 +1,21 @@ -import { FileResponse, FileType, Metadata } from "../../../api/explorer.ts"; -import { useEffect, useState } from "react"; -import { loadFileThumb } from "../../../redux/thunks/file.ts"; -import { useAppDispatch } from "../../../redux/hooks.ts"; -import { FileManagerIndex } from "../FileManager.tsx"; import { Box, Stack, Typography } from "@mui/material"; -import MediaInfo from "./MediaInfo.tsx"; +import { useEffect, useState } from "react"; +import { FileResponse, FileType, Metadata } from "../../../api/explorer.ts"; +import { useAppDispatch } from "../../../redux/hooks.ts"; +import { loadFileThumb } from "../../../redux/thunks/file.ts"; +import { FileManagerIndex } from "../FileManager.tsx"; import BasicInfo from "./BasicInfo.tsx"; -import Tags from "./Tags.tsx"; +import CustomProps from "./CustomProps/CustomProps.tsx"; import Data from "./Data.tsx"; +import MediaInfo from "./MediaInfo.tsx"; +import Tags from "./Tags.tsx"; +import { DisplayOption } from "../ContextMenu/useActionDisplayOpt.ts"; export interface DetailsProps { inPhotoViewer?: boolean; target: FileResponse; + setTarget: (target: FileResponse | undefined | null) => void; + targetDisplayOptions?: DisplayOption; } const InfoBlock = ({ title, children }: { title: string; children: React.ReactNode }) => { @@ -25,7 +29,7 @@ const InfoBlock = ({ title, children }: { title: string; children: React.ReactNo ); }; -const Details = ({ target, inPhotoViewer }: DetailsProps) => { +const Details = ({ target, inPhotoViewer, setTarget, targetDisplayOptions }: DetailsProps) => { const dispatch = useAppDispatch(); const [thumbSrc, setThumbSrc] = useState(null); useEffect(() => { @@ -53,6 +57,7 @@ const Details = ({ target, inPhotoViewer }: DetailsProps) => { /> )} + diff --git a/src/component/FileManager/Sidebar/MediaMetaCard.tsx b/src/component/FileManager/Sidebar/MediaMetaCard.tsx index 11d8000..452d40c 100644 --- a/src/component/FileManager/Sidebar/MediaMetaCard.tsx +++ b/src/component/FileManager/Sidebar/MediaMetaCard.tsx @@ -1,4 +1,3 @@ -import { useTranslation } from "react-i18next"; import { Box, Link, @@ -11,14 +10,15 @@ import { Typography, } from "@mui/material"; import SvgIcon from "@mui/material/SvgIcon/SvgIcon"; -import React, { useState } from "react"; -import { SquareMenuItem } from "../ContextMenu/ContextMenu.tsx"; -import Search from "../../Icons/Search.tsx"; -import Clipboard from "../../Icons/Clipboard.tsx"; -import { searchMetadata } from "../../../redux/thunks/filemanager.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useAppDispatch } from "../../../redux/hooks.ts"; -import { FileManagerIndex } from "../FileManager.tsx"; +import { searchMetadata } from "../../../redux/thunks/filemanager.ts"; import { copyToClipboard } from "../../../util"; +import Clipboard from "../../Icons/Clipboard.tsx"; +import Search from "../../Icons/Search.tsx"; +import { SquareMenuItem } from "../ContextMenu/ContextMenu.tsx"; +import { FileManagerIndex } from "../FileManager.tsx"; export interface MediaMetaElements { display: string; @@ -36,7 +36,7 @@ export interface MediaMetaCardProps { icon?: typeof SvgIcon | ((props: SvgIconProps) => JSX.Element); } -const StyledButtonBase = styled(Box)(({ theme }) => { +export const StyledButtonBase = styled(Box)(({ theme }) => { let bgColor = theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]; let bgColorHover = theme.palette.mode === "light" ? theme.palette.grey[300] : theme.palette.grey[700]; return { diff --git a/src/component/FileManager/Sidebar/Sidebar.tsx b/src/component/FileManager/Sidebar/Sidebar.tsx index b0fc8ec..f3c19f2 100644 --- a/src/component/FileManager/Sidebar/Sidebar.tsx +++ b/src/component/FileManager/Sidebar/Sidebar.tsx @@ -1,10 +1,10 @@ -import { RadiusFrame } from "../../Frame/RadiusFrame.tsx"; -import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { Box, Collapse } from "@mui/material"; -import SidebarContent from "./SidebarContent.tsx"; import { useCallback, useEffect, useState } from "react"; -import { FileResponse } from "../../../api/explorer.ts"; import { getFileInfo } from "../../../api/api.ts"; +import { FileResponse } from "../../../api/explorer.ts"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; +import { RadiusFrame } from "../../Frame/RadiusFrame.tsx"; +import SidebarContent from "./SidebarContent.tsx"; export interface SideBarProps { inPhotoViewer?: boolean; @@ -25,7 +25,7 @@ const Sidebar = ({ inPhotoViewer }: SideBarProps) => { extended: true, }), ).then((res) => { - setTarget(res); + setTarget((r) => ({ ...res, capability: r?.capability })); }); }, [target, dispatch, setTarget], @@ -72,7 +72,7 @@ const Sidebar = ({ inPhotoViewer }: SideBarProps) => { }} withBorder={!inPhotoViewer} > - + diff --git a/src/component/FileManager/Sidebar/SidebarContent.tsx b/src/component/FileManager/Sidebar/SidebarContent.tsx index ca62b2d..ce0729b 100644 --- a/src/component/FileManager/Sidebar/SidebarContent.tsx +++ b/src/component/FileManager/Sidebar/SidebarContent.tsx @@ -1,12 +1,14 @@ import { Box } from "@mui/material"; import { useTranslation } from "react-i18next"; import { FileResponse } from "../../../api/explorer.ts"; +import useActionDisplayOpt from "../ContextMenu/useActionDisplayOpt.ts"; import Details from "./Details.tsx"; import Header from "./Header.tsx"; export interface SidebarContentProps { target: FileResponse | undefined | null; inPhotoViewer?: boolean; + setTarget: (target: FileResponse | undefined | null) => void; } interface TabPanelProps { @@ -31,8 +33,9 @@ function TabPanel(props: TabPanelProps) { ); } -const SidebarContent = ({ target, inPhotoViewer }: SidebarContentProps) => { +const SidebarContent = ({ target, inPhotoViewer, setTarget }: SidebarContentProps) => { const { t } = useTranslation(); + const targetDisplayOptions = useActionDisplayOpt(target ? [target] : []); return (
@@ -48,7 +51,12 @@ const SidebarContent = ({ target, inPhotoViewer }: SidebarContentProps) => { > -
+
diff --git a/src/component/FileManager/Sidebar/SidebarDialog.tsx b/src/component/FileManager/Sidebar/SidebarDialog.tsx index 09819cf..b43fc32 100644 --- a/src/component/FileManager/Sidebar/SidebarDialog.tsx +++ b/src/component/FileManager/Sidebar/SidebarDialog.tsx @@ -1,12 +1,12 @@ -import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; -import SidebarContent from "./SidebarContent.tsx"; -import { forwardRef, useCallback, useEffect, useState } from "react"; -import { FileResponse } from "../../../api/explorer.ts"; -import { getFileInfo } from "../../../api/api.ts"; -import { SideBarProps } from "./Sidebar.tsx"; import { Dialog, Slide } from "@mui/material"; -import { closeSidebar } from "../../../redux/globalStateSlice.ts"; import { TransitionProps } from "@mui/material/transitions"; +import { forwardRef, useCallback, useEffect, useState } from "react"; +import { getFileInfo } from "../../../api/api.ts"; +import { FileResponse } from "../../../api/explorer.ts"; +import { closeSidebar } from "../../../redux/globalStateSlice.ts"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; +import { SideBarProps } from "./Sidebar.tsx"; +import SidebarContent from "./SidebarContent.tsx"; const Transition = forwardRef(function Transition( props: TransitionProps & { @@ -32,7 +32,7 @@ const SidebarDialog = ({ inPhotoViewer }: SideBarProps) => { extended: true, }), ).then((res) => { - setTarget(res); + setTarget((r) => ({ ...res, capability: r?.capability })); }); }, [target, dispatch, setTarget], @@ -59,7 +59,7 @@ const SidebarDialog = ({ inPhotoViewer }: SideBarProps) => { dispatch(closeSidebar()); }} > - + ); }; diff --git a/src/component/Frame/NavBar/PageNavigation.tsx b/src/component/Frame/NavBar/PageNavigation.tsx index c79003e..933faf8 100644 --- a/src/component/Frame/NavBar/PageNavigation.tsx +++ b/src/component/Frame/NavBar/PageNavigation.tsx @@ -14,6 +14,8 @@ import CloudDownload from "../../Icons/CloudDownload.tsx"; import CloudDownloadOutlined from "../../Icons/CloudDownloadOutlined.tsx"; import CubeSync from "../../Icons/CubeSync.tsx"; import CubeSyncFilled from "../../Icons/CubeSyncFilled.tsx"; +import CubeTree from "../../Icons/CubeTree.tsx"; +import CubeTreeFilled from "../../Icons/CubeTreeFilled.tsx"; import DataHistogram from "../../Icons/DataHistogram.tsx"; import DataHistogramFilled from "../../Icons/DataHistogramFilled.tsx"; import Folder from "../../Icons/Folder.tsx"; @@ -134,6 +136,11 @@ AdminNavigationItems = [ icon: [Setting, SettingsOutlined], path: "/admin/settings", }, + { + label: "dashboard:nav.fileSystem", + icon: [CubeTreeFilled, CubeTree], + path: "/admin/filesystem", + }, { label: "dashboard:nav.storagePolicy", icon: [Storage, StorageOutlined], diff --git a/src/component/Icons/CheckboxChecked.tsx b/src/component/Icons/CheckboxChecked.tsx new file mode 100644 index 0000000..362e0c0 --- /dev/null +++ b/src/component/Icons/CheckboxChecked.tsx @@ -0,0 +1,9 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; + +export default function CheckboxChecked(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/component/Icons/CubeTreeFilled.tsx b/src/component/Icons/CubeTreeFilled.tsx new file mode 100644 index 0000000..1938882 --- /dev/null +++ b/src/component/Icons/CubeTreeFilled.tsx @@ -0,0 +1,9 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; + +export default function CubeTreeFilled(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/component/Icons/DataBarVerticalStar.tsx b/src/component/Icons/DataBarVerticalStar.tsx new file mode 100644 index 0000000..e019c9d --- /dev/null +++ b/src/component/Icons/DataBarVerticalStar.tsx @@ -0,0 +1,9 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; + +export default function DataBarVerticalStar(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/component/Icons/SlideText.tsx b/src/component/Icons/SlideText.tsx new file mode 100644 index 0000000..3d55e74 --- /dev/null +++ b/src/component/Icons/SlideText.tsx @@ -0,0 +1,9 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; + +export default function Add(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/component/Icons/TaskListRegular.tsx b/src/component/Icons/TaskListRegular.tsx new file mode 100644 index 0000000..4bcc6b9 --- /dev/null +++ b/src/component/Icons/TaskListRegular.tsx @@ -0,0 +1,9 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; + +export default function TaskListRegular(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/component/Icons/TextBulletListSquareEdiFilled.tsx b/src/component/Icons/TextBulletListSquareEdiFilled.tsx new file mode 100644 index 0000000..c2c0963 --- /dev/null +++ b/src/component/Icons/TextBulletListSquareEdiFilled.tsx @@ -0,0 +1,9 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; + +export default function TextBulletListSquareEditFilled(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/component/Icons/TextBulletListSquareEdit.tsx b/src/component/Icons/TextBulletListSquareEdit.tsx new file mode 100644 index 0000000..273a646 --- /dev/null +++ b/src/component/Icons/TextBulletListSquareEdit.tsx @@ -0,0 +1,9 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; + +export default function TextBulletListSquareEdit(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/component/Icons/TextIndentIncrease.tsx b/src/component/Icons/TextIndentIncrease.tsx new file mode 100644 index 0000000..332283b --- /dev/null +++ b/src/component/Icons/TextIndentIncrease.tsx @@ -0,0 +1,9 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; + +export default function TextIndentIncrease(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/component/Viewers/MusicPlayer/PlayerPopup.tsx b/src/component/Viewers/MusicPlayer/PlayerPopup.tsx index 23eea0a..c6ba655 100644 --- a/src/component/Viewers/MusicPlayer/PlayerPopup.tsx +++ b/src/component/Viewers/MusicPlayer/PlayerPopup.tsx @@ -80,7 +80,6 @@ const CoverImage = styled("div")({ objectFit: "cover", overflow: "hidden", flexShrink: 0, - borderRadius: 8, backgroundColor: "rgba(0,0,0,0.08)", "& > img": { width: "100%", diff --git a/src/redux/thunks/filemanager.ts b/src/redux/thunks/filemanager.ts index f08e06b..940f0af 100644 --- a/src/redux/thunks/filemanager.ts +++ b/src/redux/thunks/filemanager.ts @@ -453,7 +453,13 @@ export function retrySharePassword(index: number, password: string): AppThunk { }; } -export function searchMetadata(index: number, metaKey: string, metaValue?: string, newTab?: boolean): AppThunk { +export function searchMetadata( + index: number, + metaKey: string, + metaValue?: string, + newTab?: boolean, + strongMatch?: boolean, +): AppThunk { return async (dispatch, getState) => { const { fileManager } = getState(); const fm = fileManager[index]; @@ -463,7 +469,10 @@ export function searchMetadata(index: number, metaKey: string, metaValue?: strin } const rootUri = new CrUri(root); - rootUri.addQuery(UriQuery.metadata_prefix + metaKey, metaValue ?? ""); + rootUri.addQuery( + (strongMatch ? UriQuery.metadata_strong_match : UriQuery.metadata_prefix) + metaKey, + metaValue ?? "", + ); dispatch(navigateToPath(index, rootUri.toString(), undefined, newTab)); }; } @@ -536,12 +545,18 @@ export function advancedSearch(index: number, conditions: Condition[]): AppThunk } break; case ConditionType.metadata: - if (!params.metadata) { - params.metadata = {}; - } - if (condition.metadata_key) { + if (condition.metadata_key && !condition.metadata_strong_match) { + if (!params.metadata) { + params.metadata = {}; + } params.metadata[condition.metadata_key] = condition.metadata_value ?? ""; } + if (condition.metadata_key && condition.metadata_strong_match) { + if (!params.metadata_strong_match) { + params.metadata_strong_match = {}; + } + params.metadata_strong_match[condition.metadata_key] = condition.metadata_value ?? ""; + } break; case ConditionType.size: if (condition.size_gte != undefined || condition.size_lte != undefined) { diff --git a/src/router/index.tsx b/src/router/index.tsx index 9180880..b62b35b 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -77,6 +77,7 @@ export const router = createBrowserRouter([ return { Component: Settings }; }, }, + { path: "policy", async lazy() { @@ -161,6 +162,13 @@ export const router = createBrowserRouter([ return { Component: ShareList }; }, }, + { + path: "filesystem", + async lazy() { + let { FileSystem } = await import("../component/Admin/AdminBundle.tsx"); + return { Component: FileSystem }; + }, + }, ], }, { diff --git a/src/util/uri.ts b/src/util/uri.ts index eb8e8bf..aed208e 100644 --- a/src/util/uri.ts +++ b/src/util/uri.ts @@ -17,6 +17,7 @@ export const UriQuery = { name: "name", name_op_or: "name_op_or", metadata_prefix: "meta_", + metadata_strong_match: "exact_meta_", case_folding: "case_folding", type: "type", category: "category", @@ -41,6 +42,9 @@ export interface SearchParam { metadata?: { [key: string]: string; }; + metadata_strong_match?: { + [key: string]: string; + }; case_folding?: boolean; category?: string; type?: number; @@ -115,6 +119,11 @@ export default class CrUri { this.addQuery(UriQuery.metadata_prefix + k, v); }); } + if (param.metadata_strong_match) { + Object.entries(param.metadata_strong_match).forEach(([k, v]) => { + this.addQuery(UriQuery.metadata_strong_match + k, v); + }); + } if (param.size_gte) { this.addQuery(UriQuery.size_gte, param.size_gte.toString()); } @@ -181,13 +190,17 @@ export default class CrUri { case UriQuery.updated_lte: res.updated_at_lte = parseInt(v); break; - default: if (k.startsWith(UriQuery.metadata_prefix)) { if (!res.metadata) { res.metadata = {}; } res.metadata[k.slice(UriQuery.metadata_prefix.length)] = v; + } else if (k.startsWith(UriQuery.metadata_strong_match)) { + if (!res.metadata_strong_match) { + res.metadata_strong_match = {}; + } + res.metadata_strong_match[k.slice(UriQuery.metadata_strong_match.length)] = v; } } }); diff --git a/yarn.lock b/yarn.lock index eccf3ce..8f9a725 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1860,6 +1860,18 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== +"@iconify/react@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@iconify/react/-/react-6.0.0.tgz#8e7ed943792746a0401cc49b9b4e9a5bd5f09b1a" + integrity sha512-eqNscABVZS8eCpZLU/L5F5UokMS9mnCf56iS1nM9YYHdH8ZxqZL9zyjSwW60IOQFsXZkilbBiv+1paMXBhSQnw== + dependencies: + "@iconify/types" "^2.0.0" + +"@iconify/types@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" + integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== + "@jridgewell/gen-mapping@^0.3.5": version "0.3.8" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142"