mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
Various improvements, fixes, and QoL
1. Improved the Navigation bar in both mobile and desktop. It is no longer hard coded, but an array you can change in resources\scripts\routers, which is referenced in desktop and mobile. I also added a tiny bit of left padding to the icon so that it looked nicer (it drove me insane). You can use isSubRoute: true in order to hide a route from the nav bar while still letting the router handle that route. Check existing examples to understand more. 2. Backups / deletions in progress disable the create backup button (I could spam it to make more backups than I should have been allowed to). Additionally, backups now properly disappear from the list when deleted without needing to reload the page. 3. Fixed the pretty little highlight that animates itself to the menu position. The issue was that it did not work on scroll, so if you had a smaller screen size or scrolled due to adding more items to the list, then the highlight would remain in place, which was sad. It now follows the entry when scrolling, and if the selected entry is hidden from view, the highligher disappears as well :P
This commit is contained in:
@@ -1,26 +1,16 @@
|
||||
import {
|
||||
AbbrApi,
|
||||
Box,
|
||||
BranchesDown,
|
||||
Clock,
|
||||
ClockArrowRotateLeft,
|
||||
CloudArrowUpIn,
|
||||
Database,
|
||||
Ellipsis,
|
||||
FolderOpen,
|
||||
Gear,
|
||||
House,
|
||||
Key,
|
||||
PencilToLine,
|
||||
Persons,
|
||||
Terminal,
|
||||
Xmark,
|
||||
} from '@gravity-ui/icons';
|
||||
import React from 'react';
|
||||
import { AbbrApi, Gear, House, Key, Xmark } from '@gravity-ui/icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import type { FeatureLimitKey, ServerRouteDefinition } from '@/routers/routes';
|
||||
import { getServerNavRoutes } from '@/routers/routes';
|
||||
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
import { getSubdomainInfo } from '@/api/server/network/subdomain';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface MobileFullScreenMenuProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
@@ -52,182 +42,150 @@ const MobileFullScreenMenu = ({ isVisible, onClose, children }: MobileFullScreen
|
||||
);
|
||||
};
|
||||
|
||||
interface NavigationItemProps {
|
||||
to: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
children: React.ReactNode;
|
||||
end?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const NavigationItem = ({ to, icon: Icon, children, end = false, onClick }: NavigationItemProps) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-4 p-4 rounded-md transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-brand/20 to-brand/10 border-l-4 border-brand text-white'
|
||||
: 'text-white/80 hover:text-white hover:bg-[#ffffff11] border-l-4 border-transparent'
|
||||
}`
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div>
|
||||
<Icon width={22} height={22} fill='currentColor' />
|
||||
</div>
|
||||
<span className='text-lg font-medium'>{children}</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
interface DashboardMobileMenuProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DashboardMobileMenu = ({ isVisible, onClose }: DashboardMobileMenuProps) => {
|
||||
const NavigationItem = ({
|
||||
to,
|
||||
icon: Icon,
|
||||
children,
|
||||
end = false,
|
||||
}: {
|
||||
to: string;
|
||||
icon: React.ComponentType<{ width: number; height: number; fill: string }>;
|
||||
children: React.ReactNode;
|
||||
end?: boolean;
|
||||
}) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-4 p-4 rounded-md transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-brand/20 to-brand/10 border-l-4 border-brand text-white'
|
||||
: 'text-white/80 hover:text-white hover:bg-[#ffffff11] border-l-4 border-transparent'
|
||||
}`
|
||||
}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div>
|
||||
<Icon width={22} height={22} fill='currentColor' />
|
||||
</div>
|
||||
<span className='text-lg font-medium'>{children}</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<MobileFullScreenMenu isVisible={isVisible} onClose={onClose}>
|
||||
<NavigationItem to='/' icon={House} end>
|
||||
<NavigationItem to='/' icon={House} end onClick={onClose}>
|
||||
Servers
|
||||
</NavigationItem>
|
||||
<NavigationItem to='/account/api' icon={AbbrApi} end>
|
||||
<NavigationItem to='/account/api' icon={AbbrApi} end onClick={onClose}>
|
||||
API Keys
|
||||
</NavigationItem>
|
||||
<NavigationItem to='/account/ssh' icon={Key} end>
|
||||
<NavigationItem to='/account/ssh' icon={Key} end onClick={onClose}>
|
||||
SSH Keys
|
||||
</NavigationItem>
|
||||
<NavigationItem to='/account' icon={Gear} end>
|
||||
<NavigationItem to='/account' icon={Gear} end onClick={onClose}>
|
||||
Settings
|
||||
</NavigationItem>
|
||||
</MobileFullScreenMenu>
|
||||
);
|
||||
};
|
||||
|
||||
interface ServerMobileNavItemProps {
|
||||
route: ServerRouteDefinition;
|
||||
serverId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile navigation item that handles permission and feature limit checks.
|
||||
*/
|
||||
const ServerMobileNavItem = ({ route, serverId, onClose }: ServerMobileNavItemProps) => {
|
||||
const { icon: Icon, name, path, permission, featureLimit, end } = route;
|
||||
|
||||
// Feature limits from server state
|
||||
const featureLimits = ServerContext.useStoreState((state) => state.server.data?.featureLimits);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
|
||||
// State for subdomain support check (only for network route)
|
||||
const [subdomainSupported, setSubdomainSupported] = useState(false);
|
||||
|
||||
// Check subdomain support for network feature
|
||||
useEffect(() => {
|
||||
if (featureLimit !== 'network' || !uuid) return;
|
||||
|
||||
const checkSubdomainSupport = async () => {
|
||||
try {
|
||||
const data = await getSubdomainInfo(uuid);
|
||||
setSubdomainSupported(data.supported);
|
||||
} catch {
|
||||
setSubdomainSupported(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkSubdomainSupport();
|
||||
}, [featureLimit, uuid]);
|
||||
|
||||
// Check if the item should be visible based on feature limits
|
||||
const isVisible = (): boolean => {
|
||||
if (!featureLimit) return true;
|
||||
|
||||
if (featureLimit === 'network') {
|
||||
const allocationLimit = featureLimits?.allocations ?? 0;
|
||||
return allocationLimit > 0 || subdomainSupported;
|
||||
}
|
||||
|
||||
const limitValue = featureLimits?.[featureLimit as FeatureLimitKey] ?? 0;
|
||||
return limitValue !== 0;
|
||||
};
|
||||
|
||||
if (!isVisible() || !Icon || !name) return null;
|
||||
|
||||
const to = path ? `/server/${serverId}/${path}` : `/server/${serverId}`;
|
||||
|
||||
const NavContent = (
|
||||
<NavigationItem to={to} icon={Icon} end={end} onClick={onClose}>
|
||||
{name}
|
||||
</NavigationItem>
|
||||
);
|
||||
|
||||
if (permission === null || permission === undefined) {
|
||||
return NavContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<Can action={permission} matchAny>
|
||||
{NavContent}
|
||||
</Can>
|
||||
);
|
||||
};
|
||||
|
||||
interface ServerMobileMenuProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
serverId?: string;
|
||||
// These props are kept for backwards compatibility but are no longer used
|
||||
// The component now reads feature limits directly from ServerContext
|
||||
databaseLimit?: number | null;
|
||||
backupLimit?: number | null;
|
||||
allocationLimit?: number | null;
|
||||
subdomainSupported?: boolean;
|
||||
}
|
||||
|
||||
export const ServerMobileMenu = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
serverId,
|
||||
databaseLimit,
|
||||
backupLimit,
|
||||
allocationLimit,
|
||||
subdomainSupported = false,
|
||||
}: ServerMobileMenuProps) => {
|
||||
const NavigationItem = ({
|
||||
to,
|
||||
icon: Icon,
|
||||
children,
|
||||
end = false,
|
||||
}: {
|
||||
to: string;
|
||||
icon: React.ComponentType<{ width: number; height: number; fill: string }>;
|
||||
children: React.ReactNode;
|
||||
end?: boolean;
|
||||
}) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-4 p-4 rounded-md transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-brand/20 to-brand/10 border-l-4 border-brand text-white'
|
||||
: 'text-white/80 hover:text-white hover:bg-[#ffffff11] border-l-4 border-transparent'
|
||||
}`
|
||||
}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon width={22} height={22} fill='currentColor' />
|
||||
<span className='text-lg font-medium'>{children}</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export const ServerMobileMenu = ({ isVisible, onClose, serverId }: ServerMobileMenuProps) => {
|
||||
if (!serverId) return null;
|
||||
|
||||
// Get navigation routes from centralized config
|
||||
const navRoutes = getServerNavRoutes();
|
||||
|
||||
return (
|
||||
<MobileFullScreenMenu isVisible={isVisible} onClose={onClose}>
|
||||
<NavigationItem to={`/server/${serverId}`} icon={House} end>
|
||||
Home
|
||||
</NavigationItem>
|
||||
|
||||
<>
|
||||
<Can action={'file.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/files`} icon={FolderOpen}>
|
||||
Files
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
{databaseLimit !== 0 && (
|
||||
<Can action={'database.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/databases`} icon={Database} end>
|
||||
Databases
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
)}
|
||||
|
||||
{backupLimit !== 0 && (
|
||||
<Can action={'backup.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/backups`} icon={CloudArrowUpIn} end>
|
||||
Backups
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
)}
|
||||
|
||||
{(allocationLimit > 0 || subdomainSupported) && (
|
||||
<Can action={'allocation.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/network`} icon={BranchesDown} end>
|
||||
Networking
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
)}
|
||||
|
||||
<Can action={'user.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/users`} icon={Persons} end>
|
||||
Users
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
<Can action={['startup.read', 'startup.update', 'startup.command', 'startup.docker-image']} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/startup`} icon={Terminal} end>
|
||||
Startup
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
<Can action={'schedule.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/schedules`} icon={Clock}>
|
||||
Schedules
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
<Can action={['settings.*', 'file.sftp']} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/settings`} icon={Gear} end>
|
||||
Settings
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
<Can action={['activity.*', 'activity.read']} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/activity`} icon={PencilToLine} end>
|
||||
Activity
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
</>
|
||||
|
||||
<Can action={'startup.software'}>
|
||||
<NavigationItem to={`/server/${serverId}/shell`} icon={Box} end>
|
||||
Software
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
{navRoutes.map((route) => (
|
||||
<ServerMobileNavItem key={route.path || 'home'} route={route} serverId={serverId} onClose={onClose} />
|
||||
))}
|
||||
</MobileFullScreenMenu>
|
||||
);
|
||||
};
|
||||
|
||||
101
resources/scripts/components/server/ServerSidebarNavItem.tsx
Normal file
101
resources/scripts/components/server/ServerSidebarNavItem.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import type { FeatureLimitKey, ServerRouteDefinition } from '@/routers/routes';
|
||||
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
import { getSubdomainInfo } from '@/api/server/network/subdomain';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface ServerSidebarNavItemProps {
|
||||
route: ServerRouteDefinition;
|
||||
serverId: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dynamic sidebar navigation item that handles:
|
||||
* - Permission checking via Can component
|
||||
* - Feature limit visibility (databases, backups, allocations)
|
||||
* - Network feature with subdomain support check
|
||||
*/
|
||||
const ServerSidebarNavItem = forwardRef<HTMLAnchorElement, ServerSidebarNavItemProps>(
|
||||
({ route, serverId, onClick }, ref) => {
|
||||
const { icon: Icon, name, path, permission, featureLimit, end } = route;
|
||||
|
||||
// Feature limits from server state
|
||||
const featureLimits = ServerContext.useStoreState((state) => state.server.data?.featureLimits);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
|
||||
// State for subdomain support check (only for network route)
|
||||
const [subdomainSupported, setSubdomainSupported] = useState(false);
|
||||
|
||||
// Check subdomain support for network feature
|
||||
useEffect(() => {
|
||||
if (featureLimit !== 'network' || !uuid) return;
|
||||
|
||||
const checkSubdomainSupport = async () => {
|
||||
try {
|
||||
const data = await getSubdomainInfo(uuid);
|
||||
setSubdomainSupported(data.supported);
|
||||
} catch {
|
||||
setSubdomainSupported(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkSubdomainSupport();
|
||||
}, [featureLimit, uuid]);
|
||||
|
||||
// Check if the item should be visible based on feature limits
|
||||
const isVisible = (): boolean => {
|
||||
if (!featureLimit) return true;
|
||||
|
||||
if (featureLimit === 'network') {
|
||||
// Network is visible if allocations > 0 OR subdomain is supported
|
||||
const allocationLimit = featureLimits?.allocations ?? 0;
|
||||
return allocationLimit > 0 || subdomainSupported;
|
||||
}
|
||||
|
||||
// For other feature limits (databases, backups, allocations)
|
||||
const limitValue = featureLimits?.[featureLimit as FeatureLimitKey] ?? 0;
|
||||
return limitValue !== 0;
|
||||
};
|
||||
|
||||
// Don't render if feature limit hides this item
|
||||
if (!isVisible()) return null;
|
||||
|
||||
// Build the navigation link
|
||||
const to = path ? `/server/${serverId}/${path}` : `/server/${serverId}`;
|
||||
|
||||
const NavContent = (
|
||||
<NavLink
|
||||
ref={ref}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={onClick}
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
>
|
||||
{Icon && <Icon className='ml-3' width={22} height={22} fill='currentColor' />}
|
||||
<p>{name}</p>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
// If permission is null or undefined, render without permission check
|
||||
if (permission === null || permission === undefined) {
|
||||
return NavContent;
|
||||
}
|
||||
|
||||
// Wrap with permission check
|
||||
return (
|
||||
<Can action={permission} matchAny>
|
||||
{NavContent}
|
||||
</Can>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ServerSidebarNavItem.displayName = 'ServerSidebarNavItem';
|
||||
|
||||
export default ServerSidebarNavItem;
|
||||
@@ -123,6 +123,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
const BackupContainer = () => {
|
||||
const { page, setPage } = useContext(ServerBackupContext);
|
||||
const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash();
|
||||
const liveProgress = useContext(LiveProgressContext);
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
const [deleteAllModalVisible, setDeleteAllModalVisible] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -145,6 +146,9 @@ const BackupContainer = () => {
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
|
||||
const backupStorageLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backupStorageMb);
|
||||
|
||||
// Check if any backup operation is in progress
|
||||
const hasActiveOperation = Object.values(liveProgress).some((op) => !op.completed);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('backups:create');
|
||||
}, [createModalVisible]);
|
||||
@@ -391,7 +395,11 @@ const BackupContainer = () => {
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
{backupCount > 0 && (
|
||||
<ActionButton variant='danger' onClick={() => setDeleteAllModalVisible(true)}>
|
||||
<ActionButton
|
||||
variant='danger'
|
||||
onClick={() => setDeleteAllModalVisible(true)}
|
||||
disabled={hasActiveOperation}
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4 mr-2'
|
||||
fill='none'
|
||||
@@ -410,7 +418,11 @@ const BackupContainer = () => {
|
||||
)}
|
||||
{(backupLimit === null || backupLimit > backupCount) &&
|
||||
(!backupStorageLimit || !storage?.is_over_limit) && (
|
||||
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
disabled={hasActiveOperation}
|
||||
>
|
||||
New Backup
|
||||
</ActionButton>
|
||||
)}
|
||||
@@ -421,7 +433,8 @@ const BackupContainer = () => {
|
||||
>
|
||||
<p className='text-sm text-neutral-400 leading-relaxed'>
|
||||
Create and manage server backups to protect your data. Schedule automated backups, download existing
|
||||
ones, and restore when needed.
|
||||
ones, and restore when needed. Backups are deduplicated, meaning unchanged files are only stored
|
||||
once across all backups
|
||||
</p>
|
||||
</MainPageHeader>
|
||||
|
||||
@@ -812,6 +825,7 @@ const BackupContainerWrapper = () => {
|
||||
if (isDeletionOperation) {
|
||||
// Optimistically remove the deleted backup from SWR cache immediately
|
||||
// note: this is incredibly buggy sometimes, somebody please refactor how "live" backups work. - ellie
|
||||
// Changed this to use "revalidate: false" so the optimistic update persists - tyr
|
||||
mutate(
|
||||
(currentData) => {
|
||||
if (!currentData) return currentData;
|
||||
@@ -821,17 +835,15 @@ const BackupContainerWrapper = () => {
|
||||
backupCount: Math.max(0, (currentData.backupCount || 0) - 1),
|
||||
};
|
||||
},
|
||||
{ revalidate: true },
|
||||
{ revalidate: false },
|
||||
);
|
||||
|
||||
// Remove from live progress
|
||||
setTimeout(() => {
|
||||
setLiveProgress((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
}, 500);
|
||||
// Remove from live progress immediately
|
||||
setLiveProgress((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
// For new backups, wait for them to appear in the API
|
||||
mutate();
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
BranchesDown,
|
||||
ClockArrowRotateLeft,
|
||||
CloudArrowUpIn,
|
||||
Database,
|
||||
Ellipsis,
|
||||
FolderOpen,
|
||||
Gear,
|
||||
House,
|
||||
PencilToLine,
|
||||
Persons,
|
||||
Terminal,
|
||||
} from '@gravity-ui/icons';
|
||||
import { Ellipsis } from '@gravity-ui/icons';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import React, { Fragment, Suspense, useEffect, useRef, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import { Fragment, Suspense, createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import routes from '@/routers/routes';
|
||||
import routes, { type ServerRouteDefinition, getServerNavRoutes } from '@/routers/routes';
|
||||
|
||||
import Can from '@/components/elements/Can';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -33,125 +20,23 @@ import MainSidebar from '@/components/elements/MainSidebar';
|
||||
import MainWrapper from '@/components/elements/MainWrapper';
|
||||
import { ServerMobileMenu } from '@/components/elements/MobileFullScreenMenu';
|
||||
import MobileTopBar from '@/components/elements/MobileTopBar';
|
||||
import ModrinthLogo from '@/components/elements/ModrinthLogo';
|
||||
import PermissionRoute from '@/components/elements/PermissionRoute';
|
||||
import Logo from '@/components/elements/PyroLogo';
|
||||
import { NotFound, ServerError } from '@/components/elements/ScreenBlock';
|
||||
import CommandMenu from '@/components/elements/commandk/CmdK';
|
||||
import ConflictStateRenderer from '@/components/server/ConflictStateRenderer';
|
||||
import InstallListener from '@/components/server/InstallListener';
|
||||
import ServerSidebarNavItem from '@/components/server/ServerSidebarNavItem';
|
||||
import TransferListener from '@/components/server/TransferListener';
|
||||
import WebsocketHandler from '@/components/server/WebsocketHandler';
|
||||
import StatBlock from '@/components/server/console/StatBlock';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import http from '@/api/http';
|
||||
import { SubdomainInfo, getSubdomainInfo } from '@/api/server/network/subdomain';
|
||||
import { getSubdomainInfo } from '@/api/server/network/subdomain';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
// Sidebar item components that check both permissions and feature limits
|
||||
const DatabasesSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
|
||||
({ id, onClick }, ref) => {
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases);
|
||||
|
||||
// Hide if databases are disabled (limit is 0)
|
||||
if (databaseLimit === 0) return null;
|
||||
|
||||
return (
|
||||
<Can action={'database.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-white/10 rounded-md'
|
||||
ref={ref}
|
||||
to={`/server/${id}/databases`}
|
||||
onClick={onClick}
|
||||
end
|
||||
>
|
||||
<Database width={22} height={22} fill='currentColor' />
|
||||
<p>Databases</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
);
|
||||
},
|
||||
);
|
||||
DatabasesSidebarItem.displayName = 'DatabasesSidebarItem';
|
||||
|
||||
const BackupsSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
|
||||
({ id, onClick }, ref) => {
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups);
|
||||
|
||||
// Hide if backups are disabled (limit is 0)
|
||||
if (backupLimit === 0) return null;
|
||||
|
||||
return (
|
||||
<Can action={'backup.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={ref}
|
||||
to={`/server/${id}/backups`}
|
||||
onClick={onClick}
|
||||
end
|
||||
>
|
||||
<CloudArrowUpIn width={22} height={22} fill='currentColor' />
|
||||
<p>Backups</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
);
|
||||
},
|
||||
);
|
||||
BackupsSidebarItem.displayName = 'BackupsSidebarItem';
|
||||
|
||||
const NetworkingSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
|
||||
({ id, onClick }, ref) => {
|
||||
const [subdomainSupported, setSubdomainSupported] = useState(false);
|
||||
const allocationLimit = ServerContext.useStoreState(
|
||||
(state) => state.server.data?.featureLimits.allocations ?? 0,
|
||||
);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSubdomainSupport = async () => {
|
||||
try {
|
||||
if (uuid) {
|
||||
const data = await getSubdomainInfo(uuid);
|
||||
setSubdomainSupported(data.supported);
|
||||
}
|
||||
} catch (error) {
|
||||
setSubdomainSupported(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkSubdomainSupport();
|
||||
}, [uuid]);
|
||||
|
||||
// Show if either allocations are available OR subdomains are supported
|
||||
if (allocationLimit === 0 && !subdomainSupported) return null;
|
||||
|
||||
return (
|
||||
<Can action={'allocation.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={ref}
|
||||
to={`/server/${id}/network`}
|
||||
onClick={onClick}
|
||||
end
|
||||
>
|
||||
<BranchesDown width={22} height={22} fill='currentColor' />
|
||||
<p>Networking</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
);
|
||||
},
|
||||
);
|
||||
NetworkingSidebarItem.displayName = 'NetworkingSidebarItem';
|
||||
|
||||
/**
|
||||
* Creates a swipe event from an X and Y location at start and current co-ords.
|
||||
* Important to create a shared, but not public, space for methods.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
|
||||
const ServerRouter = () => {
|
||||
const params = useParams<'id'>();
|
||||
const location = useLocation();
|
||||
@@ -167,7 +52,6 @@ const ServerRouter = () => {
|
||||
const serverName = ServerContext.useStoreState((state) => state.server.data?.name);
|
||||
const getServer = ServerContext.useStoreActions((actions) => actions.server.getServer);
|
||||
const clearServerState = ServerContext.useStoreActions((actions) => actions.clearServerState);
|
||||
const egg_id = ServerContext.useStoreState((state) => state.server.data?.egg);
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups);
|
||||
const allocationLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.allocations);
|
||||
@@ -175,6 +59,25 @@ const ServerRouter = () => {
|
||||
// Mobile menu state
|
||||
const [isMobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||
|
||||
// Scroll tracking for highlight indicator
|
||||
const navContainerRef = useRef<HTMLUListElement>(null);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const [containerTop, setContainerTop] = useState(0);
|
||||
|
||||
// Get navigation routes from centralized config
|
||||
const navRoutes = useMemo(() => getServerNavRoutes(), []);
|
||||
|
||||
// Create refs dynamically for each navigation route
|
||||
const navRefs = useMemo(() => {
|
||||
const refs: Record<string, RefObject<HTMLAnchorElement | null>> = {};
|
||||
navRoutes.forEach((route) => {
|
||||
const key = route.path || 'home';
|
||||
refs[key] = createRef<HTMLAnchorElement>();
|
||||
});
|
||||
return refs;
|
||||
}, [navRoutes]);
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuVisible(!isMobileMenuVisible);
|
||||
};
|
||||
@@ -235,69 +138,43 @@ const ServerRouter = () => {
|
||||
}
|
||||
}, [uuid]);
|
||||
|
||||
// Define refs for navigation buttons.
|
||||
const NavigationHome = useRef(null);
|
||||
const NavigationFiles = useRef(null);
|
||||
const NavigationDatabases = useRef(null);
|
||||
const NavigationBackups = useRef(null);
|
||||
const NavigationNetworking = useRef(null);
|
||||
const NavigationUsers = useRef(null);
|
||||
const NavigationStartup = useRef(null);
|
||||
const NavigationSchedules = useRef(null);
|
||||
const NavigationSettings = useRef(null);
|
||||
const NavigationActivity = useRef(null);
|
||||
const NavigationMod = useRef(null);
|
||||
const NavigationShell = useRef(null);
|
||||
|
||||
const calculateTop = (pathname: string) => {
|
||||
/**
|
||||
* Calculate the top position of the highlight indicator based on the current route.
|
||||
* Dynamically matches routes using the route config instead of hardcoded paths.
|
||||
*/
|
||||
const calculateTop = (pathname: string): string | number => {
|
||||
if (!id) return '0';
|
||||
|
||||
// Get currents of navigation refs.
|
||||
const ButtonHome = NavigationHome.current;
|
||||
const ButtonFiles = NavigationFiles.current;
|
||||
const ButtonDatabases = NavigationDatabases.current;
|
||||
const ButtonBackups = NavigationBackups.current;
|
||||
const ButtonNetworking = NavigationNetworking.current;
|
||||
const ButtonUsers = NavigationUsers.current;
|
||||
const ButtonStartup = NavigationStartup.current;
|
||||
const ButtonSchedules = NavigationSchedules.current;
|
||||
const ButtonSettings = NavigationSettings.current;
|
||||
const ButtonShell = NavigationShell.current;
|
||||
const ButtonActivity = NavigationActivity.current;
|
||||
const ButtonMod = NavigationMod.current;
|
||||
const HighlightOffset = 8;
|
||||
|
||||
// Perfectly center the page highlighter with simple math.
|
||||
// Height of navigation links (56) minus highlight height (40) equals 16. 16 devided by 2 is 8.
|
||||
const HighlightOffset: number = 8;
|
||||
// Find matching route for the current pathname
|
||||
for (const route of navRoutes) {
|
||||
const key = route.path || 'home';
|
||||
const ref = navRefs[key];
|
||||
|
||||
if (pathname.endsWith(`/server/${id}`) && ButtonHome != null)
|
||||
return (ButtonHome as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/files`) && ButtonFiles != null)
|
||||
return (ButtonFiles as any).offsetTop + HighlightOffset;
|
||||
if (new RegExp(`^/server/${id}/files(/(new|edit).*)?$`).test(pathname) && ButtonFiles != null)
|
||||
return (ButtonFiles as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/databases`) && ButtonDatabases != null)
|
||||
return (ButtonDatabases as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/backups`) && ButtonBackups != null)
|
||||
return (ButtonBackups as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/network`) && ButtonNetworking != null)
|
||||
return (ButtonNetworking as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/users`) && ButtonUsers != null)
|
||||
return (ButtonUsers as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/startup`) && ButtonStartup != null)
|
||||
return (ButtonStartup as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/schedules`) && ButtonSchedules != null)
|
||||
return (ButtonSchedules as any).offsetTop + HighlightOffset;
|
||||
if (new RegExp(`^/server/${id}/schedules/\\d+$`).test(pathname) && ButtonSchedules != null)
|
||||
return (ButtonSchedules as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/settings`) && ButtonSettings != null)
|
||||
return (ButtonSettings as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/shell`) && ButtonShell != null)
|
||||
return (ButtonShell as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/activity`) && ButtonActivity != null)
|
||||
return (ButtonActivity as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/mods`) && ButtonMod != null)
|
||||
return (ButtonMod as any).offsetTop + HighlightOffset;
|
||||
if (!ref?.current) continue;
|
||||
|
||||
const basePath = route.path ? `/server/${id}/${route.path}` : `/server/${id}`;
|
||||
|
||||
// Check if exact match (for routes with end: true)
|
||||
if (route.end && pathname === basePath) {
|
||||
return ref.current.offsetTop + HighlightOffset;
|
||||
}
|
||||
|
||||
// Check highlight patterns if defined
|
||||
if (route.highlightPatterns) {
|
||||
for (const pattern of route.highlightPatterns) {
|
||||
if (pattern.test(pathname)) {
|
||||
return ref.current.offsetTop + HighlightOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if pathname starts with base path (for routes without end)
|
||||
if (!route.end && pathname.startsWith(basePath)) {
|
||||
return ref.current.offsetTop + HighlightOffset;
|
||||
}
|
||||
}
|
||||
|
||||
return '0';
|
||||
};
|
||||
@@ -312,6 +189,65 @@ const ServerRouter = () => {
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [top]);
|
||||
|
||||
// Track scroll position of the nav container
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLUListElement>) => {
|
||||
setScrollOffset(e.currentTarget.scrollTop);
|
||||
setContainerHeight(e.currentTarget.clientHeight);
|
||||
}, []);
|
||||
|
||||
// Measure container dimensions
|
||||
const measureContainer = useCallback(() => {
|
||||
if (navContainerRef.current) {
|
||||
setContainerHeight(navContainerRef.current.clientHeight);
|
||||
setContainerTop(navContainerRef.current.offsetTop);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Measure container on mount/update and window resize (debounced)
|
||||
useEffect(() => {
|
||||
measureContainer();
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(measureContainer, 150);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
clearTimeout(resizeTimeout);
|
||||
};
|
||||
}, [id, uuid, measureContainer]);
|
||||
|
||||
// Adjust top position based on scroll offset
|
||||
const adjustedTop = typeof top === 'number' ? top - scrollOffset : top;
|
||||
|
||||
// Check if the highlighted item is within the visible scroll area
|
||||
const isHighlightVisible = useMemo(() => {
|
||||
if (typeof top !== 'number' || top === 0) return false;
|
||||
if (containerHeight === 0) return true; // Not yet measured, assume visible
|
||||
|
||||
const itemHeight = 40; // Height of a nav item
|
||||
// top is relative to sidebar, containerTop is where the scrollable area starts
|
||||
const itemTopRelativeToContainer = top - containerTop;
|
||||
const itemBottomRelativeToContainer = itemTopRelativeToContainer + itemHeight;
|
||||
|
||||
// Check if item is within the visible scroll window
|
||||
const visibleTop = scrollOffset;
|
||||
const visibleBottom = scrollOffset + containerHeight;
|
||||
|
||||
return itemBottomRelativeToContainer > visibleTop && itemTopRelativeToContainer < visibleBottom;
|
||||
}, [top, scrollOffset, containerHeight, containerTop]);
|
||||
|
||||
/**
|
||||
* Get the ref for a specific route by its path key.
|
||||
*/
|
||||
const getRefForRoute = (route: ServerRouteDefinition) => {
|
||||
const key = route.path || 'home';
|
||||
return navRefs[key];
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment key={'server-router'}>
|
||||
{!uuid || !id ? (
|
||||
@@ -342,24 +278,6 @@ const ServerRouter = () => {
|
||||
<div className='flex flex-row w-full lg:pt-0 pt-16'>
|
||||
{/* Desktop Sidebar */}
|
||||
<MainSidebar className='hidden lg:flex lg:relative lg:shrink-0 w-[300px] bg-[#1a1a1a] flex flex-col h-screen'>
|
||||
<div
|
||||
className='absolute bg-brand w-[3px] h-10 left-0 rounded-full pointer-events-none'
|
||||
style={{
|
||||
top,
|
||||
height,
|
||||
opacity: top === '0' ? 0 : 1,
|
||||
transition:
|
||||
'linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001) 390ms',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute bg-zinc-900 w-12 h-10 blur-2xl left-0 rounded-full pointer-events-none'
|
||||
style={{
|
||||
top,
|
||||
opacity: top === '0' ? 0 : 0.5,
|
||||
transition: 'all 300ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-row items-center justify-between h-8'>
|
||||
<NavLink to={'/'} className='flex shrink-0 h-8 w-fit'>
|
||||
<Logo uniqueId='server-desktop-sidebar' />
|
||||
@@ -385,120 +303,41 @@ const ServerRouter = () => {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div aria-hidden className='mt-8 mb-4 bg-[#ffffff33] min-h-[1px] w-6'></div>
|
||||
{/* Highlight */}
|
||||
<div
|
||||
className='absolute bg-brand w-[3px] h-10 left-0 rounded-full pointer-events-none'
|
||||
style={{
|
||||
top: adjustedTop,
|
||||
height,
|
||||
opacity: isHighlightVisible ? 1 : 0,
|
||||
transition:
|
||||
'linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001) 390ms',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute bg-zinc-900 w-12 h-10 blur-2xl left-0 rounded-full pointer-events-none'
|
||||
style={{
|
||||
top: adjustedTop,
|
||||
opacity: isHighlightVisible ? 0.5 : 0,
|
||||
transition: 'all 300ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
/>
|
||||
<ul
|
||||
ref={navContainerRef}
|
||||
onScroll={handleScroll}
|
||||
data-pyro-subnav-routes-wrapper=''
|
||||
className='pyro-subnav-routes-wrapper flex-grow overflow-y-auto'
|
||||
>
|
||||
{/* lord forgive me for hardcoding this */}
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationHome}
|
||||
to={`/server/${id}`}
|
||||
end
|
||||
>
|
||||
<House width={22} height={22} fill='currentColor' />
|
||||
<p>Home</p>
|
||||
</NavLink>
|
||||
<>
|
||||
<Can action={'file.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationFiles}
|
||||
to={`/server/${id}/files`}
|
||||
>
|
||||
<FolderOpen width={22} height={22} fill='currentColor' />
|
||||
<p>Files</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<DatabasesSidebarItem id={id} ref={NavigationDatabases} onClick={() => {}} />
|
||||
<BackupsSidebarItem id={id} ref={NavigationBackups} onClick={() => {}} />
|
||||
<NetworkingSidebarItem id={id} ref={NavigationNetworking} onClick={() => {}} />
|
||||
<Can action={'user.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationUsers}
|
||||
to={`/server/${id}/users`}
|
||||
end
|
||||
>
|
||||
<Persons width={22} height={22} fill='currentColor' />
|
||||
<p>Users</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<Can
|
||||
action={[
|
||||
'startup.read',
|
||||
'startup.update',
|
||||
'startup.command',
|
||||
'startup.docker-image',
|
||||
]}
|
||||
matchAny
|
||||
>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationStartup}
|
||||
to={`/server/${id}/startup`}
|
||||
end
|
||||
>
|
||||
<Terminal width={22} height={22} fill='currentColor' />
|
||||
<p>Startup</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<Can action={'schedule.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationSchedules}
|
||||
to={`/server/${id}/schedules`}
|
||||
>
|
||||
<ClockArrowRotateLeft width={22} height={22} fill='currentColor' />
|
||||
<p>Schedules</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<Can action={['settings.*', 'file.sftp']} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationSettings}
|
||||
to={`/server/${id}/settings`}
|
||||
end
|
||||
>
|
||||
<Gear width={22} height={22} fill='currentColor' />
|
||||
<p>Settings</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<Can action={['activity.*', 'activity.read']} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationActivity}
|
||||
to={`/server/${id}/activity`}
|
||||
end
|
||||
>
|
||||
<PencilToLine width={22} height={22} fill='currentColor' />
|
||||
<p>Activity</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
{/* {/* TODO: finish modrinth support *\} */}
|
||||
{/* <Can action={['modrinth.*', 'modrinth.download']} matchAny> */}
|
||||
{/* <NavLink */}
|
||||
{/* className='flex flex-row items-center sm:hidden md:show' */}
|
||||
{/* ref={NavigationMod} */}
|
||||
{/* to={`/server/${id}/mods`} */}
|
||||
{/* end */}
|
||||
{/* > */}
|
||||
{/* <ModrinthLogo /> */}
|
||||
{/* <p>Mods/Plugins</p> */}
|
||||
{/* </NavLink> */}
|
||||
{/* </Can> */}
|
||||
</>
|
||||
<Can action={'startup.software'}>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationShell}
|
||||
to={`/server/${id}/shell`}
|
||||
end
|
||||
>
|
||||
<Box width={22} height={22} fill='currentColor' />
|
||||
<p>Software</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
{/* Dynamic navigation items from routes config */}
|
||||
{navRoutes.map((route) => (
|
||||
<ServerSidebarNavItem
|
||||
key={route.path || 'home'}
|
||||
ref={getRefForRoute(route)}
|
||||
route={route}
|
||||
serverId={id}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div className='shrink-0'>
|
||||
<div aria-hidden className='mt-8 mb-4 bg-[#ffffff33] min-h-[1px] w-full'></div>
|
||||
@@ -532,7 +371,7 @@ const ServerRouter = () => {
|
||||
key={route}
|
||||
path={route}
|
||||
element={
|
||||
<PermissionRoute permission={permission}>
|
||||
<PermissionRoute permission={permission ?? undefined}>
|
||||
<Suspense fallback={null}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import {
|
||||
Box,
|
||||
BranchesDown,
|
||||
ClockArrowRotateLeft,
|
||||
CloudArrowUpIn,
|
||||
Database,
|
||||
FolderOpen,
|
||||
Gear,
|
||||
House,
|
||||
PencilToLine,
|
||||
Persons,
|
||||
Terminal,
|
||||
} from '@gravity-ui/icons';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
|
||||
@@ -28,6 +41,12 @@ import UsersContainer from '@/components/server/users/UsersContainer';
|
||||
const FileEditContainer = lazy(() => import('@/components/server/files/FileEditContainer'));
|
||||
const ScheduleEditContainer = lazy(() => import('@/components/server/schedules/ScheduleEditContainer'));
|
||||
|
||||
// Icon component type that works with Gravity UI icons
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
|
||||
// Feature limit types for visibility conditions
|
||||
export type FeatureLimitKey = 'databases' | 'backups' | 'allocations';
|
||||
|
||||
interface RouteDefinition {
|
||||
/**
|
||||
* Route is the path that will be matched against, this field supports wildcards.
|
||||
@@ -35,7 +54,7 @@ interface RouteDefinition {
|
||||
route: string;
|
||||
/**
|
||||
* Path is the path that will be used for any navbars or links, do not use wildcards or fancy
|
||||
* matchers here. If this field is left undefined, this route will not have a navigation element,
|
||||
* matchers here. If this field is left undefined, this route will not have a navigation element.
|
||||
*/
|
||||
path?: string;
|
||||
// If undefined is passed this route is still rendered into the router itself
|
||||
@@ -45,8 +64,27 @@ interface RouteDefinition {
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
interface ServerRouteDefinition extends RouteDefinition {
|
||||
permission?: string | string[];
|
||||
export interface ServerRouteDefinition extends RouteDefinition {
|
||||
permission?: string | string[] | null;
|
||||
/**
|
||||
* Icon to display in the sidebar/navigation.
|
||||
* If undefined, the route won't have a navigation element.
|
||||
*/
|
||||
icon?: IconComponent;
|
||||
/**
|
||||
* Feature limit key to check. If the limit is 0, the nav item is hidden.
|
||||
* Special value 'network' checks both allocations limit AND subdomain support.
|
||||
*/
|
||||
featureLimit?: FeatureLimitKey | 'network';
|
||||
/**
|
||||
* Whether this is a sub-route that shouldn't appear in navigation.
|
||||
*/
|
||||
isSubRoute?: boolean;
|
||||
/**
|
||||
* Route path patterns that should highlight this nav item.
|
||||
* Used for matching nested routes to parent nav items.
|
||||
*/
|
||||
highlightPatterns?: RegExp[];
|
||||
}
|
||||
|
||||
interface Routes {
|
||||
@@ -56,7 +94,7 @@ interface Routes {
|
||||
server: ServerRouteDefinition[];
|
||||
}
|
||||
|
||||
export default {
|
||||
const routes: Routes = {
|
||||
account: [
|
||||
{
|
||||
route: '',
|
||||
@@ -89,8 +127,9 @@ export default {
|
||||
route: '',
|
||||
path: '',
|
||||
permission: null,
|
||||
name: 'Console',
|
||||
name: 'Home',
|
||||
component: ServerConsoleContainer,
|
||||
icon: House,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
@@ -99,12 +138,15 @@ export default {
|
||||
permission: 'file.*',
|
||||
name: 'Files',
|
||||
component: FileManagerContainer,
|
||||
icon: FolderOpen,
|
||||
highlightPatterns: [/^\/server\/[^/]+\/files(\/.*)?$/],
|
||||
},
|
||||
{
|
||||
route: 'files/:action/*',
|
||||
permission: 'file.*',
|
||||
name: undefined,
|
||||
component: FileEditContainer,
|
||||
isSubRoute: true,
|
||||
},
|
||||
{
|
||||
route: 'databases/*',
|
||||
@@ -112,38 +154,9 @@ export default {
|
||||
permission: 'database.*',
|
||||
name: 'Databases',
|
||||
component: DatabasesContainer,
|
||||
},
|
||||
{
|
||||
route: 'schedules/*',
|
||||
path: 'schedules',
|
||||
permission: 'schedule.*',
|
||||
name: 'Schedules',
|
||||
component: ScheduleContainer,
|
||||
},
|
||||
{
|
||||
route: 'schedules/:id/*',
|
||||
permission: 'schedule.*',
|
||||
name: undefined,
|
||||
component: ScheduleEditContainer,
|
||||
},
|
||||
{
|
||||
route: 'users/*',
|
||||
path: 'users',
|
||||
permission: 'user.*',
|
||||
name: 'Users',
|
||||
component: UsersContainer,
|
||||
},
|
||||
{
|
||||
route: 'users/new',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: CreateUserContainer,
|
||||
},
|
||||
{
|
||||
route: 'users/:id/edit',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: EditUserContainer,
|
||||
icon: Database,
|
||||
featureLimit: 'databases',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'backups/*',
|
||||
@@ -151,13 +164,42 @@ export default {
|
||||
permission: 'backup.*',
|
||||
name: 'Backups',
|
||||
component: BackupContainer,
|
||||
icon: CloudArrowUpIn,
|
||||
featureLimit: 'backups',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'network/*',
|
||||
path: 'network',
|
||||
permission: 'allocation.*',
|
||||
name: 'Network',
|
||||
name: 'Networking',
|
||||
component: NetworkContainer,
|
||||
icon: BranchesDown,
|
||||
featureLimit: 'network',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'users/*',
|
||||
path: 'users',
|
||||
permission: 'user.*',
|
||||
name: 'Users',
|
||||
component: UsersContainer,
|
||||
icon: Persons,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'users/new',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: CreateUserContainer,
|
||||
isSubRoute: true,
|
||||
},
|
||||
{
|
||||
route: 'users/:id/edit',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: EditUserContainer,
|
||||
isSubRoute: true,
|
||||
},
|
||||
{
|
||||
route: 'startup/*',
|
||||
@@ -165,6 +207,24 @@ export default {
|
||||
permission: ['startup.read', 'startup.update', 'startup.docker-image'],
|
||||
name: 'Startup',
|
||||
component: StartupContainer,
|
||||
icon: Terminal,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'schedules/*',
|
||||
path: 'schedules',
|
||||
permission: 'schedule.*',
|
||||
name: 'Schedules',
|
||||
component: ScheduleContainer,
|
||||
icon: ClockArrowRotateLeft,
|
||||
highlightPatterns: [/^\/server\/[^/]+\/schedules(\/\d+)?$/],
|
||||
},
|
||||
{
|
||||
route: 'schedules/:id/*',
|
||||
permission: 'schedule.*',
|
||||
name: undefined,
|
||||
component: ScheduleEditContainer,
|
||||
isSubRoute: true,
|
||||
},
|
||||
{
|
||||
route: 'settings/*',
|
||||
@@ -172,20 +232,8 @@ export default {
|
||||
permission: ['settings.*', 'file.sftp'],
|
||||
name: 'Settings',
|
||||
component: SettingsContainer,
|
||||
},
|
||||
{
|
||||
route: 'shell/*',
|
||||
path: 'shell',
|
||||
permission: 'startup.software',
|
||||
name: 'Software',
|
||||
component: ShellContainer,
|
||||
},
|
||||
{
|
||||
route: 'mods/*',
|
||||
path: 'mods',
|
||||
permission: ['modrinth.download', 'settings.modrinth'],
|
||||
name: 'Modrinth',
|
||||
component: ModrinthContainer,
|
||||
icon: Gear,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'activity/*',
|
||||
@@ -193,6 +241,35 @@ export default {
|
||||
permission: 'activity.*',
|
||||
name: 'Activity',
|
||||
component: ServerActivityLogContainer,
|
||||
icon: PencilToLine,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'shell/*',
|
||||
path: 'shell',
|
||||
permission: 'startup.software',
|
||||
name: 'Software',
|
||||
component: ShellContainer,
|
||||
icon: Box,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'mods/*',
|
||||
path: 'mods',
|
||||
permission: ['modrinth.download', 'settings.modrinth'],
|
||||
name: 'Modrinth',
|
||||
component: ModrinthContainer,
|
||||
isSubRoute: true, // Hidden until modrinth support is complete
|
||||
},
|
||||
],
|
||||
} as Routes;
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
/**
|
||||
* Get navigation routes (routes that should appear in sidebar/mobile menu).
|
||||
* Filters out sub-routes and routes without names or icons.
|
||||
*/
|
||||
export const getServerNavRoutes = (): ServerRouteDefinition[] => {
|
||||
return routes.server.filter((route) => route.name && route.icon && !route.isSubRoute);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user