adicao de sistema de login para admins e menu de definições

This commit is contained in:
2026-05-12 16:57:41 +01:00
parent 732e7276b7
commit f5d22bf83e
12 changed files with 1443 additions and 50 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VdcScore Live Editor</title>
<title>Admin VdcScore</title>
</head>
<body>
<div id="root"></div>

View File

@@ -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 Dashboard from './pages/Dashboard';
import Clubs from './pages/Clubs';
@@ -6,19 +7,39 @@ import Standings from './pages/Standings';
import Games from './pages/Games';
import LiveEditor from './pages/LiveEditor';
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 }) => (
<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>
</div>
);
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
{/* Wrapper containing layout for all subroutes */}
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="games" element={<Games />} />
@@ -26,13 +47,18 @@ function App() {
<Route path="games/live-editor/:escalao/:jornada/:matchId" element={<LiveEditor />} />
<Route path="standings" element={<Standings />} />
<Route path="clubs" element={<Clubs />} />
<Route path="players" element={<Placeholder title="Jogadores" />} />
<Route path="scorers" element={<Placeholder title="Artilheiros" />} />
<Route path="players" element={<Players />} />
<Route path="scorers" element={<Scorers />} />
<Route path="news" element={<Placeholder title="Notícias" />} />
<Route path="settings" element={<Placeholder title="Definições" />} />
<Route path="settings" element={<Settings />} />
</Route>
</Route>
{/* Catch all redirecting to home which is protected */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
</AuthProvider>
);
}

View File

@@ -4,9 +4,11 @@ import { MapPin, Trophy } from 'lucide-react';
interface ClubCardProps {
club: Club;
onDetailsClick: () => void;
onEditClick: () => void;
}
const ClubCard: React.FC<ClubCardProps> = ({ club }) => {
const ClubCard: React.FC<ClubCardProps> = ({ club, onDetailsClick, onEditClick }) => {
return (
<div className="card group">
<div className="h-24 bg-bg-elevated relative overflow-hidden">
@@ -52,10 +54,16 @@ const ClubCard: React.FC<ClubCardProps> = ({ club }) => {
</div>
<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
</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
</button>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../../lib/AuthContext';
import {
LayoutDashboard,
Trophy,
@@ -13,6 +14,15 @@ import {
} from 'lucide-react';
const Sidebar = () => {
const { logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
const menuItems = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/' },
{ icon: Zap, label: 'Jogos Live', path: '/games/live' },
@@ -20,47 +30,60 @@ const Sidebar = () => {
{ icon: Trophy, label: 'Classificação', path: '/standings' },
{ icon: Users, label: 'Clubes', path: '/clubs' },
{ icon: User, label: 'Jogadores', path: '/players' },
{ icon: BarChart, label: 'Artilheiros', path: '/scorers' },
{ icon: BarChart, label: 'Melhores Marcadores', path: '/scorers' },
];
return (
<aside className="w-60 bg-bg-surface border-r border-border flex flex-col h-screen sticky top-0">
<div className="p-6 flex items-center gap-3 border-b border-border">
<div className="bg-brand-primary p-2 rounded-lg">
<Zap className="text-bg-base fill-bg-base" size={24} />
<aside className="w-60 bg-bg-surface border-r border-border flex flex-col h-screen sticky top-0 z-20">
<div className="p-5 flex items-center gap-3 border-b border-border h-20">
<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={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>
<span className="font-display text-2xl tracking-wider"> ADM</span>
</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) => (
<NavLink
key={item.path}
to={item.path}
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
? 'bg-brand-primary/10 text-brand-primary border-l-4 border-brand-primary rounded-l-none'
: 'text-text-secondary hover:bg-bg-overlay hover:text-text-primary'}
? 'bg-brand-primary/10 text-brand-primary'
: 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'}
`}
>
<item.icon size={20} className="shrink-0" />
<span className="font-medium text-sm">{item.label}</span>
{({ isActive }) => (
<>
<item.icon size={18} className={`shrink-0 ${isActive ? 'animate-pulse' : ''}`} />
<span className="font-medium text-sm tracking-wide">{item.label}</span>
</>
)}
</NavLink>
))}
</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
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} />
<span className="font-medium text-sm">Definições</span>
<Settings size={18} />
<span>Definições</span>
</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">
<LogOut size={20} />
<span className="font-medium text-sm">Sair</span>
<button
onClick={handleLogout}
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>
</div>
</aside>

45
src/hooks/useScorers.ts Normal file
View 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
View 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;
};

View File

@@ -1,16 +1,99 @@
import React from 'react';
import React, { useState } from 'react';
import { useClubs } from '../hooks/useClubs';
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, 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 =>
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 (
<div className="space-y-6">
<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>
</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} />
<span>Novo Clube</span>
</button>
@@ -31,13 +114,13 @@ const Clubs = () => {
<input
type="text"
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}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<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>
@@ -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">
{filteredClubs.map((club) => (
<ClubCard key={club.id} club={club} />
<ClubCard
key={club.id}
club={club}
onDetailsClick={() => {
setActiveSquadTab('seniores');
setActiveDetailsClub(club);
}}
onEditClick={() => handleOpenEdit(club)}
/>
))}
</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>
);
};

124
src/pages/Login.tsx Normal file
View 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">
&copy; {new Date().getFullYear()} VdcScore Live Direitos Reservados
</p>
</div>
</div>
);
};
export default Login;

354
src/pages/Players.tsx Normal file
View 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
View 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
View 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;

View File

@@ -4,6 +4,7 @@ export interface Player {
foto: string;
data_nascimento: string;
naturalidade: string;
golos?: number;
}
export interface Club {
@@ -53,3 +54,12 @@ export interface Match {
}
export type Escalao = 'seniores' | 'juniores';
export interface ScorerItem {
playerName: string;
playerPhoto: string;
clubName: string;
clubLogo: string;
goals: number;
position: number;
}