diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c2e0b68f..851edf9b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,15 +13,20 @@ "@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", + "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@vercel/analytics": "^1.2.2", "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", @@ -33,13 +38,15 @@ "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", "sharp": "^0.33.5", "simple-icons": "^13.5.0", "sonner": "^1.5.0", - "tailwind-merge": "^2.3.0" + "tailwind-merge": "^2.3.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^22", @@ -1028,6 +1035,12 @@ "node": ">=14" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", @@ -1362,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", @@ -1438,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", @@ -1602,6 +1675,49 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", + "integrity": "sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@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-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "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-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", @@ -1643,6 +1759,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz", + "integrity": "sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==", + "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-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.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-tabs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", @@ -2935,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", @@ -6035,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", @@ -7550,6 +7719,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 6bd130d2..4d09c074 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,15 +23,20 @@ "@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", + "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@vercel/analytics": "^1.2.2", "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", @@ -43,13 +48,15 @@ "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", "sharp": "^0.33.5", "simple-icons": "^13.5.0", "sonner": "^1.5.0", - "tailwind-merge": "^2.3.0" + "tailwind-merge": "^2.3.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^22", diff --git a/frontend/src/app/json-editor/_components/Categories.tsx b/frontend/src/app/json-editor/_components/Categories.tsx new file mode 100644 index 00000000..e3f4c61c --- /dev/null +++ b/frontend/src/app/json-editor/_components/Categories.tsx @@ -0,0 +1,103 @@ +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Category } from "@/lib/types"; +import { cn } from "@/lib/utils"; +import { z } from "zod"; +import { ScriptSchema } from "../_schemas/schemas"; + +type Script = z.infer; + +type CategoryProps = { + script: Script; + setScript: (script: Script) => void; + setIsValid: (isValid: boolean) => void; + setZodErrors: (zodErrors: z.ZodError | null) => void; + categories: Category[]; +}; + +export default function Categories({ + script, + setScript, + categories, +}: Omit) { + const addCategory = (categoryId: number) => { + setScript({ + ...script, + categories: [...new Set([...script.categories, categoryId])], + }); + }; + + const removeCategory = (categoryId: number) => { + setScript({ + ...script, + categories: script.categories.filter((id: number) => id !== categoryId), + }); + }; + + return ( + <> +
+ + +
+ {script.categories.map((categoryId) => { + const category = categories.find((c) => c.id === categoryId); + return category ? ( + + {category.name} + + + ) : null; + })} +
+
+ + ); +} diff --git a/frontend/src/app/json-editor/_components/InstallMethod.tsx b/frontend/src/app/json-editor/_components/InstallMethod.tsx new file mode 100644 index 00000000..df0ef66e --- /dev/null +++ b/frontend/src/app/json-editor/_components/InstallMethod.tsx @@ -0,0 +1,189 @@ +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { PlusCircle, Trash2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { z } from "zod"; +import { InstallMethodSchema, ScriptSchema } from "../_schemas/schemas"; + +type Script = z.infer; + +type InstallMethodProps = { + script: Script; + setScript: (value: Script | ((prevState: Script) => Script)) => void; + setIsValid: (isValid: boolean) => void; + setZodErrors: (zodErrors: z.ZodError | null) => void; +}; + +export default function InstallMethod({ + script, + setScript, + setIsValid, + setZodErrors, +}: InstallMethodProps) { + const addInstallMethod = () => { + setScript((prev) => { + const method = InstallMethodSchema.parse({ + type: "default", + script: `/${prev.type}/${prev.slug}.sh`, + resources: { + cpu: null, + ram: null, + hdd: null, + os: null, + version: null, + }, + }); + return { + ...prev, + install_methods: [...prev.install_methods, method], + }; + }); + }; + + const updateInstallMethod = ( + index: number, + key: keyof Script["install_methods"][number], + 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 }; + + 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: updatedMethods, + }; + + const result = ScriptSchema.safeParse(updated); + setIsValid(result.success); + if (!result.success) { + setZodErrors(result.error); + } else { + setZodErrors(null); + } + return updated; + }); + }; + + const removeInstallMethod = (index: number) => { + setScript((prev) => ({ + ...prev, + install_methods: prev.install_methods.filter((_, i) => i !== index), + })); + }; + + return ( + <> +

Install Methods

+ {script.install_methods.map((method, index) => ( +
+ +
+ ) => + updateInstallMethod(index, "resources", { + ...method.resources, + cpu: e.target.value ? Number(e.target.value) : null, + }) + } + /> + ) => + updateInstallMethod(index, "resources", { + ...method.resources, + ram: e.target.value ? Number(e.target.value) : null, + }) + } + /> + ) => + updateInstallMethod(index, "resources", { + ...method.resources, + hdd: e.target.value ? Number(e.target.value) : null, + }) + } + /> +
+
+ ) => + updateInstallMethod(index, "resources", { + ...method.resources, + os: e.target.value || null, + }) + } + /> + ) => + updateInstallMethod(index, "resources", { + ...method.resources, + version: e.target.value ? Number(e.target.value) : null, + }) + } + /> +
+ +
+ ))} + + + ); +} diff --git a/frontend/src/app/json-editor/_components/Note.tsx b/frontend/src/app/json-editor/_components/Note.tsx new file mode 100644 index 00000000..84fc35d3 --- /dev/null +++ b/frontend/src/app/json-editor/_components/Note.tsx @@ -0,0 +1,115 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AlertColors } from "@/config/siteConfig"; +import { cn } from "@/lib/utils"; +import { PlusCircle, Trash2 } from "lucide-react"; +import { z } from "zod"; +import { ScriptSchema } from "../_schemas/schemas"; + +type Script = z.infer; + +type NoteProps = { + script: Script; + setScript: (script: Script) => void; + setIsValid: (isValid: boolean) => void; + setZodErrors: (zodErrors: z.ZodError | null) => void; +}; +export default function Note({ + script, + setScript, + setIsValid, + setZodErrors, +}: NoteProps) { + const addNote = () => { + const newScript: Script = { + ...script, + notes: [...script.notes, { text: "", type: "" }], + }; + setScript(newScript); + }; + + const updateNote = ( + index: number, + key: keyof Script["notes"][number], + value: string, + ) => { + const updated: Script = { + ...script, + notes: script.notes.map((note: Script["notes"][number], i: number) => + i === index ? { ...note, [key]: value } : note, + ), + }; + const result = ScriptSchema.safeParse(updated); + setIsValid(result.success); + if (!result.success) { + setZodErrors(result.error); + } else { + setZodErrors(null); + } + setScript(updated); + }; + + const removeNote = (index: number) => { + const newScript: Script = { + ...script, + notes: script.notes.filter((_: Script["notes"][number], i: number) => i !== index), + }; + setScript(newScript); + }; + + return ( + <> +

Notes

+ {script.notes.map((note, index) => ( +
+ ) => updateNote(index, "text", e.target.value)} + /> + + +
+ ))} + + + ); +} diff --git a/frontend/src/app/json-editor/_schemas/schemas.ts b/frontend/src/app/json-editor/_schemas/schemas.ts new file mode 100644 index 00000000..1a679688 --- /dev/null +++ b/frontend/src/app/json-editor/_schemas/schemas.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export const InstallMethodSchema = z.object({ + type: z.enum(["default", "alpine"], { + errorMap: () => ({ message: "Type must be either 'default' or 'alpine'" }) + }), + script: z.string().min(1, "Script content cannot be empty"), + resources: z.object({ + cpu: z.number().nullable(), + ram: z.number().nullable(), + hdd: z.number().nullable(), + os: z.string().nullable(), + version: z.number().nullable(), + }), +}); + +const NoteSchema = z.object({ + text: z.string().min(1, "Note text cannot be empty"), + type: z.string().min(1, "Note type cannot be empty"), +}); + +export const ScriptSchema = z.object({ + name: z.string().min(1, "Name is required"), + slug: z.string().min(1, "Slug is required"), + categories: z.array(z.number()), + date_created: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").min(1, "Date is required"), + type: z.enum(["vm", "ct", "misc"], { + errorMap: () => ({ message: "Type must be either 'vm', 'ct', or 'misc'" }) + }), + updateable: z.boolean(), + privileged: z.boolean(), + interface_port: z.number().nullable(), + documentation: z.string().nullable(), + website: z.string().url().nullable(), + logo: z.string().url().nullable(), + description: z.string().min(1, "Description is required"), + install_methods: z.array(InstallMethodSchema).min(1, "At least one install method is required"), + default_credentials: z.object({ + username: z.string().nullable(), + password: z.string().nullable(), + }), + notes: z.array(NoteSchema), +}); diff --git a/frontend/src/app/json-editor/page.tsx b/frontend/src/app/json-editor/page.tsx new file mode 100644 index 00000000..144478b0 --- /dev/null +++ b/frontend/src/app/json-editor/page.tsx @@ -0,0 +1,323 @@ +"use client"; + +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 { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { fetchCategories } from "@/lib/data"; +import { Category } from "@/lib/types"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import { CalendarIcon, Check, Clipboard } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { z } from "zod"; +import Categories from "./_components/Categories"; +import InstallMethod from "./_components/InstallMethod"; +import Note from "./_components/Note"; +import { ScriptSchema } from "./_schemas/schemas"; + +type Script = z.infer; + +export default function JSONGenerator() { + const [script, setScript] = useState