mirror of
https://github.com/community-scripts/ProxmoxVE
synced 2025-02-15 04:09:19 +00:00
Add calendar and label components; enhance JSON generator with date selection and script path updates for installation methods
This commit is contained in:
parent
bdce4f778d
commit
a3b2a476c1
88
frontend/package-lock.json
generated
88
frontend/package-lock.json
generated
@ -13,7 +13,9 @@
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-icons": "^1.3.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@ -24,6 +26,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.11",
|
||||
"fuse.js": "^7.0.0",
|
||||
"lucide-react": "^0.453.0",
|
||||
@ -35,6 +38,7 @@
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"react": "19.0.0-rc-02c0e824-20241028",
|
||||
"react-code-blocks": "^0.1.6",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "19.0.0-rc-02c0e824-20241028",
|
||||
"react-icons": "^5.1.0",
|
||||
"react-simple-typewriter": "^5.0.1",
|
||||
@ -1371,6 +1375,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz",
|
||||
"integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz",
|
||||
@ -1447,6 +1474,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz",
|
||||
"integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.0",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.1",
|
||||
"@radix-ui/react-focus-guards": "1.1.1",
|
||||
"@radix-ui/react-focus-scope": "1.1.0",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-popper": "1.2.0",
|
||||
"@radix-ui/react-portal": "1.1.2",
|
||||
"@radix-ui/react-presence": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.0",
|
||||
"@radix-ui/react-slot": "1.1.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"aria-hidden": "^1.1.1",
|
||||
"react-remove-scroll": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
|
||||
@ -3016,6 +3080,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
@ -6116,6 +6190,20 @@
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"date-fns": "^2.28.0 || ^3.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.0.0-rc-02c0e824-20241028",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc-02c0e824-20241028.tgz",
|
||||
|
@ -23,7 +23,9 @@
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-icons": "^1.3.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@ -34,6 +36,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.11",
|
||||
"fuse.js": "^7.0.0",
|
||||
"lucide-react": "^0.453.0",
|
||||
@ -45,6 +48,7 @@
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"react": "19.0.0-rc-02c0e824-20241028",
|
||||
"react-code-blocks": "^0.1.6",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "19.0.0-rc-02c0e824-20241028",
|
||||
"react-icons": "^5.1.0",
|
||||
"react-simple-typewriter": "^5.0.1",
|
||||
|
@ -2,7 +2,9 @@
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -14,9 +16,13 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { fetchCategories } from "@/lib/data";
|
||||
import { Category } from "@/lib/types";
|
||||
import { PlusCircle, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarIcon, Check, Clipboard, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { format } from "date-fns";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const scriptSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@ -96,13 +102,14 @@ export default function JSONGenerator() {
|
||||
},
|
||||
],
|
||||
});
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
.then((data: Category[]) => {
|
||||
.then((data) => {
|
||||
setCategories(data);
|
||||
})
|
||||
.catch((error) => console.error("Error fetching categories:", error));
|
||||
@ -111,6 +118,18 @@ export default function JSONGenerator() {
|
||||
const updateScript = (key: keyof Script, value: Script[keyof Script]) => {
|
||||
setScript((prev) => {
|
||||
const updated = { ...prev, [key]: value };
|
||||
|
||||
// Update script paths for install methods if `type` or `slug` changed
|
||||
if (key === "type" || key === "slug") {
|
||||
updated.install_methods = updated.install_methods.map((method) => ({
|
||||
...method,
|
||||
script:
|
||||
method.type === "alpine"
|
||||
? `/${updated.type}/alpine-${updated.slug}.sh`
|
||||
: `/${updated.type}/${updated.slug}.sh`,
|
||||
}));
|
||||
}
|
||||
|
||||
const result = scriptSchema.safeParse(updated);
|
||||
setIsValid(result.success);
|
||||
return updated;
|
||||
@ -118,23 +137,23 @@ export default function JSONGenerator() {
|
||||
};
|
||||
|
||||
const addInstallMethod = () => {
|
||||
setScript((prev) => ({
|
||||
...prev,
|
||||
install_methods: [
|
||||
...prev.install_methods,
|
||||
{
|
||||
type: "default",
|
||||
script: "",
|
||||
resources: {
|
||||
cpu: null,
|
||||
ram: null,
|
||||
hdd: null,
|
||||
os: null,
|
||||
version: null,
|
||||
},
|
||||
setScript((prev) => {
|
||||
const method = {
|
||||
type: "default" as "default", // Ensure type matches the union type
|
||||
script: `/${prev.type}/${prev.slug}.sh`, // Default script path
|
||||
resources: {
|
||||
cpu: null,
|
||||
ram: null,
|
||||
hdd: null,
|
||||
os: null,
|
||||
version: null,
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
install_methods: [...prev.install_methods, method],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateInstallMethod = (
|
||||
@ -143,12 +162,28 @@ export default function JSONGenerator() {
|
||||
value: Script["install_methods"][number][keyof Script["install_methods"][number]],
|
||||
) => {
|
||||
setScript((prev) => {
|
||||
const updatedMethods = prev.install_methods.map((method, i) => {
|
||||
if (i === index) {
|
||||
const updatedMethod = { ...method, [key]: value };
|
||||
|
||||
// Update script path if `type` of the install method changes
|
||||
if (key === "type") {
|
||||
updatedMethod.script =
|
||||
value === "alpine"
|
||||
? `/${prev.type}/alpine-${prev.slug}.sh`
|
||||
: `/${prev.type}/${prev.slug}.sh`;
|
||||
}
|
||||
|
||||
return updatedMethod;
|
||||
}
|
||||
return method;
|
||||
});
|
||||
|
||||
const updated = {
|
||||
...prev,
|
||||
install_methods: prev.install_methods.map((method, i) =>
|
||||
i === index ? { ...method, [key]: value } : method,
|
||||
),
|
||||
install_methods: updatedMethods,
|
||||
};
|
||||
|
||||
const result = scriptSchema.safeParse(updated);
|
||||
setIsValid(result.success);
|
||||
return updated;
|
||||
@ -209,24 +244,33 @@ export default function JSONGenerator() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="flex h-screen mt-20">
|
||||
<div className="w-1/2 p-4 overflow-y-auto">
|
||||
<h2 className="text-2xl font-bold mb-4">JSON Generator</h2>
|
||||
<form className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={script.name}
|
||||
onChange={(e) => updateScript("name", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Slug"
|
||||
value={script.slug}
|
||||
onChange={(e) => updateScript("slug", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={script.name}
|
||||
onChange={(e) => updateScript("name", e.target.value)}
|
||||
placeholder="Logo URL"
|
||||
value={script.logo || ""}
|
||||
onChange={(e) => updateScript("logo", e.target.value || null)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Slug"
|
||||
value={script.slug}
|
||||
onChange={(e) => updateScript("slug", e.target.value)}
|
||||
<Textarea
|
||||
placeholder="Description"
|
||||
value={script.description}
|
||||
onChange={(e) => updateScript("description", e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Categories
|
||||
</label>
|
||||
<Select onValueChange={(value) => addCategory(Number(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
@ -239,7 +283,12 @@ export default function JSONGenerator() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap gap-2",
|
||||
script.categories.length !== 0 && "mt-2",
|
||||
)}
|
||||
>
|
||||
{script.categories.map((categoryId) => {
|
||||
const category = categories.find((c) => c.id === categoryId);
|
||||
return category ? (
|
||||
@ -274,37 +323,77 @@ export default function JSONGenerator() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Date Created (YYYY-MM-DD)"
|
||||
value={script.date_created}
|
||||
onChange={(e) => updateScript("date_created", e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={script.type}
|
||||
onValueChange={(value) => updateScript("type", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vm">VM</SelectItem>
|
||||
<SelectItem value="ct">CT</SelectItem>
|
||||
<SelectItem value="misc">Misc</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={script.updateable}
|
||||
onCheckedChange={(checked) => updateScript("updateable", checked)}
|
||||
/>
|
||||
<label>Updateable</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Date Created</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",
|
||||
)}
|
||||
>
|
||||
{script.date_created ? (
|
||||
format(script.date_created, "PPP")
|
||||
) : (
|
||||
<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={(date) =>
|
||||
updateScript(
|
||||
"date_created",
|
||||
format(date || new Date(), "yyyy-MM-dd"),
|
||||
)
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</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="vm">Virtual Machine</SelectItem>
|
||||
<SelectItem value="ct">LXC Container</SelectItem>
|
||||
<SelectItem value="misc">Miscellaneous</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={script.privileged}
|
||||
onCheckedChange={(checked) => updateScript("privileged", checked)}
|
||||
/>
|
||||
<label>Privileged</label>
|
||||
<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>
|
||||
<Input
|
||||
placeholder="Interface Port"
|
||||
@ -317,28 +406,20 @@ export default function JSONGenerator() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Documentation URL"
|
||||
value={script.documentation || ""}
|
||||
onChange={(e) =>
|
||||
updateScript("documentation", e.target.value || null)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Website URL"
|
||||
value={script.website || ""}
|
||||
onChange={(e) => updateScript("website", e.target.value || null)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Logo URL"
|
||||
value={script.logo || ""}
|
||||
onChange={(e) => updateScript("logo", e.target.value || null)}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Description"
|
||||
value={script.description}
|
||||
onChange={(e) => updateScript("description", e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
<h3 className="text-xl font-semibold">Install Methods</h3>
|
||||
{script.install_methods.map((method, index) => (
|
||||
<div key={index} className="space-y-2 border p-4 rounded">
|
||||
@ -356,67 +437,64 @@ export default function JSONGenerator() {
|
||||
<SelectItem value="alpine">Alpine</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
placeholder="Script"
|
||||
value={method.script}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "script", e.target.value)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="CPU"
|
||||
type="number"
|
||||
value={method.resources.cpu || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
cpu: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="RAM"
|
||||
type="number"
|
||||
value={method.resources.ram || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
ram: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="HDD"
|
||||
type="number"
|
||||
value={method.resources.hdd || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
hdd: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="OS"
|
||||
value={method.resources.os || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
os: e.target.value || null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Version"
|
||||
type="number"
|
||||
value={method.resources.version || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
version: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="CPU in Cores"
|
||||
type="number"
|
||||
value={method.resources.cpu || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
cpu: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="RAM in MB"
|
||||
type="number"
|
||||
value={method.resources.ram || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
ram: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="HDD in GB"
|
||||
type="number"
|
||||
value={method.resources.hdd || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
hdd: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="OS"
|
||||
value={method.resources.os || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
os: e.target.value || null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Version"
|
||||
type="number"
|
||||
value={method.resources.version || ""}
|
||||
onChange={(e) =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
version: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => removeInstallMethod(index)}
|
||||
@ -472,8 +550,10 @@ export default function JSONGenerator() {
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="w-1/2 p-4 bg-gray-100 overflow-y-auto">
|
||||
<Alert className={isValid ? "bg-green-100" : "bg-red-100"}>
|
||||
<div className="w-1/2 p-4 bg-background overflow-y-auto">
|
||||
<Alert
|
||||
className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}
|
||||
>
|
||||
<AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{isValid
|
||||
@ -481,7 +561,24 @@ export default function JSONGenerator() {
|
||||
: "The current JSON does not match the required schema."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<pre className="mt-4 p-4 bg-white rounded shadow">
|
||||
<pre className="mt-4 p-4 relative bg-secondary rounded shadow">
|
||||
<Button
|
||||
className="absolute right-2 top-2"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(script, null, 2));
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
toast.success("Copied metadata to clipboard");
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Clipboard className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{JSON.stringify(script, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
66
frontend/src/components/ui/calendar.tsx
Normal file
66
frontend/src/components/ui/calendar.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
26
frontend/src/components/ui/label.tsx
Normal file
26
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
387
frontend/src/components/ui/multi-select.tsx
Normal file
387
frontend/src/components/ui/multi-select.tsx
Normal file
@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Check, X as RemoveIcon } from "lucide-react";
|
||||
import React, {
|
||||
KeyboardEvent,
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface MultiSelectorProps
|
||||
extends React.ComponentPropsWithoutRef<typeof CommandPrimitive> {
|
||||
values: string[];
|
||||
onValuesChange: (value: string[]) => void;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
interface MultiSelectContextProps {
|
||||
value: string[];
|
||||
onValueChange: (value: any) => void;
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
inputValue: string;
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
activeIndex: number;
|
||||
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
ref: React.RefObject<HTMLInputElement>;
|
||||
handleSelect: (e: React.SyntheticEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const MultiSelectContext = createContext<MultiSelectContextProps | null>(null);
|
||||
|
||||
const useMultiSelect = () => {
|
||||
const context = useContext(MultiSelectContext);
|
||||
if (!context) {
|
||||
throw new Error("useMultiSelect must be used within MultiSelectProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* MultiSelect Docs: {@link: https://shadcn-extension.vercel.app/docs/multi-select}
|
||||
*/
|
||||
|
||||
// TODO : expose the visibility of the popup
|
||||
|
||||
const MultiSelector = ({
|
||||
values: value,
|
||||
onValuesChange: onValueChange,
|
||||
loop = false,
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: MultiSelectorProps) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [isValueSelected, setIsValueSelected] = React.useState(false);
|
||||
const [selectedValue, setSelectedValue] = React.useState("");
|
||||
|
||||
const onValueChangeHandler = useCallback(
|
||||
(val: string) => {
|
||||
if (value.includes(val)) {
|
||||
onValueChange(value.filter((item) => item !== val));
|
||||
} else {
|
||||
onValueChange([...value, val]);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[value],
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(e: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const target = e.currentTarget;
|
||||
const selection = target.value.substring(
|
||||
target.selectionStart ?? 0,
|
||||
target.selectionEnd ?? 0,
|
||||
);
|
||||
|
||||
setSelectedValue(selection);
|
||||
setIsValueSelected(selection === inputValue);
|
||||
},
|
||||
[inputValue],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
const target = inputRef.current;
|
||||
|
||||
if (!target) return;
|
||||
|
||||
const moveNext = () => {
|
||||
const nextIndex = activeIndex + 1;
|
||||
setActiveIndex(
|
||||
nextIndex > value.length - 1 ? (loop ? 0 : -1) : nextIndex,
|
||||
);
|
||||
};
|
||||
|
||||
const movePrev = () => {
|
||||
const prevIndex = activeIndex - 1;
|
||||
setActiveIndex(prevIndex < 0 ? value.length - 1 : prevIndex);
|
||||
};
|
||||
|
||||
const moveCurrent = () => {
|
||||
const newIndex =
|
||||
activeIndex - 1 <= 0
|
||||
? value.length - 1 === 0
|
||||
? -1
|
||||
: 0
|
||||
: activeIndex - 1;
|
||||
setActiveIndex(newIndex);
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
if (dir === "rtl") {
|
||||
if (value.length > 0 && (activeIndex !== -1 || loop)) {
|
||||
moveNext();
|
||||
}
|
||||
} else {
|
||||
if (value.length > 0 && target.selectionStart === 0) {
|
||||
movePrev();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "ArrowRight":
|
||||
if (dir === "rtl") {
|
||||
if (value.length > 0 && target.selectionStart === 0) {
|
||||
movePrev();
|
||||
}
|
||||
} else {
|
||||
if (value.length > 0 && (activeIndex !== -1 || loop)) {
|
||||
moveNext();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "Backspace":
|
||||
case "Delete":
|
||||
if (value.length > 0) {
|
||||
if (activeIndex !== -1 && activeIndex < value.length) {
|
||||
onValueChangeHandler(value[activeIndex]);
|
||||
moveCurrent();
|
||||
} else {
|
||||
if (target.selectionStart === 0) {
|
||||
if (selectedValue === inputValue || isValueSelected) {
|
||||
onValueChangeHandler(value[value.length - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
setOpen(true);
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
if (activeIndex !== -1) {
|
||||
setActiveIndex(-1);
|
||||
} else if (open) {
|
||||
setOpen(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[value, inputValue, activeIndex, loop],
|
||||
);
|
||||
|
||||
return (
|
||||
<MultiSelectContext.Provider
|
||||
value={{
|
||||
value,
|
||||
onValueChange: onValueChangeHandler,
|
||||
open,
|
||||
setOpen,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
ref: inputRef,
|
||||
handleSelect,
|
||||
}}
|
||||
>
|
||||
<Command
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"overflow-visible bg-transparent flex flex-col space-y-2",
|
||||
className,
|
||||
)}
|
||||
dir={dir}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</MultiSelectContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const MultiSelectorTrigger = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { value, onValueChange, activeIndex } = useMultiSelect();
|
||||
|
||||
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap gap-1 p-1 py-2 ring-1 ring-muted rounded-lg bg-background",
|
||||
{
|
||||
"ring-1 focus-within:ring-ring": activeIndex === -1,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{value.map((item, index) => (
|
||||
<Badge
|
||||
key={item}
|
||||
className={cn(
|
||||
"px-1 rounded-xl flex items-center gap-1",
|
||||
activeIndex === index && "ring-2 ring-muted-foreground ",
|
||||
)}
|
||||
variant={"secondary"}
|
||||
>
|
||||
<span className="text-xs">{item}</span>
|
||||
<button
|
||||
aria-label={`Remove ${item} option`}
|
||||
aria-roledescription="button to remove option"
|
||||
type="button"
|
||||
onMouseDown={mousePreventDefault}
|
||||
onClick={() => onValueChange(item)}
|
||||
>
|
||||
<span className="sr-only">Remove {item} option</span>
|
||||
<RemoveIcon className="h-4 w-4 hover:stroke-destructive" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MultiSelectorTrigger.displayName = "MultiSelectorTrigger";
|
||||
|
||||
const MultiSelectorInput = forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const {
|
||||
setOpen,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
handleSelect,
|
||||
ref: inputRef,
|
||||
} = useMultiSelect();
|
||||
|
||||
return (
|
||||
<CommandPrimitive.Input
|
||||
{...props}
|
||||
tabIndex={0}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onValueChange={activeIndex === -1 ? setInputValue : undefined}
|
||||
onSelect={handleSelect}
|
||||
onBlur={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onClick={() => setActiveIndex(-1)}
|
||||
className={cn(
|
||||
"ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1",
|
||||
className,
|
||||
activeIndex !== -1 && "caret-transparent",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
MultiSelectorInput.displayName = "MultiSelectorInput";
|
||||
|
||||
const MultiSelectorContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ children }, ref) => {
|
||||
const { open } = useMultiSelect();
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MultiSelectorContent.displayName = "MultiSelectorContent";
|
||||
|
||||
const MultiSelectorList = forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, children }, ref) => {
|
||||
return (
|
||||
<CommandList
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 flex flex-col gap-2 rounded-md scrollbar-thin scrollbar-track-transparent transition-colors scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg w-full absolute bg-background shadow-md z-10 border border-muted top-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground">No results found</span>
|
||||
</CommandEmpty>
|
||||
</CommandList>
|
||||
);
|
||||
});
|
||||
|
||||
MultiSelectorList.displayName = "MultiSelectorList";
|
||||
|
||||
const MultiSelectorItem = forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
{ value: string } & React.ComponentPropsWithoutRef<
|
||||
typeof CommandPrimitive.Item
|
||||
>
|
||||
>(({ className, value, children, ...props }, ref) => {
|
||||
const { value: Options, onValueChange, setInputValue } = useMultiSelect();
|
||||
|
||||
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const isIncluded = Options.includes(value);
|
||||
return (
|
||||
<CommandItem
|
||||
ref={ref}
|
||||
{...props}
|
||||
onSelect={() => {
|
||||
onValueChange(value);
|
||||
setInputValue("");
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md cursor-pointer px-2 py-1 transition-colors flex justify-between ",
|
||||
className,
|
||||
isIncluded && "opacity-50 cursor-default",
|
||||
props.disabled && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
onMouseDown={mousePreventDefault}
|
||||
>
|
||||
{children}
|
||||
{isIncluded && <Check className="h-4 w-4" />}
|
||||
</CommandItem>
|
||||
);
|
||||
});
|
||||
|
||||
MultiSelectorItem.displayName = "MultiSelectorItem";
|
||||
|
||||
export {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorInput,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
};
|
31
frontend/src/components/ui/popover.tsx
Normal file
31
frontend/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
Loading…
Reference in New Issue
Block a user