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:
Radosław Gierwiało
2025-11-12 17:50:44 +01:00
commit 80ff4a70bf
38 changed files with 7213 additions and 0 deletions

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

123
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,123 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import EventsPage from './pages/EventsPage';
import EventChatPage from './pages/EventChatPage';
import MatchChatPage from './pages/MatchChatPage';
import RatePartnerPage from './pages/RatePartnerPage';
import HistoryPage from './pages/HistoryPage';
// Protected Route Component
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-gray-600">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
};
// Public Route Component (redirect to events if already logged in)
const PublicRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-lg text-gray-600">Loading...</div>
</div>
);
}
if (isAuthenticated) {
return <Navigate to="/events" replace />;
}
return children;
};
function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
{/* Public Routes */}
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
{/* Protected Routes */}
<Route
path="/events"
element={
<ProtectedRoute>
<EventsPage />
</ProtectedRoute>
}
/>
<Route
path="/events/:eventId/chat"
element={
<ProtectedRoute>
<EventChatPage />
</ProtectedRoute>
}
/>
<Route
path="/matches/:matchId/chat"
element={
<ProtectedRoute>
<MatchChatPage />
</ProtectedRoute>
}
/>
<Route
path="/matches/:matchId/rate"
element={
<ProtectedRoute>
<RatePartnerPage />
</ProtectedRoute>
}
/>
<Route
path="/history"
element={
<ProtectedRoute>
<HistoryPage />
</ProtectedRoute>
}
/>
{/* Default redirect */}
<Route path="/" element={<Navigate to="/events" replace />} />
<Route path="*" element={<Navigate to="/events" replace />} />
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,14 @@
import Navbar from './Navbar';
const Layout = ({ children }) => {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,59 @@
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Video, LogOut, User, History } from 'lucide-react';
const Navbar = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
if (!user) return null;
return (
<nav className="bg-white shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link to="/events" className="flex items-center space-x-2">
<Video className="w-8 h-8 text-primary-600" />
<span className="text-xl font-bold text-gray-900">spotlight.cam</span>
</Link>
</div>
<div className="flex items-center space-x-4">
<Link
to="/history"
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-100"
>
<History className="w-4 h-4" />
<span>History</span>
</Link>
<div className="flex items-center space-x-3">
<img
src={user.avatar}
alt={user.username}
className="w-8 h-8 rounded-full"
/>
<span className="text-sm font-medium text-gray-700">{user.username}</span>
</div>
<button
onClick={handleLogout}
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-red-600 hover:bg-red-50"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,70 @@
import { createContext, useContext, useState, useEffect } from 'react';
import { mockCurrentUser } from '../mocks/users';
const AuthContext = createContext(null);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user is logged in (from localStorage)
const storedUser = localStorage.getItem('user');
if (storedUser) {
setUser(JSON.parse(storedUser));
}
setLoading(false);
}, []);
const login = async (email, password) => {
// Mock login - w przyszłości będzie API call
return new Promise((resolve) => {
setTimeout(() => {
const userData = mockCurrentUser;
setUser(userData);
localStorage.setItem('user', JSON.stringify(userData));
resolve(userData);
}, 500);
});
};
const register = async (username, email, password) => {
// Mock register - w przyszłości będzie API call
return new Promise((resolve) => {
setTimeout(() => {
const userData = {
...mockCurrentUser,
username,
email,
};
setUser(userData);
localStorage.setItem('user', JSON.stringify(userData));
resolve(userData);
}, 500);
});
};
const logout = () => {
setUser(null);
localStorage.removeItem('user');
};
const value = {
user,
loading,
login,
register,
logout,
isAuthenticated: !!user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

9
frontend/src/index.css Normal file
View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-gray-900;
}
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,42 @@
export const mockEvents = [
{
id: 1,
name: 'Warsaw Dance Festival 2025',
location: 'Warsaw, Poland',
start_date: '2025-03-15',
end_date: '2025-03-17',
worldsdc_id: 'wdf-2025',
participants_count: 156,
description: 'The biggest West Coast Swing event in Central Europe',
},
{
id: 2,
name: 'Berlin Swing Out',
location: 'Berlin, Germany',
start_date: '2025-04-20',
end_date: '2025-04-22',
worldsdc_id: 'bso-2025',
participants_count: 203,
description: 'Three days of amazing dancing and workshops',
},
{
id: 3,
name: 'Prague Dance Weekend',
location: 'Prague, Czech Republic',
start_date: '2025-05-10',
end_date: '2025-05-12',
worldsdc_id: 'pdw-2025',
participants_count: 89,
description: 'Intimate event with world-class instructors',
},
{
id: 4,
name: 'Krakow Swing Festival',
location: 'Krakow, Poland',
start_date: '2025-06-07',
end_date: '2025-06-09',
worldsdc_id: 'ksf-2025',
participants_count: 124,
description: 'Dancing in the heart of historical Krakow',
},
];

View File

@@ -0,0 +1,79 @@
export const mockMatches = [
{
id: 1,
user1_id: 1,
user2_id: 2,
user2_name: 'sarah_swing',
user2_avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah',
event_id: 1,
event_name: 'Warsaw Dance Festival 2025',
status: 'active',
room_id: 10,
created_at: '2025-03-14T11:00:00Z',
},
{
id: 2,
user1_id: 1,
user2_id: 3,
user2_name: 'mike_moves',
user2_avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mike',
event_id: 1,
event_name: 'Warsaw Dance Festival 2025',
status: 'completed',
room_id: 11,
created_at: '2025-03-13T14:30:00Z',
completed_at: '2025-03-13T16:45:00Z',
},
{
id: 3,
user1_id: 1,
user2_id: 4,
user2_name: 'emma_elegant',
user2_avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Emma',
event_id: 2,
event_name: 'Berlin Swing Out',
status: 'completed',
room_id: 12,
created_at: '2025-02-20T10:00:00Z',
completed_at: '2025-02-20T15:30:00Z',
},
];
export const mockRatings = [
{
id: 1,
match_id: 2,
rater_id: 1,
rated_id: 3,
rated_name: 'mike_moves',
rated_avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mike',
score: 5,
comment: 'Great energy and smooth moves! Would love to dance again.',
would_collaborate_again: true,
created_at: '2025-03-13T17:00:00Z',
},
{
id: 2,
match_id: 3,
rater_id: 1,
rated_id: 4,
rated_name: 'emma_elegant',
rated_avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Emma',
score: 5,
comment: 'Amazing dancer with excellent technique. Highly recommend!',
would_collaborate_again: true,
created_at: '2025-02-20T16:00:00Z',
},
{
id: 3,
match_id: 2,
rater_id: 3,
rated_id: 1,
rated_name: 'john_dancer',
rated_avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=John',
score: 4,
comment: 'Good collaboration, very professional!',
would_collaborate_again: true,
created_at: '2025-03-13T17:15:00Z',
},
];

View File

@@ -0,0 +1,81 @@
export const mockEventMessages = [
{
id: 1,
room_id: 1,
user_id: 2,
username: 'sarah_swing',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah',
content: 'Hey everyone! Looking forward to dancing this weekend!',
type: 'text',
created_at: '2025-03-14T10:30:00Z',
},
{
id: 2,
room_id: 1,
user_id: 3,
username: 'mike_moves',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mike',
content: 'Anyone interested in filming some socials together?',
type: 'text',
created_at: '2025-03-14T10:32:00Z',
},
{
id: 3,
room_id: 1,
user_id: 4,
username: 'emma_elegant',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Emma',
content: 'I\'m in! Would love to collaborate',
type: 'text',
created_at: '2025-03-14T10:35:00Z',
},
{
id: 4,
room_id: 1,
user_id: 5,
username: 'alex_awesome',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex',
content: 'This is my first event. Super excited!',
type: 'text',
created_at: '2025-03-14T10:40:00Z',
},
];
export const mockPrivateMessages = [
{
id: 101,
room_id: 10,
user_id: 2,
username: 'sarah_swing',
content: 'Hi! Great to match with you!',
type: 'text',
created_at: '2025-03-14T11:00:00Z',
},
{
id: 102,
room_id: 10,
user_id: 1,
username: 'john_dancer',
content: 'Same here! When do you want to record?',
type: 'text',
created_at: '2025-03-14T11:02:00Z',
},
{
id: 103,
room_id: 10,
user_id: 2,
username: 'sarah_swing',
content: 'How about after the next workshop?',
type: 'text',
created_at: '2025-03-14T11:03:00Z',
},
{
id: 104,
room_id: 10,
user_id: 1,
username: 'john_dancer',
content: 'Perfect! See you then 👍',
type: 'text',
created_at: '2025-03-14T11:05:00Z',
},
];

View File

@@ -0,0 +1,58 @@
export const mockUsers = [
{
id: 1,
username: 'john_dancer',
email: 'john@example.com',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=John',
rating: 4.8,
matches_count: 23,
created_at: '2024-01-15',
},
{
id: 2,
username: 'sarah_swing',
email: 'sarah@example.com',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah',
rating: 4.9,
matches_count: 31,
created_at: '2024-02-20',
},
{
id: 3,
username: 'mike_moves',
email: 'mike@example.com',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mike',
rating: 4.6,
matches_count: 18,
created_at: '2024-03-10',
},
{
id: 4,
username: 'emma_elegant',
email: 'emma@example.com',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Emma',
rating: 4.95,
matches_count: 42,
created_at: '2023-11-05',
},
{
id: 5,
username: 'alex_awesome',
email: 'alex@example.com',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex',
rating: 4.7,
matches_count: 27,
created_at: '2024-04-12',
},
];
// Current logged-in user
export const mockCurrentUser = {
id: 1,
username: 'john_dancer',
email: 'john@example.com',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=John',
rating: 4.8,
matches_count: 23,
created_at: '2024-01-15',
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;