feat(hackathon): update team page

- update dark mode color scheme for team page: view, team edit,
  and submit project page
- fix bug in chat where messages are displayed double
- make the form field size consistent
This commit is contained in:
Hafid Nur
2025-11-27 14:53:38 +07:00
parent ff9a02c020
commit 3470853858
11 changed files with 548 additions and 259 deletions
+3 -3
View File
@@ -2,13 +2,13 @@ import { Link } from 'react-router-dom';
export default function NotFoundPage() { export default function NotFoundPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center"> <div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1> <h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 mb-4"> <h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
Page Not Found Page Not Found
</h2> </h2>
<p className="text-gray-600 mb-8"> <p className="text-gray-600 dark:text-gray-400 mb-8">
The page you are looking for doesn't exist or has been moved. The page you are looking for doesn't exist or has been moved.
</p> </p>
<Link <Link
@@ -1,8 +1,15 @@
import { FC, ReactElement, useState, useRef, useEffect } from 'react'; import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamMessages, useSendMessage, useDeleteMessage, useAuthStore } from '@imphnen-frontend-service/service'; import {
useTeamById,
useTeamMessages,
useSendMessage,
useDeleteMessage,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const TeamChatPage: FC = (): ReactElement => { const TeamChatPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -13,16 +20,54 @@ const TeamChatPage: FC = (): ReactElement => {
const { data: teamData } = useTeamById(teamId || ''); const { data: teamData } = useTeamById(teamId || '');
const { data: messages, isLoading } = useTeamMessages(teamId || ''); const { data: messages, isLoading } = useTeamMessages(teamId || '');
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(teamId || ''); const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(
teamId || ''
);
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || ''); const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
const team = teamData?.data; const team = teamData?.data;
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
// Message type used for UI rendering
interface ChatMessage {
id: string;
user_id: string;
user?: {
avatar?: string;
fullname?: string;
};
message: string;
created_at: string;
}
// Inline delete confirmation UI state
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// Prepare messages: de-duplicate by id and sort by created_at
const displayMessages = useMemo(() => {
const raw: ChatMessage[] = Array.isArray(messages)
? (messages as ChatMessage[])
: [];
const seen = new Set<string>();
const dedup: ChatMessage[] = [];
for (const m of raw) {
const key = m.id ?? `${m.user_id}-${m.created_at}`;
if (!seen.has(key)) {
seen.add(key);
dedup.push(m);
}
}
dedup.sort(
(a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
return dedup;
}, [messages]);
// Auto-scroll to bottom when new messages arrive // Auto-scroll to bottom when new messages arrive
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [displayMessages]);
const handleSendMessage = async (e: React.FormEvent) => { const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -38,11 +83,10 @@ const TeamChatPage: FC = (): ReactElement => {
}; };
const handleDeleteMessage = async (messageId: string) => { const handleDeleteMessage = async (messageId: string) => {
if (!confirm('Are you sure you want to delete this message?')) return;
try { try {
await deleteMessage(messageId); await deleteMessage(messageId);
toast.success('Message deleted'); toast.success('Message deleted');
setDeleteTargetId(null);
} catch (error) { } catch (error) {
console.error('Failed to delete message:', error); console.error('Failed to delete message:', error);
toast.error('Failed to delete message'); toast.error('Failed to delete message');
@@ -63,16 +107,23 @@ const TeamChatPage: FC = (): ReactElement => {
}; };
return ( return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */} {/* Header */}
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700 shadow-sm dark:shadow-neutral-950/50"> <div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Team Chat</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
<p className="text-gray-600 dark:text-neutral-400 text-sm mt-0.5">{team?.name}</p> Team Chat
</h1>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-0.5">
{team?.name}
</p>
</div> </div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}> <Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -86,21 +137,27 @@ const TeamChatPage: FC = (): ReactElement => {
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-primary-400"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-primary-400"></div>
</div> </div>
) : messages && messages.length > 0 ? ( ) : displayMessages && displayMessages.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{messages.map((msg) => { {displayMessages.map((msg) => {
const isOwnMessage = msg.user_id === currentUserId; const isOwnMessage = msg.user_id === currentUserId;
const isLeader = team?.leader_id === currentUserId; const isLeader = team?.leader_id === currentUserId;
const canDelete = isOwnMessage || isLeader; const canDelete = isOwnMessage || isLeader;
return ( return (
<div <div
key={msg.id} key={`${msg.id ?? 'message'}-${msg.created_at}`}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`} className={`flex ${
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
> >
<div className={`flex gap-3 max-w-lg ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}> <div
className={`flex gap-3 max-w-lg ${
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
}`}
>
{/* Avatar */} {/* Avatar */}
<div className="flex-shrink-0"> <div className="shrink-0">
{msg.user?.avatar ? ( {msg.user?.avatar ? (
<img <img
src={msg.user.avatar} src={msg.user.avatar}
@@ -115,13 +172,17 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
{/* Message Bubble */} {/* Message Bubble */}
<div className={`flex-1 ${isOwnMessage ? 'text-right' : 'text-left'}`}> <div className={`flex-1 text-left`}>
<div className={`inline-block ${isOwnMessage ? 'items-end' : 'items-start'}`}> <div
className={`inline-block ${
isOwnMessage ? 'items-end' : 'items-start'
}`}
>
<div className="flex items-baseline gap-2 mb-1"> <div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900 dark:text-white"> <span className="font-semibold text-sm text-gray-900 dark:text-white">
{isOwnMessage ? 'You' : msg.user?.fullname} {isOwnMessage ? 'You' : msg.user?.fullname}
</span> </span>
<span className="text-xs text-gray-500 dark:text-neutral-500"> <span className="text-xs text-gray-500 dark:text-gray-500">
{formatTime(msg.created_at)} {formatTime(msg.created_at)}
</span> </span>
</div> </div>
@@ -129,24 +190,58 @@ const TeamChatPage: FC = (): ReactElement => {
className={`relative group rounded-2xl px-4 py-2.5 ${ className={`relative group rounded-2xl px-4 py-2.5 ${
isOwnMessage isOwnMessage
? 'bg-blue-600 dark:bg-primary-600 text-white' ? 'bg-blue-600 dark:bg-primary-600 text-white'
: 'bg-white dark:bg-neutral-800 text-gray-900 dark:text-white border border-gray-200 dark:border-neutral-700' : 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700'
}`} }`}
> >
<p className="text-sm whitespace-pre-wrap break-words">{msg.message}</p> <p className="text-sm whitespace-pre-wrap wrap-break-word font-sans">
{msg.message}
</p>
{/* Delete button */} {/* Delete button */}
{canDelete && ( {canDelete && (
<button <button
onClick={() => handleDeleteMessage(msg.id)} onClick={() =>
className={`absolute top-1 ${isOwnMessage ? 'left-1' : 'right-1'} opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 ${isOwnMessage ? 'hover:bg-blue-700 dark:hover:bg-primary-700' : ''}`} setDeleteTargetId(
deleteTargetId === msg.id ? null : msg.id
)
}
className={`absolute top-1 ${
isOwnMessage ? 'left-1' : 'right-1'
} opacity-0 hover:opacity-100 p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer ${
isOwnMessage
? 'hover:bg-blue-700 dark:hover:bg-primary-700'
: ''
}`}
title="Delete message" title="Delete message"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {/* Icon trash */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <Icon
</svg> icon="mdi:trash-can-outline"
className="w-4 h-4"
/>
</button> </button>
)} )}
</div> </div>
{canDelete && deleteTargetId === msg.id && (
<div
className={`mt-2 flex gap-2 ${
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
>
<button
onClick={() => handleDeleteMessage(msg.id)}
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 cursor-pointer"
>
Delete
</button>
<button
onClick={() => setDeleteTargetId(null)}
className="px-2 py-1 text-xs rounded bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600 cursor-pointer"
>
Cancel
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -157,11 +252,14 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-64 text-center"> <div className="flex flex-col items-center justify-center h-64 text-center">
<div className="text-6xl mb-4">💬</div> <Icon
icon="mdi:chat-outline"
className="text-6xl mb-4 text-gray-400"
/>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2"> <h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
No messages yet No messages yet
</h3> </h3>
<p className="text-gray-600 dark:text-neutral-400"> <p className="text-gray-600 dark:text-gray-400 font-sans">
Be the first to start the conversation! Be the first to start the conversation!
</p> </p>
</div> </div>
@@ -170,7 +268,7 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
{/* Message Input */} {/* Message Input */}
<div className="bg-white dark:bg-neutral-900 border-t dark:border-neutral-700 shadow-lg dark:shadow-neutral-950/50"> <div className="bg-white dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<form onSubmit={handleSendMessage} className="flex gap-3"> <form onSubmit={handleSendMessage} className="flex gap-3">
<input <input
@@ -178,7 +276,7 @@ const TeamChatPage: FC = (): ReactElement => {
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..." placeholder="Type your message..."
className="flex-1 px-4 py-3 border border-gray-300 dark:border-neutral-600 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent" className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent"
disabled={isSending} disabled={isSending}
/> />
<Button <Button
@@ -193,8 +291,18 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /> className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg> </svg>
Send Send
</div> </div>
@@ -3,10 +3,19 @@ import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms'; import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { teamUpdateSchema, TTeamUpdateForm, useUpdateTeam, useTeamById, ETeamVisibility, useUploadFile, useAuthStore } from '@imphnen-frontend-service/service'; import {
teamUpdateSchema,
TTeamUpdateForm,
useUpdateTeam,
useTeamById,
ETeamVisibility,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../../components/city-select'; import { CitySelect } from '../../../../components/city-select';
import { Icon } from '@iconify/react';
const EditTeamPage: FC = (): ReactElement => { const EditTeamPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -17,8 +26,12 @@ const EditTeamPage: FC = (): ReactElement => {
const [bannerFile, setBannerFile] = useState<File | null>(null); const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>(''); const [bannerPreview, setBannerPreview] = useState<string>('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || ''); const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(teamId || ''); teamId || ''
);
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(
teamId || ''
);
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data; const team = teamData?.data;
@@ -47,18 +60,24 @@ const EditTeamPage: FC = (): ReactElement => {
if (isLoadingTeam) { if (isLoadingTeam) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-gray-600 dark:text-neutral-400">Loading team...</div> <div className="text-gray-600 dark:text-gray-400">Loading team...</div>
</div> </div>
); );
} }
if (!isLeader) { if (!isLeader) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Access Denied</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
<p className="text-gray-600 dark:text-neutral-400 mb-4">Only the team leader can edit team information</p> Access Denied
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can edit team information
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div> </div>
); );
} }
@@ -115,28 +134,35 @@ const EditTeamPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700"> <div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Edit Team Info</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
<p className="text-gray-600 dark:text-neutral-400 mt-1">Update your team details</p> Edit Team Info
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Update your team details
</p>
</div> </div>
</div> </div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-8"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8">
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */} {/* Banner Upload */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2"> <label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
Team Banner Team Banner
</label> </label>
{bannerPreview ? ( {bannerPreview ? (
<div className="relative"> <div
className="relative w-full"
style={{ aspectRatio: '3 / 1' }}
>
<img <img
src={bannerPreview} src={bannerPreview}
alt="Banner preview" alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700" className="absolute inset-0 w-full h-full object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
/> />
<button <button
type="button" type="button"
@@ -145,16 +171,20 @@ const EditTeamPage: FC = (): ReactElement => {
setBannerPreview(''); setBannerPreview('');
form.setValue('banner', null); form.setValue('banner', null);
}} }}
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600" className="absolute top-2 right-2 bg-danger-600 text-white px-3 py-1 rounded-lg text-sm hover:bg-danger-700 cursor-pointer"
> >
Remove Remove
</button> </button>
</div> </div>
) : ( ) : (
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-800"> <label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
<div className="text-center"> <div className="text-center">
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p> <p className="text-gray-500 dark:text-gray-400">
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended</p> Click to upload banner
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
1200x400 recommended
</p>
</div> </div>
<input <input
type="file" type="file"
@@ -168,7 +198,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Logo Upload */} {/* Logo Upload */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2"> <label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
Team Logo Team Logo
</label> </label>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
@@ -176,16 +206,18 @@ const EditTeamPage: FC = (): ReactElement => {
<img <img
src={logoPreview} src={logoPreview}
alt="Logo preview" alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-neutral-700" className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
/> />
) : ( ) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-400 dark:text-neutral-500 text-3xl">👥</span> <span className="text-gray-400 dark:text-gray-500 text-3xl">
<Icon icon="mdi:account-group" />
</span>
</div> </div>
)} )}
<div> <div className="flex flex-col md:flex-row items-start justify-start gap-1">
<label htmlFor="logo" className="cursor-pointer"> <label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block"> <span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'} {logoPreview ? 'Change Logo' : 'Upload Logo'}
</span> </span>
<input <input
@@ -204,7 +236,7 @@ const EditTeamPage: FC = (): ReactElement => {
setLogoPreview(''); setLogoPreview('');
form.setValue('logo', null); form.setValue('logo', null);
}} }}
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600" className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
> >
Remove Remove
</button> </button>
@@ -219,11 +251,13 @@ const EditTeamPage: FC = (): ReactElement => {
label="Team Name" label="Team Name"
placeholder="Enter team name" placeholder="Enter team name"
name="name" name="name"
size="lg"
isRequired={true}
/> />
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
City City
</label> </label>
<Controller <Controller
@@ -242,7 +276,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
Description Description
</label> </label>
<Controller <Controller
@@ -256,9 +290,12 @@ const EditTeamPage: FC = (): ReactElement => {
placeholder="Tell others about your team..." placeholder="Tell others about your team..."
rows={4} rows={4}
className="w-full" className="w-full"
size="lg"
/> />
{fieldState.error && ( {fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p> <p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)} )}
</div> </div>
)} )}
@@ -267,7 +304,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Visibility */} {/* Visibility */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
Team Visibility Team Visibility
</label> </label>
<Controller <Controller
@@ -275,7 +312,7 @@ const EditTeamPage: FC = (): ReactElement => {
name="visibility" name="visibility"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-3"> <div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800"> <label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -284,13 +321,15 @@ const EditTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">Public</p> <p className="font-medium text-gray-900 dark:text-white">
<p className="text-sm text-gray-600 dark:text-neutral-400"> Public
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team will be visible in Browse Teams Team will be visible in Browse Teams
</p> </p>
</div> </div>
</label> </label>
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800"> <label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -299,8 +338,10 @@ const EditTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">Private</p> <p className="font-medium text-gray-900 dark:text-white">
<p className="text-sm text-gray-600 dark:text-neutral-400"> Private
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team hidden, invite-only Team hidden, invite-only
</p> </p>
</div> </div>
@@ -6,21 +6,33 @@ const TeamDetailLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<div className="flex min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} /> <Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col overflow-auto"> <div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */} {/* Mobile Header with Hamburger */}
<div className="lg:hidden bg-white dark:bg-neutral-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center"> <div className="lg:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button <button
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
> >
<svg className="w-6 h-6 text-gray-600 dark:text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg> </svg>
</button> </button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">Hackathon</h1> <h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div> </div>
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">
@@ -23,17 +23,28 @@ const ManageMembersPage: FC = (): ReactElement => {
const { session } = useAuthStore(); const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false); const [showInviteModal, setShowInviteModal] = useState(false);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || ''); const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || ''); teamId || ''
);
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const { data: joinRequestsData } = useTeamJoinRequests(teamId || ''); const { data: joinRequestsData } = useTeamJoinRequests(teamId || '');
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || ''); const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(teamId || ''); teamId || ''
const { mutateAsync: respondToRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || ''); );
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(
teamId || ''
);
const { mutateAsync: respondToRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const team = teamData?.data; const team = teamData?.data;
const members = membersData?.data || []; const members = membersData?.data || [];
const joinRequests = Array.isArray(joinRequestsData?.data) ? joinRequestsData.data : []; const joinRequests = Array.isArray(joinRequestsData?.data)
? joinRequestsData.data
: [];
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id; const isLeader = currentUserId === team?.leader_id;
@@ -45,18 +56,24 @@ const ManageMembersPage: FC = (): ReactElement => {
// Show loading state while fetching team data // Show loading state while fetching team data
if (isLoadingTeam) { if (isLoadingTeam) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<p className="text-gray-600 dark:text-neutral-400">Loading...</p> <p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div> </div>
); );
} }
if (!isLeader) { if (!isLeader) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Access Denied</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
<p className="text-gray-600 dark:text-neutral-400 mb-4">Only the team leader can manage members</p> Access Denied
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can manage members
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div> </div>
); );
} }
@@ -103,19 +120,26 @@ const ManageMembersPage: FC = (): ReactElement => {
}; };
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700"> <div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row gap-y-4 items-start md:items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Manage Members</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p> Manage Members
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team?.name}
</p>
</div> </div>
<div className="flex space-x-3"> <div className="flex space-x-3">
<Button onClick={() => setShowInviteModal(true)}> <Button onClick={() => setShowInviteModal(true)}>
Invite Member Invite Member
</Button> </Button>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}> <Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -126,13 +150,16 @@ const ManageMembersPage: FC = (): ReactElement => {
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Join Requests */} {/* Join Requests */}
{joinRequests.length > 0 && ( {joinRequests.length > 0 && (
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Join Requests ({joinRequests.length}) Join Requests ({joinRequests.length})
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
{joinRequests.map((request) => ( {joinRequests.map((request) => (
<div key={request.id} className="border dark:border-neutral-700 rounded-lg p-4"> <div
key={request.id}
className="border dark:border-gray-700 rounded-lg p-4"
>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1"> <div className="flex items-center space-x-3 flex-1">
{request.user.avatar ? ( {request.user.avatar ? (
@@ -142,17 +169,27 @@ const ManageMembersPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-neutral-400">👤</span> <span className="text-gray-500 dark:text-gray-400">
👤
</span>
</div> </div>
)} )}
<div className="flex-1"> <div className="flex-1">
<p className="font-medium text-gray-900 dark:text-white">{request.user.fullname}</p> <p className="font-medium text-gray-900 dark:text-white">
<p className="text-sm text-gray-600 dark:text-neutral-400">{request.user.email}</p> {request.user.fullname}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{request.user.email}
</p>
{request.user.location && ( {request.user.location && (
<p className="text-sm text-gray-500 dark:text-neutral-500">📍 {request.user.location}</p> <p className="text-sm text-gray-500 dark:text-gray-500">
📍 {request.user.location}
</p>
)} )}
<p className="text-sm text-gray-700 dark:text-neutral-300 mt-2 italic">"{request.message}"</p> <p className="text-sm text-gray-700 dark:text-gray-300 mt-2 italic">
"{request.message}"
</p>
</div> </div>
</div> </div>
<div className="flex space-x-2 ml-4"> <div className="flex space-x-2 ml-4">
@@ -180,16 +217,21 @@ const ManageMembersPage: FC = (): ReactElement => {
)} )}
{/* Current Members */} {/* Current Members */}
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Current Members ({members.length}) Current Members ({members.length})
</h2> </h2>
{isLoadingMembers ? ( {isLoadingMembers ? (
<p className="text-gray-600 dark:text-neutral-400">Loading members...</p> <p className="text-gray-600 dark:text-gray-400">
Loading members...
</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{members.map((member) => ( {members.map((member) => (
<div key={member.id} className="border dark:border-neutral-700 rounded-lg p-4 flex items-center justify-between"> <div
key={member.id}
className="border dark:border-gray-700 rounded-lg p-4 flex items-center justify-between"
>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{member.user.avatar ? ( {member.user.avatar ? (
<img <img
@@ -198,15 +240,23 @@ const ManageMembersPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-neutral-400">👤</span> <span className="text-gray-500 dark:text-gray-400">
👤
</span>
</div> </div>
)} )}
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">{member.user.fullname}</p> <p className="font-medium text-gray-900 dark:text-white">
<p className="text-sm text-gray-600 dark:text-neutral-400">{member.user.email}</p> {member.user.fullname}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{member.user.email}
</p>
{member.user.location && ( {member.user.location && (
<p className="text-sm text-gray-500 dark:text-neutral-500">📍 {member.user.location}</p> <p className="text-sm text-gray-500 dark:text-gray-500">
📍 {member.user.location}
</p>
)} )}
<div className="flex items-center space-x-2 mt-1"> <div className="flex items-center space-x-2 mt-1">
{member.role === 'leader' && ( {member.role === 'leader' && (
@@ -241,7 +291,8 @@ const ManageMembersPage: FC = (): ReactElement => {
{/* Warning */} {/* Warning */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200"> <p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Note:</strong> Members cannot leave the team without your approval. Only you can remove members from the team. <strong>Note:</strong> Members cannot leave the team without your
approval. Only you can remove members from the team.
</p> </p>
</div> </div>
</div> </div>
@@ -249,27 +300,30 @@ const ManageMembersPage: FC = (): ReactElement => {
{/* Invite Member Modal */} {/* Invite Member Modal */}
{showInviteModal && ( {showInviteModal && (
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl dark:shadow-neutral-950/50 max-w-md w-full p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invite Member Invite Member
</h2> </h2>
<p className="text-gray-600 dark:text-neutral-400 mb-4"> <p className="text-gray-600 dark:text-gray-400 mb-4 font-sans">
Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in. Send an invitation to join your team. The invited member will see
the invitation on their dashboard after logging in.
</p> </p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-200"> <p className="text-sm text-yellow-800 dark:text-yellow-200 font-sans">
<strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in. <strong>Important:</strong> The email you enter must match the
GitHub email address the member uses to sign in.
</p> </p>
</div> </div>
<form onSubmit={handleInvite} className="space-y-4"> <form onSubmit={handleInvite} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Address Email Address
</label> </label>
<Input <Input
{...form.register('email')} {...form.register('email')}
type="email" type="email"
placeholder="member@example.com" placeholder="member@example.com"
size="lg"
/> />
{form.formState.errors.email && ( {form.formState.errors.email && (
<p className="text-sm text-red-500 mt-1"> <p className="text-sm text-red-500 mt-1">
+127 -97
View File
@@ -11,6 +11,7 @@ import {
useAuthStore, useAuthStore,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const MAX_TEAM_MEMBERS = 5; const MAX_TEAM_MEMBERS = 5;
@@ -140,18 +141,18 @@ const TeamDashboardPage: FC = (): ReactElement => {
if (isLoadingTeam || isLoadingMembers) { if (isLoadingTeam || isLoadingMembers) {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Skeleton Header */} {/* Skeleton Header */}
<div className="bg-white border-b"> <div className="bg-white dark:bg-gray-950 border-b">
<div className="w-full h-48 bg-gray-200 animate-pulse" /> <div className="w-full h-48 bg-gray-200 dark:bg-gray-800 animate-pulse" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="w-20 h-20 rounded-full bg-gray-300 animate-pulse -mt-10" /> <div className="w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
<div> <div>
<div className="h-8 w-48 bg-gray-300 rounded animate-pulse" /> <div className="h-8 w-48 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse mt-2" /> <div className="h-4 w-32 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
<div className="h-4 w-24 bg-gray-200 rounded animate-pulse mt-2" /> <div className="h-4 w-24 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
</div> </div>
</div> </div>
</div> </div>
@@ -162,27 +163,27 @@ const TeamDashboardPage: FC = (): ReactElement => {
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* Main Content Skeleton */} {/* Main Content Skeleton */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-lg shadow-md p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
<div className="h-6 w-32 bg-gray-300 rounded animate-pulse mb-4" /> <div className="h-6 w-32 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
<div className="space-y-2"> <div className="space-y-2">
<div className="h-4 w-full bg-gray-200 rounded animate-pulse" /> <div className="h-4 w-full bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-3/4 bg-gray-200 rounded animate-pulse" /> <div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-5/6 bg-gray-200 rounded animate-pulse" /> <div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
</div> </div>
</div> </div>
</div> </div>
{/* Sidebar Skeleton */} {/* Sidebar Skeleton */}
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white rounded-lg shadow-md p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
<div className="h-6 w-40 bg-gray-300 rounded animate-pulse mb-4" /> <div className="h-6 w-40 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
<div className="space-y-4"> <div className="space-y-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="flex items-center space-x-3"> <div key={i} className="flex items-center space-x-3">
<div className="w-12 h-12 rounded-full bg-gray-300 animate-pulse" /> <div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse" />
<div> <div>
<div className="h-4 w-24 bg-gray-300 rounded animate-pulse" /> <div className="h-4 w-24 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-3 w-16 bg-gray-200 rounded animate-pulse mt-1" /> <div className="h-3 w-16 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-1" />
</div> </div>
</div> </div>
))} ))}
@@ -196,7 +197,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
<div className="fixed inset-0 bg-white/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-white/60 flex items-center justify-center z-50">
<div className="text-center"> <div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div> <div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600 dark:text-neutral-400"> <p className="mt-4 text-gray-600 dark:text-white">
Loading team data... Loading team data...
</p> </p>
</div> </div>
@@ -219,7 +220,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
} }
return ( return (
<div className="min-h-screen bg-gray-50 relative dark:bg-neutral-950"> <div className="min-h-screen bg-gray-50 relative dark:bg-gray-950">
{/* Loading overlay while images are loading */} {/* Loading overlay while images are loading */}
{!imagesLoaded && totalImagesToLoad > 0 && ( {!imagesLoaded && totalImagesToLoad > 0 && (
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
@@ -234,9 +235,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
)} )}
{/* Header with Banner */} {/* Header with Banner */}
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700"> <div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
{team.banner && ( {team.banner && (
<div className="w-full h-32 md:h-48 overflow-hidden relative"> <div className="relative w-full aspect-3/1 overflow-hidden">
<div <div
className={`absolute inset-0 bg-gray-200 animate-pulse ${ className={`absolute inset-0 bg-gray-200 animate-pulse ${
imagesLoaded ? 'hidden' : '' imagesLoaded ? 'hidden' : ''
@@ -245,26 +246,27 @@ const TeamDashboardPage: FC = (): ReactElement => {
<img <img
src={team.banner} src={team.banner}
alt={team.name} alt={team.name}
className={`w-full h-full object-cover transition-opacity duration-300 ${ className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
imagesLoaded ? 'opacity-100' : 'opacity-0' imagesLoaded ? 'opacity-100' : 'opacity-0'
}`} }`}
onLoad={handleImageLoad} onLoad={handleImageLoad}
onError={handleImageError} onError={handleImageError}
/> />
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-black/20 to-black/60" />
</div> </div>
)} )}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-3 md:space-x-4"> <div className="flex items-center space-x-3 md:space-x-4">
{team.logo && ( {team.logo && (
<div className="relative"> <div className="relative shrink-0">
{!imagesLoaded && ( {!imagesLoaded && (
<div className="absolute inset-0 w-20 h-20 rounded-full bg-gray-300 animate-pulse -mt-10" /> <div className="absolute inset-0 w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
)} )}
<img <img
src={team.logo} src={team.logo}
alt={team.name} alt={team.name}
className={`w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-8 md:-mt-10 transition-opacity duration-300 ${ className={`w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-8 md:-mt-10 transition-opacity duration-300 shrink-0 ${
imagesLoaded ? 'opacity-100' : 'opacity-0' imagesLoaded ? 'opacity-100' : 'opacity-0'
}`} }`}
onLoad={handleImageLoad} onLoad={handleImageLoad}
@@ -276,20 +278,32 @@ const TeamDashboardPage: FC = (): ReactElement => {
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white line-clamp-2"> <h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white line-clamp-2">
{team.name} {team.name}
</h1> </h1>
<p className="text-sm md:text-base text-gray-600 dark:text-neutral-400 mt-1 line-clamp-1"> <p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1 line-clamp-1 font-sans flex items-center">
📍 {team.city} <Icon icon="mdi:map-marker" className="inline-block mr-1" />
<span>{team.city}</span>
</p> </p>
<div className="flex flex-wrap items-center gap-2 md:space-x-4 mt-2"> <div className="flex flex-wrap items-center gap-2 md:space-x-4 mt-2">
<span className="text-sm text-gray-500 dark:text-neutral-400"> <span className="text-sm text-gray-500 dark:text-gray-400 flex items-center">
<Icon
icon="mdi:account-multiple"
className="inline-block mr-1"
/>
{members.length}{' '} {members.length}{' '}
{members.length === 1 ? 'Member' : 'Members'} {members.length === 1 ? 'Member' : 'Members'}
</span> </span>
<span className="text-sm text-gray-500 dark:text-neutral-400"> <span className="text-sm text-gray-500 dark:text-gray-400 flex items-center">
{team.visibility === 'public' ? '🌐 Public' : '🔒 Private'} <Icon
icon={
team.visibility === 'public' ? 'mdi:web' : 'mdi:lock'
}
className="inline-block mr-1"
/>
{team.visibility === 'public' ? 'Public' : 'Private'}
</span> </span>
{isLeader && ( {isLeader && (
<span className="hidden md:inline-flex px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm"> <span className="hidden md:inline-flex px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm items-center">
👑 Team Leader <Icon icon="mdi:crown" className="inline-block mr-1" />
<span>Team Leader</span>
</span> </span>
)} )}
</div> </div>
@@ -300,22 +314,22 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div> </div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
<div className="grid gap-4 md:gap-6 lg:grid-cols-3"> <div className="grid gap-4 md:gap-6 xl:grid-cols-3">
{/* Main Content */} {/* Main Content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6"> <div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* Team Description */} {/* Team Description */}
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
About Team About Team
</h2> </h2>
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap"> <p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-all font-sans">
{team.description} {team.description}
</p> </p>
</div> </div>
{/* Team Actions - Only for Leader */} {/* Team Actions - Only for Leader */}
{isLeader && ( {isLeader && (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Team Management Team Management
</h2> </h2>
@@ -326,7 +340,8 @@ const TeamDashboardPage: FC = (): ReactElement => {
onClick={() => setShowInviteModal(true)} onClick={() => setShowInviteModal(true)}
disabled={!canInvite} disabled={!canInvite}
> >
Invite Member{' '} <Icon icon="mdi:plus" className="inline-block mr-2" />
Invite Member{' '}
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`} {!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button> </Button>
<Button <Button
@@ -334,7 +349,8 @@ const TeamDashboardPage: FC = (): ReactElement => {
variant="secondary" variant="secondary"
onClick={() => setShowJoinRequestsModal(true)} onClick={() => setShowJoinRequestsModal(true)}
> >
📩 Join Requests <Icon icon="mdi:email" className="inline-block mr-2" /> Join
Requests
{pendingJoinRequests.length > 0 && ( {pendingJoinRequests.length > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center"> <span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
{pendingJoinRequests.length} {pendingJoinRequests.length}
@@ -343,25 +359,34 @@ const TeamDashboardPage: FC = (): ReactElement => {
</Button> </Button>
<Link to={`/teams/${teamId}/edit`}> <Link to={`/teams/${teamId}/edit`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
Edit Team Info <Icon icon="mdi:pencil" className="inline-block mr-2" />
Edit Team Info
</Button> </Button>
</Link> </Link>
<Link to={`/teams/${teamId}/members`}> <Link to={`/teams/${teamId}/members`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
👥 Manage Members <Icon
icon="mdi:account-group"
className="inline-block mr-2"
/>
Manage Members
</Button> </Button>
</Link> </Link>
<Link to={`/teams/${teamId}/chat`}> <Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
💬 Team Chat <Icon icon="mdi:chat" className="inline-block mr-2" />
Team Chat
</Button> </Button>
</Link> </Link>
<Link to={`/teams/${teamId}/submit`}> <Link to={`/teams/${teamId}/submit`}>
<Button className="w-full">🚀 Submit Project</Button> <Button className="w-full">
<Icon icon="mdi:rocket" className="inline-block mr-2" />
Submit Project
</Button>
</Link> </Link>
</div> </div>
{!canInvite && members.length >= MAX_TEAM_MEMBERS && ( {!canInvite && members.length >= MAX_TEAM_MEMBERS && (
<p className="text-sm text-gray-600 dark:text-neutral-400 mt-3 text-center"> <p className="text-sm text-gray-600 dark:text-gray-400 mt-3 text-center">
Maximum team size reached ({MAX_TEAM_MEMBERS} members) Maximum team size reached ({MAX_TEAM_MEMBERS} members)
</p> </p>
)} )}
@@ -370,7 +395,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Quick Actions for Members (non-leaders) */} {/* Quick Actions for Members (non-leaders) */}
{!isLeader && isMember && ( {!isLeader && isMember && (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Quick Actions Quick Actions
</h2> </h2>
@@ -417,14 +442,14 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Sidebar */} {/* Sidebar */}
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
{/* Team Leader */} {/* Team Leader */}
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Team Leader Team Leader
</h3> </h3>
{team.leader && ( {team.leader && (
<button <button
onClick={() => navigate(`/users/${team.leader.id}`)} onClick={() => navigate(`/users/${team.leader.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-neutral-700 rounded-lg p-2 transition-colors text-left cursor-pointer" className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
> >
{team.leader.avatar ? ( {team.leader.avatar ? (
<img <img
@@ -433,8 +458,8 @@ const TeamDashboardPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-neutral-400"> <span className="text-gray-500 dark:text-gray-400">
👤 👤
</span> </span>
</div> </div>
@@ -443,7 +468,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900 dark:text-white">
{team.leader.fullname} {team.leader.fullname}
</p> </p>
<p className="text-sm text-gray-600 dark:text-neutral-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
{team.leader.email} {team.leader.email}
</p> </p>
</div> </div>
@@ -452,7 +477,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div> </div>
{/* Team Members */} {/* Team Members */}
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Members ({members.length}) Members ({members.length})
</h3> </h3>
@@ -461,7 +486,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
<button <button
key={member.id} key={member.id}
onClick={() => navigate(`/users/${member.user.id}`)} onClick={() => navigate(`/users/${member.user.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-neutral-700 rounded-lg p-2 transition-colors text-left cursor-pointer" className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
> >
{member.user?.avatar ? ( {member.user?.avatar ? (
<img <img
@@ -472,8 +497,8 @@ const TeamDashboardPage: FC = (): ReactElement => {
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-neutral-400 text-sm"> <span className="text-gray-500 dark:text-gray-400 text-sm">
👤 👤
</span> </span>
</div> </div>
@@ -482,7 +507,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
<p className="font-medium text-gray-900 dark:text-white truncate"> <p className="font-medium text-gray-900 dark:text-white truncate">
{member.user?.fullname || 'Unknown'} {member.user?.fullname || 'Unknown'}
</p> </p>
<p className="text-xs text-gray-500 dark:text-neutral-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
{member.role === ETeamMemberRole.LEADER {member.role === ETeamMemberRole.LEADER
? 'Leader' ? 'Leader'
: 'Member'} : 'Member'}
@@ -499,16 +524,16 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Invite Member Modal */} {/* Invite Member Modal */}
{showInviteModal && ( {showInviteModal && (
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl max-w-md w-full p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invite Team Member Invite Team Member
</h2> </h2>
<p className="text-gray-600 dark:text-neutral-400 mb-4"> <p className="text-gray-600 dark:text-gray-400 mb-4 font-sans">
Send an invitation to join your team. The invited member will see Send an invitation to join your team. The invited member will see
the invitation on their dashboard after logging in. the invitation on their dashboard after logging in.
</p> </p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-300"> <p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans">
<strong>Important:</strong> The email you enter must match the <strong>Important:</strong> The email you enter must match the
GitHub email address the member uses to sign in. GitHub email address the member uses to sign in.
</p> </p>
@@ -517,7 +542,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
<div> <div>
<label <label
htmlFor="invite-email" htmlFor="invite-email"
className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
> >
Email Address Email Address
</label> </label>
@@ -527,10 +552,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
value={inviteEmail} value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)} onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address..." placeholder="Enter email address..."
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-neutral-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-neutral-500" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
required required
/> />
<p className="text-sm text-gray-500 dark:text-neutral-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Current members: {members.length}/{MAX_TEAM_MEMBERS} Current members: {members.length}/{MAX_TEAM_MEMBERS}
</p> </p>
</div> </div>
@@ -562,14 +587,14 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Join Requests Modal */} {/* Join Requests Modal */}
{showJoinRequestsModal && ( {showJoinRequestsModal && (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white"> <h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Join Requests ({pendingJoinRequests.length}) Join Requests ({pendingJoinRequests.length})
</h2> </h2>
<button <button
onClick={() => setShowJoinRequestsModal(false)} onClick={() => setShowJoinRequestsModal(false)}
className="text-gray-400 dark:text-neutral-500 hover:text-gray-600 dark:hover:text-neutral-300 text-2xl" className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-2xl cursor-pointer"
> >
</button> </button>
@@ -577,10 +602,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
{pendingJoinRequests.length === 0 ? ( {pendingJoinRequests.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-600 dark:text-neutral-400 text-lg"> <p className="text-gray-600 dark:text-gray-400 text-lg">
No pending join requests No pending join requests
</p> </p>
<p className="text-gray-500 dark:text-neutral-500 text-sm mt-2"> <p className="text-gray-500 dark:text-gray-500 text-sm mt-2 font-sans">
When users request to join your team, they'll appear here When users request to join your team, they'll appear here
</p> </p>
</div> </div>
@@ -589,7 +614,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
{pendingJoinRequests.map((request: any) => ( {pendingJoinRequests.map((request: any) => (
<div <div
key={request.id} key={request.id}
className="border border-gray-200 dark:border-neutral-700 rounded-lg p-4 hover:shadow-md transition-shadow" className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow"
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1"> <div className="flex items-start space-x-3 flex-1">
@@ -600,9 +625,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center shrink-0"> <div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
<span className="text-gray-500 dark:text-neutral-400 text-xl"> <span className="text-gray-500 dark:text-gray-400 text-xl">
👤 <Icon
icon="mdi:account-circle"
className="text-3xl"
/>
</span> </span>
</div> </div>
)} )}
@@ -610,49 +638,51 @@ const TeamDashboardPage: FC = (): ReactElement => {
<p className="font-semibold text-gray-900 dark:text-white"> <p className="font-semibold text-gray-900 dark:text-white">
{request.user?.fullname || 'Unknown User'} {request.user?.fullname || 'Unknown User'}
</p> </p>
<p className="text-sm text-gray-600 dark:text-neutral-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
{request.user?.email} {request.user?.email}
</p> </p>
{request.message && ( {request.message && (
<div className="mt-2 bg-gray-50 dark:bg-neutral-800 rounded-lg p-3"> <div className="mt-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<p className="text-sm text-gray-700 dark:text-neutral-300"> <p className="text-sm text-gray-700 dark:text-gray-300">
<strong>Message:</strong> {request.message} <strong>Message:</strong> {request.message}
</p> </p>
</div> </div>
)} )}
<p className="text-xs text-gray-500 dark:text-neutral-500 mt-2"> <p className="text-sm text-gray-500 dark:text-gray-500 mt-2 font-sans">
Requested{' '} Requested{' '}
{new Date(request.created_at).toLocaleDateString()} {new Date(request.created_at).toLocaleDateString()}
</p> </p>
</div> </div>
</div> </div>
<div className="flex space-x-2 ml-4">
<Button
onClick={() =>
handleRespondToJoinRequest(request.id, 'approve')
}
disabled={
isResponding || members.length >= MAX_TEAM_MEMBERS
}
className="px-4 py-2 text-sm"
>
✓ Accept
</Button>
<Button
onClick={() =>
handleRespondToJoinRequest(request.id, 'reject')
}
disabled={isResponding}
variant="secondary"
className="px-4 py-2 text-sm"
>
✕ Reject
</Button>
</div>
</div> </div>
<div className="flex space-x-2 mt-4 justify-end">
<Button
onClick={() =>
handleRespondToJoinRequest(request.id, 'approve')
}
disabled={
isResponding || members.length >= MAX_TEAM_MEMBERS
}
className="px-4 py-2 text-sm"
>
Accept
</Button>
<Button
onClick={() =>
handleRespondToJoinRequest(request.id, 'reject')
}
disabled={isResponding}
variant="secondary"
className="px-4 py-2 text-sm"
>
Reject
</Button>
</div>
{members.length >= MAX_TEAM_MEMBERS && ( {members.length >= MAX_TEAM_MEMBERS && (
<div className="mt-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-2"> <div className="mt-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<p className="text-xs text-yellow-800 dark:text-yellow-300"> <p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans">
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS}{' '} Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS}{' '}
members). Remove a member before accepting new members). Remove a member before accepting new
requests. requests.
@@ -23,7 +23,8 @@ const SubmitProjectPage: FC = (): ReactElement => {
const { data: teamData } = useTeamById(teamId || ''); const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId); const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
const { mutateAsync: submitProject, isPending: isSubmitting } = useSubmitProject(teamId || ''); const { mutateAsync: submitProject, isPending: isSubmitting } =
useSubmitProject(teamId || '');
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data; const team = teamData?.data;
@@ -38,25 +39,38 @@ const SubmitProjectPage: FC = (): ReactElement => {
if (!isLeader) { if (!isLeader) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Access Denied</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
<p className="text-gray-600 dark:text-neutral-400 mb-4">Only the team leader can submit projects</p> Access Denied
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can submit projects
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div> </div>
); );
} }
if (hasSubmission) { if (hasSubmission) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4"></div> <div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Project Already Submitted</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team has already submitted a project</p> Project Already Submitted
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Your team has already submitted a project
</p>
<div className="flex space-x-3"> <div className="flex space-x-3">
<Button onClick={() => navigate(`/teams/${teamId}/submission`)}> <Button onClick={() => navigate(`/teams/${teamId}/submission`)}>
View Submission View Submission
</Button> </Button>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}> <Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -64,14 +78,16 @@ const SubmitProjectPage: FC = (): ReactElement => {
); );
} }
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleScreenshotUpload = async (
e: React.ChangeEvent<HTMLInputElement>
) => {
const files = e.target.files; const files = e.target.files;
if (!files) return; if (!files) return;
try { try {
const uploadPromises = Array.from(files).map(file => uploadFile(file)); const uploadPromises = Array.from(files).map((file) => uploadFile(file));
const results = await Promise.all(uploadPromises); const results = await Promise.all(uploadPromises);
const urls = results.map(r => r.data.url); const urls = results.map((r) => r.data.url);
setScreenshots([...screenshots, ...urls]); setScreenshots([...screenshots, ...urls]);
} catch (error) { } catch (error) {
console.error('Failed to upload screenshots:', error); console.error('Failed to upload screenshots:', error);
@@ -95,11 +111,13 @@ const SubmitProjectPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700"> <div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Submit Project</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p> Submit Project
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
</div> </div>
</div> </div>
@@ -109,33 +127,42 @@ const SubmitProjectPage: FC = (): ReactElement => {
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<span className="text-3xl"></span> <span className="text-3xl"></span>
<div> <div>
<h3 className="font-bold text-red-900 dark:text-red-100 text-lg">IMPORTANT WARNING</h3> <h3 className="font-bold text-red-900 dark:text-red-100 text-lg">
<ul className="text-red-800 dark:text-red-200 mt-2 space-y-1 text-sm"> IMPORTANT WARNING
</h3>
<ul className="text-red-800 dark:text-red-200 mt-2 space-y-1 text-sm font-sans">
<li> You can only submit your project ONCE</li> <li> You can only submit your project ONCE</li>
<li> After submission, you CANNOT edit or change anything</li> <li> After submission, you CANNOT edit or change anything</li>
<li> Make sure all information is correct before submitting</li> <li>
Make sure all information is correct before submitting
</li>
<li> Review your project details carefully</li> <li> Review your project details carefully</li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-8"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8 border dark:border-gray-800">
<form onSubmit={(e) => { <form
e.preventDefault(); onSubmit={(e) => {
setShowConfirmModal(true); e.preventDefault();
}} className="space-y-6"> setShowConfirmModal(true);
}}
className="space-y-6"
>
{/* Project Name */} {/* Project Name */}
<ControlledInputField <ControlledInputField
control={form.control} control={form.control}
label="Project Name" label="Project Name"
placeholder="Enter your project name" placeholder="Enter your project name"
name="project_name" name="project_name"
size="lg"
isRequired={true}
/> />
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
Project Description <span className="text-red-500">*</span> Project Description <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
@@ -148,9 +175,12 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="Describe your project, its features, and what problem it solves..." placeholder="Describe your project, its features, and what problem it solves..."
rows={6} rows={6}
className="w-full" className="w-full"
size="lg"
/> />
{fieldState.error && ( {fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p> <p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)} )}
</div> </div>
)} )}
@@ -164,6 +194,8 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="https://github.com/username/project" placeholder="https://github.com/username/project"
name="repository_url" name="repository_url"
type="url" type="url"
size="lg"
isRequired={true}
/> />
{/* Demo URL */} {/* Demo URL */}
@@ -173,6 +205,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="https://your-project-demo.com" placeholder="https://your-project-demo.com"
name="demo_url" name="demo_url"
type="url" type="url"
size="lg"
/> />
{/* Presentation URL */} {/* Presentation URL */}
@@ -182,12 +215,13 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="https://slides.com/your-presentation or Google Drive link" placeholder="https://slides.com/your-presentation or Google Drive link"
name="presentation_url" name="presentation_url"
type="url" type="url"
size="lg"
/> />
{/* Screenshots */} {/* Screenshots */}
<div className="space-y-2"> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
Project Screenshots <span className="text-gray-400 dark:text-neutral-500">(Optional)</span> Project Screenshots (Optional)
</label> </label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
{screenshots.map((url, index) => ( {screenshots.map((url, index) => (
@@ -195,7 +229,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
<img <img
src={url} src={url}
alt={`Screenshot ${index + 1}`} alt={`Screenshot ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700" className="w-full h-32 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
/> />
<button <button
type="button" type="button"
@@ -207,10 +241,14 @@ const SubmitProjectPage: FC = (): ReactElement => {
</div> </div>
))} ))}
</div> </div>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-800"> <label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
<div className="text-center"> <div className="text-center">
<p className="text-gray-500 dark:text-neutral-400">Click to upload screenshots</p> <p className="text-gray-500 dark:text-gray-400">
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">PNG, JPG up to 5MB each</p> Click to upload screenshots
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-sans">
PNG, JPG up to 5MB each
</p>
</div> </div>
<input <input
type="file" type="file"
@@ -248,7 +286,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
{/* Confirmation Modal */} {/* Confirmation Modal */}
{showConfirmModal && ( {showConfirmModal && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl dark:shadow-neutral-950/50 max-w-md w-full p-6"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="text-5xl mb-4"></div> <div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
@@ -258,11 +296,11 @@ const SubmitProjectPage: FC = (): ReactElement => {
This action is IRREVERSIBLE! This action is IRREVERSIBLE!
</p> </p>
</div> </div>
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4 mb-6"> <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-700 dark:text-neutral-300 mb-3"> <p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
By clicking "Submit Project", you confirm that: By clicking "Submit Project", you confirm that:
</p> </p>
<ul className="text-sm text-gray-600 dark:text-neutral-400 space-y-2"> <ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
<li> All information is correct and complete</li> <li> All information is correct and complete</li>
<li> You understand this can only be done once</li> <li> You understand this can only be done once</li>
<li> You cannot edit after submission</li> <li> You cannot edit after submission</li>
@@ -62,6 +62,12 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
icon: <Icon icon="heroicons:home" className="w-5 h-5" />, icon: <Icon icon="heroicons:home" className="w-5 h-5" />,
show: true, show: true,
}, },
{
name: 'My Teams',
path: '/teams/' + myTeams[0]?.id,
icon: <Icon icon="heroicons:users" className="w-5 h-5" />,
show: myTeams.length > 0,
},
{ {
name: 'Browse Teams', name: 'Browse Teams',
path: '/teams/browse', path: '/teams/browse',
+1 -1
View File
@@ -56,7 +56,7 @@ export const Input: FC<TInputProps> = ({
}; };
const mergedClassName = cn( const mergedClassName = cn(
`px-[12px] py-[8px] text-neutral-800 dark:text-white bg-white dark:bg-neutral-800 placeholder:text-neutral-300 dark:placeholder:text-neutral-500 border border-neutral-200 dark:border-neutral-600 hover:border-blue-300 dark:hover:border-blue-500 focus:outline-1 focus:outline-blue-500 dark:focus:outline-primary-500 rounded-md font-bai-jamjuree w-full ${ `px-[12px] py-[8px] text-neutral-800 dark:text-white bg-white dark:bg-gray-800 placeholder:text-neutral-300 dark:placeholder:text-neutral-500 border border-neutral-200 dark:border-neutral-600 hover:border-blue-300 dark:hover:border-blue-500 focus:outline-1 focus:outline-blue-500 dark:focus:outline-primary-500 rounded-md font-bai-jamjuree w-full ${
widthform === 'standard' ? 'min-w-70' : '' widthform === 'standard' ? 'min-w-70' : ''
}`, }`,
sizeClasses[size].textSize, sizeClasses[size].textSize,
+1 -1
View File
@@ -38,7 +38,7 @@ export const Textarea: FC<TTextareaProps> = ({
...rest ...rest
}): ReactElement => { }): ReactElement => {
const mergedClassName = cn( const mergedClassName = cn(
'rounded-md border border-neutral-200 dark:border-neutral-600 hover:border-blue-300 dark:hover:border-blue-500 focus:outline-1 focus:outline-blue-500 dark:focus:outline-primary-500 px-[12px] py-[8px] bg-white dark:bg-neutral-800 text-neutral-800 dark:text-white placeholder:text-neutral-300 dark:placeholder:text-neutral-500 invalid:border-danger-500 invalid:text-danger-500', 'rounded-md border border-neutral-200 dark:border-neutral-600 hover:border-blue-300 dark:hover:border-blue-500 focus:outline-1 focus:outline-blue-500 dark:focus:outline-primary-500 px-[12px] py-[8px] bg-white dark:bg-gray-800 text-neutral-800 dark:text-white placeholder:text-neutral-300 dark:placeholder:text-neutral-500 invalid:border-danger-500 invalid:text-danger-500',
sizeClasses[size], sizeClasses[size],
disabled && disabledClass, disabled && disabledClass,
error && errorClass, error && errorClass,
@@ -56,7 +56,7 @@ export const InputField: FC<TInputFieldProps> = ({
<label <label
htmlFor={htmlFor} htmlFor={htmlFor}
className={cn( className={cn(
'items-start justify-item-start text-start !text-neutral-800', 'items-start justify-item-start text-start text-neutral-800! dark:text-gray-300!',
sizeClasses[size].label sizeClasses[size].label
)} )}
> >