mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2026-03-17 07:23:00 +01:00
- Moved the search function to the JSON editor for better organization. - Improved search functionality in the command menu with a new filtering approach. - Cleaned up unused imports and optimized state management. - Enhanced user experience by refining the search results display and handling. - Updated error handling for JSON import and file reading processes.
591 lines
21 KiB
TypeScript
591 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import type { z } from "zod";
|
|
|
|
import { githubGist, nord } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
|
import { CalendarIcon, Check, Clipboard, Download } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
import { useTheme } from "next-themes";
|
|
import { format } from "date-fns";
|
|
import { toast } from "sonner";
|
|
import Image from "next/image";
|
|
|
|
import type { Category } from "@/lib/types";
|
|
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { basePath } from "@/config/site-config";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { fetchCategories } from "@/lib/data";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import type { Script } from "./_schemas/schemas";
|
|
|
|
import { ScriptItem } from "../scripts/_components/script-item";
|
|
import InstallMethod from "./_components/install-method";
|
|
import { ScriptSchema } from "./_schemas/schemas";
|
|
import Categories from "./_components/categories";
|
|
import Note from "./_components/note";
|
|
|
|
function search(scripts: Script[], query: string): Script[] {
|
|
const queryLower = query.toLowerCase().trim();
|
|
const searchWords = queryLower.split(/\s+/).filter(Boolean);
|
|
|
|
return scripts
|
|
.map((script) => {
|
|
const nameLower = script.name.toLowerCase();
|
|
const descriptionLower = (script.description || "").toLowerCase();
|
|
|
|
let score = 0;
|
|
|
|
for (const word of searchWords) {
|
|
if (nameLower.includes(word)) {
|
|
score += 10;
|
|
}
|
|
if (descriptionLower.includes(word)) {
|
|
score += 5;
|
|
}
|
|
}
|
|
|
|
return { script, score };
|
|
})
|
|
.filter(({ score }) => score > 0)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 20)
|
|
.map(({ script }) => script);
|
|
}
|
|
|
|
const initialScript: Script = {
|
|
name: "",
|
|
slug: "",
|
|
categories: [],
|
|
date_created: format(new Date(), "yyyy-MM-dd"),
|
|
type: "ct",
|
|
updateable: false,
|
|
privileged: false,
|
|
interface_port: null,
|
|
documentation: null,
|
|
config_path: "",
|
|
website: null,
|
|
logo: null,
|
|
description: "",
|
|
disable: undefined,
|
|
disable_description: undefined,
|
|
install_methods: [],
|
|
default_credentials: {
|
|
username: null,
|
|
password: null,
|
|
},
|
|
notes: [],
|
|
};
|
|
|
|
export default function JSONGenerator() {
|
|
const { theme } = useTheme();
|
|
const [script, setScript] = useState<Script>(initialScript);
|
|
const [isCopied, setIsCopied] = useState(false);
|
|
const [isValid, setIsValid] = useState(false);
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
const [currentTab, setCurrentTab] = useState<"json" | "preview">("json");
|
|
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
|
const [zodErrors, setZodErrors] = useState<z.ZodError | null>(null);
|
|
|
|
const selectedCategoryObj = useMemo(
|
|
() => categories.find(cat => cat.id.toString() === selectedCategory),
|
|
[categories, selectedCategory],
|
|
);
|
|
|
|
const allScripts = useMemo(
|
|
() => categories.flatMap(cat => cat.scripts || []),
|
|
[categories],
|
|
);
|
|
|
|
const scripts = useMemo(() => {
|
|
const query = searchQuery.trim();
|
|
|
|
if (query) {
|
|
return search(allScripts, query);
|
|
}
|
|
|
|
if (selectedCategoryObj) {
|
|
return selectedCategoryObj.scripts || [];
|
|
}
|
|
|
|
return [];
|
|
}, [allScripts, selectedCategoryObj, searchQuery]);
|
|
|
|
useEffect(() => {
|
|
fetchCategories()
|
|
.then(setCategories)
|
|
.catch(error => console.error("Error fetching categories:", error));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isValid && currentTab === "preview") {
|
|
setCurrentTab("json");
|
|
toast.error("Switched to JSON tab due to invalid configuration.");
|
|
}
|
|
}, [isValid, currentTab]);
|
|
|
|
const updateScript = useCallback((key: keyof Script, value: Script[keyof Script]) => {
|
|
setScript((prev) => {
|
|
const updated = { ...prev, [key]: value };
|
|
|
|
if (updated.slug && updated.type) {
|
|
updated.install_methods = updated.install_methods.map((method) => {
|
|
let scriptPath = "";
|
|
|
|
if (updated.type === "pve") {
|
|
scriptPath = `tools/pve/${updated.slug}.sh`;
|
|
}
|
|
else if (updated.type === "addon") {
|
|
scriptPath = `tools/addon/${updated.slug}.sh`;
|
|
}
|
|
else if (method.type === "alpine") {
|
|
scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
|
|
}
|
|
else {
|
|
scriptPath = `${updated.type}/${updated.slug}.sh`;
|
|
}
|
|
|
|
return {
|
|
...method,
|
|
script: scriptPath,
|
|
};
|
|
});
|
|
}
|
|
|
|
const result = ScriptSchema.safeParse(updated);
|
|
setIsValid(result.success);
|
|
setZodErrors(result.success ? null : result.error);
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
const handleCopy = useCallback(() => {
|
|
if (!isValid)
|
|
toast.warning("JSON schema is invalid. Copying anyway.");
|
|
navigator.clipboard.writeText(JSON.stringify(script, null, 2));
|
|
setIsCopied(true);
|
|
setTimeout(() => setIsCopied(false), 2000);
|
|
if (isValid)
|
|
toast.success("Copied metadata to clipboard");
|
|
}, [script]);
|
|
|
|
const importScript = (script: Script) => {
|
|
try {
|
|
const result = ScriptSchema.safeParse(script);
|
|
if (!result.success) {
|
|
setIsValid(false);
|
|
setZodErrors(result.error);
|
|
toast.error("Imported JSON is invalid according to the schema.");
|
|
return;
|
|
}
|
|
|
|
setScript(result.data);
|
|
setIsValid(true);
|
|
setZodErrors(null);
|
|
toast.success("Imported JSON successfully");
|
|
}
|
|
catch (error) {
|
|
toast.error("Failed to read or parse the JSON file.");
|
|
}
|
|
};
|
|
|
|
const handleFileImport = useCallback(() => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "application/json";
|
|
|
|
input.onchange = (e: Event) => {
|
|
const target = e.target as HTMLInputElement;
|
|
const file = target.files?.[0];
|
|
if (!file)
|
|
return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
try {
|
|
const content = event.target?.result as string;
|
|
const parsed = JSON.parse(content);
|
|
importScript(parsed);
|
|
toast.success("Imported JSON successfully");
|
|
}
|
|
catch (error) {
|
|
toast.error("Failed to read the JSON file.");
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
input.click();
|
|
}, [setScript]);
|
|
|
|
const handleDownload = useCallback(() => {
|
|
if (isValid === false) {
|
|
toast.error("Cannot download invalid JSON");
|
|
return;
|
|
}
|
|
const jsonString = JSON.stringify(script, null, 2);
|
|
const blob = new Blob([jsonString], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${script.slug || "script"}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
}, [script]);
|
|
|
|
const handleDateSelect = useCallback(
|
|
(date: Date | undefined) => {
|
|
updateScript("date_created", format(date || new Date(), "yyyy-MM-dd"));
|
|
},
|
|
[updateScript],
|
|
);
|
|
|
|
const formattedDate = useMemo(
|
|
() => (script.date_created ? format(script.date_created, "PPP") : undefined),
|
|
[script.date_created],
|
|
);
|
|
|
|
const validationAlert = useMemo(
|
|
() => (
|
|
<Alert className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}>
|
|
<AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle>
|
|
<AlertDescription>
|
|
{isValid
|
|
? "The current JSON is valid according to the schema."
|
|
: "The current JSON does not match the required schema."}
|
|
</AlertDescription>
|
|
{zodErrors && (
|
|
<div className="mt-2 space-y-1">
|
|
{zodErrors.issues.map((error, index) => (
|
|
<AlertDescription key={index} className="p-1 text-red-500">
|
|
{error.path.join(".")}
|
|
{" "}
|
|
-
|
|
{error.message}
|
|
</AlertDescription>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Alert>
|
|
),
|
|
[isValid, zodErrors],
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-screen mt-20">
|
|
<div className="w-1/2 p-4 overflow-y-auto">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-2xl font-bold">JSON Generator</h2>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button>Import</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-52" align="start">
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem onSelect={handleFileImport}>Import local JSON file</DropdownMenuItem>
|
|
<Dialog
|
|
open={isImportDialogOpen}
|
|
onOpenChange={setIsImportDialogOpen}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<DropdownMenuItem onSelect={e => e.preventDefault()}>
|
|
Import existing script
|
|
</DropdownMenuItem>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-md w-full">
|
|
<DialogHeader>
|
|
<DialogTitle>Import existing script</DialogTitle>
|
|
<DialogDescription>
|
|
Select one of the puplished scripts to import its metadata.
|
|
</DialogDescription>
|
|
|
|
</DialogHeader>
|
|
<div className="flex items-center gap-2">
|
|
<div className="grid flex-1 gap-2">
|
|
<Select
|
|
value={selectedCategory}
|
|
onValueChange={setSelectedCategory}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Category" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{categories.map(category => (
|
|
<SelectItem key={category.id} value={category.id.toString()}>
|
|
{category.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
placeholder="Search for a script..."
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
/>
|
|
{!selectedCategory && !searchQuery
|
|
? (
|
|
<p className="text-muted-foreground text-sm text-center">
|
|
Select a category or search for a script
|
|
</p>
|
|
)
|
|
: scripts.length === 0
|
|
? (
|
|
<p className="text-muted-foreground text-sm text-center">
|
|
No scripts found
|
|
</p>
|
|
)
|
|
: (
|
|
<div className="grid grid-cols-3 auto-rows-min h-64 overflow-y-auto gap-4">
|
|
{scripts.map(script => (
|
|
<div
|
|
key={script.slug}
|
|
className="p-2 border rounded cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
|
onClick={() => {
|
|
importScript(script);
|
|
setIsImportDialogOpen(false);
|
|
}}
|
|
>
|
|
<Image
|
|
src={script.logo || `/${basePath}/logo.png`}
|
|
alt={script.name}
|
|
className="w-full h-12 object-contain mb-2"
|
|
width={16}
|
|
height={16}
|
|
unoptimized
|
|
/>
|
|
<p className="text-sm text-center">{script.name}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<form className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>
|
|
Name
|
|
{" "}
|
|
<span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input placeholder="Example" value={script.name} onChange={e => updateScript("name", e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label>
|
|
Slug
|
|
{" "}
|
|
<span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input placeholder="example" value={script.slug} onChange={e => updateScript("slug", e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label>
|
|
Logo
|
|
</Label>
|
|
<Input
|
|
placeholder="Full logo URL"
|
|
value={script.logo || ""}
|
|
onChange={e => updateScript("logo", e.target.value || null)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Config Path</Label>
|
|
<Input
|
|
placeholder="Path to config file"
|
|
value={script.config_path || ""}
|
|
onChange={e => updateScript("config_path", e.target.value || "")}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>
|
|
Description
|
|
{" "}
|
|
<span className="text-red-500">*</span>
|
|
</Label>
|
|
<Textarea
|
|
placeholder="Example"
|
|
value={script.description}
|
|
onChange={e => updateScript("description", e.target.value)}
|
|
/>
|
|
</div>
|
|
<Categories script={script} setScript={setScript} categories={categories} />
|
|
<div className="flex gap-2">
|
|
<div className="flex flex-col gap-2 w-full">
|
|
<Label>
|
|
Date Created
|
|
{" "}
|
|
<span className="text-red-500">*</span>
|
|
</Label>
|
|
<Popover>
|
|
<PopoverTrigger asChild className="flex-1">
|
|
<Button
|
|
variant="outline"
|
|
className={cn("pl-3 text-left font-normal w-full", !script.date_created && "text-muted-foreground")}
|
|
>
|
|
{formattedDate || <span>Pick a date</span>}
|
|
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={new Date(script.date_created)}
|
|
onSelect={handleDateSelect}
|
|
autoFocus
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<div className="flex flex-col gap-2 w-full">
|
|
<Label>Type</Label>
|
|
<Select value={script.type} onValueChange={value => updateScript("type", value)}>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ct">LXC Container</SelectItem>
|
|
<SelectItem value="vm">Virtual Machine</SelectItem>
|
|
<SelectItem value="pve">PVE-Tool</SelectItem>
|
|
<SelectItem value="addon">Add-On</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="w-full flex gap-5">
|
|
<div className="flex items-center space-x-2">
|
|
<Switch checked={script.updateable} onCheckedChange={checked => updateScript("updateable", checked)} />
|
|
<label>Updateable</label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Switch checked={script.privileged} onCheckedChange={checked => updateScript("privileged", checked)} />
|
|
<label>Privileged</label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
checked={script.disable || false}
|
|
onCheckedChange={checked => updateScript("disable", checked)}
|
|
/>
|
|
<label>Disabled</label>
|
|
</div>
|
|
</div>
|
|
{script.disable && (
|
|
<div>
|
|
<Label>
|
|
Disable Description
|
|
{" "}
|
|
<span className="text-red-500">*</span>
|
|
</Label>
|
|
<Textarea
|
|
placeholder="Explain why this script is disabled..."
|
|
value={script.disable_description || ""}
|
|
onChange={e => updateScript("disable_description", e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<Input
|
|
placeholder="Interface Port"
|
|
type="number"
|
|
value={script.interface_port || ""}
|
|
onChange={e => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="Website URL"
|
|
value={script.website || ""}
|
|
onChange={e => updateScript("website", e.target.value || null)}
|
|
/>
|
|
<Input
|
|
placeholder="Documentation URL"
|
|
value={script.documentation || ""}
|
|
onChange={e => updateScript("documentation", e.target.value || null)}
|
|
/>
|
|
</div>
|
|
<InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
|
|
<h3 className="text-xl font-semibold">Default Credentials</h3>
|
|
<Input
|
|
placeholder="Username"
|
|
value={script.default_credentials.username || ""}
|
|
onChange={e =>
|
|
updateScript("default_credentials", {
|
|
...script.default_credentials,
|
|
username: e.target.value || null,
|
|
})}
|
|
/>
|
|
<Input
|
|
placeholder="Password"
|
|
value={script.default_credentials.password || ""}
|
|
onChange={e =>
|
|
updateScript("default_credentials", {
|
|
...script.default_credentials,
|
|
password: e.target.value || null,
|
|
})}
|
|
/>
|
|
<Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
|
|
</form>
|
|
</div>
|
|
<div className="w-1/2 p-4 bg-background overflow-y-auto">
|
|
<Tabs
|
|
defaultValue="json"
|
|
className="w-full"
|
|
onValueChange={value => setCurrentTab(value as "json" | "preview")}
|
|
value={currentTab}
|
|
>
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="json">JSON</TabsTrigger>
|
|
<TabsTrigger disabled={!isValid} value="preview">Preview</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="json" className="h-full w-full">
|
|
{validationAlert}
|
|
<div className="relative">
|
|
<div className="absolute right-2 top-2 flex gap-1">
|
|
<Button size="icon" variant="outline" onClick={handleCopy}>
|
|
{isCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
|
</Button>
|
|
<Button size="icon" variant="outline" onClick={handleDownload}>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<SyntaxHighlighter
|
|
language="json"
|
|
style={theme === "light" ? githubGist : nord}
|
|
className="mt-4 p-4 bg-secondary rounded shadow overflow-x-scroll"
|
|
>
|
|
{JSON.stringify(script, null, 2)}
|
|
</SyntaxHighlighter>
|
|
</div>
|
|
</TabsContent>
|
|
<TabsContent value="preview" className="h-full w-full">
|
|
<ScriptItem item={script} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|