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