mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
Use real links for navigation so they open in new tabs
In-app navigation was done with JS onClick -> navigate() handlers instead of real <a href> anchors, so the browser's right-click "Open in new tab" (and middle-click / ctrl-click) was unavailable. (closes #929) - Sidebar nav items and the account "user" entry now render their label as the existing Link component (a real anchor via react-router useHref), keeping the Menu onClick for plain left-clicks; stopPropagation avoids double navigation. - The "back to instances" alert action becomes a Link. - Table rows (Hosts, Roles, Plugins, Instances, Users, the user instance-stats table, Mod Packs): the name cell becomes a real Link (color inherit, so it looks unchanged) which carries the right-click context menu, and a new useRowNavigation hook handles the whole row — left-click navigates in place, middle-click / ctrl-click open the target in a new tab. The hook ignores clicks landing on a real link inside the row so the anchor handles those. Genuine actions (download/delete/modify/logout), post-create navigation and external links are left as-is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9e727f9a13
commit
f04fc68513
11 files changed
+123
-60
No files matched your search
@@ -1,5 +1,4 @@
|
||||
import React, { useContext, useRef, useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
|
||||
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
|
||||
|
||||
@@ -20,6 +19,8 @@ import {
|
||||
import { useHosts } from "../model/host";
|
||||
import { useSystems } from "../model/system";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -199,12 +200,12 @@ function CopyButton({ text, message }: { text:string, message:string }) {
|
||||
|
||||
export default function HostsPage() {
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
let [hosts] = useHosts();
|
||||
const [systems] = useSystems();
|
||||
const [showRatios, setShowRatios] = useState(true);
|
||||
const [showNumbers, setShowNumbers] = useState(false);
|
||||
const [showCpuModel, setShowCpuModel] = useState(false);
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
return <PageLayout nav={[{ name: "Hosts" }]}>
|
||||
<PageHeader
|
||||
@@ -219,6 +220,9 @@ export default function HostsPage() {
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
render: (_, host) => <Link to={`/hosts/${host.id}/view`} style={{ color: "inherit" }}>
|
||||
{host.name}
|
||||
</Link>,
|
||||
},
|
||||
{
|
||||
title: "CPU Model",
|
||||
@@ -306,11 +310,7 @@ export default function HostsPage() {
|
||||
dataSource={[...hosts.values()]}
|
||||
rowKey={host => host.id}
|
||||
pagination={false}
|
||||
onRow={(record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/hosts/${record.id}/view`);
|
||||
},
|
||||
})}
|
||||
onRow={record => rowNav(`/hosts/${record.id}/view`)}
|
||||
/>
|
||||
<Flex wrap="wrap" style={{ margin: "16px 0 0 0" }} gap="small">
|
||||
<label style={{ width: "14em", display: "flex", justifyContent: "space-between", marginRight: 16 }}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { message, Button, Table, Space } from "antd";
|
||||
import CopyOutlined from "@ant-design/icons/CopyOutlined";
|
||||
import type { SizeType } from "antd/es/config-provider/SizeContext";
|
||||
@@ -14,6 +13,7 @@ import InstanceControlButton, { InstanceControlButtonPermissions } from "./Insta
|
||||
import * as lib from "@clusterio/lib";
|
||||
import Link from "./Link";
|
||||
import { instancePublicAddress } from "../util/instance";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -25,9 +25,9 @@ type InstanceListProps = {
|
||||
|
||||
export default function InstanceList(props: InstanceListProps) {
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
let [hosts] = useHosts();
|
||||
const [systems] = useSystems();
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
function hostName(hostId?: number) {
|
||||
if (hostId === undefined) {
|
||||
@@ -49,6 +49,9 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a["name"], b["name"]),
|
||||
render: (_, instance) => <Link to={`/instances/${instance.id}/view`} style={{ color: "inherit" }}>
|
||||
{instance.name}
|
||||
</Link>,
|
||||
},
|
||||
{
|
||||
title: "Assigned Host",
|
||||
@@ -124,10 +127,6 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
dataSource={[...props.instances.values()]}
|
||||
rowKey={instance => instance["id"]}
|
||||
pagination={false}
|
||||
onRow={(record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/instances/${record.id}/view`);
|
||||
},
|
||||
})}
|
||||
onRow={record => rowNav(`/instances/${record.id}/view`)}
|
||||
/>;
|
||||
}
|
||||
@@ -198,12 +198,7 @@ export default function InstanceViewPage() {
|
||||
description={<>Instance with id {instanceId} was not found on the controller.</>}
|
||||
type="warning"
|
||||
action={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => { navigate("/instances"); }}
|
||||
>
|
||||
Go back to instances list
|
||||
</Button>
|
||||
<Link to="/instances">Go back to instances list</Link>
|
||||
}
|
||||
/>
|
||||
</PageLayout>;
|
||||
|
||||
@@ -18,6 +18,8 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
import ModDetails from "./ModDetails";
|
||||
import { Dropzone } from "./Dropzone";
|
||||
import UploadButton from "./UploadButton";
|
||||
@@ -507,9 +509,9 @@ function SearchModsButton() {
|
||||
export default function ModsPage() {
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
let navigate = useNavigate();
|
||||
let [mods] = useMods();
|
||||
let [modPacks] = useModPacks();
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
function actions(mod: lib.ModInfo) {
|
||||
return <Space>
|
||||
@@ -577,6 +579,12 @@ export default function ModsPage() {
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
render: (_, modPack) => <Link
|
||||
to={`/mods/mod-packs/${modPack.id}/view`}
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{modPack.name}
|
||||
</Link>,
|
||||
},
|
||||
{
|
||||
title: "Factorio Version",
|
||||
@@ -592,11 +600,7 @@ export default function ModsPage() {
|
||||
dataSource={[...modPacks.values()]}
|
||||
pagination={false}
|
||||
rowKey={modPack => Number(modPack.id)}
|
||||
onRow={(modPack, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/mods/mod-packs/${modPack.id}/view`);
|
||||
},
|
||||
})}
|
||||
onRow={modPack => rowNav(`/mods/mod-packs/${modPack.id}/view`)}
|
||||
/>
|
||||
<SectionHeader title="Stored Mods" extra={<Space wrap>
|
||||
<SearchModsButton />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Table } from "antd";
|
||||
import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled";
|
||||
import InfoCircleFilled from "@ant-design/icons/InfoCircleFilled";
|
||||
@@ -10,14 +9,16 @@ import notify from "../util/notify";
|
||||
import ControlContext from "./ControlContext";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PageHeader from "./PageHeader";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
|
||||
export default function PluginsPage() {
|
||||
const control = useContext(ControlContext);
|
||||
let navigate = useNavigate();
|
||||
let [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -54,7 +55,12 @@ export default function PluginsPage() {
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_, plugin) => (plugin.info ? plugin.info.title : plugin.meta.name),
|
||||
render: (_, plugin) => <Link
|
||||
to={`/plugins/${plugin.meta.name}/view`}
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{plugin.info ? plugin.info.title : plugin.meta.name}
|
||||
</Link>,
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name),
|
||||
},
|
||||
@@ -86,11 +92,7 @@ export default function PluginsPage() {
|
||||
dataSource={tableContents}
|
||||
rowKey={plugin => plugin.meta.name}
|
||||
pagination={false}
|
||||
onRow={plugin => ({
|
||||
onClick: event => {
|
||||
navigate(`/plugins/${plugin.meta.name}/view`);
|
||||
},
|
||||
})}
|
||||
onRow={plugin => rowNav(`/plugins/${plugin.meta.name}/view`)}
|
||||
/>
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { notifyErrorHandler } from "../util/notify";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -61,8 +63,8 @@ function CreateRoleButton() {
|
||||
|
||||
export default function RolesPage() {
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
const [roles] = useRoles();
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
return <PageLayout nav={[{ name: "Roles" }]}>
|
||||
<PageHeader
|
||||
@@ -75,6 +77,9 @@ export default function RolesPage() {
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
render: (_, role) => <Link to={`/roles/${role.id}/view`} style={{ color: "inherit" }}>
|
||||
{role.name}
|
||||
</Link>,
|
||||
},
|
||||
{
|
||||
title: "Description",
|
||||
@@ -84,11 +89,7 @@ export default function RolesPage() {
|
||||
dataSource={[...roles.values()]}
|
||||
pagination={false}
|
||||
rowKey={role => role.id}
|
||||
onRow={(role, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/roles/${role.id}/view`);
|
||||
},
|
||||
})}
|
||||
onRow={role => rowNav(`/roles/${role.id}/view`)}
|
||||
/>
|
||||
<PluginExtra component="RolesPage" />
|
||||
</PageLayout>;
|
||||
|
||||
@@ -8,6 +8,7 @@ import ErrorPage from "./ErrorPage";
|
||||
import ControlContext from "./ControlContext";
|
||||
import ChangeLogModal from "./ChangeLogModal";
|
||||
import AboutModal from "./AboutModal";
|
||||
import Link from "./Link";
|
||||
|
||||
import { pages } from "../pages";
|
||||
import { saveJson } from "../util/save_file";
|
||||
@@ -21,6 +22,12 @@ type MenuItem = Required<MenuProps>["items"][number];
|
||||
|
||||
const { Header, Sider } = Layout;
|
||||
|
||||
// Render a sidebar menu label as a real link so right-click/middle-click can
|
||||
// open it in a new tab; stopPropagation keeps the Menu onClick from also firing.
|
||||
function navLink(path: string, label: string) {
|
||||
return <Link to={path} onClick={e => e.stopPropagation()}>{label}</Link>;
|
||||
}
|
||||
|
||||
function isActiveDropzone(element: HTMLElement | null): boolean {
|
||||
if (!element) {
|
||||
return false;
|
||||
@@ -74,7 +81,12 @@ export default function SiteLayout() {
|
||||
}
|
||||
},
|
||||
items: [
|
||||
{ label: account.name, key: "user" },
|
||||
{
|
||||
label: <Link to={`/users/${account.name}/view`} onClick={e => e.stopPropagation()}>
|
||||
{account.name}
|
||||
</Link>,
|
||||
key: "user",
|
||||
},
|
||||
{
|
||||
label: <Tooltip title="Download credentials and configuration file for cli interface">
|
||||
Ctl Config <Button size="small" icon={<DownloadOutlined />} />
|
||||
@@ -110,9 +122,9 @@ export default function SiteLayout() {
|
||||
group = [];
|
||||
menuGroups.set(sidebarGroup, group);
|
||||
}
|
||||
group.push({ label: sidebarName, key: path });
|
||||
group.push({ label: navLink(path, sidebarName), key: path });
|
||||
} else {
|
||||
menuItems.push({ label: sidebarName, key: path });
|
||||
menuItems.push({ label: navLink(path, sidebarName), key: path });
|
||||
}
|
||||
}
|
||||
for (let [name, group] of menuGroups) {
|
||||
|
||||
@@ -16,6 +16,8 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import { formatDuration } from "../util/time_format";
|
||||
import { formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, useUser } from "../model/user";
|
||||
@@ -31,6 +33,7 @@ export default function UserViewPage() {
|
||||
let userName = params.name as string;
|
||||
|
||||
let navigate = useNavigate();
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
@@ -298,7 +301,9 @@ export default function UserViewPage() {
|
||||
{
|
||||
title: "Instance",
|
||||
key: "instance",
|
||||
render: (_, [id]) => instanceName(id),
|
||||
render: (_, [id]) => <Link to={`/instances/${id}/view`} style={{ color: "inherit" }}>
|
||||
{instanceName(id)}
|
||||
</Link>,
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(instanceName(a[0]), instanceName(b[0])),
|
||||
},
|
||||
@@ -331,11 +336,7 @@ export default function UserViewPage() {
|
||||
dataSource={[...(user.instanceStats || []).entries()]}
|
||||
pagination={false}
|
||||
rowKey={([id]) => id}
|
||||
onRow={([id], rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/instances/${id}/view`);
|
||||
},
|
||||
})}
|
||||
onRow={([id]) => rowNav(`/instances/${id}/view`)}
|
||||
/>
|
||||
<PluginExtra component="UserViewPage" user={user} />
|
||||
</PageLayout>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Table, Tag } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
|
||||
import { onFilterUser, Username, useUserFilter } from "./UsersFilters";
|
||||
import Link from "./Link";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
|
||||
export interface UsersTableProps {
|
||||
/** Optional instance id. If provided, stats will be filtered to that instance only */
|
||||
@@ -32,7 +32,7 @@ const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base"
|
||||
export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) {
|
||||
const [roles, rolesSynced] = useRoles();
|
||||
const [users, usersSynced] = useUsers();
|
||||
const navigate = useNavigate();
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
const {filterDropdown, filterDropdownProps} = useUserFilter(true);
|
||||
|
||||
@@ -85,7 +85,9 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_: any, user: lib.UserDetails) => (
|
||||
<Username user={user} withStatus />
|
||||
<Link to={`/users/${user.name}/view`} style={{ color: "inherit" }}>
|
||||
<Username user={user} withStatus />
|
||||
</Link>
|
||||
),
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name),
|
||||
@@ -178,14 +180,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
|
||||
size={size}
|
||||
scroll={{ x: "max-content" }}
|
||||
loading={!usersSynced || !rolesSynced}
|
||||
onRow={(user) => ({
|
||||
onClick: (event) => {
|
||||
if ((event.target as HTMLElement).closest("a")) {
|
||||
return;
|
||||
}
|
||||
navigate(`/users/${user.name}/view`);
|
||||
},
|
||||
})}
|
||||
onRow={(user) => rowNav(`/users/${user.name}/view`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,3 +42,5 @@ export { default as SectionHeader } from "./components/SectionHeader";
|
||||
export { default as Link } from "./components/Link";
|
||||
|
||||
export { default as FactorioIcon } from "./components/FactorioIcon";
|
||||
|
||||
export { default as useRowNavigation } from "./util/useRowNavigation";
|
||||
@@ -0,0 +1,52 @@
|
||||
import type React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
/** Resolve an in-app path to a full path including the router basename. */
|
||||
function resolvePath(to: string): string {
|
||||
const base = webRoot.replace(/\/$/, "");
|
||||
return base + to;
|
||||
}
|
||||
|
||||
function isInsideLink(target: EventTarget | null): boolean {
|
||||
return target instanceof HTMLElement && target.closest("a") !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation handlers for a whole table row that also support "open in new
|
||||
* tab": left-click navigates in place, middle-click or ctrl/cmd-click opens the
|
||||
* target in a new tab. Spread the result into the object returned from antd's
|
||||
* `onRow`, e.g. `onRow={record => rowNav(`/hosts/${record.id}/view`)}`. Clicks
|
||||
* that land on a real link inside the row are ignored so the anchor handles
|
||||
* them (and shows the native context menu / new-tab affordances itself).
|
||||
*/
|
||||
export default function useRowNavigation() {
|
||||
const navigate = useNavigate();
|
||||
return function rowNavigation(to: string): React.HTMLAttributes<HTMLElement> {
|
||||
return {
|
||||
style: { cursor: "pointer" },
|
||||
onClick: (event) => {
|
||||
if (event.defaultPrevented || isInsideLink(event.target)) {
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
window.open(resolvePath(to), "_blank", "noopener");
|
||||
} else {
|
||||
navigate(to);
|
||||
}
|
||||
},
|
||||
onAuxClick: (event) => {
|
||||
// Middle click opens the row in a new tab.
|
||||
if (event.button === 1 && !isInsideLink(event.target)) {
|
||||
event.preventDefault();
|
||||
window.open(resolvePath(to), "_blank", "noopener");
|
||||
}
|
||||
},
|
||||
onMouseDown: (event) => {
|
||||
// Suppress the middle-click autoscroll cursor.
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user