Update participant and study API routes

This commit is contained in:
2024-09-25 22:13:29 -04:00
parent 33d36007c8
commit ccc3423953
36 changed files with 1448 additions and 228 deletions

View File

@@ -1,122 +0,0 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
interface Study {
id: number;
title: string;
description: string;
}
export function Studies() {
const [studies, setStudies] = useState<Study[]>([]);
const [newStudy, setNewStudy] = useState({ title: '', description: '' });
const [editingStudy, setEditingStudy] = useState<Study | null>(null);
useEffect(() => {
fetchStudies();
}, []);
const fetchStudies = async () => {
const response = await fetch('/api/studies');
const data = await response.json();
setStudies(data);
};
const createStudy = async () => {
const response = await fetch('/api/studies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newStudy),
});
const createdStudy = await response.json();
setStudies([...studies, createdStudy]);
setNewStudy({ title: '', description: '' });
};
const updateStudy = async () => {
if (!editingStudy) return;
const response = await fetch('/api/studies', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingStudy),
});
const updatedStudy = await response.json();
setStudies(studies.map(s => s.id === updatedStudy.id ? updatedStudy : s));
setEditingStudy(null);
};
const deleteStudy = async (id: number) => {
await fetch('/api/studies', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
setStudies(studies.filter(s => s.id !== id));
};
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Studies</h2>
<Dialog>
<DialogTrigger asChild>
<Button>Create New Study</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Study</DialogTitle>
</DialogHeader>
<Input
placeholder="Title"
value={newStudy.title}
onChange={(e) => setNewStudy({ ...newStudy, title: e.target.value })}
/>
<Input
placeholder="Description"
value={newStudy.description}
onChange={(e) => setNewStudy({ ...newStudy, description: e.target.value })}
/>
<Button onClick={createStudy}>Create</Button>
</DialogContent>
</Dialog>
{studies.map((study) => (
<Card key={study.id}>
<CardHeader>
<CardTitle>{study.title}</CardTitle>
</CardHeader>
<CardContent>
<p>{study.description}</p>
<div className="flex space-x-2 mt-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => setEditingStudy(study)}>Edit</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Study</DialogTitle>
</DialogHeader>
<Input
placeholder="Title"
value={editingStudy?.title || ''}
onChange={(e) => setEditingStudy({ ...editingStudy!, title: e.target.value })}
/>
<Input
placeholder="Description"
value={editingStudy?.description || ''}
onChange={(e) => setEditingStudy({ ...editingStudy!, description: e.target.value })}
/>
<Button onClick={updateStudy}>Update</Button>
</DialogContent>
</Dialog>
<Button variant="destructive" onClick={() => deleteStudy(study.id)}>Delete</Button>
</div>
</CardContent>
</Card>
))}
</div>
);
}

19
src/components/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { PropsWithChildren } from "react";
import { Sidebar } from "~/components/sidebar";
import { StudyHeader } from "~/components/study/StudyHeader";
const Layout = ({ children }: PropsWithChildren) => {
return (
<div className="flex h-screen bg-gradient-to-b from-blue-100 to-white">
<Sidebar />
<main className="flex-1 overflow-y-auto p-4 pt-16 lg:pt-4">
<div className="container mx-auto space-y-4">
<StudyHeader />
{children}
</div>
</main>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
import { Label } from "~/components/ui/label";
import { PlusCircle } from 'lucide-react';
interface CreateParticipantDialogProps {
onCreateParticipant: (name: string) => void;
}
export function CreateParticipantDialog({ onCreateParticipant }: CreateParticipantDialogProps) {
const [newParticipant, setNewParticipant] = useState({ name: '' });
const [isOpen, setIsOpen] = useState(false);
const handleCreate = () => {
if (newParticipant.name) {
onCreateParticipant(newParticipant.name);
setNewParticipant({ name: '' });
setIsOpen(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<PlusCircle className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Participant</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
value={newParticipant.name}
onChange={(e) => setNewParticipant({ name: e.target.value })}
className="col-span-3"
/>
</div>
</div>
<Button onClick={handleCreate}>Add Participant</Button>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { useStudyContext } from '../../context/StudyContext';
import { Participant } from '../../types/Participant';
import { CreateParticipantDialog } from './CreateParticipantDialog';
import { Trash2 } from 'lucide-react';
import { useToast } from '~/hooks/use-toast';
export function Participants() {
const [participants, setParticipants] = useState<Participant[]>([]);
const { selectedStudy } = useStudyContext();
const { toast } = useToast();
useEffect(() => {
if (selectedStudy) {
fetchParticipants();
}
}, [selectedStudy]);
const fetchParticipants = async () => {
if (!selectedStudy) return;
const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`);
const data = await response.json();
setParticipants(data);
};
const createParticipant = async (name: string) => {
if (!selectedStudy) return;
const response = await fetch('/api/participants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, studyId: selectedStudy.id }),
});
const createdParticipant = await response.json();
setParticipants([...participants, createdParticipant]);
};
const deleteParticipant = async (id: number) => {
if (!selectedStudy) return;
try {
console.log(`Attempting to delete participant with ID: ${id}`);
const response = await fetch(`/api/participants/${id}`, {
method: 'DELETE',
});
console.log('Delete response:', response);
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
const result = await response.json();
console.log('Delete result:', result);
if (!response.ok) {
throw new Error(result.error || `Failed to delete participant. Status: ${response.status}`);
}
setParticipants(participants.filter(p => p.id !== id));
toast({
title: "Success",
description: "Participant deleted successfully",
});
} else {
const text = await response.text();
console.error('Unexpected response:', text);
throw new Error(`Unexpected response from server. Status: ${response.status}`);
}
} catch (error) {
console.error('Error deleting participant:', error);
toast({
title: "Error",
description: error instanceof Error ? error.message : 'Failed to delete participant',
variant: "destructive",
});
}
};
if (!selectedStudy) {
return <div>Please select a study to manage participants.</div>;
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-2xl font-bold">Participants for {selectedStudy.title}</CardTitle>
<CreateParticipantDialog onCreateParticipant={createParticipant} />
</CardHeader>
<CardContent>
{participants.length > 0 ? (
<ul className="space-y-2">
{participants.map(participant => (
<li key={participant.id} className="bg-gray-100 p-2 rounded flex justify-between items-center">
<span>{participant.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => deleteParticipant(participant.id)}
className="text-red-500 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
) : (
<p>No participants added yet.</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -19,8 +19,8 @@ import { cn } from "~/lib/utils"
const navItems = [
{ name: "Dashboard", href: "/dash", icon: LayoutDashboard },
{ name: "Projects", href: "/projects", icon: FolderIcon },
{ name: "Experiments", href: "/experiments", icon: BeakerIcon },
{ name: "Studies", href: "/studies", icon: FolderIcon },
{ name: "Participants", href: "/participants", icon: BeakerIcon },
{ name: "Data Analysis", href: "/analysis", icon: BarChartIcon },
{ name: "Settings", href: "/settings", icon: Settings },
];

View File

@@ -0,0 +1,90 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import { Label } from "~/components/ui/label";
import { PlusCircle } from 'lucide-react';
import { Study } from '../../types/Study';
interface CreateStudyDialogProps {
onCreateStudy: (study: Omit<Study, 'id'>) => void;
}
export function CreateStudyDialog({ onCreateStudy }: CreateStudyDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [newStudy, setNewStudy] = useState({ title: '', description: '' });
const [touched, setTouched] = useState({ title: false, description: false });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setNewStudy({ ...newStudy, [name]: value });
setTouched({ ...touched, [name]: true });
};
const isFieldInvalid = (field: 'title' | 'description') => {
return field === 'title' ? (touched.title && !newStudy.title) : false;
};
const handleCreateStudy = () => {
setTouched({ title: true, description: true });
if (!newStudy.title) {
return;
}
onCreateStudy({
title: newStudy.title,
description: newStudy.description || undefined
});
setNewStudy({ title: '', description: '' });
setTouched({ title: false, description: false });
setIsOpen(false);
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<PlusCircle className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Study</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
Title
</Label>
<Input
id="title"
name="title"
className={`col-span-3 ${isFieldInvalid('title') ? 'border-red-500' : ''}`}
value={newStudy.title}
onChange={handleInputChange}
/>
</div>
{isFieldInvalid('title') && (
<p className="text-red-500 text-sm col-span-4">Title is required</p>
)}
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
</Label>
<Textarea
id="description"
name="description"
className="col-span-3"
value={newStudy.description}
onChange={handleInputChange}
/>
</div>
</div>
<Button onClick={handleCreateStudy}>Create Study</Button>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { useStudies } from '~/hooks/useStudies';
import { Button } from "~/components/ui/button";
export function Studies() {
const { studies, deleteStudy } = useStudies();
return (
<div className="space-y-4">
{studies.map((study) => (
<Card key={study.id}>
<CardHeader>
<CardTitle>{study.title}</CardTitle>
</CardHeader>
<CardContent>
<p>{study.description}</p>
<div className="flex space-x-2 mt-2">
<Button variant="destructive" onClick={() => deleteStudy(study.id)}>Delete</Button>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import React, { useEffect } from 'react';
import { Card, CardContent } from "~/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
import { useStudyContext } from '~/context/StudyContext';
import { StudySelector } from './StudySelector';
import { CreateStudyDialog } from '~/components/study/CreateStudyDialog';
import { Study } from '~/types/Study';
export function StudyHeader() {
const { studies, selectedStudy, setSelectedStudy, validateAndSetSelectedStudy, fetchAndSetStudies } = useStudyContext();
useEffect(() => {
const savedStudyId = localStorage.getItem('selectedStudyId');
if (savedStudyId) {
validateAndSetSelectedStudy(parseInt(savedStudyId, 10));
}
}, [validateAndSetSelectedStudy]);
const handleStudyChange = (studyId: string) => {
const study = studies.find(s => s.id.toString() === studyId);
if (study) {
setSelectedStudy(study);
localStorage.setItem('selectedStudyId', studyId);
}
};
const createStudy = async (newStudy: Omit<Study, "id">) => {
const response = await fetch('/api/studies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newStudy),
});
if (!response.ok) {
throw new Error('Failed to create study');
}
const createdStudy = await response.json();
await fetchAndSetStudies();
return createdStudy;
};
const handleCreateStudy = async (newStudy: Omit<Study, "id">) => {
const createdStudy = await createStudy(newStudy);
setSelectedStudy(createdStudy);
localStorage.setItem('selectedStudyId', createdStudy.id.toString());
};
return (
<Card className="mt-2 lg:mt-0">
<CardContent className="flex justify-between items-center p-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<h2 className="text-2xl font-bold truncate max-w-[200px]">
{selectedStudy ? selectedStudy.title : 'Select a Study'}
</h2>
</TooltipTrigger>
<TooltipContent>
<p>{selectedStudy ? selectedStudy.title : 'No study selected'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center space-x-2">
<StudySelector
studies={studies}
selectedStudy={selectedStudy}
onStudyChange={handleStudyChange}
/>
<CreateStudyDialog onCreateStudy={(study: Omit<Study, "id">) => handleCreateStudy(study as Study)} />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,31 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Study } from '../../types/Study';
interface StudySelectorProps {
studies: Study[];
selectedStudy: Study | null;
onStudyChange: (studyId: string) => void;
}
export function StudySelector({ studies, selectedStudy, onStudyChange }: StudySelectorProps) {
return (
<Select onValueChange={onStudyChange} value={selectedStudy?.id?.toString() || ""}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select a study" />
</SelectTrigger>
<SelectContent>
{studies.length > 0 ? (
studies.map((study) => (
<SelectItem key={study.id} value={study.id.toString()}>
{study.title}
</SelectItem>
))
) : (
<SelectItem value="no-studies" disabled className="text-gray-400 italic">
No studies available
</SelectItem>
)}
</SelectContent>
</Select>
);
}

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,164 @@
"use client"
import * as React from "react"
import {
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "~/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "~/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

129
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import { Cross2Icon } from "@radix-ui/react-icons"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,35 @@
"use client"
import { useToast } from "~/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "~/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "~/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }