diff --git a/bun.lock b/bun.lock index fb6b2a4..de4e9a9 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,13 @@ "name": "hristudio", "dependencies": { "@auth/drizzle-adapter": "^1.10.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.1.1", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -20,6 +24,7 @@ "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "drizzle-orm": "^0.41.0", "lucide-react": "^0.525.0", "next": "^15.2.3", @@ -63,6 +68,14 @@ "@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.10.0", "", { "dependencies": { "@auth/core": "0.40.0" } }, "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@emnapi/core": ["@emnapi/core@1.4.4", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.3", "tslib": "^2.4.0" } }, "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g=="], @@ -279,6 +292,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="], "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], @@ -545,6 +560,8 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], diff --git a/package.json b/package.json index 6d68e76..687c480 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,13 @@ }, "dependencies": { "@auth/drizzle-adapter": "^1.10.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.1.1", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -38,6 +42,7 @@ "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "drizzle-orm": "^0.41.0", "lucide-react": "^0.525.0", "next": "^15.2.3", diff --git a/src/app/(dashboard)/experiments/[id]/designer/page.tsx b/src/app/(dashboard)/experiments/[id]/designer/page.tsx new file mode 100644 index 0000000..b471b87 --- /dev/null +++ b/src/app/(dashboard)/experiments/[id]/designer/page.tsx @@ -0,0 +1,26 @@ +import { notFound } from "next/navigation"; +import { ExperimentDesignerClient } from "~/components/experiments/designer/ExperimentDesignerClient"; +import { api } from "~/trpc/server"; + +interface ExperimentDesignerPageProps { + params: { + id: string; + }; +} + +export default async function ExperimentDesignerPage({ + params, +}: ExperimentDesignerPageProps) { + try { + const experiment = await api.experiments.get({ id: params.id }); + + if (!experiment) { + notFound(); + } + + return ; + } catch (error) { + console.error("Error loading experiment:", error); + notFound(); + } +} diff --git a/src/app/(dashboard)/experiments/new/page.tsx b/src/app/(dashboard)/experiments/new/page.tsx new file mode 100644 index 0000000..c7f3801 --- /dev/null +++ b/src/app/(dashboard)/experiments/new/page.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import Link from "next/link"; +import { ArrowLeft, FlaskConical } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Textarea } from "~/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Separator } from "~/components/ui/separator"; +import { api } from "~/trpc/react"; + +const createExperimentSchema = z.object({ + name: z.string().min(1, "Experiment name is required").max(100, "Name too long"), + description: z + .string() + .min(10, "Description must be at least 10 characters") + .max(1000, "Description too long"), + studyId: z.string().uuid("Please select a study"), + estimatedDuration: z + .number() + .min(1, "Duration must be at least 1 minute") + .max(480, "Duration cannot exceed 8 hours") + .optional(), + status: z.enum(["draft", "active", "completed", "archived"]), +}); + +type CreateExperimentFormData = z.infer; + +export default function NewExperimentPage() { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createExperimentSchema), + defaultValues: { + status: "draft" as const, + }, + }); + + // Fetch user's studies for the dropdown + const { data: studiesData, isLoading: studiesLoading } = api.studies.list.useQuery( + { memberOnly: true }, + ); + + const createExperimentMutation = api.experiments.create.useMutation({ + onSuccess: (experiment) => { + router.push(`/experiments/${experiment.id}/designer`); + }, + onError: (error) => { + console.error("Failed to create experiment:", error); + setIsSubmitting(false); + }, + }); + + const onSubmit = async (data: CreateExperimentFormData) => { + setIsSubmitting(true); + try { + await createExperimentMutation.mutateAsync({ + ...data, + estimatedDuration: data.estimatedDuration || null, + }); + } catch (error) { + // Error handling is done in the mutation's onError callback + } + }; + + const watchedStatus = watch("status"); + const watchedStudyId = watch("studyId"); + + return ( +
+ {/* Header */} +
+
+ + + Experiments + + / + New Experiment +
+ +
+
+ +
+
+

Create New Experiment

+

Design a new experimental protocol for your HRI study

+
+
+
+ +
+ {/* Main Form */} +
+ + + Experiment Details + + Define the basic information for your experiment. You'll design the protocol steps next. + + + +
+ {/* Experiment Name */} +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ + {/* Description */} +
+ +