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:
arsam
2026-09-18 23:10:52 +03:30
parent 5aadd6fc2b
commit e5bce0d0f0
+386
View File
@@ -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>
);
}