mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
Update participant and study API routes
This commit is contained in:
@@ -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
19
src/components/layout.tsx
Normal 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;
|
||||
52
src/components/participant/CreateParticipantDialog.tsx
Normal file
52
src/components/participant/CreateParticipantDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/participant/Participants.tsx
Normal file
112
src/components/participant/Participants.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
90
src/components/study/CreateStudyDialog.tsx
Normal file
90
src/components/study/CreateStudyDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/study/Studies.tsx
Normal file
28
src/components/study/Studies.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/study/StudyHeader.tsx
Normal file
75
src/components/study/StudyHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/study/StudySelector.tsx
Normal file
31
src/components/study/StudySelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal 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 }
|
||||
164
src/components/ui/select.tsx
Normal file
164
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal 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
129
src/components/ui/toast.tsx
Normal 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,
|
||||
}
|
||||
35
src/components/ui/toaster.tsx
Normal file
35
src/components/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user