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:
Tyrthurey
2025-12-31 00:05:15 +02:00
parent a6ce80ee72
commit 274e2bd710
5 changed files with 524 additions and 537 deletions

View File

@@ -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>
);
};

View 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;

View File

@@ -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();

View File

@@ -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>

View File

@@ -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);
};