feat(fs): custom properties for files (#2407)

This commit is contained in:
Aaron Liu 2025-07-12 11:15:31 +08:00
parent ee9e3ba54c
commit ada49fd21d
58 changed files with 2280 additions and 137 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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.",

View File

@ -113,6 +113,16 @@
"dashboard": "管理パネル"
},
"fileManager": {
"customProps": "カスタム属性",
"rating": "評価",
"description": "説明",
"add": "追加",
"clickToEdit": "編集...",
"clickToEditSelect": "選択...",
"enterUrl": "URLを入力...",
"searchUser": "ユーザーを検索...",
"typeToSearch": "名前またはメールアドレスを入力...",
"searchProperty": "同じ属性のファイルを検索",
"permissions": "権限",
"quality": "解像度",
"audioTrack": "音声トラック",

View File

@ -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>を含むメールアドレスは登録不可になります。",

View File

@ -113,6 +113,16 @@
"dashboard": "管理面板"
},
"fileManager": {
"customProps": "自定义属性",
"rating": "评级",
"description": "描述",
"add": "添加",
"clickToEdit": "点击编辑...",
"clickToEditSelect": "点击选择...",
"enterUrl": "输入 URL...",
"searchUser": "搜索用户...",
"typeToSearch": "输入昵称或邮箱...",
"searchProperty": "搜索相同属性的文件",
"permissions": "权限",
"quality": "清晰度",
"audioTrack": "音轨",

View File

@ -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> 的邮箱地址无法注册账户。",

View File

@ -113,6 +113,16 @@
"dashboard": "管理面板"
},
"fileManager": {
"customProps": "自定義屬性",
"rating": "評級",
"description": "描述",
"add": "新增",
"clickToEdit": "點擊編輯...",
"clickToEditSelect": "點擊選擇...",
"enterUrl": "輸入 URL...",
"searchUser": "搜尋用戶...",
"typeToSearch": "輸入暱稱或郵箱...",
"searchProperty": "搜尋相同屬性的檔案",
"permissions": "權限",
"quality": "清晰度",
"audioTrack": "音軌",

View File

@ -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> 的郵箱地址無法註冊賬戶。",

View File

@ -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",
}

View File

@ -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 {

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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",

View File

@ -87,6 +87,7 @@ export function CascadingSubmenu({ title, popupId, menuItemProps, icon, ...props
return (
<>
<SquareMenuItem
dense
{...(isMobile ? bindTrigger(popupState) : bindHover(popupState))}
{...(isMobile ? {} : bindFocus(popupState))}
{...menuItemProps}

View File

@ -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 =

View File

@ -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>
</>
);

View File

@ -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 />;
}
});

View File

@ -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,

View File

@ -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"), {

View File

@ -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;

View File

@ -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>
</>
);

View File

@ -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;
};

View File

@ -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"} />

View File

@ -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>
);
};

View File

@ -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]);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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} />

View File

@ -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 {

View File

@ -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>

View File

@ -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>
</>

View File

@ -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>
);
};

View File

@ -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],

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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%",

View File

@ -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) {

View File

@ -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 };
},
},
],
},
{

View File

@ -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;
}
}
});

View File

@ -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"