From d13763995e8430eb2f4cebe8fc8238711fc73349 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 16 Aug 2025 14:22:24 +0200 Subject: [PATCH] feat(frontend): create volume --- internal/core/text-utils.go | 18 +++ internal/modules/volumes/handlers.go | 23 ++-- web/app/components/create-volume-dialog.tsx | 123 ++++++++++++++++++++ web/app/components/ui/button.tsx | 4 +- web/app/hooks/useCreateVolume.ts | 57 +++++++++ web/app/lib/utils.ts | 30 ++++- web/app/welcome/welcome.tsx | 32 ++--- 7 files changed, 254 insertions(+), 33 deletions(-) create mode 100644 internal/core/text-utils.go create mode 100644 web/app/components/create-volume-dialog.tsx create mode 100644 web/app/hooks/useCreateVolume.ts diff --git a/internal/core/text-utils.go b/internal/core/text-utils.go new file mode 100644 index 0000000..6cfc626 --- /dev/null +++ b/internal/core/text-utils.go @@ -0,0 +1,18 @@ +package core + +import ( + "regexp" + "strings" +) + +var nonAlnum = regexp.MustCompile(`[^a-z0-9_-]+`) + +var hyphenRuns = regexp.MustCompile(`[-_]{2,}`) + +func Slugify(input string) string { + s := strings.ToLower(strings.TrimSpace(input)) + s = nonAlnum.ReplaceAllString(s, "-") + s = hyphenRuns.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + return s +} diff --git a/internal/modules/volumes/handlers.go b/internal/modules/volumes/handlers.go index cc604e2..a36f730 100644 --- a/internal/modules/volumes/handlers.go +++ b/internal/modules/volumes/handlers.go @@ -2,56 +2,57 @@ package volumes import ( + "ironmount/internal/core" + "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" ) // SetupHandlers sets up the API routes for the application. func SetupHandlers(router *gin.Engine) { - volumeService := VolumeService{} router.GET("/api/volumes", func(c *gin.Context) { volumes := volumeService.ListVolumes() - log.Debug().Msgf("Listing volumes: %v", volumes) - - c.JSON(200, gin.H{ - "volumes": volumes, - }) + c.JSON(200, gin.H{"volumes": volumes}) }) router.POST("/api/volumes", func(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } - volume, status, err := volumeService.CreateVolume(req.Name) + clean := core.Slugify(req.Name) + if clean == "" || clean != req.Name { + c.JSON(400, gin.H{"error": "invalid volume name"}) + return + } + volume, status, err := volumeService.CreateVolume(clean) if err != nil { c.JSON(status, gin.H{"error": err.Error()}) return } + c.JSON(status, volume) }) router.GET("/api/volumes/:name", func(c *gin.Context) { volume, err := volumeService.GetVolume(c.Param("name")) - if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } - if volume == nil { c.JSON(404, gin.H{"error": "Volume not found"}) return } - c.JSON(200, gin.H{ "name": volume.Name, "mountpoint": volume.Path, @@ -62,12 +63,10 @@ func SetupHandlers(router *gin.Engine) { router.DELETE("/api/volumes/:name", func(c *gin.Context) { status, err := volumeService.DeleteVolume(c.Param("name")) - if err != nil { c.JSON(status, gin.H{"error": err.Error()}) return } - c.JSON(200, gin.H{"message": "Volume deleted successfully"}) }) } diff --git a/web/app/components/create-volume-dialog.tsx b/web/app/components/create-volume-dialog.tsx new file mode 100644 index 0000000..8fb8a12 --- /dev/null +++ b/web/app/components/create-volume-dialog.tsx @@ -0,0 +1,123 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useCreateVolume } from "~/hooks/useCreateVolume"; +import { slugify } from "~/lib/utils"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "./ui/form"; +import { Input } from "./ui/input"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; +}; + +const formSchema = z.object({ + name: z + .string() + .min(2, { + message: "Volume name must be at least 2 characters long", + }) + .max(32, { + message: "Volume name must be at most 32 characters long", + }), +}); + +export const CreateVolumeDialog = ({ open, setOpen }: Props) => { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + }, + }); + + const nameValue = form.watch("name"); + const createVolume = useCreateVolume(); + + const onSubmit = (values: { name: string }) => { + createVolume.mutate(values, { + onSuccess: () => { + form.reset(); + setOpen(false); + }, + }); + }; + + return ( + + + + + + + Create volume + + Enter a name for the new volume. + + +
+ + ( + + Name + + field.onChange(slugify(e.target.value))} + max={32} + min={1} + /> + + + Unique identifier for the volume. + + + + )} + /> + {createVolume.error && ( +
+ {createVolume.error.message} +
+ )} + + + + + + +
+
+ ); +}; diff --git a/web/app/components/ui/button.tsx b/web/app/components/ui/button.tsx index 3d4654b..38be944 100644 --- a/web/app/components/ui/button.tsx +++ b/web/app/components/ui/button.tsx @@ -1,11 +1,11 @@ -import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; import { cn } from "~/lib/utils"; const buttonVariants = cva( - "inline-flex cursor-pointer uppercase border items-center justify-center dark:border-white dark:sbg-secondary dark:text-white gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex cursor-pointer uppercase border items-center justify-center dark:border-white dark:bg-secondary dark:text-white gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { diff --git a/web/app/hooks/useCreateVolume.ts b/web/app/hooks/useCreateVolume.ts new file mode 100644 index 0000000..4f421df --- /dev/null +++ b/web/app/hooks/useCreateVolume.ts @@ -0,0 +1,57 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { type } from "arktype"; +import { slugify } from "~/lib/utils"; + +const createVolume = async (variables: { name: string }) => { + const cleanName = slugify(variables.name); + if (!cleanName) { + throw new Error("Invalid volume name"); + } + const response = await fetch("/api/volumes", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: cleanName }), + }); + + if (!response.ok) { + let errorText = "Network response was not ok"; + + try { + const errorData = await response.json(); + if (errorData.error && typeof errorData.error === "string") { + errorText = errorData.error; + } else { + errorText = JSON.stringify(errorData); + } + } catch (_) {} + + throw new Error(errorText); + } + + return response.json(); +}; + +const createVolumeSchema = type({ + name: "string", +}); + +export const useCreateVolume = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createVolume, + onSuccess: (data) => { + const result = createVolumeSchema(data); + + if (result instanceof type.errors) { + console.error("Create volume response validation failed:", result); + return { message: "Invalid data format" }; + } + + queryClient.invalidateQueries({ queryKey: ["volumes"] }); + return result; + }, + }); +}; diff --git a/web/app/lib/utils.ts b/web/app/lib/utils.ts index bd0c391..211eb30 100644 --- a/web/app/lib/utils.ts +++ b/web/app/lib/utils.ts @@ -1,6 +1,30 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +/** Conditional merge of class names */ export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); +} + +/** + * Converts an arbitrary string into a URL-safe slug: + * - lowercase + * - trims whitespace + * - replaces non-alphanumeric runs with "-" + * - collapses multiple hyphens + * - trims leading/trailing hyphens + */ +/** + * Live slugify for UI: lowercases, normalizes dashes, replaces invalid runs with "-", + * collapses repeats, but DOES NOT trim leading/trailing hyphens so the user can type + * spaces/dashes progressively while editing. + */ +export function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[ ]/g, "-") + .replace(/[^a-z0-9_-]+/g, "") + .replace(/[-]{2,}/g, "-") + .replace(/[_]{2,}/g, "_") + .trim(); } diff --git a/web/app/welcome/welcome.tsx b/web/app/welcome/welcome.tsx index a1e1a00..17719c4 100644 --- a/web/app/welcome/welcome.tsx +++ b/web/app/welcome/welcome.tsx @@ -1,4 +1,6 @@ -import { Copy, Folder, Plus } from "lucide-react"; +import { Copy, Folder } from "lucide-react"; +import { useState } from "react"; +import { CreateVolumeDialog } from "~/components/create-volume-dialog"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { @@ -24,6 +26,7 @@ import { cn } from "~/lib/utils"; export function Welcome() { const { data } = useVolumes(); const deleteVolume = useDeleteVolume(); + const [open, setOpen] = useState(false); return (
- + - +
A list of your managed Docker volumes. @@ -84,7 +84,7 @@ export function Welcome() { {data?.volumes.map((volume) => ( {volume.name} - + Dir @@ -98,10 +98,10 @@ export function Welcome() { - + - - + +