chore(deps): Update project dependencies and refactor authentication flow

- Upgrade Next.js to version 15.1.7
- Update Drizzle ORM and related dependencies
- Add Nodemailer and related type definitions
- Refactor authentication routes and components
- Modify user schema to include first and last name
- Update authentication configuration and session handling
- Remove deprecated login and register pages
- Restructure authentication-related components and routes
This commit is contained in:
2025-02-11 23:55:27 -05:00
parent e6962aef79
commit 6e3f2e1601
65 changed files with 6171 additions and 1273 deletions

View File

@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { useToast } from "~/hooks/use-toast";
import React from "react";
const signInSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type SignInValues = z.infer<typeof signInSchema>;
interface SignInFormProps {
error?: boolean;
}
export function SignInForm({ error }: SignInFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
// Show error toast if credentials are invalid
React.useEffect(() => {
if (error) {
toast({
title: "Error",
description: "Invalid email or password",
variant: "destructive",
});
}
}, [error, toast]);
const form = useForm<SignInValues>({
resolver: zodResolver(signInSchema),
defaultValues: {
email: searchParams.get("email") ?? "",
password: "",
},
});
async function onSubmit(data: SignInValues) {
setIsLoading(true);
try {
const result = await signIn("credentials", {
redirect: false,
email: data.email,
password: data.password,
});
if (result?.error) {
toast({
title: "Error",
description: "Invalid email or password",
variant: "destructive",
});
return;
}
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
router.push(callbackUrl);
router.refresh();
} catch (error) {
toast({
title: "Error",
description: "Something went wrong. Please try again.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="name@example.com"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { useToast } from "~/hooks/use-toast";
import React from "react";
const signUpSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email(),
password: z.string().min(8),
});
type SignUpValues = z.infer<typeof signUpSchema>;
interface SignUpFormProps {
error?: boolean;
}
export function SignUpForm({ error }: SignUpFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
// Show error toast if credentials are invalid
React.useEffect(() => {
if (error) {
toast({
title: "Error",
description: "Something went wrong. Please try again.",
variant: "destructive",
});
}
}, [error, toast]);
const form = useForm<SignUpValues>({
resolver: zodResolver(signUpSchema),
defaultValues: {
firstName: "",
lastName: "",
email: searchParams.get("email") ?? "",
password: "",
},
});
async function onSubmit(data: SignUpValues) {
setIsLoading(true);
try {
const formData = new FormData();
formData.append("firstName", data.firstName);
formData.append("lastName", data.lastName);
formData.append("email", data.email);
formData.append("password", data.password);
const response = await fetch("/api/auth/register", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error ?? "Something went wrong");
}
const result = await signIn("credentials", {
redirect: false,
email: data.email,
password: data.password,
});
if (result?.error) {
toast({
title: "Error",
description: "Something went wrong. Please try again.",
variant: "destructive",
});
return;
}
// Get the invitation token if it exists
const token = searchParams.get("token");
// Redirect to onboarding with token if it exists
const onboardingUrl = token
? `/onboarding?token=${token}`
: "/onboarding";
router.push(onboardingUrl);
router.refresh();
} catch (error) {
if (error instanceof Error) {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Error",
description: "Something went wrong. Please try again.",
variant: "destructive",
});
}
} finally {
setIsLoading(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input
placeholder="John"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input
placeholder="Doe"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="name@example.com"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Create account"}
</Button>
</form>
</Form>
);
}

View File

@@ -4,10 +4,14 @@ import {
Beaker,
Home,
Settings2,
User
User,
Microscope,
Users,
Plus
} from "lucide-react"
import * as React from "react"
import { useSession } from "next-auth/react"
import { useStudy } from "~/components/providers/study-provider"
import { StudySwitcher } from "~/components/auth/study-switcher"
import {
@@ -20,18 +24,23 @@ import {
import { NavMain } from "~/components/navigation/nav-main"
import { NavUser } from "~/components/navigation/nav-user"
const data = {
navMain: [
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: session } = useSession()
const { activeStudy } = useStudy()
if (!session) return null
// Base navigation items that are always shown
const baseNavItems = [
{
title: "Overview",
url: "/dashboard",
icon: Home,
isActive: true,
},
{
title: "Studies",
url: "/dashboard/studies",
icon: Beaker,
icon: Microscope,
items: [
{
title: "All Studies",
@@ -43,6 +52,33 @@ const data = {
},
],
},
]
// Study-specific navigation items that are only shown when a study is active
const studyNavItems = activeStudy
? [
{
title: "Participants",
url: `/dashboard/studies/${activeStudy.id}/participants`,
icon: Users,
items: [
{
title: "All Participants",
url: `/dashboard/studies/${activeStudy.id}/participants`,
},
{
title: "Add Participant",
url: `/dashboard/studies/${activeStudy.id}/participants/new`,
// Only show if user is admin
hidden: activeStudy.role !== "ADMIN",
},
],
},
]
: []
// Settings navigation items
const settingsNavItems = [
{
title: "Settings",
url: "/dashboard/settings",
@@ -63,12 +99,9 @@ const data = {
},
],
},
],
}
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: session } = useSession()
if (!session) return null
const navItems = [...baseNavItems, ...studyNavItems, ...settingsNavItems]
return (
<Sidebar
@@ -81,7 +114,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<StudySwitcher />
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
<NavUser />

View File

@@ -31,6 +31,39 @@ export function BreadcrumbNav() {
label: "Create Study",
current: true,
})
} else if (paths[2]) {
items.push({
label: "Study Details",
href: `/dashboard/studies/${paths[2]}`,
current: paths.length === 3,
})
if (paths[3] === "participants") {
items.push({
label: "Participants",
href: `/dashboard/studies/${paths[2]}/participants`,
current: paths.length === 4,
})
if (paths[4] === "new") {
items.push({
label: "Add Participant",
current: true,
})
} else if (paths[4]) {
items.push({
label: "Participant Details",
current: paths.length === 5,
})
if (paths[5] === "edit") {
items.push({
label: "Edit",
current: true,
})
}
}
}
}
}
@@ -41,11 +74,11 @@ export function BreadcrumbNav() {
return (
<nav aria-label="breadcrumb">
<ol className="flex items-center gap-2">
<ol className="flex items-center">
{breadcrumbs.map((item, index) => (
<li key={item.label} className="flex items-center">
{index > 0 && (
<span role="presentation" aria-hidden="true" className="mx-2 text-muted-foreground">
<span role="presentation" aria-hidden="true" className="mx-1 text-muted-foreground">
/
</span>
)}

View File

@@ -63,7 +63,7 @@ export function NavUser() {
<div className="relative size-full overflow-hidden rounded-lg">
<Image
src={session.user.image}
alt={session.user.name ?? "User"}
alt={session.user.firstName ?? "User"}
fill
sizes="32px"
className="object-cover"

View File

@@ -0,0 +1,192 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
const participantFormSchema = z.object({
identifier: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
firstName: z.string().optional(),
lastName: z.string().optional(),
notes: z.string().optional(),
status: z.enum(["active", "inactive", "completed", "withdrawn"]).default("active"),
});
export type ParticipantFormValues = z.infer<typeof participantFormSchema>;
interface ParticipantFormProps {
defaultValues?: Partial<ParticipantFormValues>;
onSubmit: (data: ParticipantFormValues) => void;
isSubmitting?: boolean;
submitLabel?: string;
}
export function ParticipantForm({
defaultValues = {
identifier: "",
email: "",
firstName: "",
lastName: "",
notes: "",
status: "active",
},
onSubmit,
isSubmitting = false,
submitLabel = "Save",
}: ParticipantFormProps) {
const form = useForm<ParticipantFormValues>({
resolver: zodResolver(participantFormSchema),
defaultValues,
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-2">
<FormField
control={form.control}
name="identifier"
render={({ field }) => (
<FormItem>
<FormLabel>Identifier</FormLabel>
<FormControl>
<Input
placeholder="Enter participant identifier"
{...field}
readOnly
className="bg-muted"
/>
</FormControl>
<FormDescription>
Auto-generated unique identifier
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select participant status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="withdrawn">Withdrawn</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Enter first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Enter last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="sm:col-span-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email (Optional)</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Enter participant email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="sm:col-span-2">
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Enter any notes about this participant"
className="h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : submitLabel}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import { useToast } from "~/hooks/use-toast";
import { api } from "~/trpc/react";
import { useSession } from "next-auth/react";
const createStudySchema = z.object({
title: z.string().min(1, "Title is required").max(256, "Title is too long"),
description: z.string().optional(),
});
type FormData = z.infer<typeof createStudySchema>;
export function CreateStudyForm() {
const router = useRouter();
const { toast } = useToast();
const { data: session, status } = useSession();
const form = useForm<FormData>({
resolver: zodResolver(createStudySchema),
defaultValues: {
title: "",
description: "",
},
});
const createStudy = api.study.create.useMutation({
onSuccess: (study) => {
toast({
title: "Study created",
description: "Your study has been created successfully.",
});
router.push(`/dashboard/studies/${study.id}`);
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const onSubmit = (data: FormData) => {
if (status !== "authenticated") {
toast({
title: "Error",
description: "You must be logged in to create a study.",
variant: "destructive",
});
return;
}
createStudy.mutate(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter study title" {...field} />
</FormControl>
<FormDescription>
A descriptive name for your study.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Enter study description"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
A brief description of your study and its objectives.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
Cancel
</Button>
<Button
type="submit"
disabled={createStudy.isLoading || status !== "authenticated"}
>
{createStudy.isLoading ? "Creating..." : "Create Study"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Trash2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { api } from "~/trpc/react";
interface DeleteStudyButtonProps {
id: number;
}
export function DeleteStudyButton({ id }: DeleteStudyButtonProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
const { mutate: deleteStudy, isLoading } = api.study.delete.useMutation({
onSuccess: () => {
router.push("/studies");
router.refresh();
},
});
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the study
and all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteStudy({ id })}
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { api } from "~/trpc/react";
import { format } from "date-fns";
import { Activity, User, UserPlus, Settings, FileEdit } from "lucide-react";
interface StudyActivityProps {
studyId: number;
role: string;
}
interface ActivityItem {
id: number;
type: "member_added" | "member_role_changed" | "study_updated" | "participant_added" | "participant_updated";
description: string;
userId: string;
userName: string;
createdAt: Date;
}
function getActivityIcon(type: ActivityItem["type"]) {
switch (type) {
case "member_added":
return <UserPlus className="h-4 w-4" />;
case "member_role_changed":
return <Settings className="h-4 w-4" />;
case "study_updated":
return <FileEdit className="h-4 w-4" />;
case "participant_added":
case "participant_updated":
return <User className="h-4 w-4" />;
default:
return <Activity className="h-4 w-4" />;
}
}
export function StudyActivity({ studyId, role }: StudyActivityProps) {
const { data: activities } = api.study.getActivities.useQuery({ studyId });
return (
<Card>
<CardHeader>
<CardTitle>Activity Log</CardTitle>
<CardDescription>Recent activity in this study</CardDescription>
</CardHeader>
<CardContent>
{!activities || activities.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No activity recorded yet
</div>
) : (
<div className="space-y-8">
{activities.map((activity) => (
<div key={activity.id} className="flex gap-4">
<div className="mt-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{getActivityIcon(activity.type)}
</div>
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">
{activity.userName}
</p>
<p className="text-sm text-muted-foreground">
{activity.description}
</p>
<p className="text-xs text-muted-foreground">
{format(new Date(activity.createdAt), "PPpp")}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { Card, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
interface StudyCardProps {
id: number;
title: string;
description?: string | null;
role: string;
}
export function StudyCard({ id, title, description, role }: StudyCardProps) {
return (
<Link href={`/studies/${id}`}>
<Card className="hover:bg-muted/50 transition-colors">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
<div className="text-sm text-muted-foreground mt-2">
Role: {role}
</div>
</CardHeader>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
const studyFormSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
});
export type StudyFormValues = z.infer<typeof studyFormSchema>;
interface StudyFormProps {
defaultValues?: StudyFormValues;
onSubmit: (data: StudyFormValues) => void;
isSubmitting?: boolean;
submitLabel?: string;
}
export function StudyForm({
defaultValues = {
title: "",
description: "",
},
onSubmit,
isSubmitting = false,
submitLabel = "Save",
}: StudyFormProps) {
const form = useForm<StudyFormValues>({
resolver: zodResolver(studyFormSchema),
defaultValues,
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter study title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Enter study description"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : submitLabel}
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,372 @@
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { api } from "~/trpc/react";
import { useToast } from "~/hooks/use-toast";
import { ROLES } from "~/lib/permissions/constants";
import { Plus, UserPlus, Crown } from "lucide-react";
interface StudyMembersProps {
studyId: number;
role: string;
}
export function StudyMembers({ studyId, role }: StudyMembersProps) {
const { data: session } = useSession();
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [transferToUserId, setTransferToUserId] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [selectedRole, setSelectedRole] = useState(ROLES.RESEARCHER);
const { toast } = useToast();
const { data: members, refetch: refetchMembers } = api.study.getMembers.useQuery({ studyId });
const { data: pendingInvitations, refetch: refetchInvitations } = api.study.getPendingInvitations.useQuery({ studyId });
const { mutate: inviteMember, isPending: isInviting } = api.study.inviteMember.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Member invited successfully",
});
setIsInviteOpen(false);
setEmail("");
setSelectedRole(ROLES.RESEARCHER);
refetchMembers();
refetchInvitations();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const { mutate: transferOwnership, isPending: isTransferring } = api.study.transferOwnership.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Study ownership transferred successfully",
});
setIsTransferOpen(false);
setTransferToUserId(null);
refetchMembers();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const { mutate: revokeInvitation } = api.study.revokeInvitation.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Invitation revoked successfully",
});
refetchInvitations();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const { mutate: updateMemberRole } = api.study.updateMemberRole.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Member role updated successfully",
});
refetchMembers();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const canManageMembers = role.toUpperCase() === ROLES.OWNER.toUpperCase() || role.toUpperCase() === ROLES.ADMIN.toUpperCase();
const isOwner = role.toUpperCase() === ROLES.OWNER.toUpperCase();
// Get available roles based on current user's role
const getAvailableRoles = (userRole: string) => {
const roleHierarchy = {
[ROLES.OWNER.toUpperCase()]: [ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD],
[ROLES.ADMIN.toUpperCase()]: [ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD],
[ROLES.PRINCIPAL_INVESTIGATOR.toUpperCase()]: [ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD],
};
return roleHierarchy[userRole.toUpperCase()] ?? [];
};
const availableRoles = getAvailableRoles(role);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Study Members</CardTitle>
<CardDescription>Manage members and their roles</CardDescription>
</div>
{canManageMembers && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlus className="h-4 w-4 mr-2" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite New Member</DialogTitle>
<DialogDescription>
Enter the email address of the person you want to invite to this study.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="Enter email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="role">Role</Label>
<Select
value={selectedRole}
onValueChange={setSelectedRole}
defaultValue={availableRoles[0]}
>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{availableRoles.map((availableRole) => (
<SelectItem key={availableRole} value={availableRole}>
{availableRole === ROLES.PRINCIPAL_INVESTIGATOR ? "Principal Investigator" : availableRole}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
onClick={() => inviteMember({ studyId, email, role: selectedRole })}
disabled={isInviting}
>
{isInviting ? "Inviting..." : "Send Invite"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent>
{!members || members.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No members found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
{canManageMembers && <TableHead>Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.userId}>
<TableCell>{member.name}</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>
{canManageMembers && member.role.toUpperCase() !== ROLES.OWNER.toUpperCase() ? (
<Select
value={member.role}
onValueChange={(newRole) =>
updateMemberRole({
studyId,
userId: member.userId,
role: newRole,
})
}
disabled={member.userId === session?.user.id}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ROLES.ADMIN}>Admin</SelectItem>
<SelectItem value={ROLES.PRINCIPAL_INVESTIGATOR}>Principal Investigator</SelectItem>
<SelectItem value={ROLES.RESEARCHER}>Researcher</SelectItem>
<SelectItem value={ROLES.OBSERVER}>Observer</SelectItem>
<SelectItem value={ROLES.WIZARD}>Wizard</SelectItem>
</SelectContent>
</Select>
) : (
<div className="px-3 py-2">{member.role}</div>
)}
</TableCell>
{canManageMembers && (
<TableCell>
{isOwner && member.userId !== session?.user.id && (
<AlertDialog open={isTransferOpen && transferToUserId === member.userId} onOpenChange={(open) => {
setIsTransferOpen(open);
if (!open) setTransferToUserId(null);
}}>
<Button
variant="outline"
size="sm"
onClick={() => {
setTransferToUserId(member.userId);
setIsTransferOpen(true);
}}
>
<Crown className="h-4 w-4 mr-2" />
Transfer Ownership
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Transfer Study Ownership</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to transfer ownership to {member.name}? This action cannot be undone.
You will become an admin of the study.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
transferOwnership({
studyId,
newOwnerId: member.userId,
});
}}
disabled={isTransferring}
>
{isTransferring ? "Transferring..." : "Transfer Ownership"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{canManageMembers && (
<Card>
<CardHeader>
<CardTitle>Pending Invitations</CardTitle>
<CardDescription>Manage outstanding invitations to join the study</CardDescription>
</CardHeader>
<CardContent>
{!pendingInvitations || pendingInvitations.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No pending invitations
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Invited By</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingInvitations.map((invitation) => (
<TableRow key={invitation.id}>
<TableCell>{invitation.email}</TableCell>
<TableCell>{invitation.role}</TableCell>
<TableCell>{invitation.creatorName}</TableCell>
<TableCell>{new Date(invitation.expiresAt).toLocaleDateString()}</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => revokeInvitation({ studyId, invitationId: invitation.id })}
>
Revoke
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { api } from "~/trpc/react";
import { useToast } from "~/hooks/use-toast";
import { Plus, Trash2 } from "lucide-react";
import { Badge } from "~/components/ui/badge";
interface StudyMetadataProps {
studyId: number;
role: string;
}
export function StudyMetadata({ studyId, role }: StudyMetadataProps) {
const [isAddOpen, setIsAddOpen] = useState(false);
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { toast } = useToast();
const { data: metadata, refetch } = api.study.getMetadata.useQuery({ studyId });
const { mutate: addMetadata, isPending: isAdding } = api.study.addMetadata.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Metadata added successfully",
});
setIsAddOpen(false);
setKey("");
setValue("");
refetch();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const { mutate: deleteMetadata } = api.study.deleteMetadata.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Metadata deleted successfully",
});
refetch();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const canManageMetadata = role === "ADMIN";
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Study Metadata</CardTitle>
<CardDescription>Custom fields and tags for this study</CardDescription>
</div>
{canManageMetadata && (
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Field
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Metadata Field</DialogTitle>
<DialogDescription>
Add a new custom field or tag to this study.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="key">Field Name</Label>
<Input
id="key"
placeholder="Enter field name"
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="value">Value</Label>
<Input
id="value"
placeholder="Enter value"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={() => addMetadata({ studyId, key, value })}
disabled={isAdding}
>
{isAdding ? "Adding..." : "Add Field"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent>
{!metadata || metadata.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No metadata fields found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Field</TableHead>
<TableHead>Value</TableHead>
{canManageMetadata && <TableHead className="w-[100px]">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{metadata.map((item) => (
<TableRow key={item.key}>
<TableCell className="font-medium">{item.key}</TableCell>
<TableCell>
<Badge variant="secondary">{item.value}</Badge>
</TableCell>
{canManageMetadata && (
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => deleteMetadata({ studyId, key: item.key })}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { api } from "~/trpc/react";
import { Users, Calendar, Clock } from "lucide-react";
interface StudyOverviewProps {
study: {
id: number;
title: string;
description: string | null;
role: string;
};
}
export function StudyOverview({ study }: StudyOverviewProps) {
const { data: participantCount } = api.participant.getCount.useQuery({ studyId: study.id });
return (
<div className="grid gap-4">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Study Details</CardTitle>
<CardDescription>Basic information about the study</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-muted-foreground">Your Role</dt>
<dd className="mt-1 text-sm">{study.role}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Description</dt>
<dd className="mt-1 text-sm">{study.description || "No description provided"}</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Participants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{participantCount ?? 0}</div>
<p className="text-xs text-muted-foreground">
Active participants in study
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last Activity</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold"></div>
<p className="text-xs text-muted-foreground">
Most recent update
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Study Duration</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold"></div>
<p className="text-xs text-muted-foreground">
Days since creation
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react";
import { Plus as PlusIcon, Eye, EyeOff } from "lucide-react";
import { ROLES } from "~/lib/permissions/constants";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { useState } from "react";
interface StudyParticipantsProps {
studyId: number;
role: string;
}
export function StudyParticipants({ studyId, role }: StudyParticipantsProps) {
const router = useRouter();
const { data: participants, isLoading } = api.participant.getByStudyId.useQuery({ studyId });
const [showIdentifiable, setShowIdentifiable] = useState(false);
const canViewIdentifiableInfo = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
.map(r => r.toLowerCase())
.includes(role.toLowerCase());
const canManageParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
.map(r => r.toLowerCase())
.includes(role.toLowerCase());
if (isLoading) {
return <div>Loading...</div>;
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Study Participants</CardTitle>
{!canViewIdentifiableInfo ? (
<CardDescription className="text-yellow-600">
Personal information is redacted based on your role.
</CardDescription>
) : (
<CardDescription>
{showIdentifiable
? "Showing personal information."
: "Personal information is hidden."}
</CardDescription>
)}
</div>
<div className="flex items-center gap-4">
{canViewIdentifiableInfo && (
<div className="flex items-center gap-2 border rounded-lg p-2 bg-muted/50">
<div className="flex items-center gap-2">
<Switch
id="show-identifiable"
checked={showIdentifiable}
onCheckedChange={setShowIdentifiable}
/>
<Label
htmlFor="show-identifiable"
className="text-sm font-medium flex items-center gap-2 cursor-pointer select-none"
>
{showIdentifiable ? (
<>
<Eye className="h-4 w-4" />
Personal Info Visible
</>
) : (
<>
<EyeOff className="h-4 w-4" />
Personal Info Hidden
</>
)}
</Label>
</div>
</div>
)}
{canManageParticipants && (
<Button
size="sm"
onClick={() => router.push(`/dashboard/studies/${studyId}/participants/new`)}
>
<PlusIcon className="h-4 w-4 mr-2" />
Add Participant
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
{!participants || participants.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No participants have been added to this study yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Status</TableHead>
{(canViewIdentifiableInfo && showIdentifiable) && (
<>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
</>
)}
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{participants.map((participant) => (
<TableRow
key={participant.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() =>
router.push(`/dashboard/studies/${studyId}/participants/${participant.id}`)
}
>
<TableCell>{participant.identifier || "—"}</TableCell>
<TableCell>
<Badge
variant={
participant.status === "active"
? "default"
: participant.status === "completed"
? "secondary"
: "outline"
}
>
{participant.status}
</Badge>
</TableCell>
{(canViewIdentifiableInfo && showIdentifiable) && (
<>
<TableCell>
{participant.firstName && participant.lastName
? `${participant.firstName} ${participant.lastName}`
: "—"}
</TableCell>
<TableCell>{participant.email || "—"}</TableCell>
</>
)}
<TableCell className="max-w-[200px] truncate">
{participant.notes || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "~/lib/utils"
import { buttonVariants } from "~/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -5,21 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "underline-offset-4 hover:underline text-primary",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3 rounded-md",
lg: "h-11 px-8 rounded-md",
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "~/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }