From c1edecba2a866ee859a722d496726b0bf442c72e Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Sun, 18 Oct 2026 22:55:48 +0200 Subject: [PATCH] refactor(webpanel/resources): redesign layout and add collapsible sections -> offers a more compact view -> collapsible sections --- apps/webpanel/src/pages/resources/index.tsx | 459 ++++++++++++++------ 1 file changed, 321 insertions(+), 138 deletions(-) diff --git a/apps/webpanel/src/pages/resources/index.tsx b/apps/webpanel/src/pages/resources/index.tsx index 550c42e9..231054e2 100644 --- a/apps/webpanel/src/pages/resources/index.tsx +++ b/apps/webpanel/src/pages/resources/index.tsx @@ -15,6 +15,11 @@ import { SelectValue, } from '@fxmanager/ui/components/select'; import { Skeleton } from '@fxmanager/ui/components/skeleton'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@fxmanager/ui/components/collapsible'; import { cn } from '@fxmanager/ui/lib/utils'; import { LayoutList, @@ -25,27 +30,261 @@ import { AlertCircle, RefreshCcwIcon, FileQuestionMark, + Plus, + Minus, + ChevronsUpDown, + FolderIcon, } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@fxmanager/ui/components/tooltip'; type FilterValue = 'all' | 'started' | 'stopped'; -function formatPath(path: string): string { - const normalized = path.replace(/\\/g, '/'); +type ResourceGroupType = { + resources: ResourceData[]; + [key: string]: ResourceData[] | ResourceGroupType; +}; - const parts = normalized.split(/\/resources\/+/i); - if (parts.length > 1) { - return parts[parts.length - 1]!; - } +const REGEX_RESOURCE_GROUPS = /\[([^\]]+)\]/g; + +const STATE_CONFIG = { + stopped: { + icon: Square, + title: 'Server Stopped', + description: + 'The server is currently offline. Start it to view and manage resources.', + styles: 'bg-zinc-200 dark:bg-zinc-700/40 text-zinc-600 dark:text-zinc-400', + titleStyle: 'text-zinc-900 dark:text-zinc-200', + }, + crashed: { + icon: AlertCircle, + title: 'Server Crashed', + description: + 'The server process terminated unexpectedly. Check logs for more details.', + styles: 'bg-red-100 dark:bg-red-500/10 text-red-600 dark:text-red-500', + titleStyle: 'text-red-600 dark:text-red-500', + }, + no_resources: { + icon: FileQuestionMark, + title: 'No Resources', + description: 'No resources were found on the server.', + styles: 'bg-zinc-200 dark:bg-zinc-700/40 text-zinc-600 dark:text-zinc-400', + titleStyle: 'text-zinc-900 dark:text-zinc-200', + }, +} as const; - return normalized; +function formatVersion(version: string) { + if (version.startsWith('v')) return version; + return `v${version}`; +} + +function ResourceCard({ + resource, + handleAction, +}: { + resource: ResourceData; + handleAction: ( + action: 'start' | 'stop' | 'refresh', + resource?: string | undefined, + ) => void; +}) { + const isStarted = resource.status === 'started'; + + return ( +
+
+
+ +
+ +
+
+

+ {resource.name} +

+ {resource.version && ( + + {formatVersion(resource.version)} + + )} + {resource.author && ( + + {resource.author} + + )} +
+ + {resource.description && ( +

+ {resource.description} +

+ )} +
+
+ +
+ + {isStarted ? ( + + ) : ( + + )} + {resource.status} + + +
+ {isStarted ? ( + + ) : ( + + )} + + +
+
+
+ ); +} + +function ResourceGroup({ + name, + node, + rootElement, + handleAction, + globalExpand, +}: { + name: string; + node: ResourceGroupType; + rootElement?: boolean; + handleAction: ( + action: 'start' | 'stop' | 'refresh', + resource?: string | undefined, + ) => void; + globalExpand: boolean | null; +}) { + const [collapsed, setCollapsed] = useState(false); + + // Synchronize with global expand/collapse toggle changes + useEffect(() => { + if (globalExpand !== null) { + setCollapsed(!globalExpand); + } + }, [globalExpand]); + + const { resources, ...subcategories } = node; + const totalResources = resources?.length || 0; + + return ( +
+ setCollapsed(!open)} + > + + + + + +
+ {resources && resources.length > 0 && ( +
+ {[...resources] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((res) => ( + + ))} +
+ )} + + {resources && resources.length === 0 && !rootElement && ( +
+ No resources in this group +
+ )} + + {Object.entries(subcategories).map(([subName, subNode]) => ( + + ))} +
+
+
+
+ ); } function ResourceCardSkeleton() { const skeletonBg = 'bg-zinc-300 dark:bg-zinc-800/80'; return ( -
+
@@ -87,32 +326,6 @@ function LoadingSkeleton({ blur }: { blur?: boolean }) { ); } -const STATE_CONFIG = { - stopped: { - icon: Square, - title: 'Server Stopped', - description: - 'The server is currently offline. Start it to view and manage resources.', - styles: 'bg-zinc-200 dark:bg-zinc-700/40 text-zinc-600 dark:text-zinc-400', - titleStyle: 'text-zinc-900 dark:text-zinc-200', - }, - crashed: { - icon: AlertCircle, - title: 'Server Crashed', - description: - 'The server process terminated unexpectedly. Check logs for more details.', - styles: 'bg-red-100 dark:bg-red-500/10 text-red-600 dark:text-red-500', - titleStyle: 'text-red-600 dark:text-red-500', - }, - no_resources: { - icon: FileQuestionMark, - title: 'No Resources', - description: 'No resources were found on the server.', - styles: 'bg-zinc-200 dark:bg-zinc-700/40 text-zinc-600 dark:text-zinc-400', - titleStyle: 'text-zinc-900 dark:text-zinc-200', - }, -} as const; - function StateDisplay({ type }: { type: keyof typeof STATE_CONFIG }) { const config = STATE_CONFIG[type]; const Icon = config.icon; @@ -147,6 +360,7 @@ export function ResourceList() { const [searchValue, setSearchValue] = useState(''); const [filterValue, setFilterValue] = useState('all'); const [refreshing, setRefresh] = useState(false); + const [globalExpand, setGlobalExpand] = useState(null); useEffect(() => { const filtered = resources.filter((res) => { @@ -167,11 +381,6 @@ export function ResourceList() { setSearchValue(value); } - function formatVersion(version: string) { - if (version.startsWith('v')) return version; - return `v${version}`; - } - async function handleAction( action: 'start' | 'stop' | 'refresh', resource?: string, @@ -212,6 +421,38 @@ export function ResourceList() { } } + const resourceGroups = useMemo(() => { + return displayedResources.reduce( + (acc, cur) => { + const groups = [...cur.path.matchAll(REGEX_RESOURCE_GROUPS)] + .map((match) => match[1]) + .filter((i) => typeof i === 'string'); + + let location; + if (groups.length > 0 && groups[0] !== undefined) { + location = acc.other; + for (const grp of groups) { + if (!Object.hasOwn(location, grp)) { + location[grp] = { resources: [] }; + } + location = location[grp]; + } + location = location.resources; + } else { + location = acc.root; + } + + location.push(cur); + + return acc; + }, + { + root: [] as ResourceData[], + other: {} as Record, + }, + ); + }, [displayedResources]); + return (
@@ -255,9 +496,9 @@ export function ResourceList() {
-
+
{state.status === 'running' && ( -
+
Total @@ -287,6 +528,25 @@ export function ResourceList() {
)} + + + + + + Collapse / Expand all resource folders + + + - ) : ( - - )} - - -
-
-
-
- )) - ) : ( - +
+ {resourceGroups.root.length > 0 && ( + )} + {Object.entries(resourceGroups.other).map(([name, node]) => ( + + ))}
) : (