Spaces:
Running
Running
| 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> | |
| ); | |
| } | |