Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.7",
Expand All @@ -57,6 +58,7 @@
"lib": "workspace:*",
"lucide-react": "^0.441.0",
"mustache": "^4.2.0",
"next-themes": "^0.4.6",
"postcss": "^8.4.45",
"react": "^18.3.1",
"react-day-picker": "8.10.1",
Expand All @@ -80,8 +82,8 @@
"@types/human-date": "^1.4.5",
"@types/mustache": "^4.2.6",
"@types/node": "^22.5.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^9.1.0",
Expand All @@ -96,7 +98,7 @@
"prettier": "3.3.3",
"prettier-plugin-tailwindcss": "^0.6.6",
"terser": "^5.43.1",
"typescript": "^5.5.3",
"typescript": "^5.8.2",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
Expand Down
9 changes: 6 additions & 3 deletions client/src/components/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { GOOGLE_CLIENT_ID } from "@/lib/constants";
import { GoogleOAuthProvider } from "@react-oauth/google";
import { AuthProvider } from "@/hooks/Auth";
import { SidebarProvider } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/sonner";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "next-themes";

const queryClient = new QueryClient();

Expand All @@ -12,8 +13,10 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
<QueryClientProvider client={queryClient}>
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<AuthProvider>
<SidebarProvider>{children}</SidebarProvider>
<Toaster />
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<SidebarProvider>{children}</SidebarProvider>
<Toaster />
</ThemeProvider>
</AuthProvider>
</GoogleOAuthProvider>
</QueryClientProvider>
Expand Down
149 changes: 149 additions & 0 deletions client/src/components/profile/SocialAcademicProfile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Link, GraduationCap } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as z from "zod";

const socialProfileSchema = z.object({
linkedin: z.string().url("Please enter a valid LinkedIn URL").optional().or(z.literal("")),
orchidID: z.string().optional(),
scopusID: z.string().optional(),
googleScholar: z.string().url("Please enter a valid Google Scholar URL").optional().or(z.literal("")),
});

type SocialProfileFormData = z.infer<typeof socialProfileSchema>;

const SocialAcademicProfile = () => {
const queryClient = useQueryClient();

const { data: profileData, isLoading } = useQuery({
queryKey: ["user-profile"],
queryFn: async () => {
const response = await api.get("/profile");
return response.data;
},
});

const form = useForm<SocialProfileFormData>({
resolver: zodResolver(socialProfileSchema),
defaultValues: {
linkedin: profileData?.linkedin || "",
orchidID: profileData?.orchidID || "",
scopusID: profileData?.scopusID || "",
googleScholar: profileData?.googleScholar || "",
},
Comment on lines +59 to +64
Copy link

Copilot AI Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The form default values are set during initialization but won't update when profileData loads asynchronously. Use the reset method in a useEffect to update form values after data loads, or use the values prop instead of defaultValues.

Copilot uses AI. Check for mistakes.
});

const updateProfileMutation = useMutation({
mutationFn: async (data: SocialProfileFormData) => {
return api.put("/profile/edit", data);
},
onSuccess: () => {
toast.success("Social/academic profile updated successfully");
queryClient.invalidateQueries({ queryKey: ["user-profile"] });
},
onError: () => {
toast.error("Failed to update profile. Please try again.");
},
});

const onSubmit = (data: SocialProfileFormData) => {
updateProfileMutation.mutate(data);
};

if (isLoading) {
return <div>Loading...</div>;
}

return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link className="h-5 w-5" />
<GraduationCap className="h-5 w-5" />
Social & Academic Profiles
</CardTitle>
<p className="text-sm text-gray-600">
Connect your professional and academic profiles
</p>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="linkedin"
render={({ field }) => (
<FormItem>
<FormLabel>LinkedIn Profile</FormLabel>
<FormControl>
<Input placeholder="https://linkedin.com/in/yourusername" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="orchidID"
render={({ field }) => (
<FormItem>
<FormLabel>ORCID ID</FormLabel>
<FormControl>
<Input placeholder="0000-0000-0000-0000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="scopusID"
render={({ field }) => (
<FormItem>
<FormLabel>Scopus ID</FormLabel>
<FormControl>
<Input placeholder="Your Scopus ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="googleScholar"
render={({ field }) => (
<FormItem>
<FormLabel>Google Scholar Profile</FormLabel>
<FormControl>
<Input placeholder="https://scholar.google.com/citations?user=..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<Button
type="submit"
className="w-full"
disabled={updateProfileMutation.isLoading}
>
{updateProfileMutation.isLoading ? "Updating..." : "Save Changes"}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
};

export default SocialAcademicProfile;
10 changes: 7 additions & 3 deletions client/src/components/profile/UserProfile.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { type FC } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { User, FileImage } from "lucide-react";
import { useAuth } from "@/hooks/Auth";
import ProfileImageUploader from "@/components/shared/ProfileImageUploader";
import SignatureUploader from "@/components/profile/SignatureUploader";
import SocialAcademicProfile from "@/components/profile/SocialAcademicProfile";

const Profile = () => {
const Profile: FC = () => {
const { authState } = useAuth();
const userEmail = authState!.email;
const userType = authState!.userType;
const userEmail = authState?.email ?? '';
const userType = authState?.userType ?? '';

return (
<div className="max-w-2xl space-y-4 p-2">
Expand All @@ -26,6 +28,8 @@ const Profile = () => {
</CardContent>
</Card>

<SocialAcademicProfile />

{userType === "faculty" && (
<Card>
<CardHeader>
Expand Down
127 changes: 127 additions & 0 deletions client/src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"

import { cn } from "@/lib/utils"

const ToastProvider = ToastPrimitives.Provider

const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName

const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)

const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName

const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName

const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName

const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName

const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName

type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>

type ToastActionElement = React.ReactElement<typeof ToastAction>

export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
28 changes: 28 additions & 0 deletions client/src/components/ui/toaster.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useTheme } from "next-themes"

import { ToastProvider } from "@/components/ui/toast"
import { Toaster as Sonner } from "sonner"

export function Toaster() {
const { theme = "system" } = useTheme()

return (
<ToastProvider>
<Sonner
theme={theme as "light" | "dark" | "system"}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
/>
</ToastProvider>
)
}
Loading