adicao de sistema de login para admins e menu de definições
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>VdcScore Live Editor</title>
|
<title>Admin VdcScore</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
38
src/App.tsx
38
src/App.tsx
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './lib/AuthContext';
|
||||||
import Layout from './components/layout/Layout';
|
import Layout from './components/layout/Layout';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Clubs from './pages/Clubs';
|
import Clubs from './pages/Clubs';
|
||||||
@@ -6,19 +7,39 @@ import Standings from './pages/Standings';
|
|||||||
import Games from './pages/Games';
|
import Games from './pages/Games';
|
||||||
import LiveEditor from './pages/LiveEditor';
|
import LiveEditor from './pages/LiveEditor';
|
||||||
import LiveGames from './pages/LiveGames';
|
import LiveGames from './pages/LiveGames';
|
||||||
|
import Players from './pages/Players';
|
||||||
|
import Scorers from './pages/Scorers';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
|
||||||
// Placeholder components for other pages
|
// Protected Route Component
|
||||||
|
const ProtectedRoute = () => {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Placeholder for News screen if not created yet
|
||||||
const Placeholder = ({ title }: { title: string }) => (
|
const Placeholder = ({ title }: { title: string }) => (
|
||||||
<div className="p-20 text-center text-text-muted border border-dashed border-border rounded-2xl">
|
<div className="p-20 text-center text-text-muted border border-dashed border-border rounded-2xl">
|
||||||
<h2 className="text-2xl uppercase">{title}</h2>
|
<h2 className="text-2xl uppercase font-display tracking-wide text-text-primary mb-2">{title}</h2>
|
||||||
<p>Esta funcionalidade será implementada brevemente.</p>
|
<p>Esta funcionalidade será implementada brevemente.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
|
{/* Wrapper containing layout for all subroutes */}
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="games" element={<Games />} />
|
<Route path="games" element={<Games />} />
|
||||||
@@ -26,13 +47,18 @@ function App() {
|
|||||||
<Route path="games/live-editor/:escalao/:jornada/:matchId" element={<LiveEditor />} />
|
<Route path="games/live-editor/:escalao/:jornada/:matchId" element={<LiveEditor />} />
|
||||||
<Route path="standings" element={<Standings />} />
|
<Route path="standings" element={<Standings />} />
|
||||||
<Route path="clubs" element={<Clubs />} />
|
<Route path="clubs" element={<Clubs />} />
|
||||||
<Route path="players" element={<Placeholder title="Jogadores" />} />
|
<Route path="players" element={<Players />} />
|
||||||
<Route path="scorers" element={<Placeholder title="Artilheiros" />} />
|
<Route path="scorers" element={<Scorers />} />
|
||||||
<Route path="news" element={<Placeholder title="Notícias" />} />
|
<Route path="news" element={<Placeholder title="Notícias" />} />
|
||||||
<Route path="settings" element={<Placeholder title="Definições" />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Catch all redirecting to home which is protected */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { MapPin, Trophy } from 'lucide-react';
|
|||||||
|
|
||||||
interface ClubCardProps {
|
interface ClubCardProps {
|
||||||
club: Club;
|
club: Club;
|
||||||
|
onDetailsClick: () => void;
|
||||||
|
onEditClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClubCard: React.FC<ClubCardProps> = ({ club }) => {
|
const ClubCard: React.FC<ClubCardProps> = ({ club, onDetailsClick, onEditClick }) => {
|
||||||
return (
|
return (
|
||||||
<div className="card group">
|
<div className="card group">
|
||||||
<div className="h-24 bg-bg-elevated relative overflow-hidden">
|
<div className="h-24 bg-bg-elevated relative overflow-hidden">
|
||||||
@@ -52,10 +54,16 @@ const ClubCard: React.FC<ClubCardProps> = ({ club }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-bg-overlay/30 flex gap-2">
|
<div className="p-4 bg-bg-overlay/30 flex gap-2">
|
||||||
<button className="flex-1 bg-bg-elevated hover:bg-bg-overlay text-xs font-bold py-2 rounded transition-colors border border-border">
|
<button
|
||||||
|
onClick={onDetailsClick}
|
||||||
|
className="flex-1 bg-bg-elevated hover:bg-bg-overlay text-xs font-bold py-2 rounded transition-colors border border-border"
|
||||||
|
>
|
||||||
DETALHES
|
DETALHES
|
||||||
</button>
|
</button>
|
||||||
<button className="flex-1 bg-brand-primary/10 hover:bg-brand-primary/20 text-brand-primary text-xs font-bold py-2 rounded transition-colors border border-brand-primary/20">
|
<button
|
||||||
|
onClick={onEditClick}
|
||||||
|
className="flex-1 bg-brand-primary/10 hover:bg-brand-primary/20 text-brand-primary text-xs font-bold py-2 rounded transition-colors border border-brand-primary/20"
|
||||||
|
>
|
||||||
EDITAR
|
EDITAR
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../lib/AuthContext';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Trophy,
|
Trophy,
|
||||||
@@ -13,6 +14,15 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const Sidebar = () => {
|
const Sidebar = () => {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/' },
|
{ icon: LayoutDashboard, label: 'Dashboard', path: '/' },
|
||||||
{ icon: Zap, label: 'Jogos Live', path: '/games/live' },
|
{ icon: Zap, label: 'Jogos Live', path: '/games/live' },
|
||||||
@@ -20,47 +30,60 @@ const Sidebar = () => {
|
|||||||
{ icon: Trophy, label: 'Classificação', path: '/standings' },
|
{ icon: Trophy, label: 'Classificação', path: '/standings' },
|
||||||
{ icon: Users, label: 'Clubes', path: '/clubs' },
|
{ icon: Users, label: 'Clubes', path: '/clubs' },
|
||||||
{ icon: User, label: 'Jogadores', path: '/players' },
|
{ icon: User, label: 'Jogadores', path: '/players' },
|
||||||
{ icon: BarChart, label: 'Artilheiros', path: '/scorers' },
|
{ icon: BarChart, label: 'Melhores Marcadores', path: '/scorers' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 bg-bg-surface border-r border-border flex flex-col h-screen sticky top-0">
|
<aside className="w-60 bg-bg-surface border-r border-border flex flex-col h-screen sticky top-0 z-20">
|
||||||
<div className="p-6 flex items-center gap-3 border-b border-border">
|
<div className="p-5 flex items-center gap-3 border-b border-border h-20">
|
||||||
<div className="bg-brand-primary p-2 rounded-lg">
|
<div className="bg-brand-primary p-2 rounded-xl shadow-lg shadow-brand-primary/20 shrink-0">
|
||||||
<Zap className="text-bg-base fill-bg-base" size={24} />
|
<Zap className="text-bg-base fill-bg-base" size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col leading-tight overflow-hidden">
|
||||||
|
<span className="font-display text-[10px] text-brand-primary tracking-[0.2em] uppercase">Admin</span>
|
||||||
|
<span className="font-display text-xl tracking-wide truncate">VdcScore</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-display text-2xl tracking-wider">⚽ ADM</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-6 px-3 space-y-1">
|
<nav className="flex-1 py-6 px-3 space-y-1 overflow-y-auto">
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={({ isActive }) => `
|
className={({ isActive }) => `
|
||||||
flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group
|
flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 group
|
||||||
${isActive
|
${isActive
|
||||||
? 'bg-brand-primary/10 text-brand-primary border-l-4 border-brand-primary rounded-l-none'
|
? 'bg-brand-primary/10 text-brand-primary'
|
||||||
: 'text-text-secondary hover:bg-bg-overlay hover:text-text-primary'}
|
: 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<item.icon size={20} className="shrink-0" />
|
{({ isActive }) => (
|
||||||
<span className="font-medium text-sm">{item.label}</span>
|
<>
|
||||||
|
<item.icon size={18} className={`shrink-0 ${isActive ? 'animate-pulse' : ''}`} />
|
||||||
|
<span className="font-medium text-sm tracking-wide">{item.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-border space-y-1">
|
<div className="p-3 border-t border-border space-y-1 bg-bg-surface">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-secondary hover:bg-bg-overlay hover:text-text-primary transition-all"
|
className={({ isActive }) => `
|
||||||
|
flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all text-sm font-medium
|
||||||
|
${isActive ? 'bg-bg-elevated text-brand-primary' : 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<Settings size={20} />
|
<Settings size={18} />
|
||||||
<span className="font-medium text-sm">Definições</span>
|
<span>Definições</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<button className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-brand-danger hover:bg-brand-danger/10 transition-all">
|
<button
|
||||||
<LogOut size={20} />
|
onClick={handleLogout}
|
||||||
<span className="font-medium text-sm">Sair</span>
|
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-brand-danger hover:bg-brand-danger/10 transition-all text-sm font-medium text-left"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
<span>Sair</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
45
src/hooks/useScorers.ts
Normal file
45
src/hooks/useScorers.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ref, onValue } from 'firebase/database';
|
||||||
|
import { db } from '../lib/firebase';
|
||||||
|
import type { ScorerItem } from '../types/database';
|
||||||
|
|
||||||
|
export function useScorers() {
|
||||||
|
const [scorers, setScorers] = useState<{ seniores: ScorerItem[]; juniores: ScorerItem[] }>({
|
||||||
|
seniores: [],
|
||||||
|
juniores: [],
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scorersRef = ref(db, 'marcadores');
|
||||||
|
|
||||||
|
const unsubscribe = onValue(scorersRef, (snapshot) => {
|
||||||
|
const data = snapshot.val();
|
||||||
|
if (data) {
|
||||||
|
// Safe parser that filters out null values (Firebase arrays with empty slots)
|
||||||
|
const parseArray = (arr: any): ScorerItem[] => {
|
||||||
|
if (!arr) return [];
|
||||||
|
const list = Array.isArray(arr) ? arr : Object.values(arr);
|
||||||
|
return list.filter((item: any) => item !== null && item !== undefined) as ScorerItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
setScorers({
|
||||||
|
seniores: parseArray(data.seniores),
|
||||||
|
juniores: parseArray(data.juniores),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setScorers({ seniores: [], juniores: [] });
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, (err) => {
|
||||||
|
console.error("Erro ao carregar marcadores:", err);
|
||||||
|
setError(err);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { scorers, loading, error };
|
||||||
|
}
|
||||||
43
src/lib/AuthContext.tsx
Normal file
43
src/lib/AuthContext.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { createContext, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (username: string, pass: string) => boolean;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => {
|
||||||
|
return localStorage.getItem('vdc_auth') === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = (username: string, pass: string): boolean => {
|
||||||
|
if (username === 'admvdc' && pass === 'interfreguesias') {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
localStorage.setItem('vdc_auth', 'true');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
localStorage.removeItem('vdc_auth');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -1,16 +1,99 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useClubs } from '../hooks/useClubs';
|
import { useClubs } from '../hooks/useClubs';
|
||||||
import ClubCard from '../components/clubs/ClubCard';
|
import ClubCard from '../components/clubs/ClubCard';
|
||||||
import { Search, Loader2, Plus } from 'lucide-react';
|
import { Search, Loader2, Plus, X, Save, Trophy, MapPin, Calendar, User, Building, Map } from 'lucide-react';
|
||||||
|
import { ref, update } from 'firebase/database';
|
||||||
|
import { db } from '../lib/firebase';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Club, Player } from '../types/database';
|
||||||
|
|
||||||
const Clubs = () => {
|
const Clubs = () => {
|
||||||
const { clubs, loading, error } = useClubs();
|
const { clubs, loading, error } = useClubs();
|
||||||
const [searchTerm, setSearchTerm] = React.useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Modals state
|
||||||
|
const [activeDetailsClub, setActiveDetailsClub] = useState<Club | null>(null);
|
||||||
|
const [activeEditClub, setActiveEditClub] = useState<Club | null>(null);
|
||||||
|
|
||||||
|
// Tab within details modal
|
||||||
|
const [activeSquadTab, setActiveSquadTab] = useState<'seniores' | 'juniores'>('seniores');
|
||||||
|
|
||||||
|
// Edit Form state
|
||||||
|
const [editForm, setEditForm] = useState<{
|
||||||
|
nome: string;
|
||||||
|
imagem: string;
|
||||||
|
campo: string;
|
||||||
|
ano_fundacao: string;
|
||||||
|
morada: string;
|
||||||
|
presidente: string;
|
||||||
|
}>({
|
||||||
|
nome: '',
|
||||||
|
imagem: '',
|
||||||
|
campo: '',
|
||||||
|
ano_fundacao: '',
|
||||||
|
morada: '',
|
||||||
|
presidente: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const filteredClubs = clubs.filter(club =>
|
const filteredClubs = clubs.filter(club =>
|
||||||
club.nome.toLowerCase().includes(searchTerm.toLowerCase())
|
club.nome.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialize edit form when opening edit modal
|
||||||
|
const handleOpenEdit = (club: Club) => {
|
||||||
|
setEditForm({
|
||||||
|
nome: club.nome || '',
|
||||||
|
imagem: club.imagem || '',
|
||||||
|
campo: club.campo || '',
|
||||||
|
ano_fundacao: club.ano_fundacao ? club.ano_fundacao.toString() : '',
|
||||||
|
morada: club.morada || '',
|
||||||
|
presidente: club.presidente || '',
|
||||||
|
});
|
||||||
|
setActiveEditClub(club);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save club updates to Firebase Realtime Database
|
||||||
|
const handleSaveClub = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!activeEditClub) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
const clubId = activeEditClub.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: any = {};
|
||||||
|
updates[`clubes/${clubId}/nome`] = editForm.nome;
|
||||||
|
updates[`clubes/${clubId}/imagem`] = editForm.imagem;
|
||||||
|
updates[`clubes/${clubId}/campo`] = editForm.campo;
|
||||||
|
updates[`clubes/${clubId}/ano_fundacao`] = editForm.ano_fundacao ? Number(editForm.ano_fundacao) : null;
|
||||||
|
updates[`clubes/${clubId}/morada`] = editForm.morada;
|
||||||
|
updates[`clubes/${clubId}/presidente`] = editForm.presidente;
|
||||||
|
|
||||||
|
await update(ref(db), updates);
|
||||||
|
toast.success('Clube atualizado com sucesso!');
|
||||||
|
setActiveEditClub(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Erro ao salvar clube:', err);
|
||||||
|
toast.error('Erro ao atualizar o clube: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date of birth to a localized string
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '---';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return dateStr;
|
||||||
|
return date.toLocaleDateString('pt-PT');
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
@@ -19,7 +102,7 @@ const Clubs = () => {
|
|||||||
<p className="text-text-secondary mt-1">Gestão de equipas participantes na liga.</p>
|
<p className="text-text-secondary mt-1">Gestão de equipas participantes na liga.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn-primary flex items-center gap-2 self-start">
|
<button className="btn-primary flex items-center gap-2 self-start opacity-50 cursor-not-allowed" disabled title="Funcionalidade em desenvolvimento">
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
<span>Novo Clube</span>
|
<span>Novo Clube</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -31,13 +114,13 @@ const Clubs = () => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Pesquisar por nome do clube..."
|
placeholder="Pesquisar por nome do clube..."
|
||||||
className="w-full bg-bg-base border border-border rounded-lg py-2 pl-10 pr-4 focus:outline-none focus:border-brand-primary transition-all"
|
className="w-full bg-bg-base border border-border rounded-lg py-2 pl-10 pr-4 focus:outline-none focus:border-brand-primary transition-all text-sm"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-text-secondary">
|
<div className="text-sm text-text-secondary">
|
||||||
Total: <span className="text-text-primary font-mono">{clubs.length}</span>
|
Total: <span className="text-text-primary font-mono font-bold">{clubs.length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -58,10 +141,268 @@ const Clubs = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
{filteredClubs.map((club) => (
|
{filteredClubs.map((club) => (
|
||||||
<ClubCard key={club.id} club={club} />
|
<ClubCard
|
||||||
|
key={club.id}
|
||||||
|
club={club}
|
||||||
|
onDetailsClick={() => {
|
||||||
|
setActiveSquadTab('seniores');
|
||||||
|
setActiveDetailsClub(club);
|
||||||
|
}}
|
||||||
|
onEditClick={() => handleOpenEdit(club)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* --- CLUB DETAILS MODAL --- */}
|
||||||
|
{activeDetailsClub && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-base/80 backdrop-blur-md animate-fade-in">
|
||||||
|
<div className="bg-bg-surface border border-border w-full max-w-4xl max-h-[85vh] rounded-2xl overflow-hidden shadow-2xl flex flex-col">
|
||||||
|
{/* Header / Banner */}
|
||||||
|
<div className="p-6 bg-bg-elevated border-b border-border flex items-start justify-between relative">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="w-20 h-20 bg-bg-surface rounded-xl border border-border flex items-center justify-center p-2 shadow-inner shrink-0">
|
||||||
|
<img
|
||||||
|
src={activeDetailsClub.imagem}
|
||||||
|
alt={activeDetailsClub.nome}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = 'https://via.placeholder.com/150?text=Logo';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl text-text-primary mb-1">{activeDetailsClub.nome}</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-text-secondary">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin size={14} className="text-brand-accent" />
|
||||||
|
<span>{activeDetailsClub.campo || 'Estádio não definido'}</span>
|
||||||
|
</div>
|
||||||
|
{activeDetailsClub.ano_fundacao && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar size={14} className="text-brand-primary" />
|
||||||
|
<span>Fundado em {activeDetailsClub.ano_fundacao}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveDetailsClub(null)}
|
||||||
|
className="p-1.5 rounded-lg bg-bg-base hover:bg-bg-overlay border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Tabs & Metadata */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
{/* Club Info Quick Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 bg-bg-base rounded-xl border border-border flex items-center gap-3">
|
||||||
|
<Building size={20} className="text-brand-primary shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="block text-[10px] text-text-muted uppercase font-bold tracking-wider">Presidente</span>
|
||||||
|
<span className="text-sm text-text-primary font-medium">{activeDetailsClub.presidente || 'Não definido'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-bg-base rounded-xl border border-border flex items-center gap-3">
|
||||||
|
<Map size={20} className="text-brand-accent shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="block text-[10px] text-text-muted uppercase font-bold tracking-wider">Morada</span>
|
||||||
|
<span className="text-sm text-text-primary font-medium truncate max-w-[200px]" title={activeDetailsClub.morada}>{activeDetailsClub.morada || 'Não definida'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-bg-base rounded-xl border border-border flex items-center gap-3">
|
||||||
|
<Trophy size={20} className="text-brand-warning shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="block text-[10px] text-text-muted uppercase font-bold tracking-wider">Total Plantel</span>
|
||||||
|
<span className="text-sm text-text-primary font-mono font-bold">
|
||||||
|
{((activeDetailsClub.jogadores?.seniores?.length || 0) + (activeDetailsClub.jogadores?.juniores?.length || 0))} Jogadores
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plantel Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||||
|
<h3 className="text-xl text-text-primary font-bold">PLANTEL</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSquadTab('seniores')}
|
||||||
|
className={`px-4 py-1.5 rounded-lg text-xs font-bold uppercase transition-all ${
|
||||||
|
activeSquadTab === 'seniores'
|
||||||
|
? 'bg-brand-primary/15 text-brand-primary border border-brand-primary/30'
|
||||||
|
: 'bg-bg-base hover:bg-bg-elevated text-text-secondary border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Seniores ({activeDetailsClub.jogadores?.seniores?.length || 0})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSquadTab('juniores')}
|
||||||
|
className={`px-4 py-1.5 rounded-lg text-xs font-bold uppercase transition-all ${
|
||||||
|
activeSquadTab === 'juniores'
|
||||||
|
? 'bg-brand-warning/15 text-brand-warning border border-brand-warning/30'
|
||||||
|
: 'bg-bg-base hover:bg-bg-elevated text-text-secondary border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Juniores ({activeDetailsClub.jogadores?.juniores?.length || 0})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Grid */}
|
||||||
|
{(!activeDetailsClub.jogadores?.[activeSquadTab] || activeDetailsClub.jogadores[activeSquadTab]?.length === 0) ? (
|
||||||
|
<div className="text-center py-12 bg-bg-base rounded-xl border border-dashed border-border text-text-muted text-sm">
|
||||||
|
Nenhum jogador registado neste escalão.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{activeDetailsClub.jogadores[activeSquadTab]?.map((player: Player) => (
|
||||||
|
<div key={player.id} className="p-4 bg-bg-base rounded-xl border border-border flex items-center gap-4 hover:border-brand-primary/40 transition-colors">
|
||||||
|
<div className="w-12 h-12 rounded-full overflow-hidden border border-border bg-bg-elevated shrink-0">
|
||||||
|
{player.foto ? (
|
||||||
|
<img
|
||||||
|
src={player.foto}
|
||||||
|
alt={player.nome}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = 'https://api.dicebear.com/7.x/adventurer/svg?seed=' + player.nome;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-text-muted"><User size={20} /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h4 className="text-sm font-bold text-text-primary truncate" title={player.nome}>{player.nome}</h4>
|
||||||
|
<span className="block text-[10px] text-text-secondary mt-0.5 font-mono">Nasc: {formatDate(player.data_nascimento)}</span>
|
||||||
|
<span className="block text-[10px] text-text-muted truncate mt-0.5">Nat: {player.naturalidade || '---'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- CLUB EDIT MODAL --- */}
|
||||||
|
{activeEditClub && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-base/80 backdrop-blur-md animate-fade-in">
|
||||||
|
<div className="bg-bg-surface border border-border w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl flex flex-col">
|
||||||
|
<div className="p-6 bg-bg-elevated border-b border-border flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-brand-primary/10 rounded-lg text-brand-primary">
|
||||||
|
<Building size={20} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl text-text-primary">Editar Clube</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveEditClub(null)}
|
||||||
|
className="p-1.5 rounded-lg bg-bg-base hover:bg-bg-overlay border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveClub} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-text-muted uppercase mb-1.5">Nome do Clube</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full bg-bg-base border border-border rounded-lg py-2 px-3 focus:outline-none focus:border-brand-primary transition-all text-sm text-text-primary"
|
||||||
|
value={editForm.nome}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, nome: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-text-muted uppercase mb-1.5">URL do Logótipo / Imagem</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full bg-bg-base border border-border rounded-lg py-2 px-3 focus:outline-none focus:border-brand-primary transition-all text-sm text-text-primary font-mono"
|
||||||
|
value={editForm.imagem}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, imagem: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-text-muted uppercase mb-1.5">Estádio / Campo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-bg-base border border-border rounded-lg py-2 px-3 focus:outline-none focus:border-brand-primary transition-all text-sm text-text-primary"
|
||||||
|
value={editForm.campo}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, campo: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-text-muted uppercase mb-1.5">Ano de Fundação</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full bg-bg-base border border-border rounded-lg py-2 px-3 focus:outline-none focus:border-brand-primary transition-all text-sm text-text-primary font-mono"
|
||||||
|
value={editForm.ano_fundacao}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, ano_fundacao: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-text-muted uppercase mb-1.5">Presidente</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-bg-base border border-border rounded-lg py-2 px-3 focus:outline-none focus:border-brand-primary transition-all text-sm text-text-primary"
|
||||||
|
value={editForm.presidente}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, presidente: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-text-muted uppercase mb-1.5">Morada / Sede</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-bg-base border border-border rounded-lg py-2 px-3 focus:outline-none focus:border-brand-primary transition-all text-sm text-text-primary"
|
||||||
|
value={editForm.morada}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, morada: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-border mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveEditClub(null)}
|
||||||
|
className="flex-1 bg-bg-elevated hover:bg-bg-overlay text-sm font-bold py-2.5 rounded-lg transition-all border border-border uppercase"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 bg-brand-primary hover:bg-green-600 disabled:bg-brand-primary/40 text-bg-base text-sm font-bold py-2.5 rounded-lg transition-all shadow-lg shadow-brand-primary/10 flex items-center justify-center gap-2 uppercase"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
<span>A Gravar...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={18} />
|
||||||
|
<span>Guardar</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
124
src/pages/Login.tsx
Normal file
124
src/pages/Login.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../lib/AuthContext';
|
||||||
|
import { useNavigate, Navigate } from 'react-router-dom';
|
||||||
|
import { Zap, Lock, User, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login, isAuthenticated } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Artificial delay for feel
|
||||||
|
setTimeout(() => {
|
||||||
|
const success = login(username, password);
|
||||||
|
if (success) {
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
setError('Utilizador ou palavra-passe incorretos.');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-base flex flex-col justify-center items-center p-4 relative overflow-hidden">
|
||||||
|
{/* Background elements for premium look */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0 pointer-events-none opacity-20">
|
||||||
|
<div className="absolute -top-48 -left-48 w-96 h-96 bg-brand-primary rounded-full blur-[100px]"></div>
|
||||||
|
<div className="absolute bottom-0 right-0 w-96 h-96 bg-brand-accent/30 rounded-full blur-[120px]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-md z-10">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center bg-brand-primary p-4 rounded-2xl shadow-[0_0_30px_rgba(34,197,94,0.3)] mb-4">
|
||||||
|
<Zap className="text-bg-base fill-bg-base" size={40} />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-4xl tracking-wider text-text-primary uppercase mb-2">Admin VdcScore</h1>
|
||||||
|
<p className="text-text-secondary text-sm">Portal Administrativo Inter-Freguesias</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-bg-surface border border-border rounded-3xl p-8 shadow-2xl backdrop-blur-sm bg-opacity-80">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-text-secondary text-xs uppercase tracking-widest font-bold mb-2 ml-1">
|
||||||
|
Utilizador
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-text-muted group-focus-within:text-brand-primary transition-colors">
|
||||||
|
<User size={18} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="block w-full pl-10 pr-3 py-3.5 bg-bg-elevated border border-border rounded-xl focus:border-brand-primary focus:ring-2 focus:ring-brand-primary/20 outline-none text-text-primary transition-all placeholder:text-text-muted/50"
|
||||||
|
placeholder="Insira o utilizador"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-text-secondary text-xs uppercase tracking-widest font-bold mb-2 ml-1">
|
||||||
|
Palavra-passe
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-text-muted group-focus-within:text-brand-primary transition-colors">
|
||||||
|
<Lock size={18} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="block w-full pl-10 pr-3 py-3.5 bg-bg-elevated border border-border rounded-xl focus:border-brand-primary focus:ring-2 focus:ring-brand-primary/20 outline-none text-text-primary transition-all placeholder:text-text-muted/50"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-brand-danger/10 border border-brand-danger/20 text-brand-danger text-sm p-3 rounded-lg flex items-center gap-2 animate-pulse">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full py-3.5 px-4 rounded-xl bg-brand-primary hover:bg-opacity-90 text-bg-base font-bold uppercase tracking-widest transition-all transform active:scale-95 shadow-lg shadow-brand-primary/20 flex items-center justify-center gap-2 ${loading ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-5 h-5 border-2 border-bg-base border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>A carregar...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Entrar no Painel'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-text-muted text-xs mt-8 opacity-50">
|
||||||
|
© {new Date().getFullYear()} VdcScore Live • Direitos Reservados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
354
src/pages/Players.tsx
Normal file
354
src/pages/Players.tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useClubs } from '../hooks/useClubs';
|
||||||
|
import { useScorers } from '../hooks/useScorers';
|
||||||
|
import { Search, Loader2, User, Calendar, MapPin, Shield, Trophy, X } from 'lucide-react';
|
||||||
|
import type { Player, Club } from '../types/database';
|
||||||
|
|
||||||
|
interface ExtendedPlayer extends Player {
|
||||||
|
clubId: number | string;
|
||||||
|
clubNome: string;
|
||||||
|
clubLogo: string;
|
||||||
|
escalao: 'seniores' | 'juniores';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Players = () => {
|
||||||
|
const { clubs, loading: loadingClubs, error: errorClubs } = useClubs();
|
||||||
|
const { scorers, loading: loadingScorers, error: errorScorers } = useScorers();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedClubId, setSelectedClubId] = useState<string>('todos');
|
||||||
|
const [selectedEscalao, setSelectedEscalao] = useState<string>('todos');
|
||||||
|
|
||||||
|
// State for selected player details modal
|
||||||
|
const [selectedPlayer, setSelectedPlayer] = useState<ExtendedPlayer | null>(null);
|
||||||
|
|
||||||
|
// Build a reactive goals lookup map from the useScorers hook
|
||||||
|
const goalsMap = React.useMemo(() => {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
if (scorers) {
|
||||||
|
if (scorers.seniores) {
|
||||||
|
scorers.seniores.forEach((item) => {
|
||||||
|
if (item && item.playerName) {
|
||||||
|
map[item.playerName.toLowerCase().trim()] = item.goals;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (scorers.juniores) {
|
||||||
|
scorers.juniores.forEach((item) => {
|
||||||
|
if (item && item.playerName) {
|
||||||
|
map[item.playerName.toLowerCase().trim()] = item.goals;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [scorers]);
|
||||||
|
|
||||||
|
// Extract and combine all players from all clubs
|
||||||
|
const allPlayers: ExtendedPlayer[] = React.useMemo(() => {
|
||||||
|
const list: ExtendedPlayer[] = [];
|
||||||
|
clubs.forEach((club) => {
|
||||||
|
if (club.jogadores) {
|
||||||
|
if (club.jogadores.seniores) {
|
||||||
|
club.jogadores.seniores.forEach((p) => {
|
||||||
|
const trimmedName = p.nome ? p.nome.toLowerCase().trim() : '';
|
||||||
|
list.push({
|
||||||
|
...p,
|
||||||
|
clubId: club.id,
|
||||||
|
clubNome: club.nome,
|
||||||
|
clubLogo: club.imagem,
|
||||||
|
escalao: 'seniores',
|
||||||
|
golos: goalsMap[trimmedName] || 0, // Lookup goal count from scorers map
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (club.jogadores.juniores) {
|
||||||
|
club.jogadores.juniores.forEach((p) => {
|
||||||
|
const trimmedName = p.nome ? p.nome.toLowerCase().trim() : '';
|
||||||
|
list.push({
|
||||||
|
...p,
|
||||||
|
clubId: club.id,
|
||||||
|
clubNome: club.nome,
|
||||||
|
clubLogo: club.imagem,
|
||||||
|
escalao: 'juniores',
|
||||||
|
golos: goalsMap[trimmedName] || 0, // Lookup goal count from scorers map
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}, [clubs, goalsMap]);
|
||||||
|
|
||||||
|
// Filter the combined list of players
|
||||||
|
const filteredPlayers = React.useMemo(() => {
|
||||||
|
return allPlayers.filter((player) => {
|
||||||
|
const playerNome = player.nome || '';
|
||||||
|
const playerNat = player.naturalidade || '';
|
||||||
|
const matchesSearch = playerNome.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
playerNat.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesClub = selectedClubId === 'todos' || player.clubId.toString() === selectedClubId;
|
||||||
|
const matchesEscalao = selectedEscalao === 'todos' || player.escalao === selectedEscalao;
|
||||||
|
return matchesSearch && matchesClub && matchesEscalao;
|
||||||
|
});
|
||||||
|
}, [allPlayers, searchTerm, selectedClubId, selectedEscalao]);
|
||||||
|
|
||||||
|
// Format date of birth to a localized string
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '---';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return dateStr;
|
||||||
|
return date.toLocaleDateString('pt-PT');
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = loadingClubs || loadingScorers;
|
||||||
|
const error = errorClubs || errorScorers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl">JOGADORES</h1>
|
||||||
|
<p className="text-text-secondary mt-1">Explora todos os jogadores e clica em qualquer cartão para ver as estatísticas e golos.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Bar */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-bg-surface p-4 rounded-xl border border-border">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative md:col-span-2">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Pesquisar por nome ou naturalidade..."
|
||||||
|
className="w-full bg-bg-base border border-border rounded-lg py-2 pl-10 pr-4 focus:outline-none focus:border-brand-primary transition-all text-sm"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Club Filter */}
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
className="w-full bg-bg-base border border-border rounded-lg py-2 px-3 focus:outline-none focus:border-brand-primary transition-all text-sm text-text-primary"
|
||||||
|
value={selectedClubId}
|
||||||
|
onChange={(e) => setSelectedClubId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="todos">Todos os Clubes</option>
|
||||||
|
{clubs.map((club) => (
|
||||||
|
<option key={club.id} value={club.id.toString()}>{club.nome}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
className="w-full bg-bg-base border border-border rounded-lg py-2 px-3 focus:outline-none focus:border-brand-primary transition-all text-sm text-text-primary"
|
||||||
|
value={selectedEscalao}
|
||||||
|
onChange={(e) => setSelectedEscalao(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="todos">Todos os Escalões</option>
|
||||||
|
<option value="seniores">Seniores</option>
|
||||||
|
<option value="juniores">Juniores</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Counters */}
|
||||||
|
<div className="flex justify-between items-center text-sm text-text-secondary">
|
||||||
|
<div>
|
||||||
|
A mostrar <span className="text-brand-primary font-mono font-bold">{filteredPlayers.length}</span> de <span className="text-text-primary font-mono">{allPlayers.length}</span> jogadores
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-brand-primary">
|
||||||
|
<Loader2 className="animate-spin mb-4" size={48} />
|
||||||
|
<p className="text-text-secondary font-medium">A carregar os dados dos jogadores...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="bg-brand-danger/10 border border-brand-danger/20 p-6 rounded-xl text-center">
|
||||||
|
<p className="text-brand-danger font-semibold">Erro ao carregar dados!</p>
|
||||||
|
<p className="text-text-secondary text-sm mt-1">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
) : filteredPlayers.length === 0 ? (
|
||||||
|
<div className="text-center py-20 bg-bg-surface rounded-xl border border-dashed border-border">
|
||||||
|
<User className="mx-auto mb-4 text-text-muted" size={48} />
|
||||||
|
<p className="text-text-muted">Nenhum jogador encontrado com os filtros atuais.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{filteredPlayers.map((player) => (
|
||||||
|
<div
|
||||||
|
key={`${player.clubId}-${player.escalao}-${player.id}`}
|
||||||
|
onClick={() => setSelectedPlayer(player)}
|
||||||
|
className="card group hover:scale-[1.02] hover:border-brand-primary cursor-pointer transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="h-28 bg-bg-elevated relative overflow-hidden flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-brand-primary/5 to-transparent"></div>
|
||||||
|
|
||||||
|
{/* Parent Club Subtle Watermark */}
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1.5 bg-bg-base/80 border border-border px-2.5 py-1 rounded-full backdrop-blur-sm z-10">
|
||||||
|
<img
|
||||||
|
src={player.clubLogo}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = 'https://via.placeholder.com/50?text=C';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-bold text-text-primary truncate max-w-[80px]">{player.clubNome}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Escalao Badge */}
|
||||||
|
<span className={`absolute bottom-2 left-2 status-badge ${
|
||||||
|
player.escalao === 'seniores' ? 'bg-brand-accent/20 text-brand-accent' : 'bg-brand-warning/20 text-brand-warning'
|
||||||
|
}`}>
|
||||||
|
{player.escalao}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Goals Stats Bubble on Card */}
|
||||||
|
<div className="absolute top-2 left-2 flex items-center gap-1 bg-brand-primary/10 border border-brand-primary/20 px-2.5 py-1 rounded-full backdrop-blur-sm z-10">
|
||||||
|
<span className="text-[10px] font-bold text-brand-primary uppercase">⚽ {player.golos || 0} Golos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 pt-0 -mt-10 relative z-10 flex flex-col items-center text-center">
|
||||||
|
<div className="w-20 h-20 bg-bg-surface rounded-full border-4 border-bg-base overflow-hidden flex items-center justify-center shadow-xl mb-3 group-hover:border-brand-primary transition-all duration-300">
|
||||||
|
{player.foto ? (
|
||||||
|
<img
|
||||||
|
src={player.foto}
|
||||||
|
alt={player.nome}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = 'https://api.dicebear.com/7.x/adventurer/svg?seed=' + player.nome;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<User className="text-text-muted" size={32} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold text-text-primary line-clamp-1 group-hover:text-brand-primary transition-colors" title={player.nome}>
|
||||||
|
{player.nome}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5 w-full border-t border-border mt-4 pt-4 text-xs text-text-secondary text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar size={14} className="text-brand-primary shrink-0" />
|
||||||
|
<span>Nascimento: <strong className="text-text-primary font-mono">{formatDate(player.data_nascimento)}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin size={14} className="text-brand-accent shrink-0" />
|
||||||
|
<span className="truncate">Naturalidade: <strong className="text-text-primary" title={player.naturalidade}>{player.naturalidade || '---'}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield size={14} className="text-brand-warning shrink-0" />
|
||||||
|
<span className="truncate">Clube: <strong className="text-text-primary">{player.clubNome}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- PLAYER STATS / GOALS DETAILS MODAL --- */}
|
||||||
|
{selectedPlayer && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-base/80 backdrop-blur-md animate-fade-in">
|
||||||
|
<div className="bg-bg-surface border border-border w-full max-w-md rounded-2xl overflow-hidden shadow-2xl flex flex-col p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Header / Close button */}
|
||||||
|
<div className="flex justify-between items-center border-b border-border pb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trophy size={20} className="text-brand-warning" />
|
||||||
|
<span className="text-sm font-bold uppercase tracking-widest text-text-secondary">Ficha do Jogador</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPlayer(null)}
|
||||||
|
className="p-1.5 rounded-lg bg-bg-base hover:bg-bg-overlay border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Summary */}
|
||||||
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
<div className="w-24 h-24 rounded-full border-4 border-brand-primary/30 bg-bg-base overflow-hidden p-0.5 shadow-xl">
|
||||||
|
{selectedPlayer.foto ? (
|
||||||
|
<img
|
||||||
|
src={selectedPlayer.foto}
|
||||||
|
alt={selectedPlayer.nome}
|
||||||
|
className="w-full h-full object-cover rounded-full"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = 'https://api.dicebear.com/7.x/adventurer/svg?seed=' + selectedPlayer.nome;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<User size={48} className="text-text-muted mt-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-text-primary line-clamp-1">{selectedPlayer.nome}</h2>
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-xs text-text-secondary mt-1">
|
||||||
|
<img
|
||||||
|
src={selectedPlayer.clubLogo}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).src = 'https://via.placeholder.com/50?text=C'; }}
|
||||||
|
/>
|
||||||
|
<span>{selectedPlayer.clubNome}</span>
|
||||||
|
<span className="text-text-muted">•</span>
|
||||||
|
<span className="capitalize">{selectedPlayer.escalao}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BIG GOALS DISPLAY STAT CARD */}
|
||||||
|
<div className="bg-gradient-to-br from-brand-primary/10 via-brand-primary/5 to-transparent p-6 rounded-2xl border border-brand-primary/20 flex flex-col items-center justify-center text-center relative overflow-hidden">
|
||||||
|
<div className="absolute top-[-20px] right-[-20px] opacity-5">
|
||||||
|
<Trophy size={120} className="text-brand-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-5xl font-display font-black text-brand-primary tracking-wide">{selectedPlayer.golos || 0}</span>
|
||||||
|
<span className="text-xs uppercase font-bold tracking-widest text-text-secondary mt-1">Golos Marcados</span>
|
||||||
|
<p className="text-[10px] text-text-muted mt-2 uppercase tracking-wide">Estatísticas atualizadas em tempo real</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Biographical Info list */}
|
||||||
|
<div className="bg-bg-base/60 rounded-xl border border-border p-4 space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-text-secondary">Data de Nascimento</span>
|
||||||
|
<span className="font-mono text-text-primary font-bold">{formatDate(selectedPlayer.data_nascimento)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center border-t border-border/40 pt-3">
|
||||||
|
<span className="text-text-secondary">Naturalidade</span>
|
||||||
|
<span className="text-text-primary font-bold">{selectedPlayer.naturalidade || '---'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center border-t border-border/40 pt-3">
|
||||||
|
<span className="text-text-secondary">Clube</span>
|
||||||
|
<span className="text-text-primary font-bold">{selectedPlayer.clubNome}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center border-t border-border/40 pt-3">
|
||||||
|
<span className="text-text-secondary">Categoria / Escalão</span>
|
||||||
|
<span className="text-text-primary font-bold capitalize">{selectedPlayer.escalao}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPlayer(null)}
|
||||||
|
className="w-full bg-brand-primary hover:bg-green-600 text-bg-base text-sm font-bold py-2.5 rounded-xl transition-all uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Fechar Detalhes
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Players;
|
||||||
257
src/pages/Scorers.tsx
Normal file
257
src/pages/Scorers.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useScorers } from '../hooks/useScorers';
|
||||||
|
import { Loader2, Trophy, Medal, User } from 'lucide-react';
|
||||||
|
import type { ScorerItem } from '../types/database';
|
||||||
|
|
||||||
|
const Scorers = () => {
|
||||||
|
const { scorers, loading, error } = useScorers();
|
||||||
|
const [selectedEscalao, setSelectedEscalao] = useState<'seniores' | 'juniores'>('seniores');
|
||||||
|
|
||||||
|
// Process and sort top 10 scorers
|
||||||
|
const topScorers: ScorerItem[] = React.useMemo(() => {
|
||||||
|
const list = scorers[selectedEscalao] || [];
|
||||||
|
// Sort descending by goals and slice top 10
|
||||||
|
return [...list]
|
||||||
|
.sort((a, b) => b.goals - a.goals)
|
||||||
|
.slice(0, 10);
|
||||||
|
}, [scorers, selectedEscalao]);
|
||||||
|
|
||||||
|
const maxGoals = topScorers.length > 0 ? topScorers[0].goals : 0;
|
||||||
|
|
||||||
|
// Split into podium (top 3) and remaining (4-10)
|
||||||
|
const podium = topScorers.slice(0, 3);
|
||||||
|
const remaining = topScorers.slice(3, 10);
|
||||||
|
|
||||||
|
// Position ordering for podium representation: 2nd, 1st, 3rd
|
||||||
|
const podiumOrdered = React.useMemo(() => {
|
||||||
|
const ordered: { player: ScorerItem | null; position: number }[] = [
|
||||||
|
{ player: null, position: 2 },
|
||||||
|
{ player: null, position: 1 },
|
||||||
|
{ player: null, position: 3 },
|
||||||
|
];
|
||||||
|
podium.forEach((p, idx) => {
|
||||||
|
const pos = idx + 1;
|
||||||
|
if (pos === 1) ordered[1] = { player: p, position: 1 };
|
||||||
|
if (pos === 2) ordered[0] = { player: p, position: 2 };
|
||||||
|
if (pos === 3) ordered[2] = { player: p, position: 3 };
|
||||||
|
});
|
||||||
|
return ordered;
|
||||||
|
}, [podium]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl">MELHORES MARCADORES</h1>
|
||||||
|
<p className="text-text-secondary mt-1">Os goleadores do campeonato que lideram a corrida à bota de ouro.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Switcher */}
|
||||||
|
<div className="flex gap-2 bg-bg-surface p-1.5 rounded-xl border border-border self-start">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEscalao('seniores')}
|
||||||
|
className={`px-5 py-2 rounded-lg text-xs font-bold uppercase transition-all ${
|
||||||
|
selectedEscalao === 'seniores'
|
||||||
|
? 'bg-brand-primary text-bg-base shadow-lg shadow-brand-primary/15'
|
||||||
|
: 'text-text-secondary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Seniores
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEscalao('juniores')}
|
||||||
|
className={`px-5 py-2 rounded-lg text-xs font-bold uppercase transition-all ${
|
||||||
|
selectedEscalao === 'juniores'
|
||||||
|
? 'bg-brand-primary text-bg-base shadow-lg shadow-brand-primary/15'
|
||||||
|
: 'text-text-secondary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Juniores
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-brand-primary">
|
||||||
|
<Loader2 className="animate-spin mb-4" size={48} />
|
||||||
|
<p className="text-text-secondary font-medium">A carregar goleadores...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="bg-brand-danger/10 border border-brand-danger/20 p-6 rounded-xl text-center">
|
||||||
|
<p className="text-brand-danger font-semibold">Erro ao carregar dados!</p>
|
||||||
|
<p className="text-text-secondary text-sm mt-1">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
) : topScorers.length === 0 ? (
|
||||||
|
<div className="text-center py-20 bg-bg-surface rounded-xl border border-dashed border-border">
|
||||||
|
<Trophy className="mx-auto mb-4 text-text-muted" size={48} />
|
||||||
|
<p className="text-text-muted">Nenhum registo de golos encontrado para este escalão.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* --- PODIUM SECTION (TOP 3) --- */}
|
||||||
|
{podium.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-end max-w-4xl mx-auto pt-6">
|
||||||
|
{podiumOrdered.map(({ player, position }) => {
|
||||||
|
if (!player) {
|
||||||
|
// Empty space filler if less than 3 players exist
|
||||||
|
return <div key={`empty-podium-${position}`} className="hidden md:block"></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirst = position === 1;
|
||||||
|
const isSecond = position === 2;
|
||||||
|
|
||||||
|
// Podium styling tokens
|
||||||
|
const podiumStyle = isFirst
|
||||||
|
? {
|
||||||
|
borderColor: 'border-brand-warning/40',
|
||||||
|
bgColor: 'bg-gradient-to-t from-brand-warning/10 to-transparent',
|
||||||
|
badgeColor: 'bg-brand-warning text-bg-base',
|
||||||
|
textGolos: 'text-brand-warning',
|
||||||
|
height: 'md:h-80',
|
||||||
|
icon: <Trophy size={24} className="text-brand-warning animate-bounce" />,
|
||||||
|
}
|
||||||
|
: isSecond
|
||||||
|
? {
|
||||||
|
borderColor: 'border-text-secondary/40',
|
||||||
|
bgColor: 'bg-gradient-to-t from-text-secondary/10 to-transparent',
|
||||||
|
badgeColor: 'bg-text-secondary text-bg-base',
|
||||||
|
textGolos: 'text-text-secondary',
|
||||||
|
height: 'md:h-72',
|
||||||
|
icon: <Medal size={22} className="text-text-secondary" />,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
borderColor: 'border-orange-500/40',
|
||||||
|
bgColor: 'bg-gradient-to-t from-orange-500/10 to-transparent',
|
||||||
|
badgeColor: 'bg-orange-500 text-bg-base',
|
||||||
|
textGolos: 'text-orange-500',
|
||||||
|
height: 'md:h-64',
|
||||||
|
icon: <Medal size={20} className="text-orange-500" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${player.playerName}-${position}`}
|
||||||
|
className={`card overflow-hidden border-2 ${podiumStyle.borderColor} ${podiumStyle.bgColor} ${podiumStyle.height} flex flex-col justify-between p-6 transition-all duration-300 hover:scale-[1.03] shadow-xl`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className={`w-8 h-8 rounded-full ${podiumStyle.badgeColor} font-display text-lg font-black flex items-center justify-center`}>
|
||||||
|
{position}º
|
||||||
|
</div>
|
||||||
|
{podiumStyle.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center text-center my-4">
|
||||||
|
{/* Photo */}
|
||||||
|
<div className={`w-20 h-20 rounded-full overflow-hidden border-4 ${
|
||||||
|
isFirst ? 'border-brand-warning' : isSecond ? 'border-text-secondary' : 'border-orange-500'
|
||||||
|
} bg-bg-base flex items-center justify-center shadow-lg relative mb-3`}>
|
||||||
|
{player.playerPhoto ? (
|
||||||
|
<img src={player.playerPhoto} alt="" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLImageElement).src = 'https://api.dicebear.com/7.x/adventurer/svg?seed=' + player.playerName; }} />
|
||||||
|
) : (
|
||||||
|
<User size={32} className="text-text-muted" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<h3 className="text-xl font-bold text-text-primary line-clamp-1" title={player.playerName}>{player.playerName}</h3>
|
||||||
|
|
||||||
|
{/* Club Name & Logo */}
|
||||||
|
<div className="flex items-center gap-1.5 mt-1 text-xs text-text-secondary">
|
||||||
|
<img
|
||||||
|
src={player.clubLogo}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).src = 'https://via.placeholder.com/50?text=C'; }}
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-[120px] font-medium" title={player.clubName}>{player.clubName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center pt-3 border-t border-border/60">
|
||||||
|
<span className={`text-4xl font-display font-black ${podiumStyle.textGolos}`}>{player.goals}</span>
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-widest text-text-muted ml-1">Golos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- LEADERBOARD LIST (PLACES 4-10) --- */}
|
||||||
|
{remaining.length > 0 && (
|
||||||
|
<div className="bg-bg-surface border border-border rounded-2xl overflow-hidden shadow-xl max-w-4xl mx-auto">
|
||||||
|
<div className="px-6 py-4 bg-bg-elevated border-b border-border">
|
||||||
|
<h3 className="text-lg text-text-primary font-bold">POSIÇÕES 4º AO 10º</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{remaining.map((player, idx) => {
|
||||||
|
const position = idx + 4;
|
||||||
|
// Percentage of goals relative to the top scorer for the progress bar
|
||||||
|
const percentage = maxGoals > 0 ? (player.goals / maxGoals) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${player.playerName}-${position}`} className="px-6 py-4 flex items-center gap-4 hover:bg-bg-elevated/40 transition-colors">
|
||||||
|
{/* Position */}
|
||||||
|
<div className="w-8 font-mono text-sm text-text-muted font-bold text-center shrink-0">
|
||||||
|
{position}º
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo */}
|
||||||
|
<div className="w-10 h-10 rounded-full overflow-hidden border border-border bg-bg-base shrink-0 flex items-center justify-center">
|
||||||
|
{player.playerPhoto ? (
|
||||||
|
<img src={player.playerPhoto} alt="" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLImageElement).src = 'https://api.dicebear.com/7.x/adventurer/svg?seed=' + player.playerName; }} />
|
||||||
|
) : (
|
||||||
|
<User size={18} className="text-text-muted" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Info */}
|
||||||
|
<div className="min-w-0 flex-1 md:grid md:grid-cols-3 md:items-center md:gap-4">
|
||||||
|
<div className="min-w-0 md:col-span-1">
|
||||||
|
<h4 className="text-sm font-bold text-text-primary truncate" title={player.playerName}>{player.playerName}</h4>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-text-secondary md:hidden">
|
||||||
|
<img src={player.clubLogo} alt="" className="w-3 h-3 object-contain shrink-0" onError={(e) => { (e.target as HTMLImageElement).src = 'https://via.placeholder.com/50?text=C'; }} />
|
||||||
|
<span className="truncate" title={player.clubName}>{player.clubName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Club Logo and Name (Hidden on small screens, shown inside grid on md+) */}
|
||||||
|
<div className="hidden md:flex items-center gap-2 md:col-span-1 min-w-0 text-xs text-text-secondary">
|
||||||
|
<img
|
||||||
|
src={player.clubLogo}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 object-contain shrink-0"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).src = 'https://via.placeholder.com/50?text=C'; }}
|
||||||
|
/>
|
||||||
|
<span className="truncate" title={player.clubName}>{player.clubName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar (goals visual representation) */}
|
||||||
|
<div className="hidden md:block md:col-span-1 pr-6">
|
||||||
|
<div className="h-2 w-full bg-bg-base border border-border rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-brand-primary rounded-full"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Goal Count */}
|
||||||
|
<div className="text-right shrink-0 min-w-[50px]">
|
||||||
|
<span className="text-2xl font-display font-black text-brand-primary">{player.goals}</span>
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-widest text-text-muted block md:inline md:ml-1 mt-[-4px] md:mt-0">golos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Scorers;
|
||||||
162
src/pages/Settings.tsx
Normal file
162
src/pages/Settings.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Database,
|
||||||
|
Bell,
|
||||||
|
Shield,
|
||||||
|
Save,
|
||||||
|
CheckCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Power
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const Settings = () => {
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Example setting states
|
||||||
|
const [scraperEnabled, setScraperEnabled] = useState(true);
|
||||||
|
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 3000);
|
||||||
|
}, 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in duration-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-display uppercase tracking-wider flex items-center gap-3">
|
||||||
|
<SettingsIcon className="text-brand-primary" />
|
||||||
|
Definições
|
||||||
|
</h1>
|
||||||
|
<p className="text-text-secondary mt-1">Configure as preferências do sistema Admin VdcScore.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex items-center gap-2 px-5 py-2.5 bg-brand-primary text-bg-base font-bold rounded-xl uppercase tracking-wider text-sm transition-all active:scale-95 shadow-lg shadow-brand-primary/20 ${loading ? 'opacity-70 cursor-wait' : 'hover:bg-opacity-90'}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<RefreshCw className="animate-spin" size={18} />
|
||||||
|
) : saved ? (
|
||||||
|
<CheckCircle size={18} />
|
||||||
|
) : (
|
||||||
|
<Save size={18} />
|
||||||
|
)}
|
||||||
|
{saved ? 'Guardado!' : 'Guardar Alterações'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column - Tabs or sections */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
|
||||||
|
{/* Scraper Status Section */}
|
||||||
|
<div className="bg-bg-surface border border-border rounded-2xl overflow-hidden">
|
||||||
|
<div className="border-b border-border p-5 bg-bg-elevated/50 flex items-center gap-3">
|
||||||
|
<Database className="text-brand-accent" size={20} />
|
||||||
|
<h3 className="font-bold tracking-wide">Gestão de Dados & API</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-text-primary">Sincronização Automática (Scraper)</h4>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">Executar o scraper a cada 30 minutos para obter atualizações automáticas.</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" checked={scraperEnabled} onChange={() => setScraperEnabled(!scraperEnabled)} className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-bg-elevated peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-text-secondary after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-primary peer-checked:after:bg-white"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-border"></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Intervalo de Pull do Firebase (ms)</label>
|
||||||
|
<input type="number" defaultValue="3000" className="w-full bg-bg-elevated border border-border rounded-xl px-4 py-2.5 text-text-primary focus:border-brand-primary outline-none transition-colors" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Chave de API do Sistema</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="password" value="••••••••••••••••••••••••" readOnly className="flex-1 bg-bg-elevated border border-border rounded-xl px-4 py-2.5 text-text-muted outline-none" />
|
||||||
|
<button className="bg-bg-overlay hover:bg-bg-elevated px-4 rounded-xl border border-border transition-colors text-sm font-medium">Gerar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Widgets */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Admin Info */}
|
||||||
|
<div className="bg-bg-surface border border-border rounded-2xl overflow-hidden p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-16 h-16 bg-bg-overlay rounded-full flex items-center justify-center text-2xl font-bold text-brand-primary border border-border">
|
||||||
|
AV
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg">Admin VdcScore</h3>
|
||||||
|
<p className="text-sm text-text-secondary">Utilizador: admvdc</p>
|
||||||
|
<span className="inline-block mt-1 px-2 py-0.5 bg-brand-primary/10 text-brand-primary text-xs font-bold rounded">ROOT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="w-full py-2.5 border border-border rounded-xl hover:bg-bg-elevated transition-colors text-sm font-medium flex items-center justify-center gap-2">
|
||||||
|
<Shield size={16} />
|
||||||
|
Alterar Palavra-passe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="bg-bg-surface border border-border rounded-2xl overflow-hidden">
|
||||||
|
<div className="p-5 flex items-center justify-between border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell size={18} className="text-brand-primary" />
|
||||||
|
<span className="font-bold">Notificações</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>Alertas de Golos (Apps)</span>
|
||||||
|
<input type="checkbox" defaultChecked className="accent-brand-primary h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>Novos comentários/denúncias</span>
|
||||||
|
<input type="checkbox" defaultChecked className="accent-brand-primary h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>Emails diários do sistema</span>
|
||||||
|
<input type="checkbox" className="accent-brand-primary h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="bg-brand-danger/5 border border-brand-danger/20 rounded-2xl overflow-hidden p-5">
|
||||||
|
<h3 className="text-brand-danger font-bold flex items-center gap-2 mb-3">
|
||||||
|
<Power size={16} />
|
||||||
|
Zona de Perigo
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-text-secondary mb-4">Estas ações podem causar interrupção de serviço.</p>
|
||||||
|
<button className="w-full py-2.5 bg-brand-danger/10 text-brand-danger border border-brand-danger/20 rounded-xl hover:bg-brand-danger hover:text-white transition-all text-sm font-bold">
|
||||||
|
LIMPAR CACHE DO SISTEMA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@@ -4,6 +4,7 @@ export interface Player {
|
|||||||
foto: string;
|
foto: string;
|
||||||
data_nascimento: string;
|
data_nascimento: string;
|
||||||
naturalidade: string;
|
naturalidade: string;
|
||||||
|
golos?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Club {
|
export interface Club {
|
||||||
@@ -53,3 +54,12 @@ export interface Match {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Escalao = 'seniores' | 'juniores';
|
export type Escalao = 'seniores' | 'juniores';
|
||||||
|
|
||||||
|
export interface ScorerItem {
|
||||||
|
playerName: string;
|
||||||
|
playerPhoto: string;
|
||||||
|
clubName: string;
|
||||||
|
clubLogo: string;
|
||||||
|
goals: number;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user