Add calendar and label components; enhance JSON generator with date selection and script path updates for installation methods

This commit is contained in:
Bram Suurd 2024-11-13 18:34:10 +01:00
parent bdce4f778d
commit a3b2a476c1
7 changed files with 848 additions and 149 deletions

View File

@ -13,7 +13,9 @@
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.1", "@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-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
@ -24,6 +26,7 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"framer-motion": "^11.11.11", "framer-motion": "^11.11.11",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
@ -35,6 +38,7 @@
"prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-organize-imports": "^4.1.0",
"react": "19.0.0-rc-02c0e824-20241028", "react": "19.0.0-rc-02c0e824-20241028",
"react-code-blocks": "^0.1.6", "react-code-blocks": "^0.1.6",
"react-day-picker": "8.10.1",
"react-dom": "19.0.0-rc-02c0e824-20241028", "react-dom": "19.0.0-rc-02c0e824-20241028",
"react-icons": "^5.1.0", "react-icons": "^5.1.0",
"react-simple-typewriter": "^5.0.1", "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": { "node_modules/@radix-ui/react-menu": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz", "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": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", "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" "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": { "node_modules/debug": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@ -6116,6 +6190,20 @@
"react": ">=16" "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": { "node_modules/react-dom": {
"version": "19.0.0-rc-02c0e824-20241028", "version": "19.0.0-rc-02c0e824-20241028",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc-02c0e824-20241028.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc-02c0e824-20241028.tgz",

View File

@ -23,7 +23,9 @@
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.1", "@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-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
@ -34,6 +36,7 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"framer-motion": "^11.11.11", "framer-motion": "^11.11.11",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
@ -45,6 +48,7 @@
"prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-organize-imports": "^4.1.0",
"react": "19.0.0-rc-02c0e824-20241028", "react": "19.0.0-rc-02c0e824-20241028",
"react-code-blocks": "^0.1.6", "react-code-blocks": "^0.1.6",
"react-day-picker": "8.10.1",
"react-dom": "19.0.0-rc-02c0e824-20241028", "react-dom": "19.0.0-rc-02c0e824-20241028",
"react-icons": "^5.1.0", "react-icons": "^5.1.0",
"react-simple-typewriter": "^5.0.1", "react-simple-typewriter": "^5.0.1",

View File

@ -2,7 +2,9 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -14,9 +16,13 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { fetchCategories } from "@/lib/data"; import { fetchCategories } from "@/lib/data";
import { Category } from "@/lib/types"; 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 { useEffect, useState } from "react";
import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { format } from "date-fns";
import { Label } from "@/components/ui/label";
const scriptSchema = z.object({ const scriptSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@ -96,13 +102,14 @@ export default function JSONGenerator() {
}, },
], ],
}); });
const [isCopied, setIsCopied] = useState(false)
const [isValid, setIsValid] = useState(false); const [isValid, setIsValid] = useState(false);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => { useEffect(() => {
fetchCategories() fetchCategories()
.then((data: Category[]) => { .then((data) => {
setCategories(data); setCategories(data);
}) })
.catch((error) => console.error("Error fetching categories:", error)); .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]) => { const updateScript = (key: keyof Script, value: Script[keyof Script]) => {
setScript((prev) => { setScript((prev) => {
const updated = { ...prev, [key]: value }; 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); const result = scriptSchema.safeParse(updated);
setIsValid(result.success); setIsValid(result.success);
return updated; return updated;
@ -118,23 +137,23 @@ export default function JSONGenerator() {
}; };
const addInstallMethod = () => { const addInstallMethod = () => {
setScript((prev) => ({ setScript((prev) => {
...prev, const method = {
install_methods: [ type: "default" as "default", // Ensure type matches the union type
...prev.install_methods, script: `/${prev.type}/${prev.slug}.sh`, // Default script path
{ resources: {
type: "default", cpu: null,
script: "", ram: null,
resources: { hdd: null,
cpu: null, os: null,
ram: null, version: null,
hdd: null,
os: null,
version: null,
},
}, },
], };
})); return {
...prev,
install_methods: [...prev.install_methods, method],
};
});
}; };
const updateInstallMethod = ( const updateInstallMethod = (
@ -143,12 +162,28 @@ export default function JSONGenerator() {
value: Script["install_methods"][number][keyof Script["install_methods"][number]], value: Script["install_methods"][number][keyof Script["install_methods"][number]],
) => { ) => {
setScript((prev) => { 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 = { const updated = {
...prev, ...prev,
install_methods: prev.install_methods.map((method, i) => install_methods: updatedMethods,
i === index ? { ...method, [key]: value } : method,
),
}; };
const result = scriptSchema.safeParse(updated); const result = scriptSchema.safeParse(updated);
setIsValid(result.success); setIsValid(result.success);
return updated; return updated;
@ -209,24 +244,33 @@ export default function JSONGenerator() {
}; };
return ( return (
<div className="flex h-screen"> <div className="flex h-screen mt-20">
<div className="w-1/2 p-4 overflow-y-auto"> <div className="w-1/2 p-4 overflow-y-auto">
<h2 className="text-2xl font-bold mb-4">JSON Generator</h2> <h2 className="text-2xl font-bold mb-4">JSON Generator</h2>
<form className="space-y-4"> <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 <Input
placeholder="Name" placeholder="Logo URL"
value={script.name} value={script.logo || ""}
onChange={(e) => updateScript("name", e.target.value)} onChange={(e) => updateScript("logo", e.target.value || null)}
/> />
<Input <Textarea
placeholder="Slug" placeholder="Description"
value={script.slug} value={script.description}
onChange={(e) => updateScript("slug", e.target.value)} onChange={(e) => updateScript("description", e.target.value)}
/> />
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Categories
</label>
<Select onValueChange={(value) => addCategory(Number(value))}> <Select onValueChange={(value) => addCategory(Number(value))}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a category" /> <SelectValue placeholder="Select a category" />
@ -239,7 +283,12 @@ export default function JSONGenerator() {
))} ))}
</SelectContent> </SelectContent>
</Select> </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) => { {script.categories.map((categoryId) => {
const category = categories.find((c) => c.id === categoryId); const category = categories.find((c) => c.id === categoryId);
return category ? ( return category ? (
@ -274,37 +323,77 @@ export default function JSONGenerator() {
})} })}
</div> </div>
</div> </div>
<Input <div className="flex gap-2">
placeholder="Date Created (YYYY-MM-DD)" <div className="flex flex-col gap-2 w-full">
value={script.date_created} <Label>Date Created</Label>
onChange={(e) => updateScript("date_created", e.target.value)} <Popover>
/> <PopoverTrigger asChild className="flex-1">
<Select <Button
value={script.type} variant={"outline"}
onValueChange={(value) => updateScript("type", value)} className={cn(
> "pl-3 text-left font-normal w-full",
<SelectTrigger> !script.date_created && "text-muted-foreground",
<SelectValue placeholder="Type" /> )}
</SelectTrigger> >
<SelectContent> {script.date_created ? (
<SelectItem value="vm">VM</SelectItem> format(script.date_created, "PPP")
<SelectItem value="ct">CT</SelectItem> ) : (
<SelectItem value="misc">Misc</SelectItem> <span>Pick a date</span>
</SelectContent> )}
</Select> <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
<div className="flex items-center space-x-2"> </Button>
<Switch </PopoverTrigger>
checked={script.updateable} <PopoverContent className="w-auto p-0" align="start">
onCheckedChange={(checked) => updateScript("updateable", checked)} <Calendar
/> mode="single"
<label>Updateable</label> 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>
<div className="flex items-center space-x-2"> <div className="w-full flex gap-5">
<Switch <div className="flex items-center space-x-2">
checked={script.privileged} <Switch
onCheckedChange={(checked) => updateScript("privileged", checked)} checked={script.updateable}
/> onCheckedChange={(checked) =>
<label>Privileged</label> 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> </div>
<Input <Input
placeholder="Interface Port" placeholder="Interface Port"
@ -317,28 +406,20 @@ export default function JSONGenerator() {
) )
} }
/> />
<Input <div className="flex gap-2">
placeholder="Documentation URL" <Input
value={script.documentation || ""} placeholder="Website URL"
onChange={(e) => value={script.website || ""}
updateScript("documentation", e.target.value || null) onChange={(e) => updateScript("website", e.target.value || null)}
} />
/> <Input
<Input placeholder="Documentation URL"
placeholder="Website URL" value={script.documentation || ""}
value={script.website || ""} onChange={(e) =>
onChange={(e) => updateScript("website", e.target.value || null)} updateScript("documentation", e.target.value || null)
/> }
<Input />
placeholder="Logo URL" </div>
value={script.logo || ""}
onChange={(e) => updateScript("logo", e.target.value || null)}
/>
<Textarea
placeholder="Description"
value={script.description}
onChange={(e) => updateScript("description", e.target.value)}
/>
<h3 className="text-xl font-semibold">Install Methods</h3> <h3 className="text-xl font-semibold">Install Methods</h3>
{script.install_methods.map((method, index) => ( {script.install_methods.map((method, index) => (
<div key={index} className="space-y-2 border p-4 rounded"> <div key={index} className="space-y-2 border p-4 rounded">
@ -356,67 +437,64 @@ export default function JSONGenerator() {
<SelectItem value="alpine">Alpine</SelectItem> <SelectItem value="alpine">Alpine</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Textarea <div className="flex gap-2">
placeholder="Script" <Input
value={method.script} placeholder="CPU in Cores"
onChange={(e) => type="number"
updateInstallMethod(index, "script", e.target.value) value={method.resources.cpu || ""}
} onChange={(e) =>
/> updateInstallMethod(index, "resources", {
<Input ...method.resources,
placeholder="CPU" cpu: e.target.value ? Number(e.target.value) : null,
type="number" })
value={method.resources.cpu || ""} }
onChange={(e) => />
updateInstallMethod(index, "resources", { <Input
...method.resources, placeholder="RAM in MB"
cpu: e.target.value ? Number(e.target.value) : null, type="number"
}) value={method.resources.ram || ""}
} onChange={(e) =>
/> updateInstallMethod(index, "resources", {
<Input ...method.resources,
placeholder="RAM" ram: e.target.value ? Number(e.target.value) : null,
type="number" })
value={method.resources.ram || ""} }
onChange={(e) => />
updateInstallMethod(index, "resources", { <Input
...method.resources, placeholder="HDD in GB"
ram: e.target.value ? Number(e.target.value) : null, type="number"
}) value={method.resources.hdd || ""}
} onChange={(e) =>
/> updateInstallMethod(index, "resources", {
<Input ...method.resources,
placeholder="HDD" hdd: e.target.value ? Number(e.target.value) : null,
type="number" })
value={method.resources.hdd || ""} }
onChange={(e) => />
updateInstallMethod(index, "resources", { </div>
...method.resources, <div className="flex gap-2">
hdd: e.target.value ? Number(e.target.value) : null, <Input
}) placeholder="OS"
} value={method.resources.os || ""}
/> onChange={(e) =>
<Input updateInstallMethod(index, "resources", {
placeholder="OS" ...method.resources,
value={method.resources.os || ""} os: e.target.value || null,
onChange={(e) => })
updateInstallMethod(index, "resources", { }
...method.resources, />
os: e.target.value || null, <Input
}) placeholder="Version"
} type="number"
/> value={method.resources.version || ""}
<Input onChange={(e) =>
placeholder="Version" updateInstallMethod(index, "resources", {
type="number" ...method.resources,
value={method.resources.version || ""} version: e.target.value ? Number(e.target.value) : null,
onChange={(e) => })
updateInstallMethod(index, "resources", { }
...method.resources, />
version: e.target.value ? Number(e.target.value) : null, </div>
})
}
/>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => removeInstallMethod(index)} onClick={() => removeInstallMethod(index)}
@ -472,8 +550,10 @@ export default function JSONGenerator() {
</Button> </Button>
</form> </form>
</div> </div>
<div className="w-1/2 p-4 bg-gray-100 overflow-y-auto"> <div className="w-1/2 p-4 bg-background overflow-y-auto">
<Alert className={isValid ? "bg-green-100" : "bg-red-100"}> <Alert
className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}
>
<AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle> <AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle>
<AlertDescription> <AlertDescription>
{isValid {isValid
@ -481,7 +561,24 @@ export default function JSONGenerator() {
: "The current JSON does not match the required schema."} : "The current JSON does not match the required schema."}
</AlertDescription> </AlertDescription>
</Alert> </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)} {JSON.stringify(script, null, 2)}
</pre> </pre>
</div> </div>

View 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 }

View 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 }

View 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,
};

View 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 }