feat: initial project setup with frontend mockup
- Docker Compose setup with nginx reverse proxy and frontend service - React + Vite + Tailwind CSS configuration - Complete mockup of all application views: - Authentication (login/register) - Events list and selection - Event chat with matchmaking - 1:1 private chat with WebRTC P2P video transfer mockup - Partner rating system - Collaboration history - Mock data for users, events, messages, matches, and ratings - All UI text and messages in English - Project documentation (CONTEXT.md, TODO.md, README.md, QUICKSTART.md)
This commit is contained in:
183
frontend/src/pages/EventChatPage.jsx
Normal file
183
frontend/src/pages/EventChatPage.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/layout/Layout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { mockEvents } from '../mocks/events';
|
||||
import { mockEventMessages } from '../mocks/messages';
|
||||
import { mockUsers } from '../mocks/users';
|
||||
import { Send, UserPlus } from 'lucide-react';
|
||||
|
||||
const EventChatPage = () => {
|
||||
const { eventId } = useParams();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [messages, setMessages] = useState(mockEventMessages);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [activeUsers, setActiveUsers] = useState(mockUsers.slice(1, 5));
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const event = mockEvents.find(e => e.id === parseInt(eventId));
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = (e) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
|
||||
const message = {
|
||||
id: messages.length + 1,
|
||||
room_id: parseInt(eventId),
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
content: newMessage,
|
||||
type: 'text',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages([...messages, message]);
|
||||
setNewMessage('');
|
||||
};
|
||||
|
||||
const handleMatchWith = (userId) => {
|
||||
// Mockup - in the future will be WebSocket request
|
||||
alert(`Match request sent to user!`);
|
||||
// Simulate acceptance after 1 second
|
||||
setTimeout(() => {
|
||||
navigate(`/matches/1/chat`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="text-center">Event not found</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-primary-600 text-white p-4">
|
||||
<h2 className="text-2xl font-bold">{event.name}</h2>
|
||||
<p className="text-primary-100 text-sm">{event.location}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(100vh-280px)]">
|
||||
{/* Active Users Sidebar */}
|
||||
<div className="w-64 border-r bg-gray-50 p-4 overflow-y-auto">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">
|
||||
Active users ({activeUsers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{activeUsers.map((activeUser) => (
|
||||
<div
|
||||
key={activeUser.id}
|
||||
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={activeUser.avatar}
|
||||
alt={activeUser.username}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{activeUser.username}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
⭐ {activeUser.rating}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleMatchWith(activeUser.id)}
|
||||
className="p-1 text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="Connect"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.map((message) => {
|
||||
const isOwnMessage = message.user_id === user.id;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`flex items-start space-x-2 max-w-md ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
||||
<img
|
||||
src={message.avatar}
|
||||
alt={message.username}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-baseline space-x-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{message.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(message.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg px-4 py-2 ${
|
||||
isOwnMessage
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="border-t p-4">
|
||||
<form onSubmit={handleSendMessage} className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Write a message..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventChatPage;
|
||||
60
frontend/src/pages/EventsPage.jsx
Normal file
60
frontend/src/pages/EventsPage.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/layout/Layout';
|
||||
import { mockEvents } from '../mocks/events';
|
||||
import { Calendar, MapPin, Users } from 'lucide-react';
|
||||
|
||||
const EventsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleJoinEvent = (eventId) => {
|
||||
navigate(`/events/${eventId}/chat`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Choose an event</h1>
|
||||
<p className="text-gray-600 mb-8">Join an event and start connecting with other dancers</p>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{mockEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200"
|
||||
>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-3">{event.name}</h3>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center text-gray-600">
|
||||
<MapPin className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">{event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">
|
||||
{new Date(event.start_date).toLocaleDateString('en-US')} - {new Date(event.end_date).toLocaleDateString('en-US')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">{event.participants_count} participants</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4">{event.description}</p>
|
||||
|
||||
<button
|
||||
onClick={() => handleJoinEvent(event.id)}
|
||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Join chat
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventsPage;
|
||||
129
frontend/src/pages/HistoryPage.jsx
Normal file
129
frontend/src/pages/HistoryPage.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import Layout from '../components/layout/Layout';
|
||||
import { mockMatches, mockRatings } from '../mocks/matches';
|
||||
import { Calendar, MapPin, Star, MessageCircle } from 'lucide-react';
|
||||
|
||||
const HistoryPage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Collaboration history</h1>
|
||||
<p className="text-gray-600 mb-8">Your previous matches and ratings</p>
|
||||
|
||||
{/* Matches Section */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Your matches</h2>
|
||||
<div className="space-y-4">
|
||||
{mockMatches.map((match) => (
|
||||
<div
|
||||
key={match.id}
|
||||
className="bg-white rounded-lg shadow-md p-6 border border-gray-200"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<img
|
||||
src={match.user2_avatar}
|
||||
alt={match.user2_name}
|
||||
className="w-16 h-16 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{match.user2_name}</h3>
|
||||
<div className="flex items-center space-x-4 mt-2 text-sm text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<MapPin className="w-4 h-4 mr-1" />
|
||||
{match.event_name}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{new Date(match.created_at).toLocaleDateString('pl-PL')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
match.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}
|
||||
>
|
||||
{match.status === 'completed' ? 'Completed' : 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ratings Section */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Received ratings</h2>
|
||||
<div className="space-y-4">
|
||||
{mockRatings.filter(r => r.rated_id === 1).map((rating) => (
|
||||
<div
|
||||
key={rating.id}
|
||||
className="bg-white rounded-lg shadow-md p-6 border border-gray-200"
|
||||
>
|
||||
<div className="flex items-start space-x-4 mb-4">
|
||||
<img
|
||||
src={rating.rated_avatar}
|
||||
alt={rating.rated_name}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-bold text-gray-900">{rating.rated_name}</h3>
|
||||
<div className="flex items-center space-x-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < rating.score
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">{rating.comment}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>{new Date(rating.created_at).toLocaleDateString('en-US')}</span>
|
||||
{rating.would_collaborate_again && (
|
||||
<span className="text-green-600 font-medium">
|
||||
✓ Would collaborate again
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<div className="mt-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg shadow-md p-6 text-white">
|
||||
<h3 className="text-xl font-bold mb-4">Your statistics</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold">{mockMatches.length}</div>
|
||||
<div className="text-sm text-primary-100">Collaborations</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold">4.8</div>
|
||||
<div className="text-sm text-primary-100">Average rating</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold">100%</div>
|
||||
<div className="text-sm text-primary-100">Would collaborate again</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryPage;
|
||||
102
frontend/src/pages/LoginPage.jsx
Normal file
102
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Video, Mail, Lock } from 'lucide-react';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/events');
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<Video className="w-16 h-16 text-primary-600 mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">spotlight.cam</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p className="text-xs text-yellow-800">
|
||||
<strong>Demo:</strong> Enter any email and password to sign in (mock auth)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
405
frontend/src/pages/MatchChatPage.jsx
Normal file
405
frontend/src/pages/MatchChatPage.jsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/layout/Layout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { mockPrivateMessages } from '../mocks/messages';
|
||||
import { mockUsers } from '../mocks/users';
|
||||
import { Send, Video, Upload, X, Check, Link as LinkIcon } from 'lucide-react';
|
||||
|
||||
const MatchChatPage = () => {
|
||||
const { matchId } = useParams();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [messages, setMessages] = useState(mockPrivateMessages);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [transferProgress, setTransferProgress] = useState(0);
|
||||
const [webrtcStatus, setWebrtcStatus] = useState('disconnected'); // disconnected, connecting, connected, failed
|
||||
const [showLinkInput, setShowLinkInput] = useState(false);
|
||||
const [videoLink, setVideoLink] = useState('');
|
||||
const messagesEndRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Partner user (mockup)
|
||||
const partner = mockUsers[1]; // sarah_swing
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = (e) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
|
||||
const message = {
|
||||
id: messages.length + 1,
|
||||
room_id: 10,
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
content: newMessage,
|
||||
type: 'text',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages([...messages, message]);
|
||||
setNewMessage('');
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file && file.type.startsWith('video/')) {
|
||||
setSelectedFile(file);
|
||||
} else {
|
||||
alert('Proszę wybrać plik wideo');
|
||||
}
|
||||
};
|
||||
|
||||
const simulateWebRTCConnection = () => {
|
||||
setWebrtcStatus('connecting');
|
||||
setTimeout(() => {
|
||||
setWebrtcStatus('connected');
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleStartTransfer = () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
// Simulate WebRTC connection
|
||||
simulateWebRTCConnection();
|
||||
|
||||
setTimeout(() => {
|
||||
setIsTransferring(true);
|
||||
setTransferProgress(0);
|
||||
|
||||
// Simulate transfer progress
|
||||
const interval = setInterval(() => {
|
||||
setTransferProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(interval);
|
||||
setIsTransferring(false);
|
||||
setSelectedFile(null);
|
||||
setWebrtcStatus('disconnected');
|
||||
|
||||
// Add message about completed transfer
|
||||
const message = {
|
||||
id: messages.length + 1,
|
||||
room_id: 10,
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
content: `📹 Video sent: ${selectedFile.name} (${(selectedFile.size / 1024 / 1024).toFixed(2)} MB)`,
|
||||
type: 'video',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, message]);
|
||||
|
||||
return 0;
|
||||
}
|
||||
return prev + 5;
|
||||
});
|
||||
}, 200);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleCancelTransfer = () => {
|
||||
setIsTransferring(false);
|
||||
setTransferProgress(0);
|
||||
setSelectedFile(null);
|
||||
setWebrtcStatus('disconnected');
|
||||
};
|
||||
|
||||
const handleSendLink = (e) => {
|
||||
e.preventDefault();
|
||||
if (!videoLink.trim()) return;
|
||||
|
||||
const message = {
|
||||
id: messages.length + 1,
|
||||
room_id: 10,
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
content: `🔗 Video link: ${videoLink}`,
|
||||
type: 'link',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages([...messages, message]);
|
||||
setVideoLink('');
|
||||
setShowLinkInput(false);
|
||||
};
|
||||
|
||||
const handleEndMatch = () => {
|
||||
navigate(`/matches/${matchId}/rate`);
|
||||
};
|
||||
|
||||
const getWebRTCStatusColor = () => {
|
||||
switch (webrtcStatus) {
|
||||
case 'connected':
|
||||
return 'text-green-600';
|
||||
case 'connecting':
|
||||
return 'text-yellow-600';
|
||||
case 'failed':
|
||||
return 'text-red-600';
|
||||
default:
|
||||
return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getWebRTCStatusText = () => {
|
||||
switch (webrtcStatus) {
|
||||
case 'connected':
|
||||
return 'Connected (P2P)';
|
||||
case 'connecting':
|
||||
return 'Connecting...';
|
||||
case 'failed':
|
||||
return 'Connection failed';
|
||||
default:
|
||||
return 'Disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{/* Header with Partner Info */}
|
||||
<div className="bg-primary-600 text-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<img
|
||||
src={partner.avatar}
|
||||
alt={partner.username}
|
||||
className="w-12 h-12 rounded-full border-2 border-white"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{partner.username}</h2>
|
||||
<p className="text-sm text-primary-100">⭐ {partner.rating} • {partner.matches_count} collaborations</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEndMatch}
|
||||
className="px-4 py-2 bg-white text-primary-600 rounded-md hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
End & rate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WebRTC Status Bar */}
|
||||
<div className="bg-gray-50 border-b px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${webrtcStatus === 'connected' ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
<span className={`text-sm font-medium ${getWebRTCStatusColor()}`}>
|
||||
{getWebRTCStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{webrtcStatus === 'connected' ? '🔒 E2E Encrypted (DTLS/SRTP)' : 'WebRTC ready to connect'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-[calc(100vh-320px)]">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.map((message) => {
|
||||
const isOwnMessage = message.user_id === user.id;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`flex items-start space-x-2 max-w-md ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
||||
<img
|
||||
src={isOwnMessage ? user.avatar : partner.avatar}
|
||||
alt={message.username}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-baseline space-x-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{message.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(message.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg px-4 py-2 ${
|
||||
isOwnMessage
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Video Transfer Section */}
|
||||
{(selectedFile || isTransferring) && (
|
||||
<div className="border-t bg-blue-50 p-4">
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Video className="w-6 h-6 text-primary-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{selectedFile?.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{selectedFile && `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isTransferring && (
|
||||
<button
|
||||
onClick={() => setSelectedFile(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isTransferring ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Transferring via WebRTC...</span>
|
||||
<span>{transferProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-200"
|
||||
style={{ width: `${transferProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancelTransfer}
|
||||
className="w-full px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartTransfer}
|
||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>Send video (P2P)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link Input Section */}
|
||||
{showLinkInput && (
|
||||
<div className="border-t bg-yellow-50 p-4">
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<form onSubmit={handleSendLink} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Video link (Google Drive, Dropbox, etc.)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={videoLink}
|
||||
onChange={(e) => setVideoLink(e.target.value)}
|
||||
placeholder="https://drive.google.com/..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Send link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowLinkInput(false);
|
||||
setVideoLink('');
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Input & Actions */}
|
||||
<div className="border-t p-4 bg-gray-50">
|
||||
<div className="flex space-x-2 mb-3">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isTransferring || selectedFile}
|
||||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
<span>Send video (WebRTC)</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLinkInput(!showLinkInput)}
|
||||
disabled={isTransferring}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
<span>Link</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSendMessage} className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Write a message..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>🚀 WebRTC P2P Functionality Mockup:</strong> In the full version, videos will be transferred directly
|
||||
between users via RTCDataChannel, with chunking and progress monitoring.
|
||||
The server is only used for SDP/ICE exchange (signaling).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchChatPage;
|
||||
135
frontend/src/pages/RatePartnerPage.jsx
Normal file
135
frontend/src/pages/RatePartnerPage.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/layout/Layout';
|
||||
import { mockUsers } from '../mocks/users';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
const RatePartnerPage = () => {
|
||||
const { matchId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [comment, setComment] = useState('');
|
||||
const [wouldCollaborateAgain, setWouldCollaborateAgain] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Partner user (mockup)
|
||||
const partner = mockUsers[1]; // sarah_swing
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (rating === 0) {
|
||||
alert('Please select a rating (1-5 stars)');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
// Mockup - in the future will be API call
|
||||
setTimeout(() => {
|
||||
alert('Rating saved!');
|
||||
navigate('/history');
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6 text-center">
|
||||
Rate the collaboration
|
||||
</h1>
|
||||
|
||||
{/* Partner Info */}
|
||||
<div className="flex items-center justify-center space-x-4 mb-8 p-6 bg-gray-50 rounded-lg">
|
||||
<img
|
||||
src={partner.avatar}
|
||||
alt={partner.username}
|
||||
className="w-16 h-16 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">{partner.username}</h3>
|
||||
<p className="text-gray-600">⭐ {partner.rating} • {partner.matches_count} collaborations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Rating Stars */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3 text-center">
|
||||
How would you rate the collaboration?
|
||||
</label>
|
||||
<div className="flex justify-center space-x-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoveredRating(star)}
|
||||
onMouseLeave={() => setHoveredRating(0)}
|
||||
className="focus:outline-none transition-transform hover:scale-110"
|
||||
>
|
||||
<Star
|
||||
className={`w-12 h-12 ${
|
||||
star <= (hoveredRating || rating)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-sm text-gray-500 mt-2">
|
||||
{rating === 0 && 'Click to rate'}
|
||||
{rating === 1 && 'Poor'}
|
||||
{rating === 2 && 'Fair'}
|
||||
{rating === 3 && 'Good'}
|
||||
{rating === 4 && 'Very good'}
|
||||
{rating === 5 && 'Excellent!'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Comment (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Share your thoughts about the collaboration..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Would Collaborate Again */}
|
||||
<div className="flex items-center space-x-3 p-4 bg-gray-50 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="collaborate"
|
||||
checked={wouldCollaborateAgain}
|
||||
onChange={(e) => setWouldCollaborateAgain(e.target.checked)}
|
||||
className="w-5 h-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="collaborate" className="text-sm font-medium text-gray-700 cursor-pointer">
|
||||
I would like to collaborate again
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || rating === 0}
|
||||
className="w-full px-6 py-3 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save rating'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatePartnerPage;
|
||||
140
frontend/src/pages/RegisterPage.jsx
Normal file
140
frontend/src/pages/RegisterPage.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Video, Mail, Lock, User } from 'lucide-react';
|
||||
|
||||
const RegisterPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
alert('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(username, email, password);
|
||||
navigate('/events');
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<Video className="w-16 h-16 text-primary-600 mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">spotlight.cam</h1>
|
||||
<p className="text-gray-600 mt-2">Create a new account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your_username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
Reference in New Issue
Block a user