feat: implement user profile editing and sidebar navigation
This commit is contained in:
@@ -29,7 +29,6 @@
|
||||
content="Total hadiah Rp14.500.000! Daftar sekarang dan wujudkan inovasi AI-mu untuk membantu usaha lokal."
|
||||
/>
|
||||
|
||||
{/* Twitter */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://hackathon.imphnen.dev/" />
|
||||
<meta
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { Sidebar } from '../../components/sidebar';
|
||||
|
||||
const DashboardLayout: FC = (): ReactElement => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
<div className="lg:hidden bg-white border-b px-4 py-3 flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="ml-3 text-lg font-bold text-gray-900">🏆 Hackathon</h1>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardLayout;
|
||||
@@ -1,12 +1,16 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useMyTeams, useMyInvitations, useRespondToInvitation, supabase, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
useMyInvitations,
|
||||
useRespondToInvitation,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
|
||||
const DashboardPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { session, clearSession } = useAuthStore();
|
||||
const { session } = useAuthStore();
|
||||
const { data: teamsData } = useMyTeams();
|
||||
const { data: invitationsData } = useMyInvitations();
|
||||
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
||||
@@ -35,243 +39,117 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// Clear Supabase session
|
||||
await supabase.auth.signOut();
|
||||
|
||||
// Clear Zustand store
|
||||
clearSession();
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.clear();
|
||||
|
||||
toast.success('Logged out successfully');
|
||||
|
||||
// Redirect to login
|
||||
navigate('/auth/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Even if there's an error, clear everything and redirect
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
navigate('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Welcome, {user?.fullname || 'User'}!
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{user?.location && `📍 ${user.location}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.avatar && (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt="Profile"
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="secondary"
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}!
|
||||
</h1>
|
||||
{user?.location && (
|
||||
<p className="text-gray-600 mt-1">Location: {user.location}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Team Invitations */}
|
||||
{invitations.length > 0 && (
|
||||
<div className="mb-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Team Invitations ({invitations.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{invitations.map((invitation) => (
|
||||
<div key={invitation.id} className="bg-white p-4 rounded-lg shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{invitation.team.name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Invited by {invitation.inviter.fullname}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptInvitation(invitation.id)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRejectInvitation(invitation.id)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
{invitations.length > 0 && (
|
||||
<div className="mb-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Team Invitations ({invitations.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{invitations.map((invitation) => (
|
||||
<div key={invitation.id} className="bg-white p-4 rounded-lg shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{invitation.team.name}</p>
|
||||
<p className="text-sm text-gray-600">Invited by {invitation.inviter.fullname}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" onClick={() => handleAcceptInvitation(invitation.id)}>Accept</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => handleRejectInvitation(invitation.id)}>Decline</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* My Teams */}
|
||||
{myTeams.length > 0 ? (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">My Team</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{myTeams.map((team) => (
|
||||
<Link
|
||||
key={team.id}
|
||||
to={`/teams/${team.id}`}
|
||||
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{team.banner && (
|
||||
<img
|
||||
src={team.banner}
|
||||
alt={team.name}
|
||||
className="w-full h-32 object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo && (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
|
||||
<p className="text-sm text-gray-600">📍 {team.city}</p>
|
||||
</div>
|
||||
{myTeams.length > 0 ? (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">My Team</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{myTeams.map((team) => (
|
||||
<Link key={team.id} to={'/teams/' + team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
|
||||
{team.banner && <img src={team.banner} alt={team.name} className="w-full h-32 object-cover" />}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo && <img src={team.logo} alt={team.name} className="w-12 h-12 rounded-full object-cover" />}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
|
||||
<p className="text-sm text-gray-600">City: {team.city}</p>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm line-clamp-2">
|
||||
{team.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* No Team - Show CTAs */
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
You're not in a team yet
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Join an existing team or create your own to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 max-w-2xl mx-auto">
|
||||
<Link
|
||||
to="/teams/browse"
|
||||
className="bg-blue-600 text-white rounded-lg p-6 hover:bg-blue-700 transition-colors text-center"
|
||||
>
|
||||
<div className="text-4xl mb-3">🔍</div>
|
||||
<h3 className="text-xl font-bold mb-2">Browse Teams</h3>
|
||||
<p className="text-blue-100">
|
||||
Find and join existing teams looking for members
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/teams/create"
|
||||
className="bg-green-600 text-white rounded-lg p-6 hover:bg-green-700 transition-colors text-center"
|
||||
>
|
||||
<div className="text-4xl mb-3">➕</div>
|
||||
<h3 className="text-xl font-bold mb-2">Create Team</h3>
|
||||
<p className="text-green-100">
|
||||
Start your own team and invite members
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Profile Card */}
|
||||
<div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 h-24"></div>
|
||||
<div className="px-8 pb-8">
|
||||
<div className="flex items-start -mt-12 mb-6">
|
||||
{user?.avatar && (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname}
|
||||
className="w-24 h-24 rounded-full border-4 border-white shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="ml-6 mt-14">
|
||||
<h2 className="text-2xl font-bold text-gray-900">{user?.fullname}</h2>
|
||||
{user?.location ? (
|
||||
<p className="text-gray-600 flex items-center mt-1">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{user.location}
|
||||
</p>
|
||||
) : (
|
||||
<Link to="/onboarding/user" className="text-blue-600 hover:text-blue-700 text-sm mt-1 inline-block">
|
||||
Complete your profile →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{user?.bio && (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">About</h3>
|
||||
<p className="text-gray-800 leading-relaxed">{user.bio}</p>
|
||||
<p className="text-gray-600 text-sm line-clamp-2">{team.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-8 bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-3">Wave</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">You are not in a team yet</h2>
|
||||
<p className="text-gray-600">Use the sidebar to browse teams or create your own</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-lg">
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 h-24 rounded-t-xl"></div>
|
||||
<div className="px-8 pb-8">
|
||||
<div className="flex items-start -mt-12 mb-6">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt={user.fullname || 'User'} className="w-24 h-24 rounded-full border-4 border-white shadow-lg object-cover" />
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full border-4 border-white shadow-lg bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-4xl">User</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-6 mt-14">
|
||||
{/* User Name and Edit profile button in one line */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900">{user?.fullname || user?.email?.split('@')[0] || 'Unnamed User'}</h2>
|
||||
<Link to="/profile" className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors">Edit Profile</Link>
|
||||
</div>
|
||||
{user?.location ? (
|
||||
<p className="text-gray-600 flex items-center mt-1">{user.location}</p>
|
||||
) : (
|
||||
<Link to="/onboarding/user" className="text-blue-600 hover:text-blue-700 text-sm mt-1 inline-block">Complete your profile</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{user?.bio && (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Contact</h3>
|
||||
<div className="flex items-center text-gray-700">
|
||||
<svg className="w-5 h-5 mr-3 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
||||
<span className="text-gray-900">{user?.email}</span>
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">About</h3>
|
||||
<p className="text-gray-800 leading-relaxed">{user.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Contact</h3>
|
||||
<div className="flex items-center text-gray-700">
|
||||
<span className="text-gray-900">{user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
{user?.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Skills</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{user.skills.map((skill: string) => (
|
||||
<span key={skill} className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors">{skill}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user?.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Skills</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{user.skills.map((skill: string) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
userEditProfileSchema,
|
||||
TUserEditProfileForm,
|
||||
useUpdateUserMe,
|
||||
useUploadAvatar,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const ProfilePage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string>('');
|
||||
|
||||
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
|
||||
const { mutateAsync: uploadAvatar, isPending: isUploading } = useUploadAvatar();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
const form = useForm<TUserEditProfileForm>({
|
||||
resolver: zodResolver(userEditProfileSchema),
|
||||
mode: 'all',
|
||||
defaultValues: {
|
||||
fullname: session?.user?.fullname || '',
|
||||
avatar: session?.user?.avatar || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Set initial avatar preview from current user avatar
|
||||
useEffect(() => {
|
||||
if (session?.user?.avatar && !avatarPreview) {
|
||||
setAvatarPreview(session.user.avatar);
|
||||
}
|
||||
if (session?.user?.fullname) {
|
||||
form.setValue('fullname', session.user.fullname);
|
||||
}
|
||||
}, [session?.user?.avatar, session?.user?.fullname, avatarPreview, form]);
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
let avatarUrl = session?.user?.avatar || null;
|
||||
|
||||
// Upload avatar if a new file was selected
|
||||
if (avatarFile) {
|
||||
const uploadResult = await uploadAvatar(avatarFile);
|
||||
avatarUrl = uploadResult.data.url;
|
||||
}
|
||||
|
||||
// Update user profile
|
||||
await updateUser({
|
||||
fullname: data.fullname,
|
||||
avatar: avatarUrl,
|
||||
});
|
||||
|
||||
toast.success('Profile updated successfully!');
|
||||
|
||||
// Wait a bit for the onSuccess handler to update localStorage
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Navigate back to dashboard
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Profile update failed:', error);
|
||||
toast.error(
|
||||
`Failed to update profile: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = isUpdating || isUploading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 px-4 py-8">
|
||||
<div className="bg-white w-full max-w-md p-8 rounded-xl shadow-lg">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Edit Profile</h1>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-gray-600">Update your photo and name</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Avatar Upload */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative group">
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 group-hover:border-blue-400 transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-gray-200 flex items-center justify-center group-hover:bg-gray-300 transition-colors">
|
||||
<span className="text-gray-400 text-4xl">👤</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Camera overlay */}
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
className="absolute bottom-0 right-0 bg-blue-600 text-white p-2 rounded-full cursor-pointer hover:bg-blue-700 transition-colors shadow-lg"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
Click the camera icon to change your photo
|
||||
<br />
|
||||
Format: JPG, PNG. Max 5MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Full Name"
|
||||
placeholder="Enter your full name"
|
||||
name="fullname"
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { Sidebar } from '../../components/sidebar';
|
||||
|
||||
const TeamsLayout: FC = (): ReactElement => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
<div className="lg:hidden bg-white border-b px-4 py-3 flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="ml-3 text-lg font-bold text-gray-900">🏆 Hackathon</h1>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamsLayout;
|
||||
@@ -0,0 +1,200 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { useMyTeams, useAuthStore, supabase } from '@imphnen-frontend-service/service';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { session, clearSession } = useAuthStore();
|
||||
const { data: teamsData } = useMyTeams();
|
||||
|
||||
const user = session?.user;
|
||||
const myTeams = teamsData?.data || [];
|
||||
const hasTeam = myTeams.length > 0;
|
||||
|
||||
// Close sidebar on route change (mobile)
|
||||
useEffect(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await supabase.auth.signOut();
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/auth/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
navigate('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'Browse Teams',
|
||||
path: '/teams/browse',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
),
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'Create Team',
|
||||
path: '/teams/create',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
),
|
||||
show: !hasTeam,
|
||||
},
|
||||
{
|
||||
name: 'Edit Profile',
|
||||
path: '/profile',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
),
|
||||
show: false,
|
||||
},
|
||||
];
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="w-64 bg-white border-r min-h-screen flex flex-col">
|
||||
{/* Logo / Brand with Close Button */}
|
||||
<div className="p-6 border-b flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900">🏆 Hackathon</h1>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname || 'User'}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500 text-lg">👤</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{user?.fullname || user?.email?.split('@')[0] || 'User'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4">
|
||||
<ul className="space-y-2">
|
||||
{navItems
|
||||
.filter((item) => item.show)
|
||||
.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Logout Button */}
|
||||
<div className="p-4 border-t">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar - Always visible on lg+, sticky position */}
|
||||
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar - Overlay */}
|
||||
{isOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Sidebar */}
|
||||
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -73,9 +73,15 @@ export const userOnboardingSchema = z.object({
|
||||
skills: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const userEditProfileSchema = z.object({
|
||||
fullname: z.string().min(3, 'Nama lengkap minimal 3 karakter'),
|
||||
avatar: z.string().url('Avatar harus berupa URL yang valid').nullable().optional(),
|
||||
});
|
||||
|
||||
export type TTeamCreateForm = z.infer<typeof teamCreateSchema>;
|
||||
export type TTeamUpdateForm = z.infer<typeof teamUpdateSchema>;
|
||||
export type TInviteMemberForm = z.infer<typeof inviteMemberSchema>;
|
||||
export type TJoinTeamForm = z.infer<typeof joinTeamSchema>;
|
||||
export type TProjectSubmissionForm = z.infer<typeof projectSubmissionSchema>;
|
||||
export type TUserOnboardingForm = z.infer<typeof userOnboardingSchema>;
|
||||
export type TUserEditProfileForm = z.infer<typeof userEditProfileSchema>;
|
||||
|
||||
Reference in New Issue
Block a user