"use client" import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { zodResolver } from "@hookform/resolvers/zod" import Image from "next/image" import { useCallback, useEffect, useMemo, useState } from "react" import { useForm } from "react-hook-form" import { z } from "zod" import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { createSandbox } from "@/lib/actions" import { projectTemplates } from "@/lib/data" import { useUser } from "@clerk/nextjs" import { ChevronLeft, ChevronRight, Loader2, Search } from "lucide-react" import { useRouter } from "next/navigation" import { Button } from "../ui/button" import { cn } from "@/lib/utils" import type { EmblaCarouselType } from "embla-carousel" import useEmblaCarousel from "embla-carousel-react" import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures" const formSchema = z.object({ name: z .string() .min(1) .max(16) .refine( (value) => /^[a-zA-Z0-9_]+$/.test(value), "Name must be alphanumeric and can contain underscores" ), visibility: z.enum(["public", "private"]), }) export default function NewProjectModal({ open, setOpen, }: { open: boolean setOpen: (open: boolean) => void }) { const router = useRouter() const user = useUser() const [selected, setSelected] = useState("reactjs") const [loading, setLoading] = useState(false) const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false }, [ WheelGesturesPlugin(), ]) const { prevBtnDisabled, nextBtnDisabled, onPrevButtonClick, onNextButtonClick, } = usePrevNextButtons(emblaApi) const [search, setSearch] = useState("") const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { name: "", visibility: "public", }, }) const handleTemplateClick = useCallback( ({ id, index }: { id: string; index: number }) => { setSelected(id) emblaApi?.scrollTo(index) }, [emblaApi] ) const filteredTemplates = useMemo( () => projectTemplates.filter( (item) => item.name.toLowerCase().includes(search.toLowerCase()) || item.description.toLowerCase().includes(search.toLowerCase()) ), [search, projectTemplates] ) const emptyTemplates = useMemo( () => filteredTemplates.length === 0, [filteredTemplates] ) async function onSubmit(values: z.infer<typeof formSchema>) { if (!user.isSignedIn) return const sandboxData = { type: selected, userId: user.user.id, ...values } setLoading(true) const id = await createSandbox(sandboxData) router.push(`/code/${id}`) } return ( <Dialog open={open} onOpenChange={(open: boolean) => { if (!loading) setOpen(open) }} > <DialogContent className="max-h-[95vh] overflow-y-auto"> <DialogHeader> <DialogTitle>Create A Sandbox</DialogTitle> </DialogHeader> <div className="flex flex-col gap-2 max-w-full overflow-hidden"> <div className="flex items-center justify-end"> <SearchInput {...{ value: search, onValueChange: setSearch, }} /> </div> <div className="overflow-hidden relative" ref={emblaRef}> <div className={cn( "grid grid-flow-col gap-x-2 min-h-[97px]", emptyTemplates ? "auto-cols-[100%]" : "auto-cols-[200px]" )} > {filteredTemplates.map((item, i) => ( <button disabled={item.disabled || loading} key={item.id} onClick={handleTemplateClick.bind(null, { id: item.id, index: i, })} className={cn( selected === item.id ? "shadow-foreground" : "shadow-border", "shadow-[0_0_0_1px_inset] rounded-md border bg-card text-card-foreground text-left p-4 flex flex-col transition-all focus-visible:outline-none focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed" )} > <div className="space-x-2 flex items-center justify-start w-full"> <Image alt="" src={item.icon} width={20} height={20} /> <div className="font-medium">{item.name}</div> </div> <div className="mt-2 text-muted-foreground text-xs line-clamp-2"> {item.description} </div> </button> ))} {emptyTemplates && ( <div className="flex flex-col gap-2 items-center text-center justify-center text-muted-foreground text-sm"> <p>No templates found</p> <Button size="xs" asChild> <a href="https://github.com/jamesmurdza/sandbox" target="_blank" > Contribute </a> </Button> </div> )} </div> <div className={cn( "absolute transition-all opacity-100 duration-400 bg-gradient-to-r from-background via-background to-transparent w-14 pl-1 left-0 top-0 -translate-x-1 bottom-0 h-full flex items-center", prevBtnDisabled && "opacity-0 pointer-events-none" )} > <Button size="smIcon" className="rounded-full" onClick={onPrevButtonClick} > <ChevronLeft className="size-5" /> </Button> </div> <div className={cn( "absolute transition-all opacity-100 duration-400 bg-gradient-to-l from-background via-background to-transparent w-14 pl-1 right-0 top-0 translate-x-1 bottom-0 h-full flex items-center", nextBtnDisabled && "opacity-0 pointer-events-none" )} > <Button size="smIcon" className="rounded-full" onClick={onNextButtonClick} > <ChevronRight className="size-5" /> </Button> </div> </div> </div> <Form {...form}> <form autoComplete="off" onSubmit={form.handleSubmit(onSubmit)}> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem className="mb-4"> <FormLabel>Name</FormLabel> <FormControl> <Input disabled={loading} placeholder="My Project" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="visibility" render={({ field }) => ( <FormItem className="mb-8"> <FormLabel>Visibility</FormLabel> <Select disabled={loading} onValueChange={field.onChange} defaultValue={field.value} > <FormControl> <SelectTrigger> <SelectValue /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="public">Public</SelectItem> <SelectItem value="private">Private</SelectItem> </SelectContent> </Select> <FormDescription> Note: All sandboxes cannot be seen by the public. Private sandboxes cannot be accessed by shared users that you add, while public sandboxes can. </FormDescription> <FormMessage /> </FormItem> )} /> <Button disabled={loading} type="submit" className="w-full"> {loading ? ( <> <Loader2 className="animate-spin mr-2 h-4 w-4" /> Creating project... </> ) : ( "Submit" )} </Button> </form> </Form> </DialogContent> </Dialog> ) } function SearchInput({ value, onValueChange, }: { value?: string onValueChange?: (value: string) => void }) { const onSubmit = useCallback((e: React.FormEvent) => { e.preventDefault() console.log("searching") }, []) return ( <form {...{ onSubmit }} className="w-40 h-8 "> <label htmlFor="template-search" className="flex gap-2 rounded-sm transition-colors bg-gray-100 dark:bg-[#2e2e2e] border border-[--s-color] [--s-color:hsl(var(--muted-foreground))] focus-within:[--s-color:hsl(var(--muted-foreground),50%)] h-full items-center px-2" > <Search className="size-4 text-[--s-color] transition-colors" /> <input id="template-search" type="text" name="search" placeholder="Search templates" value={value} onChange={(e) => onValueChange?.(e.target.value)} className="bg-transparent placeholder:text-muted-foreground w-full focus:outline-none text-xs" /> </label> </form> ) } const usePrevNextButtons = (emblaApi: EmblaCarouselType | undefined) => { const [prevBtnDisabled, setPrevBtnDisabled] = useState(true) const [nextBtnDisabled, setNextBtnDisabled] = useState(true) const onPrevButtonClick = useCallback(() => { if (!emblaApi) return emblaApi.scrollPrev() }, [emblaApi]) const onNextButtonClick = useCallback(() => { if (!emblaApi) return emblaApi.scrollNext() }, [emblaApi]) const onSelect = useCallback((emblaApi: EmblaCarouselType) => { setPrevBtnDisabled(!emblaApi.canScrollPrev()) setNextBtnDisabled(!emblaApi.canScrollNext()) }, []) useEffect(() => { if (!emblaApi) return onSelect(emblaApi) emblaApi.on("reInit", onSelect).on("select", onSelect) }, [emblaApi, onSelect]) return { prevBtnDisabled, nextBtnDisabled, onPrevButtonClick, onNextButtonClick, } }