fpl-solver / frontend /src /App.jsx
AnayShukla's picture
updates
1648544
import React, { useState, useContext, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Activity,
BarChart2,
Shield,
Calendar,
Zap,
LogIn,
Settings,
X,
} from "lucide-react";
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/react";
import { LandingPage } from "./components/LandingPage";
import ProjectionsTable from "./components/ProjectionsTable";
import AccuracyDashboard from "./components/AccuracyDashboard";
import TeamRatings from "./components/TeamRatings";
import Fixtures from "./components/Fixtures";
import Solver from "./components/Solver";
import LoginModal from "./components/LoginModal";
import { PlayerProvider, PlayerContext } from "./PlayerContext";
const tabs = [
{ id: "solver", label: "Solver", icon: Zap },
{ id: "projections", label: "Projections", icon: Activity },
{ id: "accuracy", label: "Accuracy", icon: BarChart2 },
{ id: "ratings", label: "Team Ratings", icon: Shield },
{ id: "fixtures", label: "Fixtures", icon: Calendar },
];
function AppContent() {
const [activeTab, setActiveTab] = useState(() => {
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
return params.get("tab") || tabs[0].id;
}
return tabs[0].id;
});
useEffect(() => {
if (typeof window !== 'undefined') {
const url = new URL(window.location);
url.searchParams.set("tab", activeTab);
window.history.replaceState({}, "", url);
}
}, [activeTab]);
const [showLoginModal, setShowLoginModal] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const {
isLoggedIn,
setIsLoggedIn,
userProfile,
setUserProfile,
hasGuestMadeEdits,
setHasGuestMadeEdits,
isCheckingAuth, // <-- Pull in the new state
isLoadingDB,
} = useContext(PlayerContext);
const [newDefaultId, setNewDefaultId] = useState("");
const [isSaved, setIsSaved] = useState(false);
// Sync local input with context profile
useEffect(() => {
if (userProfile?.defaultTeamId) {
setNewDefaultId(userProfile.defaultTeamId);
}
}, [userProfile]);
const handleUpdateDefaultId = () => {
const parsedId = parseInt(newDefaultId);
if (!parsedId) return;
const token = localStorage.getItem('fpl_token');
if (token) {
fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ default_team_id: parsedId })
}).then(() => {
setUserProfile(prev => ({ ...prev, defaultTeamId: parsedId }));
setIsSaved(true);
setTimeout(() => setIsSaved(false), 2000);
});
}
};
// --- THE NEW LOADING INTERCEPTOR ---
// If we are actively checking the token, show a sleek loading screen instead of the landing page
if (isCheckingAuth || isLoadingDB) {
return (
<div className="min-h-screen bg-slate-950 flex flex-col items-center justify-center">
<div className="w-12 h-12 border-4 border-slate-800 border-t-luigi-500 rounded-full animate-spin shadow-[0_0_15px_rgba(16,185,129,0.5)]"></div>
<p className="mt-4 text-luigi-400 font-bold tracking-widest uppercase text-xs animate-pulse">Entering Mansion...</p>
{isLoadingDB && <p className="text-slate-500 text-[10px] mt-2 tracking-wider font-mono">Loading database...</p>}
{isCheckingAuth && !isLoadingDB && <p className="text-slate-500 text-[10px] mt-2 tracking-wider font-mono">Verifying access...</p>}
</div>
);
}
if (!isLoggedIn) {
return <LandingPage />;
}
const handleLogout = () => {
localStorage.removeItem("fpl_token");
setIsLoggedIn(false);
setUserProfile({ username: "Guest", defaultTeamId: null, isAdmin: false });
setShowSettings(false);
setActiveTab("solver");
};
return (
<div className="min-h-screen bg-slate-950 text-slate-200 font-sans selection:bg-luigi-500/30">
<a href="#main-content" className="skip-to-content">Skip to main content</a>
<header className="border-b border-slate-800 bg-slate-950/80 backdrop-blur-md sticky top-0 z-sticky shadow-sm">
<div className="max-w-[1600px] w-full mx-auto px-4 sm:px-6 lg:px-8 py-3 flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div className="flex items-center justify-between md:contents">
{/* THE RESTORED TITLE */}
<div
onClick={() => setActiveTab("solver")}
className="flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity"
>
<img
src="/l-logo.png"
alt="Luigi's Mansion Logo"
className="w-8 h-8 object-contain drop-shadow-[0_0_12px_rgba(16,185,129,0.5)]"
/>
<h1 className="text-2xl font-black text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-cyan-500 tracking-tight whitespace-nowrap">
Luigi's Mansion
</h1>
</div>
<div className="relative md:hidden">
{isLoggedIn ? (
<div className="flex items-center gap-3 relative">
{/* Desktop: username left of button. Mobile: hidden here, shown below button */}
<div className="hidden md:flex flex-col text-right">
<div className="flex items-center gap-1.5 justify-end">
{userProfile.isAdmin && (
<Shield size={14} className="text-yellow-500" title="Admin Mode" />
)}
<span className="text-sm font-bold text-slate-200">
{userProfile.username}
</span>
</div>
</div>
<div className="flex flex-col items-center gap-1">
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2 bg-slate-900 border border-slate-700 rounded-full hover:bg-slate-800 hover:border-luigi-500 hover:text-luigi-400 transition-colors shadow-sm"
>
<Settings size={18} />
</button>
{/* Mobile only: username under the button */}
<div className="flex md:hidden items-center gap-1">
{userProfile.isAdmin && (
<Shield size={10} className="text-yellow-500" />
)}
<span className="text-[10px] font-bold text-slate-400 whitespace-nowrap">
{userProfile.username}
</span>
</div>
</div>
{showSettings && (
<div className="absolute top-full right-0 mt-2 w-64 sm:w-72 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-3 sm:p-4 z-dropdown animate-in fade-in slide-in-from-top-2 flex flex-col gap-3 sm:gap-4">
{/* Default ID Setting */}
<div>
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-wider mb-2">
Default FPL ID
</h4>
<div className="flex items-center gap-2">
<input
type="number"
value={newDefaultId}
onChange={(e) => setNewDefaultId(e.target.value)}
placeholder="e.g. 123456"
className="bg-slate-950 border border-slate-700 rounded py-1.5 px-3 text-xs font-bold text-slate-200 outline-none focus:border-luigi-500 flex-1 shadow-inner"
/>
<button
onClick={handleUpdateDefaultId}
className={`px-3 py-1.5 rounded text-xs font-bold transition-all shadow-md ${isSaved
? 'bg-luigi-500 text-slate-950'
: 'bg-slate-800 hover:bg-slate-700 border border-slate-600 text-white active:scale-95'
}`}
>
{isSaved ? 'Saved ✓' : 'Save'}
</button>
</div>
</div>
<div className="h-px w-full bg-slate-800"></div>
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 text-sm font-bold text-red-400 hover:bg-slate-800 hover:text-red-300 rounded-lg transition-colors flex items-center justify-between"
>
<span>Log Out</span>
<LogIn size={14} className="rotate-180 opacity-50" />
</button>
</div>
)}
</div>
) : (
<div className="relative">
<button
onClick={() => setShowLoginModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 border border-slate-700 hover:border-luigi-500 rounded-lg text-sm font-bold text-slate-300 hover:text-luigi-400 transition-colors shadow-sm"
>
<LogIn size={16} /> Log In
</button>
{hasGuestMadeEdits && (
<div className="absolute top-full right-0 mt-3 w-64 bg-slate-900 border border-luigi-500/50 shadow-[0_0_20px_rgba(16,185,129,0.15)] rounded-xl p-4 animate-in fade-in slide-in-from-top-4 z-50">
<button
onClick={() => setHasGuestMadeEdits(false)}
className="absolute top-2 right-2 text-slate-500 hover:text-slate-300 transition-colors"
>
<X size={14} />
</button>
<div className="flex items-start gap-3">
<div className="text-xl">💡</div>
<p className="text-xs font-medium text-slate-300 leading-tight">
You've made custom adjustments!{" "}
<span
className="text-luigi-400 font-bold cursor-pointer hover:underline"
onClick={() => setShowLoginModal(true)}
>
Log in
</span>{" "}
to save your session.
</p>
</div>
<div className="absolute -top-2 right-6 w-4 h-4 bg-slate-900 border-t border-l border-luigi-500/50 transform rotate-45"></div>
</div>
)}
</div>
)}
</div>
</div> {/* closes Row 1 */}
{/* Row 2 — nav tabs */}
<nav className="flex space-x-1 overflow-x-auto pb-1 md:pb-0 hide-scrollbar order-last md:order-none md:flex-1 md:justify-center" role="navigation" aria-label="Main navigation">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
aria-label={`Switch to ${tab.label} tab`}
aria-current={activeTab === tab.id ? "page" : undefined}
className={`flex items-center px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg text-xs sm:text-sm font-semibold whitespace-nowrap transition-all duration-200 ease-in-out touch-feedback ${activeTab === tab.id ? "bg-luigi-500/10 text-luigi-400 shadow-[inset_0_-2px_0_rgba(16,185,129,1)]" : "text-slate-400 hover:text-slate-200 hover:bg-slate-800/50"}`}
>
<Icon className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-1.5 sm:mr-2" aria-hidden="true" />
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label === "Solver" ? "Solve" : tab.label === "Projections" ? "Proj" : tab.label === "Accuracy" ? "Acc" : tab.label === "Team Ratings" ? "Ratings" : tab.label}</span>
</button>
);
})}
</nav>
<div className="relative hidden md:flex">
{isLoggedIn ? (
<div className="flex items-center gap-3 relative">
{/* Desktop: username left of button. Mobile: hidden here, shown below button */}
<div className="hidden md:flex flex-col text-right">
<div className="flex items-center gap-1.5 justify-end">
{userProfile.isAdmin && (
<Shield size={14} className="text-yellow-500" title="Admin Mode" />
)}
<span className="text-sm font-bold text-slate-200">
{userProfile.username}
</span>
</div>
</div>
<div className="flex flex-col items-center gap-1">
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2 bg-slate-900 border border-slate-700 rounded-full hover:bg-slate-800 hover:border-luigi-500 hover:text-luigi-400 transition-colors shadow-sm"
>
<Settings size={18} />
</button>
{/* Mobile only: username under the button */}
<div className="flex md:hidden items-center gap-1">
{userProfile.isAdmin && (
<Shield size={10} className="text-yellow-500" />
)}
<span className="text-[10px] font-bold text-slate-400 whitespace-nowrap">
{userProfile.username}
</span>
</div>
</div>
{showSettings && (
<div className="absolute top-full right-0 mt-2 w-64 sm:w-72 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-3 sm:p-4 z-dropdown animate-in fade-in slide-in-from-top-2 flex flex-col gap-3 sm:gap-4">
{/* Default ID Setting */}
<div>
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-wider mb-2">
Default FPL ID
</h4>
<div className="flex items-center gap-2">
<input
type="number"
value={newDefaultId}
onChange={(e) => setNewDefaultId(e.target.value)}
placeholder="e.g. 123456"
className="bg-slate-950 border border-slate-700 rounded py-1.5 px-3 text-xs font-bold text-slate-200 outline-none focus:border-luigi-500 flex-1 shadow-inner"
/>
<button
onClick={handleUpdateDefaultId}
className={`px-3 py-1.5 rounded text-xs font-bold transition-all shadow-md ${isSaved
? 'bg-luigi-500 text-slate-950'
: 'bg-slate-800 hover:bg-slate-700 border border-slate-600 text-white active:scale-95'
}`}
>
{isSaved ? 'Saved ✓' : 'Save'}
</button>
</div>
</div>
<div className="h-px w-full bg-slate-800"></div>
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 text-sm font-bold text-red-400 hover:bg-slate-800 hover:text-red-300 rounded-lg transition-colors flex items-center justify-between"
>
<span>Log Out</span>
<LogIn size={14} className="rotate-180 opacity-50" />
</button>
</div>
)}
</div>
) : (
<div className="relative">
<button
onClick={() => setShowLoginModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 border border-slate-700 hover:border-luigi-500 rounded-lg text-sm font-bold text-slate-300 hover:text-luigi-400 transition-colors shadow-sm"
>
<LogIn size={16} /> Log In
</button>
{hasGuestMadeEdits && (
<div className="absolute top-full right-0 mt-3 w-64 bg-slate-900 border border-luigi-500/50 shadow-[0_0_20px_rgba(16,185,129,0.15)] rounded-xl p-4 animate-in fade-in slide-in-from-top-4 z-50">
<button
onClick={() => setHasGuestMadeEdits(false)}
className="absolute top-2 right-2 text-slate-500 hover:text-slate-300 transition-colors"
>
<X size={14} />
</button>
<div className="flex items-start gap-3">
<div className="text-xl">💡</div>
<p className="text-xs font-medium text-slate-300 leading-tight">
You've made custom adjustments!{" "}
<span
className="text-luigi-400 font-bold cursor-pointer hover:underline"
onClick={() => setShowLoginModal(true)}
>
Log in
</span>{" "}
to save your session.
</p>
</div>
<div className="absolute -top-2 right-6 w-4 h-4 bg-slate-900 border-t border-l border-luigi-500/50 transform rotate-45"></div>
</div>
)}
</div>
)}
</div>
</div> {/* closes outer flex-col */}
</header>
<main id="main-content" className="max-w-[1600px] w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Solver is always mounted so local state (snapshot, pairs, highlights) survives tab switches */}
<div
className={activeTab !== "solver" ? "hidden" : ""}
aria-hidden={activeTab !== "solver"}
>
<Solver />
</div>
{/* Every other tab is animated and only rendered when active */}
{activeTab !== "solver" && (
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{activeTab === "projections" && <ProjectionsTable />}
{activeTab === "accuracy" && <AccuracyDashboard />}
{activeTab === "ratings" && <TeamRatings />}
{activeTab === "fixtures" && <Fixtures />}
</motion.div>
</AnimatePresence>
)}
</main>
<LoginModal
isOpen={showLoginModal}
onClose={() => setShowLoginModal(false)}
/>
</div>
);
}
export default function App() {
return (
<PlayerProvider>
<AppContent />
<Analytics />
<SpeedInsights />
</PlayerProvider>
);
}