mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
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:
138
src/components/auth/sign-in-form.tsx
Normal file
138
src/components/auth/sign-in-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
src/components/auth/sign-up-form.tsx
Normal file
210
src/components/auth/sign-up-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
192
src/components/participants/participant-form.tsx
Normal file
192
src/components/participants/participant-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/studies/create-study-form.tsx
Normal file
131
src/components/studies/create-study-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/components/studies/delete-study-button.tsx
Normal file
62
src/components/studies/delete-study-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/studies/study-activity.tsx
Normal file
79
src/components/studies/study-activity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/studies/study-card.tsx
Normal file
25
src/components/studies/study-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/studies/study-form.tsx
Normal file
86
src/components/studies/study-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
372
src/components/studies/study-members.tsx
Normal file
372
src/components/studies/study-members.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/components/studies/study-metadata.tsx
Normal file
178
src/components/studies/study-metadata.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/studies/study-overview.tsx
Normal file
84
src/components/studies/study-overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/components/studies/study-participants.tsx
Normal file
166
src/components/studies/study-participants.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal 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 }
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 }
|
||||
@@ -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: {
|
||||
|
||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user