mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2026-03-03 18:35:55 +00:00
Refactor JSON editor and command menu components (#12492)
- 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.
This commit is contained in:
@@ -2,42 +2,68 @@
|
|||||||
|
|
||||||
import type { z } from "zod";
|
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 { CalendarIcon, Check, Clipboard, Download } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "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 { format } from "date-fns";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
import type { Category } from "@/lib/types";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { basePath } from "@/config/site-config";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { fetchCategories } from "@/lib/data";
|
import { fetchCategories } from "@/lib/data";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import type { Script } from "./_schemas/schemas";
|
import type { Script } from "./_schemas/schemas";
|
||||||
|
|
||||||
|
import { ScriptItem } from "../scripts/_components/script-item";
|
||||||
import InstallMethod from "./_components/install-method";
|
import InstallMethod from "./_components/install-method";
|
||||||
import { ScriptSchema } from "./_schemas/schemas";
|
import { ScriptSchema } from "./_schemas/schemas";
|
||||||
import Categories from "./_components/categories";
|
import Categories from "./_components/categories";
|
||||||
import Note from "./_components/note";
|
import Note from "./_components/note";
|
||||||
|
|
||||||
import { githubGist, nord } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
function search(scripts: Script[], query: string): Script[] {
|
||||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
const queryLower = query.toLowerCase().trim();
|
||||||
import { ScriptItem } from "../scripts/_components/script-item";
|
const searchWords = queryLower.split(/\s+/).filter(Boolean);
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
return scripts
|
||||||
import { search } from "@/components/command-menu";
|
.map((script) => {
|
||||||
import { basePath } from "@/config/site-config";
|
const nameLower = script.name.toLowerCase();
|
||||||
import Image from "next/image";
|
const descriptionLower = (script.description || "").toLowerCase();
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
|
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 = {
|
const initialScript: Script = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -77,32 +103,32 @@ export default function JSONGenerator() {
|
|||||||
|
|
||||||
const selectedCategoryObj = useMemo(
|
const selectedCategoryObj = useMemo(
|
||||||
() => categories.find(cat => cat.id.toString() === selectedCategory),
|
() => categories.find(cat => cat.id.toString() === selectedCategory),
|
||||||
[categories, selectedCategory]
|
[categories, selectedCategory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allScripts = useMemo(
|
const allScripts = useMemo(
|
||||||
() => categories.flatMap(cat => cat.scripts || []),
|
() => categories.flatMap(cat => cat.scripts || []),
|
||||||
[categories]
|
[categories],
|
||||||
);
|
);
|
||||||
|
|
||||||
const scripts = useMemo(() => {
|
const scripts = useMemo(() => {
|
||||||
const query = searchQuery.trim()
|
const query = searchQuery.trim();
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
return search(allScripts, query)
|
return search(allScripts, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCategoryObj) {
|
if (selectedCategoryObj) {
|
||||||
return selectedCategoryObj.scripts || []
|
return selectedCategoryObj.scripts || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return []
|
return [];
|
||||||
}, [allScripts, selectedCategoryObj, searchQuery]);
|
}, [allScripts, selectedCategoryObj, searchQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
.then(setCategories)
|
.then(setCategories)
|
||||||
.catch((error) => console.error("Error fetching categories:", error));
|
.catch(error => console.error("Error fetching categories:", error));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,11 +148,14 @@ export default function JSONGenerator() {
|
|||||||
|
|
||||||
if (updated.type === "pve") {
|
if (updated.type === "pve") {
|
||||||
scriptPath = `tools/pve/${updated.slug}.sh`;
|
scriptPath = `tools/pve/${updated.slug}.sh`;
|
||||||
} else if (updated.type === "addon") {
|
}
|
||||||
|
else if (updated.type === "addon") {
|
||||||
scriptPath = `tools/addon/${updated.slug}.sh`;
|
scriptPath = `tools/addon/${updated.slug}.sh`;
|
||||||
} else if (method.type === "alpine") {
|
}
|
||||||
|
else if (method.type === "alpine") {
|
||||||
scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
|
scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
scriptPath = `${updated.type}/${updated.slug}.sh`;
|
scriptPath = `${updated.type}/${updated.slug}.sh`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,11 +174,13 @@ export default function JSONGenerator() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
if (!isValid) toast.warning("JSON schema is invalid. Copying anyway.");
|
if (!isValid)
|
||||||
|
toast.warning("JSON schema is invalid. Copying anyway.");
|
||||||
navigator.clipboard.writeText(JSON.stringify(script, null, 2));
|
navigator.clipboard.writeText(JSON.stringify(script, null, 2));
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
if (isValid) toast.success("Copied metadata to clipboard");
|
if (isValid)
|
||||||
|
toast.success("Copied metadata to clipboard");
|
||||||
}, [script]);
|
}, [script]);
|
||||||
|
|
||||||
const importScript = (script: Script) => {
|
const importScript = (script: Script) => {
|
||||||
@@ -166,11 +197,11 @@ export default function JSONGenerator() {
|
|||||||
setIsValid(true);
|
setIsValid(true);
|
||||||
setZodErrors(null);
|
setZodErrors(null);
|
||||||
toast.success("Imported JSON successfully");
|
toast.success("Imported JSON successfully");
|
||||||
} catch (error) {
|
}
|
||||||
|
catch (error) {
|
||||||
toast.error("Failed to read or parse the JSON file.");
|
toast.error("Failed to read or parse the JSON file.");
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileImport = useCallback(() => {
|
const handleFileImport = useCallback(() => {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
@@ -180,7 +211,8 @@ export default function JSONGenerator() {
|
|||||||
input.onchange = (e: Event) => {
|
input.onchange = (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const file = target.files?.[0];
|
const file = target.files?.[0];
|
||||||
if (!file) return;
|
if (!file)
|
||||||
|
return;
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
@@ -189,7 +221,8 @@ export default function JSONGenerator() {
|
|||||||
const parsed = JSON.parse(content);
|
const parsed = JSON.parse(content);
|
||||||
importScript(parsed);
|
importScript(parsed);
|
||||||
toast.success("Imported JSON successfully");
|
toast.success("Imported JSON successfully");
|
||||||
} catch (error) {
|
}
|
||||||
|
catch (error) {
|
||||||
toast.error("Failed to read the JSON file.");
|
toast.error("Failed to read the JSON file.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -243,7 +276,10 @@ export default function JSONGenerator() {
|
|||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{zodErrors.issues.map((error, index) => (
|
{zodErrors.issues.map((error, index) => (
|
||||||
<AlertDescription key={index} className="p-1 text-red-500">
|
<AlertDescription key={index} className="p-1 text-red-500">
|
||||||
{error.path.join(".")} -{error.message}
|
{error.path.join(".")}
|
||||||
|
{" "}
|
||||||
|
-
|
||||||
|
{error.message}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +306,7 @@ export default function JSONGenerator() {
|
|||||||
onOpenChange={setIsImportDialogOpen}
|
onOpenChange={setIsImportDialogOpen}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem onSelect={e => e.preventDefault()}>
|
||||||
Import existing script
|
Import existing script
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -292,7 +328,7 @@ export default function JSONGenerator() {
|
|||||||
<SelectValue placeholder="Category" />
|
<SelectValue placeholder="Category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{categories.map((category) => (
|
{categories.map(category => (
|
||||||
<SelectItem key={category.id} value={category.id.toString()}>
|
<SelectItem key={category.id} value={category.id.toString()}>
|
||||||
{category.name}
|
{category.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -302,17 +338,21 @@ export default function JSONGenerator() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Search for a script..."
|
placeholder="Search for a script..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{!selectedCategory && !searchQuery ? (
|
{!selectedCategory && !searchQuery
|
||||||
|
? (
|
||||||
<p className="text-muted-foreground text-sm text-center">
|
<p className="text-muted-foreground text-sm text-center">
|
||||||
Select a category or search for a script
|
Select a category or search for a script
|
||||||
</p>
|
</p>
|
||||||
) : scripts.length === 0 ? (
|
)
|
||||||
|
: scripts.length === 0
|
||||||
|
? (
|
||||||
<p className="text-muted-foreground text-sm text-center">
|
<p className="text-muted-foreground text-sm text-center">
|
||||||
No scripts found
|
No scripts found
|
||||||
</p>
|
</p>
|
||||||
) : (
|
)
|
||||||
|
: (
|
||||||
<div className="grid grid-cols-3 auto-rows-min h-64 overflow-y-auto gap-4">
|
<div className="grid grid-cols-3 auto-rows-min h-64 overflow-y-auto gap-4">
|
||||||
{scripts.map(script => (
|
{scripts.map(script => (
|
||||||
<div
|
<div
|
||||||
@@ -348,15 +388,19 @@ export default function JSONGenerator() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>
|
<Label>
|
||||||
Name <span className="text-red-500">*</span>
|
Name
|
||||||
|
{" "}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input placeholder="Example" value={script.name} onChange={(e) => updateScript("name", e.target.value)} />
|
<Input placeholder="Example" value={script.name} onChange={e => updateScript("name", e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>
|
<Label>
|
||||||
Slug <span className="text-red-500">*</span>
|
Slug
|
||||||
|
{" "}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input placeholder="example" value={script.slug} onChange={(e) => updateScript("slug", e.target.value)} />
|
<Input placeholder="example" value={script.slug} onChange={e => updateScript("slug", e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -366,7 +410,7 @@ export default function JSONGenerator() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Full logo URL"
|
placeholder="Full logo URL"
|
||||||
value={script.logo || ""}
|
value={script.logo || ""}
|
||||||
onChange={(e) => updateScript("logo", e.target.value || null)}
|
onChange={e => updateScript("logo", e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -374,24 +418,28 @@ export default function JSONGenerator() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Path to config file"
|
placeholder="Path to config file"
|
||||||
value={script.config_path || ""}
|
value={script.config_path || ""}
|
||||||
onChange={(e) => updateScript("config_path", e.target.value || "")}
|
onChange={e => updateScript("config_path", e.target.value || "")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>
|
<Label>
|
||||||
Description <span className="text-red-500">*</span>
|
Description
|
||||||
|
{" "}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Example"
|
placeholder="Example"
|
||||||
value={script.description}
|
value={script.description}
|
||||||
onChange={(e) => updateScript("description", e.target.value)}
|
onChange={e => updateScript("description", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Categories script={script} setScript={setScript} categories={categories} />
|
<Categories script={script} setScript={setScript} categories={categories} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<Label>
|
<Label>
|
||||||
Date Created <span className="text-red-500">*</span>
|
Date Created
|
||||||
|
{" "}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild className="flex-1">
|
<PopoverTrigger asChild className="flex-1">
|
||||||
@@ -415,7 +463,7 @@ export default function JSONGenerator() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<Label>Type</Label>
|
<Label>Type</Label>
|
||||||
<Select value={script.type} onValueChange={(value) => updateScript("type", value)}>
|
<Select value={script.type} onValueChange={value => updateScript("type", value)}>
|
||||||
<SelectTrigger className="flex-1">
|
<SelectTrigger className="flex-1">
|
||||||
<SelectValue placeholder="Type" />
|
<SelectValue placeholder="Type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -430,17 +478,17 @@ export default function JSONGenerator() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex gap-5">
|
<div className="w-full flex gap-5">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch checked={script.updateable} onCheckedChange={(checked) => updateScript("updateable", checked)} />
|
<Switch checked={script.updateable} onCheckedChange={checked => updateScript("updateable", checked)} />
|
||||||
<label>Updateable</label>
|
<label>Updateable</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch checked={script.privileged} onCheckedChange={(checked) => updateScript("privileged", checked)} />
|
<Switch checked={script.privileged} onCheckedChange={checked => updateScript("privileged", checked)} />
|
||||||
<label>Privileged</label>
|
<label>Privileged</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={script.disable || false}
|
checked={script.disable || false}
|
||||||
onCheckedChange={(checked) => updateScript("disable", checked)}
|
onCheckedChange={checked => updateScript("disable", checked)}
|
||||||
/>
|
/>
|
||||||
<label>Disabled</label>
|
<label>Disabled</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,12 +496,14 @@ export default function JSONGenerator() {
|
|||||||
{script.disable && (
|
{script.disable && (
|
||||||
<div>
|
<div>
|
||||||
<Label>
|
<Label>
|
||||||
Disable Description <span className="text-red-500">*</span>
|
Disable Description
|
||||||
|
{" "}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Explain why this script is disabled..."
|
placeholder="Explain why this script is disabled..."
|
||||||
value={script.disable_description || ""}
|
value={script.disable_description || ""}
|
||||||
onChange={(e) => updateScript("disable_description", e.target.value)}
|
onChange={e => updateScript("disable_description", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -461,18 +511,18 @@ export default function JSONGenerator() {
|
|||||||
placeholder="Interface Port"
|
placeholder="Interface Port"
|
||||||
type="number"
|
type="number"
|
||||||
value={script.interface_port || ""}
|
value={script.interface_port || ""}
|
||||||
onChange={(e) => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
|
onChange={e => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Website URL"
|
placeholder="Website URL"
|
||||||
value={script.website || ""}
|
value={script.website || ""}
|
||||||
onChange={(e) => updateScript("website", e.target.value || null)}
|
onChange={e => updateScript("website", e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Documentation URL"
|
placeholder="Documentation URL"
|
||||||
value={script.documentation || ""}
|
value={script.documentation || ""}
|
||||||
onChange={(e) => updateScript("documentation", e.target.value || null)}
|
onChange={e => updateScript("documentation", e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
|
<InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
|
||||||
@@ -480,22 +530,20 @@ export default function JSONGenerator() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
value={script.default_credentials.username || ""}
|
value={script.default_credentials.username || ""}
|
||||||
onChange={(e) =>
|
onChange={e =>
|
||||||
updateScript("default_credentials", {
|
updateScript("default_credentials", {
|
||||||
...script.default_credentials,
|
...script.default_credentials,
|
||||||
username: e.target.value || null,
|
username: e.target.value || null,
|
||||||
})
|
})}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
value={script.default_credentials.password || ""}
|
value={script.default_credentials.password || ""}
|
||||||
onChange={(e) =>
|
onChange={e =>
|
||||||
updateScript("default_credentials", {
|
updateScript("default_credentials", {
|
||||||
...script.default_credentials,
|
...script.default_credentials,
|
||||||
password: e.target.value || null,
|
password: e.target.value || null,
|
||||||
})
|
})}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
|
<Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
|
||||||
</form>
|
</form>
|
||||||
@@ -504,7 +552,7 @@ export default function JSONGenerator() {
|
|||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="json"
|
defaultValue="json"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onValueChange={(value) => setCurrentTab(value as "json" | "preview")}
|
onValueChange={value => setCurrentTab(value as "json" | "preview")}
|
||||||
value={currentTab}
|
value={currentTab}
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ArrowRightIcon, Sparkles } from "lucide-react";
|
import { ArrowRightIcon, Sparkles } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import type { Category, Script } from "@/lib/types";
|
import type { Category, Script } from "@/lib/types";
|
||||||
@@ -21,35 +22,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/t
|
|||||||
import { DialogTitle } from "./ui/dialog";
|
import { DialogTitle } from "./ui/dialog";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formattedBadge(type: string) {
|
export function formattedBadge(type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -79,11 +51,9 @@ function getRandomScript(categories: Category[], previouslySelected: Set<string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CommandMenu() {
|
function CommandMenu() {
|
||||||
const [query, setQuery] = React.useState("");
|
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [links, setLinks] = React.useState<Category[]>([]);
|
const [links, setLinks] = React.useState<Category[]>([]);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [results, setResults] = React.useState<Script[]>([]);
|
|
||||||
const [selectedScripts, setSelectedScripts] = React.useState<Set<string>>(new Set());
|
const [selectedScripts, setSelectedScripts] = React.useState<Set<string>>(new Set());
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -100,27 +70,6 @@ function CommandMenu() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (query.trim() === "") {
|
|
||||||
fetchSortedCategories();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const scriptMap = new Map<string, Script>();
|
|
||||||
|
|
||||||
for (const category of links) {
|
|
||||||
for (const script of category.scripts || []) {
|
|
||||||
if (!scriptMap.has(script.slug)) {
|
|
||||||
scriptMap.set(script.slug, script);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueScripts = Array.from(scriptMap.values());
|
|
||||||
const filteredResults = search(uniqueScripts, query);
|
|
||||||
setResults(filteredResults);
|
|
||||||
}
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -248,25 +197,27 @@ function CommandMenu() {
|
|||||||
|
|
||||||
<CommandDialog
|
<CommandDialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={setOpen}
|
||||||
setOpen(open);
|
filter={(value: string, search: string) => {
|
||||||
if (open) {
|
const searchLower = search.toLowerCase().trim();
|
||||||
setQuery("");
|
if (!searchLower)
|
||||||
setResults([]);
|
return 1;
|
||||||
}
|
const valueLower = value.toLowerCase();
|
||||||
|
const searchWords = searchLower.split(/\s+/).filter(Boolean);
|
||||||
|
// All search words must appear somewhere in the value (name + description)
|
||||||
|
const allWordsMatch = searchWords.every((word: string) => valueLower.includes(word));
|
||||||
|
return allWordsMatch ? 1 : 0;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">Search scripts</DialogTitle>
|
<DialogTitle className="sr-only">Search scripts</DialogTitle>
|
||||||
<CommandInput
|
<CommandInput placeholder="Search for a script..." />
|
||||||
placeholder="Search for a script..."
|
|
||||||
onValueChange={setQuery}
|
|
||||||
value={query}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
{isLoading ? (
|
{isLoading
|
||||||
|
? (
|
||||||
"Searching..."
|
"Searching..."
|
||||||
) : (
|
)
|
||||||
|
: (
|
||||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">No scripts match your search.</p>
|
<p className="text-sm text-muted-foreground">No scripts match your search.</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -277,51 +228,16 @@ function CommandMenu() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Documentation <ArrowRightIcon className="ml-2 h-4 w-4" />
|
Documentation
|
||||||
|
{" "}
|
||||||
|
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
{Object.entries(uniqueScriptsByCategory).map(([categoryName, scripts]) => (
|
||||||
{results.length > 0 ? (
|
|
||||||
<CommandGroup heading="Search Results">
|
|
||||||
{results.map(script => (
|
|
||||||
<CommandItem
|
|
||||||
key={`script:${script.slug}`}
|
|
||||||
value={`${script.name} ${script.type} ${script.description || ""}`}
|
|
||||||
onSelect={() => {
|
|
||||||
setOpen(false);
|
|
||||||
router.push(`/scripts?id=${script.slug}`);
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`Open script ${script.name}`}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
setOpen(false);
|
|
||||||
router.push(`/scripts?id=${script.slug}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex gap-2" onClick={() => setOpen(false)}>
|
|
||||||
<Image
|
|
||||||
src={script.logo || `/${basePath}/logo.png`}
|
|
||||||
onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
|
|
||||||
unoptimized
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
alt=""
|
|
||||||
className="h-5 w-5"
|
|
||||||
/>
|
|
||||||
<span>{script.name}</span>
|
|
||||||
<span>{formattedBadge(script.type)}</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
) : ( // When no search results, show all scripts grouped by category
|
|
||||||
Object.entries(uniqueScriptsByCategory).map(([categoryName, scripts]) => (
|
|
||||||
<CommandGroup key={`category:${categoryName}`} heading={categoryName}>
|
<CommandGroup key={`category:${categoryName}`} heading={categoryName}>
|
||||||
{scripts.map(script => (
|
{scripts.map(script => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
@@ -356,8 +272,7 @@ function CommandMenu() {
|
|||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</CommandDialog>
|
</CommandDialog>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user