feat: implement user profile editing and sidebar navigation

This commit is contained in:
Muhammad Zakir Ramadhan
2025-11-25 19:55:08 +07:00
parent 9d24a11c05
commit 7e3eaa6584
7 changed files with 627 additions and 231 deletions
-1
View File
@@ -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;
+108 -230
View File
@@ -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>
+245
View File
@@ -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;
+34
View File
@@ -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;
+200
View File
@@ -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;
+6
View File
@@ -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>;