Merge pull request #602 from Danielv123/react-to-drag-and-drop

React to drag and drop
This commit is contained in:
Daniel authored and GitHub committed 2024-05-18 17:57:31 +02:00
commit 7489a76d4a
7 files changed
+282 -116

No files matched your search

@@ -0,0 +1,51 @@
import { InboxOutlined } from "@ant-design/icons";
import { DraggingContext } from "../model/is_dragging";
import { useContext } from "react";
// This component is used to display a dropzone when a file is being dragged over the parent component
export function Dropzone({ disabled = false }: { disabled?: boolean }) {
const isDroppingFile = useContext(DraggingContext);
const textStyle: { color?: string } = {};
let text = "Drop to upload";
if (disabled) {
textStyle.color = "red";
text = "Target is offline";
}
const borderColor = disabled ? "gray" : "rgb(22, 119, 255)";
return <div
className={`dropzone ${disabled ? "disabled" : "enabled"}`} // Don't remove this class, linked to SiteLayout
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
zIndex: "90",
backgroundColor: "#88888844",
borderRadius: "20px",
border: `dashed 2px ${borderColor}`,
display: isDroppingFile ? "block" : "none",
}}
>
<div
className="dropzone-icon"
style={{
fontSize: "72px",
color: "rgb(22, 119, 255)",
display: "flex",
zIndex: "100",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
height: "100%",
}}
>
<InboxOutlined style={textStyle} />
<p style={{ ...textStyle, fontSize: "24px", display: "block", textAlign: "center", marginTop: "8px" }}>
{text}
</p>
</div>
</div>;
}
+81 -55
View File
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload } from "antd";
import ImportOutlined from "@ant-design/icons/ImportOutlined";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
import UploadOutlined from "@ant-design/icons/UploadOutlined";
import * as lib from "@clusterio/lib";
@@ -17,6 +16,8 @@ import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import { Dropzone } from "./Dropzone";
import UploadButton from "./UploadButton";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -55,11 +56,13 @@ function ImportModPackButton() {
name="string"
rules={[
{ required: true },
{ async validator(rule, value) {
if (value) {
lib.ModPack.fromModPackString(value);
}
}},
{
async validator(rule, value) {
if (value) {
lib.ModPack.fromModPackString(value);
}
},
},
]}
>
<Input.TextArea autoSize={{ minRows: 6, maxRows: 6 }} />
@@ -172,12 +175,13 @@ export default function ModsPage() {
name="file"
accept=".zip"
multiple
showUploadList={false}
headers={{
"X-Access-Token": control.connector.token||"",
"X-Access-Token": control.connector.token || "",
}}
action={`${webRoot}api/upload-mod`}
>
<Button icon={<UploadOutlined/>}>Upload</Button>
<UploadButton />
</Upload>;
}
@@ -219,54 +223,76 @@ export default function ModsPage() {
})}
/>
<SectionHeader title="Stored Mods" extra={uploadButton} />
<Table
columns={[
{
title: "Name",
dataIndex: "title",
defaultSortOrder: "ascend",
sorter: (a, b) => (
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
),
},
{
title: "Version",
key: "version",
align: "right",
render: (_, mod) => <>
{`${mod.version} `}
<Typography.Text type="secondary">{`/ ${mod.factorioVersion}`}</Typography.Text>
</>,
},
{
title: "Filename",
dataIndex: "filename",
responsive: ["xl"],
sorter: (a, b) => strcmp(a.filename, b.filename),
},
{
title: "Size",
key: "size",
responsive: ["lg"],
render: (_, mod) => lib.formatBytes(mod.size),
align: "right",
sorter: (a, b) => a.size - b.size,
},
{
title: "Action",
key: "action",
responsive: ["lg"],
render: (_, mod) => actions(mod),
},
]}
expandable={{
expandedRowRender: (mod: lib.ModInfo) => <ModDetails mod={mod} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
<Upload.Dragger
className="save-list-dragger"
openFileDialogOnClick={false}
name="file"
accept=".zip"
multiple
headers={{
"X-Access-Token": control.connector.token || "",
}}
dataSource={[...mods.values()]}
pagination={false}
rowKey={mod => mod.id}
/>
action={`${webRoot}api/upload-mod`}
showUploadList={false}
>
<div
style={{
position: "relative",
zIndex: "1010",
}}
>
<Dropzone />
<Table
columns={[
{
title: "Name",
dataIndex: "title",
defaultSortOrder: "ascend",
sorter: (a, b) => (
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
),
},
{
title: "Version",
key: "version",
align: "right",
render: (_, mod) => <>
{`${mod.version} `}
<Typography.Text type="secondary">{`/ ${mod.factorioVersion}`}</Typography.Text>
</>,
},
{
title: "Filename",
dataIndex: "filename",
responsive: ["xl"],
sorter: (a, b) => strcmp(a.filename, b.filename),
},
{
title: "Size",
key: "size",
responsive: ["lg"],
render: (_, mod) => lib.formatBytes(mod.size),
align: "right",
sorter: (a, b) => a.size - b.size,
},
{
title: "Action",
key: "action",
responsive: ["lg"],
render: (_, mod) => actions(mod),
},
]}
expandable={{
expandedRowRender: (mod: lib.ModInfo) => <ModDetails mod={mod} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={[...mods.values()]}
pagination={false}
rowKey={mod => mod.id}
/>
</div>
</Upload.Dragger>
<PluginExtra component="ModsPage" />
</PageLayout>;
}
+19 -7
View File
@@ -15,6 +15,8 @@ import SectionHeader from "./SectionHeader";
import { useInstances } from "../model/instance";
import { useSavesOfInstance } from "../model/saves";
import { notifyErrorHandler } from "../util/notify";
import { Dropzone } from "./Dropzone";
import UploadButton from "./UploadButton";
type ModalProps = {
@@ -150,7 +152,7 @@ function TransferModal(props: ModalProps) {
autoFocus
showSearch
filterOption={(input, option) => (
(option?.title!.toLowerCase().indexOf(input.toLowerCase())??-1) >= 0
(option?.title!.toLowerCase().indexOf(input.toLowerCase()) ?? -1) >= 0
)}
>
{[...instances.values()].filter(
@@ -214,8 +216,8 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
title: "Name",
render: (_, save) => <>
{save.name}
{save.loaded && <Tooltip title="Currently loaded save"><CaretLeftOutlined/></Tooltip>}
{save.loadByDefault && <Tooltip title="Save loaded by default"><LeftOutlined/></Tooltip>}
{save.loaded && <Tooltip title="Currently loaded save"><CaretLeftOutlined /></Tooltip>}
{save.loadByDefault && <Tooltip title="Save loaded by default"><LeftOutlined /></Tooltip>}
</>,
sorter: (a, b) => a.name.localeCompare(b.name),
},
@@ -240,7 +242,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
expandable={{
columnWidth: 33,
expandRowByClick: true,
expandedRowRender: save => <Space wrap style={{marginBottom: 0}}>
expandedRowRender: save => <Space wrap style={{ marginBottom: 0 }}>
{account.hasPermission("core.instance.start") && <Button
loading={starting}
disabled={props.instance.status !== "stopped"}
@@ -346,14 +348,24 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
return <div>
<SectionHeader title="Saves" extra={<Space>
{account.hasPermission("core.instance.save.upload") && <Upload {...uploadProps} >
<Button disabled={hostOffline}>Upload save</Button>
<UploadButton disabled={hostOffline}>
Upload save
</UploadButton>
</Upload>}
{account.hasPermission("core.instance.save.create") && <CreateSaveModal instance={props.instance} />}
</Space>} />
{
account.hasPermission("core.instance.save.upload")
? <Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
{saveTable}
<div
style={{
position: "relative",
zIndex: "1010",
}}
>
<Dropzone disabled={hostOffline} />
{saveTable}
</div>
</Upload.Dragger>
: saveTable
}
@@ -362,7 +374,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
{file.name}
<Progress
percent={file.percent}
format={percent => `${Math.floor(percent||0)}%`}
format={percent => `${Math.floor(percent || 0)}%`}
status={file.status === "error" ? "exception" : "normal"}
/>
</List.Item>)}
+104 -54
View File
@@ -9,16 +9,34 @@ import ErrorBoundary from "./ErrorBoundary";
import ErrorPage from "./ErrorPage";
import ControlContext from "./ControlContext";
import { pages } from "../pages";
import { DraggingContext } from "../model/is_dragging";
const { Header, Sider } = Layout;
function isActiveDropzone(element: HTMLElement | null): boolean {
if (!element) {
return false;
}
const checkDepth = 5;
let depth = 0;
while (element && depth < checkDepth) {
if (element.classList.contains("dropzone")) {
return element.classList.contains("enabled");
}
element = element.parentElement;
depth += 1;
}
return false;
}
export default function SiteLayout() {
let navigate = useNavigate();
let [currentSidebarPath, setCurrentSidebarPath] = useState<string|null>(null);
let [currentSidebarPath, setCurrentSidebarPath] = useState<string | null>(null);
let account = useAccount();
let plugins = useContext(ControlContext).plugins;
const [dragging, setDragging] = useState(0);
function SetSidebar(props: { path: string|null }) {
function SetSidebar(props: { path: string | null }) {
useEffect(() => {
setCurrentSidebarPath(props.path);
});
@@ -26,7 +44,7 @@ export default function SiteLayout() {
}
let accountMenuProps: MenuProps = {
onClick: ({ key }: {key:string}) => {
onClick: ({ key }: { key: string }) => {
if (key === "user") {
navigate(`/users/${account.name}/view`);
} else if (key === "logOut") {
@@ -72,59 +90,91 @@ export default function SiteLayout() {
menuItems.push({ type: "group", label: name, children: group, key: name });
}
return <Layout style={{ minHeight: "100vh" }}>
<Header className="header">
<div className="site-logo" />
<span className="site-name">Clusterio</span>
<span className="site-version">{ webUiPackage.version }</span>
<Menu
theme="dark"
mode="horizontal"
defaultSelectedKeys={["1"]}
items={[{ label: "Dashboard", key: "1" }]}
/>
<Dropdown
className="account-dropdown-header"
placement="bottomRight"
trigger={["click"]}
menu={accountMenuProps}
>
<UserOutlined/>
</Dropdown>
</Header>
<Layout className="site-layout">
<Sider
collapsible
collapsedWidth={0}
breakpoint="md"
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
width={250}
className="site-layout-sider"
>
// Prevent issues with event ordering
let dragChange = 0;
function setDraggingProxy(value: number) {
dragChange += value;
setDragging(dragging + dragChange);
}
return <Layout
style={{ minHeight: "100vh" }}
onDragEnter={() => setDraggingProxy(1)}
onDragLeave={() => setDraggingProxy(-1)}
// Reset dragging state when dropped
onDrop={(e) => {
setDragging(0);
// Prevent dropping outside of dropzone
if (!isActiveDropzone(e.target as HTMLElement)) {
e.preventDefault();
e.stopPropagation();
}
}}
// Allow entire document to be a drag target
onDragOver={(e) => {
e.preventDefault();
// Change cursor
if (isActiveDropzone(e.target as HTMLElement)) {
e.dataTransfer.dropEffect = "copy";
} else {
e.dataTransfer.dropEffect = "none";
}
}}
>
<DraggingContext.Provider value={dragging > 0}>
<Header className="header">
<div className="site-logo" />
<span className="site-name">Clusterio</span>
<span className="site-version">{webUiPackage.version}</span>
<Menu
mode="inline"
defaultOpenKeys={[...menuGroups.keys()]}
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => navigate(key)}
items={menuItems}
theme="dark"
mode="horizontal"
defaultSelectedKeys={["1"]}
items={[{ label: "Dashboard", key: "1" }]}
/>
</Sider>
<Layout className="site-layout-content-container">
<Routes>
{combinedPages.map(({path, sidebarPath, content}) => <Route
path={path}
key={path}
element={<Fragment key={path}>
<SetSidebar path={sidebarPath ? sidebarPath : path} />
<ErrorBoundary Component={ErrorPage}>
{content}
</ErrorBoundary>
</Fragment>}
/>)}
<Route element={<SetSidebar path={null} />} />
</Routes>
<Dropdown
className="account-dropdown-header"
placement="bottomRight"
trigger={["click"]}
menu={accountMenuProps}
>
<UserOutlined />
</Dropdown>
</Header>
<Layout className="site-layout">
<Sider
collapsible
collapsedWidth={0}
breakpoint="md"
zeroWidthTriggerStyle={{ top: 6, zIndex: -1 }}
width={250}
className="site-layout-sider"
>
<Menu
mode="inline"
defaultOpenKeys={[...menuGroups.keys()]}
selectedKeys={currentSidebarPath ? [currentSidebarPath] : []}
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => navigate(key)}
items={menuItems}
/>
</Sider>
<Layout className="site-layout-content-container">
<Routes>
{combinedPages.map(({ path, sidebarPath, content }) => <Route
path={path}
key={path}
element={<Fragment key={path}>
<SetSidebar path={sidebarPath ? sidebarPath : path} />
<ErrorBoundary Component={ErrorPage}>
{content}
</ErrorBoundary>
</Fragment>}
/>)}
<Route element={<SetSidebar path={null} />} />
</Routes>
</Layout>
</Layout>
</Layout>
</DraggingContext.Provider>
</Layout>;
}
@@ -0,0 +1,22 @@
import { useContext } from "react";
import { Button } from "antd";
import { InboxOutlined, UploadOutlined } from "@ant-design/icons";
import { DraggingContext } from "../model/is_dragging";
export default function UploadButton(props: React.ComponentProps<typeof Button>) {
const isDroppingFile = useContext(DraggingContext);
const borderColor = props.disabled ? "gray" : "rgb(22, 119, 255)";
return <Button
className={`dropzone ${props.disabled ? "disabled" : "enabled"}`}
{...props}
style={{
...props.style,
// Distinct border when dropping file
border: isDroppingFile ? `dashed 2px ${borderColor}` : undefined,
}}
icon={isDroppingFile ? <InboxOutlined /> : props.icon || <UploadOutlined />}
>
{props.children || "Upload"}
</Button>;
}
+3
View File
@@ -0,0 +1,3 @@
import { createContext } from "react";
export const DraggingContext = createContext(false);
+2
View File
@@ -185,6 +185,8 @@ body {
.save-list-dragger .ant-upload.ant-upload-drag {
border: none;
background: none;
height: auto;
cursor: inherit;
}
.save-list-dragger .ant-upload.ant-upload-btn {