diff --git a/packages/web_ui/src/components/Dropzone.tsx b/packages/web_ui/src/components/Dropzone.tsx new file mode 100644 index 00000000..cc9feb3e --- /dev/null +++ b/packages/web_ui/src/components/Dropzone.tsx @@ -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
+
+ +

+ {text} +

+
+
; +} diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 0e5541ae..2637d1cc 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -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); + } + }, + }, ]} > @@ -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`} > - + ; } @@ -219,54 +223,76 @@ export default function ModsPage() { })} /> - ( - strcmp(a.name, b.name) || a.integerVersion - b.integerVersion - ), - }, - { - title: "Version", - key: "version", - align: "right", - render: (_, mod) => <> - {`${mod.version} `} - {`/ ${mod.factorioVersion}`} - , - }, - { - 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) => , - expandedRowClassName: () => "no-expanded-padding", + + mod.id} - /> + action={`${webRoot}api/upload-mod`} + showUploadList={false} + > +
+ +
( + strcmp(a.name, b.name) || a.integerVersion - b.integerVersion + ), + }, + { + title: "Version", + key: "version", + align: "right", + render: (_, mod) => <> + {`${mod.version} `} + {`/ ${mod.factorioVersion}`} + , + }, + { + 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) => , + expandedRowClassName: () => "no-expanded-padding", + }} + dataSource={[...mods.values()]} + pagination={false} + rowKey={mod => mod.id} + /> + + ; } diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index 2d6f2c69..9a962a87 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -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 && } - {save.loadByDefault && } + {save.loaded && } + {save.loadByDefault && } , 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 => + expandedRowRender: save => {account.hasPermission("core.instance.start") && + + Upload save + } {account.hasPermission("core.instance.save.create") && } } /> { account.hasPermission("core.instance.save.upload") ? - {saveTable} +
+ + {saveTable} +
: saveTable } @@ -362,7 +374,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) { {file.name} `${Math.floor(percent||0)}%`} + format={percent => `${Math.floor(percent || 0)}%`} status={file.status === "error" ? "exception" : "normal"} /> )} diff --git a/packages/web_ui/src/components/SiteLayout.tsx b/packages/web_ui/src/components/SiteLayout.tsx index 07a995d1..5baece76 100644 --- a/packages/web_ui/src/components/SiteLayout.tsx +++ b/packages/web_ui/src/components/SiteLayout.tsx @@ -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(null); + let [currentSidebarPath, setCurrentSidebarPath] = useState(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 -
-
- Clusterio - { webUiPackage.version } - - - - -
- - + // Prevent issues with event ordering + let dragChange = 0; + function setDraggingProxy(value: number) { + dragChange += value; + setDragging(dragging + dragChange); + } + + return 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"; + } + }} + > + 0}> +
+
+ Clusterio + {webUiPackage.version} navigate(key)} - items={menuItems} + theme="dark" + mode="horizontal" + defaultSelectedKeys={["1"]} + items={[{ label: "Dashboard", key: "1" }]} /> - - - - {combinedPages.map(({path, sidebarPath, content}) => - - - {content} - - } - />)} - } /> - + + + +
+ + + navigate(key)} + items={menuItems} + /> + + + + {combinedPages.map(({ path, sidebarPath, content }) => + + + {content} + + } + />)} + } /> + + - + ; } diff --git a/packages/web_ui/src/components/UploadButton.tsx b/packages/web_ui/src/components/UploadButton.tsx new file mode 100644 index 00000000..cc3dd6b7 --- /dev/null +++ b/packages/web_ui/src/components/UploadButton.tsx @@ -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) { + const isDroppingFile = useContext(DraggingContext); + const borderColor = props.disabled ? "gray" : "rgb(22, 119, 255)"; + return ; +} diff --git a/packages/web_ui/src/model/is_dragging.ts b/packages/web_ui/src/model/is_dragging.ts new file mode 100644 index 00000000..652878e2 --- /dev/null +++ b/packages/web_ui/src/model/is_dragging.ts @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const DraggingContext = createContext(false); diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index 3cd12ed2..657e23e7 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -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 {