mirror of
https://github.com/cloudreve/frontend.git
synced 2025-12-25 19:52:48 +00:00
feat(fs): custom properties for files (#2407)
This commit is contained in:
parent
ee9e3ba54c
commit
ada49fd21d
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
"mediaProcessing": "Media processing",
|
||||
"queue": "Queue",
|
||||
"events": "Events",
|
||||
"server": "Server"
|
||||
"server": "Server",
|
||||
"customProps": "Custom properties"
|
||||
},
|
||||
"summary": {
|
||||
"generatedAt": "Generated at <0></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</0> 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>+</0> cannot be used for sign-up.",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,16 @@
|
|||
"dashboard": "管理パネル"
|
||||
},
|
||||
"fileManager": {
|
||||
"customProps": "カスタム属性",
|
||||
"rating": "評価",
|
||||
"description": "説明",
|
||||
"add": "追加",
|
||||
"clickToEdit": "編集...",
|
||||
"clickToEditSelect": "選択...",
|
||||
"enterUrl": "URLを入力...",
|
||||
"searchUser": "ユーザーを検索...",
|
||||
"typeToSearch": "名前またはメールアドレスを入力...",
|
||||
"searchProperty": "同じ属性のファイルを検索",
|
||||
"permissions": "権限",
|
||||
"quality": "解像度",
|
||||
"audioTrack": "音声トラック",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
"mediaProcessing": "メディア処理",
|
||||
"queue": "キュー",
|
||||
"events": "イベント",
|
||||
"server": "サーバー"
|
||||
"server": "サーバー",
|
||||
"customProps": "カスタムプロパティ"
|
||||
},
|
||||
"summary": {
|
||||
"generatedAt": "生成日 <0></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</0> アイコン名を入力してください。空欄の場合はアイコンを表示しません。",
|
||||
"id": "ID",
|
||||
"idDes": "プロパティのIDです。他のプロパティと重複しないようにしてください。",
|
||||
"idPatternDes": "半角英数字、アンダースコア、ハイフンのみ使用できます。",
|
||||
"minLength": "最小文字数",
|
||||
"maxLength": "最大文字数",
|
||||
"emptyLimit": "制限なしの場合は空欄にしてください。",
|
||||
"minValue": "最小値",
|
||||
"maxValue": "最大値",
|
||||
"options": "選択肢",
|
||||
"optionsDes": "1行につき1つの選択肢を入力してください。"
|
||||
},
|
||||
"vas": {
|
||||
"disableSubAddressEmail": "サブアドレスメールの無効化",
|
||||
"disableSubAddressEmailDes": "有効化後、<0>+</0>を含むメールアドレスは登録不可になります。",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,16 @@
|
|||
"dashboard": "管理面板"
|
||||
},
|
||||
"fileManager": {
|
||||
"customProps": "自定义属性",
|
||||
"rating": "评级",
|
||||
"description": "描述",
|
||||
"add": "添加",
|
||||
"clickToEdit": "点击编辑...",
|
||||
"clickToEditSelect": "点击选择...",
|
||||
"enterUrl": "输入 URL...",
|
||||
"searchUser": "搜索用户...",
|
||||
"typeToSearch": "输入昵称或邮箱...",
|
||||
"searchProperty": "搜索相同属性的文件",
|
||||
"permissions": "权限",
|
||||
"quality": "清晰度",
|
||||
"audioTrack": "音轨",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
"mediaProcessing": "媒体处理",
|
||||
"queue": "队列",
|
||||
"events": "事件",
|
||||
"server": "服务器"
|
||||
"server": "服务器",
|
||||
"customProps": "自定义属性"
|
||||
},
|
||||
"summary": {
|
||||
"generatedAt": "生成于 <0></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</0> 图标名称,留空表示不展示图标。",
|
||||
"id": "标识",
|
||||
"idDes": "属性标识,需要确保在所有属性中唯一。",
|
||||
"idPatternDes": "只能包含字母、数字、下划线和中划线。",
|
||||
"minLength": "最小长度",
|
||||
"maxLength": "最大长度",
|
||||
"emptyLimit": "留空表示不限制。",
|
||||
"minValue": "最小值",
|
||||
"maxValue": "最大值",
|
||||
"options": "选项",
|
||||
"optionsDes": "每行一个选项。"
|
||||
},
|
||||
"vas": {
|
||||
"disableSubAddressEmail": "禁用子地址邮箱",
|
||||
"disableSubAddressEmailDes": "开启后,包含加号 <0>+</0> 的邮箱地址无法注册账户。",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,16 @@
|
|||
"dashboard": "管理面板"
|
||||
},
|
||||
"fileManager": {
|
||||
"customProps": "自定義屬性",
|
||||
"rating": "評級",
|
||||
"description": "描述",
|
||||
"add": "新增",
|
||||
"clickToEdit": "點擊編輯...",
|
||||
"clickToEditSelect": "點擊選擇...",
|
||||
"enterUrl": "輸入 URL...",
|
||||
"searchUser": "搜尋用戶...",
|
||||
"typeToSearch": "輸入暱稱或郵箱...",
|
||||
"searchProperty": "搜尋相同屬性的檔案",
|
||||
"permissions": "權限",
|
||||
"quality": "清晰度",
|
||||
"audioTrack": "音軌",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
"mediaProcessing": "媒體處理",
|
||||
"queue": "佇列",
|
||||
"events": "事件",
|
||||
"server": "伺服器"
|
||||
"server": "伺服器",
|
||||
"customProps": "自定義屬性"
|
||||
},
|
||||
"summary": {
|
||||
"generatedAt": "生成於 <0></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</0> 圖標名稱,留空表示不展示圖標。",
|
||||
"id": "標識",
|
||||
"idDes": "屬性標識,需要確保在所有屬性中唯一。",
|
||||
"idPatternDes": "只能包含字母、數字、下劃線和中劃線。",
|
||||
"minLength": "最小長度",
|
||||
"maxLength": "最大長度",
|
||||
"emptyLimit": "留空表示不限制。",
|
||||
"minValue": "最小值",
|
||||
"maxValue": "最大值",
|
||||
"options": "選項",
|
||||
"optionsDes": "每行一個選項。"
|
||||
},
|
||||
"vas": {
|
||||
"disableSubAddressEmail": "禁用子地址郵箱",
|
||||
"disableSubAddressEmailDes": "開啟後,包含加號 <0>+</0> 的郵箱地址無法註冊賬戶。",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<CustomProps[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [editProps, setEditProps] = useState<CustomProps | undefined>(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 (
|
||||
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack spacing={5}>
|
||||
<ProDialog open={proOpen} onClose={() => setProOpen(false)} />
|
||||
<SettingSection>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
|
||||
<SecondaryButton variant="contained" startIcon={<Add />} {...bindTrigger(newPropsPopupState)}>
|
||||
{t("customProps.add")}
|
||||
</SecondaryButton>
|
||||
<Menu
|
||||
onClose={onClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
{...menuProps}
|
||||
>
|
||||
{(Object.keys(FieldTypes) as CustomPropsType[]).map((type, index) => {
|
||||
const fieldType = FieldTypes[type];
|
||||
const Icon = fieldType.icon;
|
||||
return (
|
||||
<SquareMenuItem
|
||||
dense
|
||||
key={index}
|
||||
onClick={() => (fieldType.pro ? setProOpen(true) : onNewProp(type))}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
{t(fieldType.title)}
|
||||
{fieldType.pro && <ProChip label="Pro" color="primary" size="small" />}
|
||||
</SquareMenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</Box>
|
||||
<TableContainer component={StyledTableContainerPaper}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Table sx={{ width: "100%" }} size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<NoWrapCell>{t("settings.displayName")}</NoWrapCell>
|
||||
<NoWrapCell>{t("customProps.type")}</NoWrapCell>
|
||||
<NoWrapCell>{t("customProps.default")}</NoWrapCell>
|
||||
<NoWrapCell>{t("settings.actions")}</NoWrapCell>
|
||||
<NoWrapCell></NoWrapCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{customProps.map((prop, idx) => {
|
||||
const rowRef = createRef<HTMLTableRowElement>();
|
||||
return (
|
||||
<DraggableCustomPropsRow
|
||||
key={prop.id}
|
||||
ref={rowRef}
|
||||
customProps={prop}
|
||||
index={idx}
|
||||
moveRow={moveRow}
|
||||
onEdit={(props) => {
|
||||
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 && (
|
||||
<TableRow>
|
||||
<NoWrapCell colSpan={6} align="center">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("application:setting.listEmpty")}
|
||||
</Typography>
|
||||
</NoWrapCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndProvider>
|
||||
</TableContainer>
|
||||
</SettingSection>
|
||||
</Stack>
|
||||
<EditPropsDialog open={open} onClose={() => setOpen(false)} onSave={handleSave} isNew={isNew} props={editProps} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomPropsSetting;
|
||||
|
|
@ -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, FieldTypeProps> = {
|
||||
[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<HTMLTableRowElement, DraggableCustomPropsRowProps>(
|
||||
(
|
||||
{ customProps, index, moveRow, onEdit, onDelete, onMoveUp, onMoveDown, isFirst, isLast, t, style },
|
||||
ref,
|
||||
): JSX.Element => {
|
||||
const theme = useTheme();
|
||||
const [, drop] = useDrop<DragItem>({
|
||||
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<DragItem, void, { isDragging: boolean }>({
|
||||
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<HTMLTableRowElement | null>).current = node;
|
||||
}
|
||||
drag(drop(node));
|
||||
};
|
||||
|
||||
const fieldType = FieldTypes[customProps.type];
|
||||
const TypeIcon = fieldType.icon;
|
||||
|
||||
return (
|
||||
<TableRow ref={setRowRef} hover style={{ opacity: isDragging ? 0.5 : 1, cursor: "move", ...style }}>
|
||||
<NoWrapCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{customProps.icon && (
|
||||
<Icon icon={customProps.icon} width={20} height={20} color={theme.palette.action.active} />
|
||||
)}
|
||||
{t(customProps.name, { ns: "application" })}
|
||||
</Box>
|
||||
</NoWrapCell>
|
||||
<NoWrapCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<TypeIcon width={20} height={20} sx={{ color: theme.palette.action.active }} />
|
||||
{t(fieldType.title)}
|
||||
</Box>
|
||||
</NoWrapCell>
|
||||
<NoWrapCell>
|
||||
{getPropsContent(
|
||||
{
|
||||
props: customProps,
|
||||
id: customProps.id,
|
||||
value: customProps.default ?? "",
|
||||
},
|
||||
() => {},
|
||||
false,
|
||||
true,
|
||||
)}
|
||||
</NoWrapCell>
|
||||
<NoWrapCell>
|
||||
<IconButton size="small" onClick={() => onEdit(customProps)}>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(customProps.id)}>
|
||||
<Dismiss fontSize="small" />
|
||||
</IconButton>
|
||||
</NoWrapCell>
|
||||
<NoWrapCell>
|
||||
<IconButton size="small" onClick={onMoveUp} disabled={isFirst}>
|
||||
<ArrowDown
|
||||
sx={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
transform: "rotate(180deg)",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onMoveDown} disabled={isLast}>
|
||||
<ArrowDown
|
||||
sx={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</NoWrapCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export default DraggableCustomPropsRow;
|
||||
|
|
@ -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<HTMLFormElement>(null);
|
||||
const [editProps, setEditProps] = useState<CustomProps | undefined>(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 (
|
||||
<DraggableDialog
|
||||
title={isNew ? t("customProps.addProp") : t("customProps.editProp")}
|
||||
showActions
|
||||
showCancel
|
||||
onAccept={handleSave}
|
||||
dialogProps={{
|
||||
open,
|
||||
onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Box component={"form"} ref={formRef} sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
|
||||
<SettingForm title={t("customProps.id")} lgWidth={12}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
disabled={!isNew}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
max: 128,
|
||||
pattern: "^[a-zA-Z0-9_-]+$",
|
||||
title: t("customProps.idPatternDes"),
|
||||
},
|
||||
}}
|
||||
value={editProps?.id || ""}
|
||||
required
|
||||
onChange={(e) => setEditProps({ ...editProps, id: e.target.value } as CustomProps)}
|
||||
/>
|
||||
<NoMarginHelperText>{t("customProps.idDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("settings.displayName")} lgWidth={12}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
value={editProps?.name || ""}
|
||||
onChange={(e) =>
|
||||
setEditProps({
|
||||
...editProps,
|
||||
name: e.target.value,
|
||||
} as CustomProps)
|
||||
}
|
||||
required
|
||||
/>
|
||||
<NoMarginHelperText>{t("settings.displayNameDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("customProps.icon")} lgWidth={12}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
value={editProps?.icon || ""}
|
||||
onChange={(e) => setEditProps({ ...editProps, icon: e.target.value } as CustomProps)}
|
||||
/>
|
||||
<NoMarginHelperText>
|
||||
{
|
||||
<Trans
|
||||
i18nKey="dashboard:customProps.iconDes"
|
||||
components={[<Link target="_blank" href="https://icon-sets.iconify.design/" />]}
|
||||
/>
|
||||
}
|
||||
</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
{(fieldType.minTitle || fieldType.maxTitle) && (
|
||||
<Grid2 container spacing={2} size={{ xs: 12 }}>
|
||||
{fieldType.minTitle && (
|
||||
<SettingForm title={t(fieldType.minTitle)} lgWidth={6} noContainer>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
type="number"
|
||||
value={editProps?.min || ""}
|
||||
onChange={(e) => setEditProps({ ...editProps, min: parseInt(e.target.value) } as CustomProps)}
|
||||
/>
|
||||
{fieldType.minDes && <NoMarginHelperText>{t(fieldType.minDes)}</NoMarginHelperText>}
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
)}
|
||||
{fieldType.maxTitle && (
|
||||
<SettingForm title={t(fieldType.maxTitle)} lgWidth={6} noContainer>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
type="number"
|
||||
required={fieldType.maxRequired}
|
||||
value={editProps?.max || ""}
|
||||
onChange={(e) => setEditProps({ ...editProps, max: parseInt(e.target.value) } as CustomProps)}
|
||||
/>
|
||||
{fieldType.maxDes && <NoMarginHelperText>{t(fieldType.maxDes)}</NoMarginHelperText>}
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
)}
|
||||
</Grid2>
|
||||
)}
|
||||
{fieldType.showOptions && (
|
||||
<SettingForm title={t("customProps.options")} lgWidth={12}>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
multiline
|
||||
rows={4}
|
||||
value={editProps?.options?.join("\n") || ""}
|
||||
onChange={(e) => setEditProps({ ...editProps, options: e.target.value.split("\n") } as CustomProps)}
|
||||
/>
|
||||
<NoMarginHelperText>{t("customProps.optionsDes")}</NoMarginHelperText>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
)}
|
||||
<SettingForm title={t("customProps.default")} lgWidth={12}>
|
||||
<FormControl fullWidth>
|
||||
{getPropsContent(
|
||||
{
|
||||
props: editProps,
|
||||
id: editProps.id,
|
||||
value: editProps.default ?? "",
|
||||
},
|
||||
(value) => {
|
||||
setEditProps({ ...editProps, default: value } as CustomProps);
|
||||
},
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
)}
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPropsDialog;
|
||||
|
|
@ -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<SettingsPageTab>[] = useMemo(() => {
|
||||
const res = [];
|
||||
res.push(
|
||||
...[
|
||||
{
|
||||
label: t("nav.customProps"),
|
||||
value: SettingsPageTab.CustomProps,
|
||||
icon: <TextBulletListSquareEdit />,
|
||||
},
|
||||
],
|
||||
);
|
||||
return res;
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Container maxWidth="xl">
|
||||
<PageHeader title={t("dashboard:nav.fileSystem")} />
|
||||
<ResponsiveTabs
|
||||
value={tab ?? SettingsPageTab.Parameters}
|
||||
onChange={(_e, newValue) => setTab(newValue)}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<SwitchTransition>
|
||||
<CSSTransition
|
||||
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
|
||||
classNames="fade"
|
||||
key={`${tab}`}
|
||||
>
|
||||
<Box>
|
||||
{(!tab || tab === SettingsPageTab.Parameters) && <div></div>}
|
||||
{tab === SettingsPageTab.CustomProps && (
|
||||
<SettingsWrapper settings={["custom_props"]}>
|
||||
<CustomPropsSetting />
|
||||
</SettingsWrapper>
|
||||
)}
|
||||
</Box>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileSystem;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export function CascadingSubmenu({ title, popupId, menuItemProps, icon, ...props
|
|||
return (
|
||||
<>
|
||||
<SquareMenuItem
|
||||
dense
|
||||
{...(isMobile ? bindTrigger(popupState) : bindHover(popupState))}
|
||||
{...(isMobile ? {} : bindFocus(popupState))}
|
||||
{...menuItemProps}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export interface DisplayOption {
|
|||
showTags?: boolean;
|
||||
showChangeFolderColor?: boolean;
|
||||
showChangeIcon?: boolean;
|
||||
showCustomProps?: boolean;
|
||||
|
||||
showMore?: boolean;
|
||||
showVersionControl?: boolean;
|
||||
|
|
@ -223,6 +224,7 @@ export const getActionOpt = (
|
|||
display.orCapability.enabled(NavigatorCapability.update_metadata);
|
||||
display.showChangeIcon =
|
||||
display.hasUpdatable && display.orCapability && display.orCapability.enabled(NavigatorCapability.update_metadata);
|
||||
display.showCustomProps = display.showChangeIcon;
|
||||
display.showDownload =
|
||||
display.hasReadable && display.orCapability && display.orCapability.enabled(NavigatorCapability.download_file);
|
||||
display.showDirectLink =
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Menu } from "@mui/material";
|
||||
import { Icon } from "@iconify/react/dist/iconify.js";
|
||||
import { ListItemIcon, Menu } from "@mui/material";
|
||||
import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
|
||||
import { useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { SecondaryButton } from "../../../Common/StyledComponents.tsx";
|
||||
import Add from "../../../Icons/Add.tsx";
|
||||
import { CascadingSubmenu } from "../../ContextMenu/CascadingMenu.tsx";
|
||||
|
|
@ -48,6 +50,7 @@ const mediaInfoOptions: (ColumType | null)[] = [
|
|||
|
||||
const AddColumn = (props: AddColumnProps) => {
|
||||
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) => {
|
|||
),
|
||||
)}
|
||||
</CascadingSubmenu>
|
||||
{customPropsOptions && customPropsOptions.length > 0 && (
|
||||
<CascadingSubmenu popupId={"customProps"} title={t("application:fileManager.customProps")}>
|
||||
{customPropsOptions.map((option, index) => (
|
||||
<SquareMenuItem
|
||||
dense
|
||||
key={index}
|
||||
onClick={() => onConditionAdd(ColumType.custom_props, { custom_props_id: option.id })}
|
||||
>
|
||||
{option.icon && (
|
||||
<ListItemIcon>
|
||||
<Icon icon={option.icon} />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
{t(option.name)}
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</CascadingSubmenu>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 <FileNameCell {...props} />;
|
||||
|
|
@ -330,6 +352,11 @@ const Cell = memo((props: CellProps) => {
|
|||
return <MediaElementsCell element={getAlbum(file)} />;
|
||||
case ColumType.duration:
|
||||
return <MediaElementsCell element={getDuration(file)} />;
|
||||
case ColumType.custom_props:
|
||||
if (customProp) {
|
||||
return getPropsContent(customProp, () => {}, false, true);
|
||||
}
|
||||
return <Box />;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<DraggableColumnRowProps> = ({ column, index, moveRow, columns, t, onDelete, isFirst, isLast }) => {
|
||||
const DraggableColumnRow: React.FC<DraggableColumnRowProps> = ({
|
||||
column,
|
||||
index,
|
||||
moveRow,
|
||||
columns,
|
||||
t,
|
||||
onDelete,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const ref = React.useRef<HTMLTableRowElement>(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<DraggableColumnRowProps> = ({ column, index,
|
|||
sx={{ "&:last-child td, &:last-child th": { border: 0 }, opacity: isDragging ? 0.5 : 1, cursor: "move" }}
|
||||
>
|
||||
<TableCell component="th" scope="row">
|
||||
{t(getColumnTypeDefaults(column).title)}
|
||||
{t(getColumnTypeDefaults(column, false, customProps).title)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex" }}>
|
||||
|
|
@ -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"), {
|
||||
|
|
|
|||
|
|
@ -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<ListViewColumn[]>(
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
</SquareMenuItem>
|
||||
))}
|
||||
</CascadingSubmenu>
|
||||
{customPropsOptions && customPropsOptions.length > 0 && (
|
||||
<CascadingSubmenu
|
||||
icon={<TextBulletListSquareEdit fontSize="small" />}
|
||||
popupId={"customProps"}
|
||||
title={t("application:fileManager.customProps")}
|
||||
>
|
||||
{customPropsOptions.map((option, index) => (
|
||||
<SquareMenuItem
|
||||
dense
|
||||
key={index}
|
||||
onClick={() =>
|
||||
onConditionAdd({
|
||||
type: ConditionType.metadata,
|
||||
id: customPropsMetadataPrefix + option.id,
|
||||
metadata_key: customPropsMetadataPrefix + option.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.icon && (
|
||||
<ListItemIcon>
|
||||
<Icon icon={option.icon} />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
{t(option.name)}
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</CascadingSubmenu>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<StyledBox
|
||||
|
|
@ -134,7 +159,11 @@ const ConditionBox = forwardRef((props: ConditionProps, ref) => {
|
|||
variant={"body2"}
|
||||
fontWeight={600}
|
||||
>
|
||||
<Icon sx={{ width: "20px", height: "20px" }} />
|
||||
{typeof ConditionIcon !== "string" ? (
|
||||
<ConditionIcon sx={{ width: "20px", height: "20px" }} />
|
||||
) : (
|
||||
<Icon icon={ConditionIcon} width={20} height={20} />
|
||||
)}
|
||||
<Box sx={{ flexGrow: 1 }}>{title}</Box>
|
||||
<Grow in={hovered && !!onRemove}>
|
||||
<IconButton onClick={onRemove ? () => onRemove(condition) : undefined}>
|
||||
|
|
@ -149,7 +178,12 @@ const ConditionBox = forwardRef((props: ConditionProps, ref) => {
|
|||
{condition.type == ConditionType.type && <FileTypeCondition condition={condition} onChange={onChange} />}
|
||||
{condition.type == ConditionType.base && <SearchBaseCondition condition={condition} onChange={onChange} />}
|
||||
{condition.type == ConditionType.tag && <TagCondition onChange={onChange} condition={condition} />}
|
||||
{condition.type == ConditionType.metadata && <MetadataCondition onChange={onChange} condition={condition} />}
|
||||
{condition.type == ConditionType.metadata && !customPropsOption && (
|
||||
<MetadataCondition onChange={onChange} condition={condition} />
|
||||
)}
|
||||
{condition.type == ConditionType.metadata && customPropsOption && (
|
||||
<CustomPropsConditon onChange={onChange} condition={condition} option={customPropsOption} />
|
||||
)}
|
||||
{condition.type == ConditionType.size && <SizeCondition condition={condition} onChange={onChange} />}
|
||||
{condition.type == ConditionType.created && (
|
||||
<DateTimeCondition condition={condition} onChange={onChange} field={"created"} />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{getPropsContent(
|
||||
{
|
||||
props: option,
|
||||
id: option.id,
|
||||
value: condition.metadata_value ?? "",
|
||||
},
|
||||
(value) => onChange({ ...condition, metadata_value: value }),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<BorderedCardClickable disabled={disabled} {...bindTrigger(propPopupState)}>
|
||||
<Add sx={{ width: 20, height: 20 }} />
|
||||
<Typography variant="body1" fontWeight={500}>
|
||||
{t("fileManager.add")}
|
||||
</Typography>
|
||||
</BorderedCardClickable>
|
||||
<Menu
|
||||
onClose={onClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
{...menuProps}
|
||||
>
|
||||
{unSelectedOptions.map((option) => (
|
||||
<SquareMenuItem dense key={option.id} onClick={() => handlePropAdd(option)}>
|
||||
{option.icon && (
|
||||
<ListItemIcon>
|
||||
<Icon icon={option.icon} />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText
|
||||
slotProps={{
|
||||
primary: { variant: "body2" },
|
||||
}}
|
||||
>
|
||||
{t(option.name)}
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddButton;
|
||||
|
|
@ -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 (
|
||||
<Box sx={{ pl: "10px" }}>
|
||||
<FormControlLabel
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: "inherit",
|
||||
pl: 1,
|
||||
},
|
||||
}}
|
||||
control={<StyledCheckbox size={"small"} checked={isTrueVal(prop.value)} onChange={handleChange} />}
|
||||
label={fullSize ? t(prop.props.name) : undefined}
|
||||
disabled={readOnly || loading}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BooleanPropsItem;
|
||||
|
|
@ -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 (
|
||||
<Stack spacing={1}>
|
||||
<Typography sx={{ pt: 1 }} color="textPrimary" fontWeight={500} variant={"subtitle1"}>
|
||||
{t("fileManager.customProps")}
|
||||
</Typography>
|
||||
<AddButton
|
||||
disabled={!targetDisplayOptions?.showCustomProps}
|
||||
loading={loading}
|
||||
options={custom_props ?? []}
|
||||
existingPropIds={existingPropIds}
|
||||
onPropAdd={(prop) => {
|
||||
handlePropPatch(false)([prop]);
|
||||
}}
|
||||
/>
|
||||
<TransitionGroup>
|
||||
{existingProps.map((prop, index) => (
|
||||
<Collapse key={prop.id} sx={{ mb: index === existingProps.length - 1 ? 0 : 1 }}>
|
||||
<CustomPropsCard
|
||||
key={prop.id}
|
||||
prop={prop}
|
||||
loading={loading}
|
||||
onPropPatch={(prop) => {
|
||||
handlePropPatch(false)([prop]);
|
||||
}}
|
||||
onPropDelete={(prop) => {
|
||||
handlePropPatch(true)([prop]);
|
||||
}}
|
||||
readOnly={!targetDisplayOptions?.showCustomProps}
|
||||
/>
|
||||
</Collapse>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomProps;
|
||||
|
|
@ -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 (
|
||||
<TextPropsContent prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.rating:
|
||||
return (
|
||||
<RatingPropsItem prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.number:
|
||||
return (
|
||||
<NumberPropsContent prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.boolean:
|
||||
return (
|
||||
<BooleanPropsItem prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.select:
|
||||
return (
|
||||
<SelectPropsContent prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.multi_select:
|
||||
return (
|
||||
<MultiSelectPropsContent
|
||||
prop={prop}
|
||||
onChange={onChange}
|
||||
loading={loading}
|
||||
readOnly={readOnly}
|
||||
fullSize={fullSize}
|
||||
/>
|
||||
);
|
||||
case CustomPropsType.link:
|
||||
return (
|
||||
<LinkPropsContent prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
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 | HTMLElement>(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 (
|
||||
<PropsCard onMouseEnter={() => setMouseOver(true)} onMouseLeave={() => setMouseOver(false)}>
|
||||
<Box sx={{ position: "relative", display: "flex", alignItems: "center", width: "100%", gap: 1 }}>
|
||||
<Grow in={mouseOver} unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
transition: "opacity 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms",
|
||||
backgroundColor:
|
||||
theme.palette.mode === "light"
|
||||
? alpha(theme.palette.grey[100], 0.73)
|
||||
: alpha(theme.palette.grey[900], 0.73),
|
||||
top: -4,
|
||||
right: -5,
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Grow>
|
||||
{prop.props.icon && <Icon width={24} height={24} color={theme.palette.action.active} icon={prop.props.icon} />}
|
||||
<Typography variant={"body2"} color="textPrimary" fontWeight={500} sx={{ flexGrow: 1 }}>
|
||||
{prop.props.type === CustomPropsType.boolean ? Content : t(prop.props.name)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{prop.props.type !== CustomPropsType.boolean && (
|
||||
<Typography variant={"body2"} color={"text.secondary"} sx={{ width: "100%" }}>
|
||||
{Content}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
{prop.value && (
|
||||
<>
|
||||
<SquareMenuItem onClick={handleCopy} dense>
|
||||
<ListItemIcon>
|
||||
<Clipboard fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.copyToClipboard")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem onClick={handleSearch} dense>
|
||||
<ListItemIcon>
|
||||
<Search fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.searchProperty")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SquareMenuItem onClick={handleDelete} dense disabled={readOnly}>
|
||||
<ListItemIcon>
|
||||
<DeleteOutlined fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.delete")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
</PropsCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomPropsCard;
|
||||
|
|
@ -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 (
|
||||
<Link href={value} target="_blank" rel="noopener noreferrer" variant="body2" sx={{ wordBreak: "break-all" }}>
|
||||
{value}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<NoLabelFilledTextField
|
||||
variant="filled"
|
||||
placeholder={t("application:fileManager.enterUrl")}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
value={value ?? ""}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ cursor: "pointer" }} onClick={handleEditClick}>
|
||||
{t("application:fileManager.clickToEdit")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ position: "relative", width: "100%" }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Link
|
||||
href={value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="body2"
|
||||
sx={{ wordBreak: "break-all", pr: isHovered ? 4 : 0 }}
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
{isHovered && (
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
opacity: 0.7,
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick();
|
||||
}}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkPropsContent;
|
||||
|
|
@ -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<string[]>(() => {
|
||||
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 (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{values.length > 0
|
||||
? values.map((value, index) => <Chip key={index} label={value} size="small" variant="outlined" />)
|
||||
: ""}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const options = prop.props.options || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<NoLabelFilledSelect
|
||||
variant="filled"
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
value={values}
|
||||
onChange={(e) => 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 (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("application:fileManager.clickToEditSelect")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{selectedArray.map((value, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={value}
|
||||
size="small"
|
||||
onDelete={() => handleDelete(value)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option} value={option} dense>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</NoLabelFilledSelect>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSelectPropsContent;
|
||||
|
|
@ -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 <Typography variant="body2">{value}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<NoLabelFilledTextField
|
||||
type="number"
|
||||
variant="filled"
|
||||
placeholder={t("application:fileManager.clickToEdit")}
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
slotProps={{
|
||||
input: {
|
||||
inputProps: {
|
||||
max: prop.props.max,
|
||||
min: prop.props.min ?? 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
value={value ?? ""}
|
||||
onBlur={onBlur}
|
||||
required
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumberPropsContent;
|
||||
|
|
@ -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 (
|
||||
<Rating
|
||||
readOnly={readOnly}
|
||||
disabled={loading}
|
||||
onChange={handleChange}
|
||||
value={parseInt(prop.value) ?? 0}
|
||||
max={prop.props.max ?? 5}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingPropsItem;
|
||||
|
|
@ -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 <Typography variant="body2">{value}</Typography>;
|
||||
}
|
||||
|
||||
const options = prop.props.options || [];
|
||||
|
||||
return (
|
||||
<NoLabelFilledSelect
|
||||
variant="filled"
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value as string)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
displayEmpty
|
||||
renderValue={(selected) => {
|
||||
if (!selected) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("application:fileManager.clickToEditSelect")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return selected as string;
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option} value={option} dense>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</NoLabelFilledSelect>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectPropsContent;
|
||||
|
|
@ -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 <Typography variant="body2">{value}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<NoLabelFilledTextField
|
||||
variant="filled"
|
||||
placeholder={t("application:fileManager.clickToEdit")}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
inputProps: {
|
||||
maxLength: prop.props.max,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
value={value ?? ""}
|
||||
onBlur={onBlur}
|
||||
required
|
||||
multiline
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextPropsContent;
|
||||
|
|
@ -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<string | null>(null);
|
||||
useEffect(() => {
|
||||
|
|
@ -53,6 +57,7 @@ const Details = ({ target, inPhotoViewer }: DetailsProps) => {
|
|||
/>
|
||||
)}
|
||||
<MediaInfo target={target} />
|
||||
<CustomProps file={target} setTarget={setTarget} targetDisplayOptions={targetDisplayOptions} />
|
||||
<BasicInfo target={target} />
|
||||
<Tags target={target} />
|
||||
<Data target={target} />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<SidebarContent inPhotoViewer={inPhotoViewer} target={target} />
|
||||
<SidebarContent inPhotoViewer={inPhotoViewer} target={target} setTarget={setTarget} />
|
||||
</RadiusFrame>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Header target={target} />
|
||||
|
|
@ -48,7 +51,12 @@ const SidebarContent = ({ target, inPhotoViewer }: SidebarContentProps) => {
|
|||
></Box>
|
||||
<Box sx={{ overflow: "auto" }}>
|
||||
<TabPanel value={0} index={0}>
|
||||
<Details inPhotoViewer={inPhotoViewer} target={target} />
|
||||
<Details
|
||||
inPhotoViewer={inPhotoViewer}
|
||||
target={target}
|
||||
setTarget={setTarget}
|
||||
targetDisplayOptions={targetDisplayOptions}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}}
|
||||
>
|
||||
<SidebarContent inPhotoViewer={inPhotoViewer} target={target} />
|
||||
<SidebarContent inPhotoViewer={inPhotoViewer} target={target} setTarget={setTarget} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { SvgIcon, SvgIconProps } from "@mui/material";
|
||||
|
||||
export default function CheckboxChecked(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M6.25 3A3.25 3.25 0 0 0 3 6.25v11.5A3.25 3.25 0 0 0 6.25 21h11.5A3.25 3.25 0 0 0 21 17.75V6.25A3.25 3.25 0 0 0 17.75 3zM4.5 6.25c0-.966.784-1.75 1.75-1.75h11.5c.966 0 1.75.784 1.75 1.75v11.5a1.75 1.75 0 0 1-1.75 1.75H6.25a1.75 1.75 0 0 1-1.75-1.75zm12.78 3.03a.75.75 0 1 0-1.06-1.06l-6.223 6.216L7.78 12.22a.75.75 0 0 0-1.06 1.06l2.745 2.746a.75.75 0 0 0 1.06 0z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { SvgIcon, SvgIconProps } from "@mui/material";
|
||||
|
||||
export default function CubeTreeFilled(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M11.763 2.038a.75.75 0 0 1 .474 0l3.75 1.25A.75.75 0 0 1 16.5 4v5a.75.75 0 0 1-.513.712L12.75 10.79V13h1.75a2.25 2.25 0 0 1 2.25 2.25v.845A3.001 3.001 0 0 1 16 22a3 3 0 0 1-.75-5.905v-.845a.75.75 0 0 0-.75-.75h-5a.75.75 0 0 0-.75.75v.845A3.001 3.001 0 0 1 8 22a3 3 0 0 1-.75-5.905v-.845A2.25 2.25 0 0 1 9.5 13h1.75v-2.21L8.013 9.713A.75.75 0 0 1 7.5 9V4a.75.75 0 0 1 .513-.712zM9.788 5.513a.75.75 0 0 0 .475.949l.987.329v.959a.75.75 0 0 0 1.5 0v-.96l.987-.328a.75.75 0 0 0-.474-1.424L12 5.46l-1.263-.42a.75.75 0 0 0-.949.474" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { SvgIcon, SvgIconProps } from "@mui/material";
|
||||
|
||||
export default function DataBarVerticalStar(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M18.251 3a2.249 2.249 0 0 0-2.249 2.25v5.924c.48-.114.982-.174 1.498-.174h.002V5.25a.749.749 0 1 1 1.498 0v5.924c.528.125 1.03.314 1.5.558V5.25A2.25 2.25 0 0 0 18.251 3m-4.25 6.25v2.772a6.536 6.536 0 0 0-1.5 1.324V9.25a.75.75 0 0 0-1.499 0v8.09a6.62 6.62 0 0 0 0 .322v1.09c0 .224.098.425.254.562c.173.598.43 1.16.756 1.672a2.249 2.249 0 0 1-2.51-2.234V9.249a2.25 2.25 0 1 1 4.498 0m-10.999 4a2.25 2.25 0 1 1 4.498 0v5.5a2.25 2.25 0 1 1-4.498 0zm2.998 0a.75.75 0 0 0-1.498 0v5.5a.75.75 0 0 0 1.498 0zm17 4.25a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0m-4.945-3.08a.577.577 0 0 0-1.11 0l-.557 1.788h-1.803c-.566 0-.8.754-.343 1.1l1.458 1.105l-.557 1.787c-.175.561.441 1.028.899.681l1.458-1.104l1.458 1.104c.458.347 1.074-.12.899-.68l-.557-1.788l1.458-1.104c.458-.347.223-1.101-.343-1.101h-1.803z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { SvgIcon, SvgIconProps } from "@mui/material";
|
||||
|
||||
export default function Add(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M6.75 8a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5zM6 11.75a.75.75 0 0 1 .75-.75h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1-.75-.75M6.75 14a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5zm-2-10A2.75 2.75 0 0 0 2 6.75v10.5A2.75 2.75 0 0 0 4.75 20h14.5A2.75 2.75 0 0 0 22 17.25V6.75A2.75 2.75 0 0 0 19.25 4zM3.5 6.75c0-.69.56-1.25 1.25-1.25h14.5c.69 0 1.25.56 1.25 1.25v10.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { SvgIcon, SvgIconProps } from "@mui/material";
|
||||
|
||||
export default function TaskListRegular(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M6.78 4.78a.75.75 0 0 0-1.06-1.06L3.75 5.69l-.47-.47a.75.75 0 0 0-1.06 1.06l1 1a.75.75 0 0 0 1.06 0zm14.47 13.227H9.75l-.102.007a.75.75 0 0 0 .102 1.493h11.5l.102-.007a.75.75 0 0 0-.102-1.493m0-6.507H9.75l-.102.007A.75.75 0 0 0 9.75 13h11.5l.102-.007a.75.75 0 0 0-.102-1.493m0-6.5H9.75l-.102.007A.75.75 0 0 0 9.75 6.5h11.5l.102-.007A.75.75 0 0 0 21.25 5M6.78 17.78a.75.75 0 1 0-1.06-1.06l-1.97 1.97l-.47-.47a.75.75 0 0 0-1.06 1.06l1 1a.75.75 0 0 0 1.06 0zm0-7.56a.75.75 0 0 1 0 1.06l-2.5 2.5a.75.75 0 0 1-1.06 0l-1-1a.75.75 0 1 1 1.06-1.06l.47.47l1.97-1.97a.75.75 0 0 1 1.06 0" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { SvgIcon, SvgIconProps } from "@mui/material";
|
||||
|
||||
export default function TextBulletListSquareEditFilled(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M6.25 3A3.25 3.25 0 0 0 3 6.25v11.5A3.25 3.25 0 0 0 6.25 21h4.915l.356-1.423c.162-.648.497-1.24.97-1.712l1.364-1.365H11.25a.75.75 0 1 1 0-1.5h4.105l3.038-3.038a3.279 3.279 0 0 1 2.607-.95V6.25A3.25 3.25 0 0 0 17.75 3zm2.5 5.25a1 1 0 1 1-2 0a1 1 0 0 1 2 0m1.75 0a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1-.75-.75m0 3.75a.75.75 0 0 1 .75-.75h5.5a.75.75 0 1 1 0 1.5h-5.5a.75.75 0 0 1-.75-.75m-2.75-1a1 1 0 1 1 0 2a1 1 0 0 1 0-2m1 4.75a1 1 0 1 1-2 0a1 1 0 0 1 2 0m10.35-3.08l-5.903 5.902a2.686 2.686 0 0 0-.706 1.247l-.458 1.831a1.087 1.087 0 0 0 1.319 1.318l1.83-.457a2.685 2.685 0 0 0 1.248-.707l5.902-5.902A2.286 2.286 0 0 0 19.1 12.67" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { SvgIcon, SvgIconProps } from "@mui/material";
|
||||
|
||||
export default function TextBulletListSquareEdit(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M3 6.25A3.25 3.25 0 0 1 6.25 3h11.5A3.25 3.25 0 0 1 21 6.25v4.762a3.294 3.294 0 0 0-1.5.22V6.25a1.75 1.75 0 0 0-1.75-1.75H6.25A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .966.784 1.75 1.75 1.75h5.291a3.329 3.329 0 0 0-.02.077L11.165 21H6.25A3.25 3.25 0 0 1 3 17.75zM15.355 15l-1.5 1.5H11.25a.75.75 0 1 1 0-1.5zM7.75 9.25a1 1 0 1 0 0-2a1 1 0 0 0 0 2m3.5-1.75a.75.75 0 0 0 0 1.5h5.5a.75.75 0 0 0 0-1.5zM10.5 12a.75.75 0 0 1 .75-.75h5.5a.75.75 0 1 1 0 1.5h-5.5a.75.75 0 0 1-.75-.75m-2.75 1a1 1 0 1 0 0-2a1 1 0 0 0 0 2m0 3.75a1 1 0 1 0 0-2a1 1 0 0 0 0 2m11.35-4.08l-5.903 5.902a2.686 2.686 0 0 0-.706 1.247l-.458 1.831a1.087 1.087 0 0 0 1.319 1.318l1.83-.457a2.685 2.685 0 0 0 1.248-.707l5.902-5.902A2.286 2.286 0 0 0 19.1 12.67" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { SvgIcon, SvgIconProps } from "@mui/material";
|
||||
|
||||
export default function TextIndentIncrease(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon {...props}>
|
||||
<path d="M17.75 16a.75.75 0 0 1 0 1.5h-9a.75.75 0 0 1 0-1.5zM2.72 9.22a.75.75 0 0 1 .976-.073l.084.073l2 2a.75.75 0 0 1 .073.976l-.073.084l-2 2a.75.75 0 0 1-1.133-.976l.073-.084l1.47-1.47l-1.47-1.47a.75.75 0 0 1 0-1.06M20.75 11a.75.75 0 0 1 0 1.5h-12a.75.75 0 0 1 0-1.5zm-3-5a.75.75 0 0 1 0 1.5h-9a.75.75 0 0 1 0-1.5z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
12
yarn.lock
12
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue