mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
Merge pull request #602 from Danielv123/react-to-drag-and-drop
React to drag and drop
This commit is contained in:
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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export const DraggingContext = createContext(false);
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user