feat: Implementácia US-002 - Používateľský profil & Dashboard
- Backend API: GET/PUT /api/profile s podporou image upload - Backend API: GET /api/activities/my pre používateľove aktivity - Frontend: Profil stránka (/profile) s zobrazením dát - Frontend: Editácia profilu (/profile/edit) s upload fotky - Dashboard: Reálne štatistiky a zoznam aktivít - Navigation: Pridaný link na profil - Validácia: Formuláre, file upload (max 2MB) - Responzívny dizajn pre všetky obrazovky
This commit is contained in:
parent
84cf61586a
commit
9a6fd12b0f
@ -34,43 +34,43 @@ aby som mohol používať aplikáciu
|
|||||||
|
|
||||||
## US-002: Používateľský profil & Dashboard
|
## US-002: Používateľský profil & Dashboard
|
||||||
|
|
||||||
**Status:** 📋 PLANNED
|
**Status:** ✅ HOTOVÉ
|
||||||
|
|
||||||
Ako používateľ
|
Ako používateľ
|
||||||
chcem vidieť a upraviť môj profil a dashboard s mojimi aktivitami
|
chcem vidieť a upraviť môj profil a dashboard s mojimi aktivitami
|
||||||
aby som mohol prezentovať svoje športové záujmy a mať prehľad o mojich udalostiach
|
aby som mohol prezentovať svoje športové záujmy a mať prehľad o mojich udalostiach
|
||||||
|
|
||||||
**Vývojár:** Kamil Berecký
|
**Vývojár:** Jozef Kovalčín
|
||||||
|
|
||||||
### Tasky:
|
### Tasky:
|
||||||
#### Profil sekcia
|
#### Profil sekcia
|
||||||
- ⏸️ Profil stránka (/profile) - **NEIMPLEMENTOVANÉ**
|
- ✅ Profil stránka (/profile)
|
||||||
- ⏸️ Zobrazenie: meno, email, mesto, bio, obľúbené športy
|
- ✅ Zobrazenie: meno, email, mesto, bio, obľúbené športy
|
||||||
- ⏸️ Formulár na editáciu profilu (/profile/edit)
|
- ✅ Formulár na editáciu profilu (/profile/edit)
|
||||||
- ⏸️ Upload profilovej fotky
|
- ✅ Upload profilovej fotky
|
||||||
- ⏸️ API: GET /api/profile
|
- ✅ API: GET /api/profile
|
||||||
- ⏸️ API: PUT /api/profile
|
- ✅ API: PUT /api/profile
|
||||||
- ⏸️ Validácia formulára
|
- ✅ Validácia formulára
|
||||||
- ⏸️ Responzívny dizajn
|
- ✅ Responzívny dizajn
|
||||||
|
|
||||||
#### Dashboard sekcia
|
#### Dashboard sekcia
|
||||||
- ⏸️ Dashboard stránka (/dashboard) - **EXISTUJE ale je prázdny**
|
- ✅ Dashboard stránka (/dashboard)
|
||||||
- ⏸️ API: GET /api/activities/my (filtrovanie podľa userId)
|
- ✅ API: GET /api/activities/my (filtrovanie podľa userId)
|
||||||
- ⏸️ Dve sekcie: "Moje aktivity" (vytvorené) a "Prihlásený na" (joined)
|
- ✅ Dve sekcie: "Moje aktivity" (vytvorené) a "Prihlásený na" (joined)
|
||||||
- ⏸️ Používanie Activity card komponentu
|
- ✅ Používanie Activity card komponentu
|
||||||
- ⏸️ Loading state
|
- ✅ Loading state
|
||||||
- ⏸️ Empty states
|
- ✅ Empty states
|
||||||
- ⏸️ Quick actions: "Vytvoriť aktivitu", "Hľadať aktivity"
|
- ✅ Quick actions: "Vytvoriť aktivitu", "Hľadať aktivity"
|
||||||
- ⏸️ Štatistiky: počet aktivít, počet prihlásení - **Základné karty existujú**
|
- ✅ Štatistiky: počet aktivít, počet prihlásení
|
||||||
- ⏸️ Responzívny layout
|
- ✅ Responzívny layout
|
||||||
|
|
||||||
### Výsledné funkcie:
|
### Výsledné funkcie:
|
||||||
- ⏸️ Zobrazenie profilu
|
- ✅ Zobrazenie profilu
|
||||||
- ⏸️ Editácia profilu
|
- ✅ Editácia profilu
|
||||||
- ⏸️ Upload fotky
|
- ✅ Upload fotky
|
||||||
- ⏸️ Dashboard so zoznamom - **Dashboard existuje, ale nezobrazuje zoznam aktivít**
|
- ✅ Dashboard so zoznamom
|
||||||
- ⏸️ Filtrovanie funguje
|
- ✅ Filtrovanie funguje
|
||||||
- ⏸️ Štatistiky sa zobrazujú - **Iba placeholder štatistiky (0, 0, -)**
|
- ✅ Štatistiky sa zobrazujú
|
||||||
|
|
||||||
**Poznámka:** Profil model existuje v databáze (User + Profile), ale žiadne UI stránky nie sú implementované. Dashboard stránka existuje s navigáciou a základnou štruktúrou (3 štatistické karty), ale neobsahuje žiadne reálne dáta ani zoznam aktivít.
|
**Poznámka:** Profil model existuje v databáze (User + Profile), ale žiadne UI stránky nie sú implementované. Dashboard stránka existuje s navigáciou a základnou štruktúrou (3 štatistické karty), ale neobsahuje žiadne reálne dáta ani zoznam aktivít.
|
||||||
|
|
||||||
|
|||||||
105
apps/backend/src/app/api/activities/my/route.ts
Normal file
105
apps/backend/src/app/api/activities/my/route.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
// GET /api/activities/my - Get current user's activities (created and joined)
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
// Get activities created by user
|
||||||
|
const createdActivities = await prisma.activity.findMany({
|
||||||
|
where: {
|
||||||
|
organizerId: userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
venue: true,
|
||||||
|
organizer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
participations: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get activities user joined
|
||||||
|
const joinedActivities = await prisma.activity.findMany({
|
||||||
|
where: {
|
||||||
|
participations: {
|
||||||
|
some: {
|
||||||
|
userId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
organizerId: {
|
||||||
|
not: userId, // Exclude activities organized by user (already in createdActivities)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
venue: true,
|
||||||
|
organizer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
participations: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
created: createdActivities,
|
||||||
|
joined: joinedActivities,
|
||||||
|
stats: {
|
||||||
|
totalCreated: createdActivities.length,
|
||||||
|
totalJoined: joinedActivities.length,
|
||||||
|
upcomingCreated: createdActivities.filter((a: any) => new Date(a.date) > new Date()).length,
|
||||||
|
upcomingJoined: joinedActivities.filter((a: any) => new Date(a.date) > new Date()).length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user activities:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
apps/backend/src/app/api/profile/route.ts
Normal file
155
apps/backend/src/app/api/profile/route.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
// GET /api/profile - Get current user's profile
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
include: {
|
||||||
|
profile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If profile doesn't exist, create it
|
||||||
|
if (!user.profile) {
|
||||||
|
const newProfile = await prisma.profile.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
user.profile = newProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
image: user.image,
|
||||||
|
profile: user.profile,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching profile:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/profile - Update current user's profile
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
bio,
|
||||||
|
phone,
|
||||||
|
city,
|
||||||
|
skillLevel,
|
||||||
|
favoriteSports,
|
||||||
|
image,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Validate skillLevel if provided
|
||||||
|
if (skillLevel && !['BEGINNER', 'INTERMEDIATE', 'ADVANCED', 'EXPERT'].includes(skillLevel)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid skill level' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate favoriteSports if provided
|
||||||
|
const validSportTypes = [
|
||||||
|
'FOOTBALL', 'BASKETBALL', 'TENNIS', 'VOLLEYBALL', 'BADMINTON',
|
||||||
|
'TABLE_TENNIS', 'RUNNING', 'CYCLING', 'SWIMMING', 'GYM', 'OTHER'
|
||||||
|
];
|
||||||
|
if (favoriteSports && !Array.isArray(favoriteSports)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Favorite sports must be an array' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (favoriteSports && !favoriteSports.every((sport: string) => validSportTypes.includes(sport))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid sport type' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Update user name and image if provided
|
||||||
|
const updateUserData: any = {};
|
||||||
|
if (name !== undefined) {
|
||||||
|
updateUserData.name = name;
|
||||||
|
}
|
||||||
|
if (image !== undefined) {
|
||||||
|
updateUserData.image = image;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateUserData).length > 0) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: updateUserData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create profile
|
||||||
|
const updateProfileData: any = {};
|
||||||
|
if (bio !== undefined) updateProfileData.bio = bio;
|
||||||
|
if (phone !== undefined) updateProfileData.phone = phone;
|
||||||
|
if (city !== undefined) updateProfileData.city = city;
|
||||||
|
if (skillLevel !== undefined) updateProfileData.skillLevel = skillLevel;
|
||||||
|
if (favoriteSports !== undefined) updateProfileData.favoriteSports = favoriteSports;
|
||||||
|
|
||||||
|
const profile = await prisma.profile.upsert({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
update: updateProfileData,
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
...updateProfileData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
include: {
|
||||||
|
profile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: user!.id,
|
||||||
|
name: user!.name,
|
||||||
|
email: user!.email,
|
||||||
|
image: user!.image,
|
||||||
|
profile: user!.profile,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,14 +2,72 @@
|
|||||||
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
import { useSession } from '@/lib/auth-client';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
sportType: string;
|
||||||
|
skillLevel: string;
|
||||||
|
date: string;
|
||||||
|
duration: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
currentParticipants: number;
|
||||||
|
status: string;
|
||||||
|
venue: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
address: string;
|
||||||
|
};
|
||||||
|
organizer: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
participations: Array<{
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserActivities {
|
||||||
|
created: Activity[];
|
||||||
|
joined: Activity[];
|
||||||
|
stats: {
|
||||||
|
totalCreated: number;
|
||||||
|
totalJoined: number;
|
||||||
|
upcomingCreated: number;
|
||||||
|
upcomingJoined: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPORT_LABELS: Record<string, string> = {
|
||||||
|
FOOTBALL: 'Futbal',
|
||||||
|
BASKETBALL: 'Basketbal',
|
||||||
|
TENNIS: 'Tenis',
|
||||||
|
VOLLEYBALL: 'Volejbal',
|
||||||
|
BADMINTON: 'Bedminton',
|
||||||
|
TABLE_TENNIS: 'Stolný tenis',
|
||||||
|
RUNNING: 'Beh',
|
||||||
|
CYCLING: 'Cyklistika',
|
||||||
|
SWIMMING: 'Plávanie',
|
||||||
|
GYM: 'Fitnes',
|
||||||
|
OTHER: 'Iné',
|
||||||
|
};
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { data: session, isPending } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [activities, setActivities] = useState<UserActivities | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPending && !session) {
|
if (!isPending && !session) {
|
||||||
@ -17,7 +75,42 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [session, isPending, router]);
|
}, [session, isPending, router]);
|
||||||
|
|
||||||
if (isPending) {
|
useEffect(() => {
|
||||||
|
if (session) {
|
||||||
|
fetchActivities();
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const fetchActivities = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/activities/my`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setActivities(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching activities:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('sk-SK', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPending || loading) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center">
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@ -64,8 +157,12 @@ export default function DashboardPage() {
|
|||||||
<CardTitle>Moje aktivity</CardTitle>
|
<CardTitle>Moje aktivity</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">0</p>
|
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">
|
||||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">Vytvorené aktivity</p>
|
{activities?.stats.totalCreated || 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">
|
||||||
|
Vytvorené aktivity ({activities?.stats.upcomingCreated || 0} nadchádzajúcich)
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -79,8 +176,12 @@ export default function DashboardPage() {
|
|||||||
<CardTitle>Prihlásený na</CardTitle>
|
<CardTitle>Prihlásený na</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">0</p>
|
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">
|
||||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">Nadchádzajúcich aktivít</p>
|
{activities?.stats.totalJoined || 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">
|
||||||
|
Aktivít ({activities?.stats.upcomingJoined || 0} nadchádzajúcich)
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -91,11 +192,15 @@ export default function DashboardPage() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle>Hodnotenie</CardTitle>
|
<CardTitle>Celkom aktivít</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">-</p>
|
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">
|
||||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">Zatiaľ žiadne hodnotenia</p>
|
{(activities?.stats.totalCreated || 0) + (activities?.stats.totalJoined || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">
|
||||||
|
Účastí na športových aktivitách
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -148,30 +253,137 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Activities Lists */}
|
||||||
<Card>
|
{activities && (activities.created.length > 0 || activities.joined.length > 0) ? (
|
||||||
<CardContent className="text-center py-16">
|
<div className="space-y-8">
|
||||||
<div className="w-20 h-20 bg-[color:var(--fluent-surface-secondary)] rounded-full mx-auto mb-6 flex items-center justify-center" style={{ boxShadow: 'var(--shadow-sm)' }}>
|
{/* Created Activities */}
|
||||||
<svg className="w-10 h-10 text-[color:var(--fluent-text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{activities.created.length > 0 && (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
<div>
|
||||||
</svg>
|
<h2 className="text-2xl font-bold text-[color:var(--fluent-text)] mb-6">
|
||||||
</div>
|
Moje vytvorené aktivity
|
||||||
<h3 className="text-xl font-bold text-[color:var(--fluent-text)] mb-2">
|
</h2>
|
||||||
Zatiaľ žiadne aktivity
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
</h3>
|
{activities.created.map((activity) => (
|
||||||
<p className="text-[color:var(--fluent-text-secondary)] mb-6 max-w-md mx-auto">
|
<Link key={activity.id} href={`/activities/${activity.id}`}>
|
||||||
Začnite vytvorením novej aktivity alebo sa pripojte k existujúcim aktivitám vo vašom okolí.
|
<Card hover className="h-full cursor-pointer">
|
||||||
</p>
|
<CardContent className="p-6">
|
||||||
<div className="flex gap-4 justify-center">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<Link href="/activities/create">
|
<span className="px-3 py-1 rounded-full text-sm font-medium bg-blue-500 text-white">
|
||||||
<Button size="lg">Vytvoriť aktivitu</Button>
|
{SPORT_LABELS[activity.sportType] || activity.sportType}
|
||||||
</Link>
|
</span>
|
||||||
<Link href="/activities">
|
<span className="text-xs text-[color:var(--fluent-text-secondary)]">
|
||||||
<Button variant="secondary" size="lg">Prehliadať aktivity</Button>
|
Organizátor
|
||||||
</Link>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<h3 className="text-lg font-bold text-[color:var(--fluent-text)] mb-2">
|
||||||
</Card>
|
{activity.title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>{formatDate(activity.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{activity.venue.name}, {activity.venue.city}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{activity.currentParticipants}/{activity.maxParticipants} účastníkov</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Joined Activities */}
|
||||||
|
{activities.joined.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-[color:var(--fluent-text)] mb-6">
|
||||||
|
Aktivity, na ktoré som prihlásený
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{activities.joined.map((activity) => (
|
||||||
|
<Link key={activity.id} href={`/activities/${activity.id}`}>
|
||||||
|
<Card hover className="h-full cursor-pointer">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<span className="px-3 py-1 rounded-full text-sm font-medium bg-green-500 text-white">
|
||||||
|
{SPORT_LABELS[activity.sportType] || activity.sportType}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[color:var(--fluent-text-secondary)]">
|
||||||
|
Účastník
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[color:var(--fluent-text)] mb-2">
|
||||||
|
{activity.title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 text-sm text-[color:var(--fluent-text-secondary)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>{formatDate(activity.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{activity.venue.name}, {activity.venue.city}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" 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>
|
||||||
|
<span>Organizuje: {activity.organizer.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Empty State */
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-center py-16">
|
||||||
|
<div className="w-20 h-20 bg-[color:var(--fluent-surface-secondary)] rounded-full mx-auto mb-6 flex items-center justify-center" style={{ boxShadow: 'var(--shadow-sm)' }}>
|
||||||
|
<svg className="w-10 h-10 text-[color:var(--fluent-text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Zatiaľ žiadne aktivity
|
||||||
|
</h3>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)] mb-6 max-w-md mx-auto">
|
||||||
|
Začnite vytvorením novej aktivity alebo sa pripojte k existujúcim aktivitám vo vašom okolí.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<Link href="/activities/create">
|
||||||
|
<Button size="lg">Vytvoriť aktivitu</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/activities">
|
||||||
|
<Button variant="secondary" size="lg">Prehliadať aktivity</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
396
apps/frontend/src/app/profile/edit/page.tsx
Normal file
396
apps/frontend/src/app/profile/edit/page.tsx
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSession } from '@/lib/auth-client';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
|
||||||
|
interface ProfileData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
image: string | null;
|
||||||
|
profile: {
|
||||||
|
bio: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
city: string | null;
|
||||||
|
skillLevel: string;
|
||||||
|
favoriteSports: string[];
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPORT_OPTIONS = [
|
||||||
|
{ value: 'FOOTBALL', label: 'Futbal' },
|
||||||
|
{ value: 'BASKETBALL', label: 'Basketbal' },
|
||||||
|
{ value: 'TENNIS', label: 'Tenis' },
|
||||||
|
{ value: 'VOLLEYBALL', label: 'Volejbal' },
|
||||||
|
{ value: 'BADMINTON', label: 'Bedminton' },
|
||||||
|
{ value: 'TABLE_TENNIS', label: 'Stolný tenis' },
|
||||||
|
{ value: 'RUNNING', label: 'Beh' },
|
||||||
|
{ value: 'CYCLING', label: 'Cyklistika' },
|
||||||
|
{ value: 'SWIMMING', label: 'Plávanie' },
|
||||||
|
{ value: 'GYM', label: 'Fitnes' },
|
||||||
|
{ value: 'OTHER', label: 'Iné' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SKILL_OPTIONS = [
|
||||||
|
{ value: 'BEGINNER', label: 'Začiatočník' },
|
||||||
|
{ value: 'INTERMEDIATE', label: 'Stredne pokročilý' },
|
||||||
|
{ value: 'ADVANCED', label: 'Pokročilý' },
|
||||||
|
{ value: 'EXPERT', label: 'Expert' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ProfileEditPage() {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const [profile, setProfile] = useState<ProfileData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
bio: '',
|
||||||
|
phone: '',
|
||||||
|
city: '',
|
||||||
|
skillLevel: 'BEGINNER',
|
||||||
|
favoriteSports: [] as string[],
|
||||||
|
image: '',
|
||||||
|
});
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && !session) {
|
||||||
|
router.push('/auth/signin');
|
||||||
|
}
|
||||||
|
}, [session, isPending, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) {
|
||||||
|
fetchProfile();
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/profile`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setProfile(data);
|
||||||
|
setFormData({
|
||||||
|
name: data.name || '',
|
||||||
|
bio: data.profile?.bio || '',
|
||||||
|
phone: data.profile?.phone || '',
|
||||||
|
city: data.profile?.city || '',
|
||||||
|
skillLevel: data.profile?.skillLevel || 'BEGINNER',
|
||||||
|
favoriteSports: data.profile?.favoriteSports || [],
|
||||||
|
image: data.image || '',
|
||||||
|
});
|
||||||
|
setImagePreview(data.image);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching profile:', err);
|
||||||
|
setError('Nepodarilo sa načítať profil');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/profile`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Failed to update profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/profile');
|
||||||
|
}, 1500);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error updating profile:', err);
|
||||||
|
setError(err.message || 'Nepodarilo sa aktualizovať profil');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSportToggle = (sport: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
favoriteSports: prev.favoriteSports.includes(sport)
|
||||||
|
? prev.favoriteSports.filter((s) => s !== sport)
|
||||||
|
: [...prev.favoriteSports, sport],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
setError('Obrázok je príliš veľký. Maximum je 2MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
setError('Môžete nahrať iba obrázky.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const base64String = reader.result as string;
|
||||||
|
setFormData((prev) => ({ ...prev, image: base64String }));
|
||||||
|
setImagePreview(base64String);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveImage = () => {
|
||||||
|
setFormData((prev) => ({ ...prev, image: '' }));
|
||||||
|
setImagePreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPending || loading) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)]">Načítavam profil...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || !profile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen" style={{ backgroundColor: 'var(--fluent-bg)' }}>
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Upraviť profil
|
||||||
|
</h1>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)]">
|
||||||
|
Aktualizujte svoje informácie a športové preferencie
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[color:var(--fluent-text)] mb-3">
|
||||||
|
Profilová fotka
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{imagePreview ? (
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Náhľad"
|
||||||
|
className="w-24 h-24 rounded-full object-cover"
|
||||||
|
style={{ boxShadow: 'var(--shadow-lg)' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-24 h-24 rounded-full gradient-primary flex items-center justify-center"
|
||||||
|
style={{ boxShadow: 'var(--shadow-lg)' }}
|
||||||
|
>
|
||||||
|
<span className="text-3xl font-bold text-white">
|
||||||
|
{formData.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="imageUpload"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor="imageUpload"
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg font-medium cursor-pointer hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
Nahrať obrázok
|
||||||
|
</label>
|
||||||
|
{imagePreview && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveImage}
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded-lg font-medium hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Odstrániť
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[color:var(--fluent-text-secondary)] mt-2">
|
||||||
|
JPG, PNG alebo GIF. Maximum 2MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Meno <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Vaše meno"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bio" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
|
||||||
|
O mne
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="bio"
|
||||||
|
value={formData.bio}
|
||||||
|
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
|
||||||
|
placeholder="Niečo o vás..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-3 rounded-lg border-2 border-[color:var(--fluent-border)] bg-[color:var(--fluent-surface)] text-[color:var(--fluent-text)] focus:border-blue-500 focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="city" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Mesto
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="city"
|
||||||
|
type="text"
|
||||||
|
value={formData.city}
|
||||||
|
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||||
|
placeholder="Bratislava"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phone" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Telefón
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
placeholder="+421 XXX XXX XXX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="skillLevel" className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Športová úroveň
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="skillLevel"
|
||||||
|
value={formData.skillLevel}
|
||||||
|
onChange={(e) => setFormData({ ...formData, skillLevel: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-lg border-2 border-[color:var(--fluent-border)] bg-[color:var(--fluent-surface)] text-[color:var(--fluent-text)] focus:border-blue-500 focus:outline-none transition-colors"
|
||||||
|
>
|
||||||
|
{SKILL_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[color:var(--fluent-text)] mb-3">
|
||||||
|
Obľúbené športy
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{SPORT_OPTIONS.map((sport) => (
|
||||||
|
<button
|
||||||
|
key={sport.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSportToggle(sport.value)}
|
||||||
|
className={`px-4 py-3 rounded-lg border-2 transition-all ${
|
||||||
|
formData.favoriteSports.includes(sport.value)
|
||||||
|
? 'border-blue-500 bg-blue-500 text-white font-medium'
|
||||||
|
: 'border-[color:var(--fluent-border)] bg-[color:var(--fluent-surface)] text-[color:var(--fluent-text)] hover:border-blue-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sport.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800">
|
||||||
|
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800">
|
||||||
|
<p className="text-green-600 dark:text-green-400">
|
||||||
|
Profil bol úspešne aktualizovaný! Presmerovávam...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{saving ? 'Ukladám...' : 'Uložiť zmeny'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.push('/profile')}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
apps/frontend/src/app/profile/page.tsx
Normal file
265
apps/frontend/src/app/profile/page.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSession } from '@/lib/auth-client';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
|
|
||||||
|
interface ProfileData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
image: string | null;
|
||||||
|
profile: {
|
||||||
|
bio: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
city: string | null;
|
||||||
|
skillLevel: string;
|
||||||
|
favoriteSports: string[];
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPORT_LABELS: Record<string, string> = {
|
||||||
|
FOOTBALL: 'Futbal',
|
||||||
|
BASKETBALL: 'Basketbal',
|
||||||
|
TENNIS: 'Tenis',
|
||||||
|
VOLLEYBALL: 'Volejbal',
|
||||||
|
BADMINTON: 'Bedminton',
|
||||||
|
TABLE_TENNIS: 'Stolný tenis',
|
||||||
|
RUNNING: 'Beh',
|
||||||
|
CYCLING: 'Cyklistika',
|
||||||
|
SWIMMING: 'Plávanie',
|
||||||
|
GYM: 'Fitnes',
|
||||||
|
OTHER: 'Iné',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKILL_LABELS: Record<string, string> = {
|
||||||
|
BEGINNER: 'Začiatočník',
|
||||||
|
INTERMEDIATE: 'Stredne pokročilý',
|
||||||
|
ADVANCED: 'Pokročilý',
|
||||||
|
EXPERT: 'Expert',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const [profile, setProfile] = useState<ProfileData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && !session) {
|
||||||
|
router.push('/auth/signin');
|
||||||
|
}
|
||||||
|
}, [session, isPending, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) {
|
||||||
|
fetchProfile();
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/profile`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setProfile(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching profile:', err);
|
||||||
|
setError('Nepodarilo sa načítať profil');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPending || loading) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)]">Načítavam profil...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || !profile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-center py-12">
|
||||||
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
|
<Button onClick={fetchProfile}>Skúsiť znova</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen" style={{ backgroundColor: 'var(--fluent-bg)' }}>
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-[color:var(--fluent-text)]">
|
||||||
|
Môj profil
|
||||||
|
</h1>
|
||||||
|
<Link href="/profile/edit">
|
||||||
|
<Button>Upraviť profil</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Card */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<div className="flex flex-col md:flex-row gap-8">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{profile.image ? (
|
||||||
|
<img
|
||||||
|
src={profile.image}
|
||||||
|
alt={profile.name}
|
||||||
|
className="w-32 h-32 rounded-full object-cover"
|
||||||
|
style={{ boxShadow: 'var(--shadow-lg)' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-32 h-32 rounded-full gradient-primary flex items-center justify-center"
|
||||||
|
style={{ boxShadow: 'var(--shadow-lg)' }}
|
||||||
|
>
|
||||||
|
<span className="text-5xl font-bold text-white">
|
||||||
|
{profile.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-2xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||||
|
{profile.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)] mb-4">
|
||||||
|
{profile.email}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{profile.profile?.bio && (
|
||||||
|
<p className="text-[color:var(--fluent-text)] mb-6">
|
||||||
|
{profile.profile.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* City */}
|
||||||
|
{profile.profile?.city && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-[color:var(--fluent-surface-secondary)] rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)]">Mesto</p>
|
||||||
|
<p className="text-[color:var(--fluent-text)] font-medium">{profile.profile.city}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
{profile.profile?.phone && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-[color:var(--fluent-surface-secondary)] rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)]">Telefón</p>
|
||||||
|
<p className="text-[color:var(--fluent-text)] font-medium">{profile.profile.phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skill Level */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-[color:var(--fluent-surface-secondary)] rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[color:var(--fluent-text-secondary)]">Úroveň</p>
|
||||||
|
<p className="text-[color:var(--fluent-text)] font-medium">
|
||||||
|
{SKILL_LABELS[profile.profile?.skillLevel || 'BEGINNER']}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Favorite Sports */}
|
||||||
|
{profile.profile?.favoriteSports && profile.profile.favoriteSports.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Obľúbené športy</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{profile.profile.favoriteSports.map((sport) => (
|
||||||
|
<span
|
||||||
|
key={sport}
|
||||||
|
className="px-4 py-2 rounded-lg gradient-feature-1 text-white font-medium"
|
||||||
|
style={{ boxShadow: 'var(--shadow-sm)' }}
|
||||||
|
>
|
||||||
|
{SPORT_LABELS[sport] || sport}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State for No Info */}
|
||||||
|
{!profile.profile?.bio && !profile.profile?.city && !profile.profile?.phone &&
|
||||||
|
(!profile.profile?.favoriteSports || profile.profile.favoriteSports.length === 0) && (
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardContent className="text-center py-12">
|
||||||
|
<div className="w-20 h-20 bg-[color:var(--fluent-surface-secondary)] rounded-full mx-auto mb-6 flex items-center justify-center">
|
||||||
|
<svg className="w-10 h-10 text-[color:var(--fluent-text-tertiary)]" 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>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||||
|
Doplňte svoj profil
|
||||||
|
</h3>
|
||||||
|
<p className="text-[color:var(--fluent-text-secondary)] mb-6 max-w-md mx-auto">
|
||||||
|
Pridajte informácie o sebe, aby ostatní vedeli viac o vás a vašich športových preferenciách.
|
||||||
|
</p>
|
||||||
|
<Link href="/profile/edit">
|
||||||
|
<Button>Upraviť profil</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -124,6 +124,18 @@ export default function Navigation() {
|
|||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="block px-4 py-3 text-base font-medium text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface-secondary)]/50 transition-all"
|
||||||
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<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>
|
||||||
|
<span>Profil</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="w-full text-left px-4 py-3 text-base font-medium text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 transition-all"
|
className="w-full text-left px-4 py-3 text-base font-medium text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 transition-all"
|
||||||
@ -251,6 +263,24 @@ export default function Navigation() {
|
|||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="block px-4 py-3 text-base font-semibold bg-blue-50/50 text-blue-600 hover:text-blue-700 hover:bg-blue-100/60 dark:bg-blue-950/40 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-950/60 rounded-lg hover-glow reveal-effect transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.12}s both` : 'none'
|
||||||
|
}}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<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>
|
||||||
|
<span>Profil</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="w-full block px-4 py-3 text-base text-left font-semibold text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 rounded-lg hover-glow reveal-effect transition-all duration-200"
|
className="w-full block px-4 py-3 text-base text-left font-semibold text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 rounded-lg hover-glow reveal-effect transition-all duration-200"
|
||||||
@ -258,7 +288,7 @@ export default function Navigation() {
|
|||||||
boxShadow: 'var(--shadow-sm)',
|
boxShadow: 'var(--shadow-sm)',
|
||||||
backdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)',
|
||||||
WebkitBackdropFilter: 'blur(20px)',
|
WebkitBackdropFilter: 'blur(20px)',
|
||||||
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.16}s both` : 'none'
|
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.20}s both` : 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user