Import source: src/components/features/tool-launcher
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,386 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ChevronUp, Grid2X2, List, Search, Star, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { groupRoutes, routeItems } from "@/lib/routes";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useUiStore } from "@/stores/ui-store";
|
||||||
|
|
||||||
|
const FavoriteToolsDnd = dynamic(
|
||||||
|
() => import("@/components/features/favorite-tools-dnd").then((mod) => mod.FavoriteToolsDnd),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const jupiterGroups = [
|
||||||
|
"Email",
|
||||||
|
"Files",
|
||||||
|
"Domains",
|
||||||
|
"Databases",
|
||||||
|
"Security",
|
||||||
|
"Software",
|
||||||
|
"Metrics",
|
||||||
|
"Advanced",
|
||||||
|
"Developer Tools",
|
||||||
|
"Backups",
|
||||||
|
"Automation",
|
||||||
|
"Users",
|
||||||
|
"Resellers",
|
||||||
|
"Preferences",
|
||||||
|
"Server Administration",
|
||||||
|
"Notifications",
|
||||||
|
"Audit",
|
||||||
|
"Support",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ToolLauncher() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [category, setCategory] = useState("All");
|
||||||
|
const [mode, setMode] = useState<"grid" | "list">("grid");
|
||||||
|
const [railWidth, setRailWidth] = useState(292);
|
||||||
|
const favorites = useUiStore((state) => state.favorites);
|
||||||
|
const toggleFavorite = useUiStore((state) => state.toggleFavorite);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => groupRoutes(routeItems), []);
|
||||||
|
const categories = ["All", ...Object.keys(grouped)];
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const term = query.toLowerCase();
|
||||||
|
return routeItems.filter((item) => {
|
||||||
|
const matchesCategory = category === "All" || item.category === category;
|
||||||
|
const matchesSearch = [
|
||||||
|
item.label,
|
||||||
|
item.description,
|
||||||
|
item.category,
|
||||||
|
...item.keywords,
|
||||||
|
].some((value) => value.toLowerCase().includes(term));
|
||||||
|
return matchesCategory && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [category, query]);
|
||||||
|
|
||||||
|
function startRailResize(event: ReactPointerEvent<HTMLDivElement>) {
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = railWidth;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
function onMove(moveEvent: globalThis.PointerEvent) {
|
||||||
|
const next = Math.min(430, Math.max(250, startWidth - (moveEvent.clientX - startX)));
|
||||||
|
setRailWidth(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
window.removeEventListener("pointerup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onMove);
|
||||||
|
window.addEventListener("pointerup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_8px_var(--tools-rail-width)]"
|
||||||
|
style={{ "--tools-rail-width": `${railWidth}px` } as CSSProperties}
|
||||||
|
>
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="glass-panel flex flex-col gap-2 rounded-lg px-3 py-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
className="h-8 rounded-md pl-8"
|
||||||
|
placeholder="Search all CoaxysPanel tools"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant={mode === "grid" ? "default" : "outline"}
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setMode("grid")}
|
||||||
|
aria-label="Grid mode"
|
||||||
|
className="h-8 w-8 rounded-md"
|
||||||
|
>
|
||||||
|
<Grid2X2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={mode === "list" ? "default" : "outline"}
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setMode("list")}
|
||||||
|
aria-label="List mode"
|
||||||
|
className="h-8 w-8 rounded-md"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{categories.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
size="sm"
|
||||||
|
variant={category === item ? "default" : "outline"}
|
||||||
|
onClick={() => setCategory(item)}
|
||||||
|
className="h-7 rounded-md px-2 text-xs"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!query && category === "All" ? (
|
||||||
|
<Card className="rounded-md">
|
||||||
|
<CardHeader className="px-3 py-2">
|
||||||
|
<CardTitle className="text-xs uppercase tracking-wide">Favorites</CardTitle>
|
||||||
|
<CardDescription>Drag to reorder your preferred tools.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<FavoriteToolsDnd favorites={favorites} onToggleFavorite={toggleFavorite} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{query || category !== "All" ? (
|
||||||
|
<ToolResults
|
||||||
|
tools={filtered}
|
||||||
|
mode={mode}
|
||||||
|
favorites={favorites}
|
||||||
|
onToggleFavorite={toggleFavorite}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
jupiterGroups
|
||||||
|
.map((group) => [group, grouped[group] ?? []] as const)
|
||||||
|
.filter(([, tools]) => tools.length)
|
||||||
|
.map(([group, tools]) => (
|
||||||
|
<ToolCategory
|
||||||
|
key={group}
|
||||||
|
title={group}
|
||||||
|
tools={tools}
|
||||||
|
favorites={favorites}
|
||||||
|
onToggleFavorite={toggleFavorite}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hidden cursor-col-resize rounded-full bg-transparent transition hover:bg-primary/30 xl:block"
|
||||||
|
onPointerDown={startRailResize}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize tools information pane"
|
||||||
|
title="Drag to resize"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside className="space-y-3">
|
||||||
|
<Card className="rounded-md">
|
||||||
|
<CardHeader className="px-3 py-2">
|
||||||
|
<CardTitle className="text-xs uppercase tracking-wide">
|
||||||
|
General Information
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-0 p-0 text-xs">
|
||||||
|
{[
|
||||||
|
["Current User", "coaxys (example.com)"],
|
||||||
|
["Primary Domain", "example.com"],
|
||||||
|
["Shared IP Address", "198.51.100.24"],
|
||||||
|
["Home Directory", "/home/coaxys"],
|
||||||
|
["Last Login IP Address", "192.0.2.44"],
|
||||||
|
["Theme", "CoaxysPanel Jupiter"],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label} className="border-b px-3 py-2 last:border-b-0">
|
||||||
|
<div className="mb-1 font-semibold text-muted-foreground">{label}</div>
|
||||||
|
<div className="truncate font-medium">{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="relative overflow-hidden rounded-md border-primary/30">
|
||||||
|
<Image
|
||||||
|
src="/art/promo-shield.jpg"
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="340px"
|
||||||
|
className="hidden object-cover opacity-35 dark:block"
|
||||||
|
/>
|
||||||
|
<CardContent className="relative p-3">
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
|
<div className="text-sm font-semibold">Site Quality Monitoring</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 rounded-md"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Monitor uptime, SSL expiry, redirects, and common website errors.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-3 h-8 w-full rounded-md text-xs">Start Monitoring</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolResults({
|
||||||
|
tools,
|
||||||
|
mode,
|
||||||
|
favorites,
|
||||||
|
onToggleFavorite,
|
||||||
|
}: {
|
||||||
|
tools: typeof routeItems;
|
||||||
|
mode: "grid" | "list";
|
||||||
|
favorites: string[];
|
||||||
|
onToggleFavorite: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
mode === "grid"
|
||||||
|
? "grid gap-2 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||||
|
: "space-y-1"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<ToolCard
|
||||||
|
key={tool.path}
|
||||||
|
tool={tool}
|
||||||
|
favorite={favorites.includes(tool.path)}
|
||||||
|
compact={mode === "list"}
|
||||||
|
onToggleFavorite={() => onToggleFavorite(tool.path)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCategory({
|
||||||
|
title,
|
||||||
|
tools,
|
||||||
|
favorites,
|
||||||
|
onToggleFavorite,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
tools: typeof routeItems;
|
||||||
|
favorites: string[];
|
||||||
|
onToggleFavorite: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const HeaderIcon = tools[0]?.icon ?? Grid2X2;
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden rounded-md">
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0 border-b border-border/60 px-3 py-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<HeaderIcon className="h-4 w-4 text-accent" />
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<ChevronUp className="h-4 w-4 text-primary" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="grid gap-x-5 gap-y-1 px-3 py-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const Icon = tool.icon;
|
||||||
|
const favorite = favorites.includes(tool.path);
|
||||||
|
return (
|
||||||
|
<div key={tool.path} className="group flex min-h-10 items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={tool.path}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1 text-[13px] text-primary hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center text-foreground/75 transition-transform duration-150 group-hover:scale-110">
|
||||||
|
<Icon className="h-6 w-6 stroke-[1.4]" />
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-card bg-accent" />
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{tool.label}</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleFavorite(tool.path)}
|
||||||
|
aria-label={`Favorite ${tool.label}`}
|
||||||
|
className="opacity-0 transition group-hover:opacity-100 focus:opacity-100"
|
||||||
|
>
|
||||||
|
<Star className={cn("h-3.5 w-3.5", favorite && "fill-accent text-accent")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCard({
|
||||||
|
tool,
|
||||||
|
favorite,
|
||||||
|
compact,
|
||||||
|
onToggleFavorite,
|
||||||
|
}: {
|
||||||
|
tool: (typeof routeItems)[number];
|
||||||
|
favorite: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
onToggleFavorite: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = tool.icon;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"rounded-md transition-colors duration-150 hover:border-primary/50",
|
||||||
|
compact && "shadow-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent
|
||||||
|
className={cn("flex gap-3 p-2", compact ? "items-center" : "min-h-24 flex-col")}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<Link href={tool.path} className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-semibold">{tool.label}</span>
|
||||||
|
<span className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{tool.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onToggleFavorite}
|
||||||
|
aria-label={`Favorite ${tool.label}`}
|
||||||
|
className="h-7 w-7 rounded-md"
|
||||||
|
>
|
||||||
|
<Star className={cn("h-4 w-4", favorite && "fill-accent text-accent")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!compact ? (
|
||||||
|
<div className="mt-auto flex items-center gap-2">
|
||||||
|
<Badge variant="outline">{tool.category}</Badge>
|
||||||
|
<Badge variant={tool.danger ? "warning" : "success"}>
|
||||||
|
{tool.danger ? "confirmation" : "allowed"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user