diff options
| author | Pinapelz <yukais@pinapelz.com> | 2025-06-30 00:59:25 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2025-06-30 00:59:25 -0700 |
| commit | 8559b615734760ff060ac2c714c8fca80d5ed251 (patch) | |
| tree | ae62093d8235985b63369d034d7ce939fc128610 /frontend | |
| parent | fae6914acace1a3b470f9d243fe8a2ba0f141388 (diff) | |
add score import page
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/src/App.tsx | 4 | ||||
| -rw-r--r-- | frontend/src/components/modals/JsonUploadModal.tsx | 161 | ||||
| -rw-r--r-- | frontend/src/pages/Home.tsx | 6 | ||||
| -rw-r--r-- | frontend/src/pages/Import.tsx | 266 |
4 files changed, 436 insertions, 1 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d317805..65f3355 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext'; import Landing from './pages/Landing'; import Login from './pages/Login'; import Register from './pages/Register'; +import Import from './pages/Import'; import Home from './pages/Home'; function App() { @@ -12,10 +13,11 @@ function App() { <Route path="/" element={<Landing />} /> <Route path="/login" element={<Login />} /> <Route path="/register" element={<Register />} /> + <Route path="/import" element={<Import />} /> <Route path="/home" element={<Home />} /> </Routes> </AuthProvider> ); } -export default App;
\ No newline at end of file +export default App; diff --git a/frontend/src/components/modals/JsonUploadModal.tsx b/frontend/src/components/modals/JsonUploadModal.tsx new file mode 100644 index 0000000..1cb541b --- /dev/null +++ b/frontend/src/components/modals/JsonUploadModal.tsx @@ -0,0 +1,161 @@ +import { useState, useRef } from 'react'; + +interface JsonUploadModalProps { + isOpen: boolean; + onClose: () => void; + onUpload: (data: any) => void; + game: string; +} + +const JsonUploadModal = ({ isOpen, onClose, onUpload, game }: JsonUploadModalProps) => { + const [file, setFile] = useState<File | null>(null); + const [error, setError] = useState<string>(''); + const [isLoading, setIsLoading] = useState(false); + const fileInputRef = useRef<HTMLInputElement>(null); + + if (!isOpen) return null; + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0]; + setError(''); + + if (selectedFile) { + if (!selectedFile.name.endsWith('.json')) { + setError('Please select a JSON file'); + return; + } + setFile(selectedFile); + } + }; + + const handleUpload = async () => { + if (!file) { + setError('Please select a file to upload'); + return; + } + + setIsLoading(true); + setError(''); + + try { + const text = await file.text(); + const data = JSON.parse(text); + onUpload(data); + setFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + onClose(); + } catch (err) { + if (err instanceof SyntaxError) { + setError('Invalid JSON file format'); + } else { + setError('Failed to process file. Please try again.'); + } + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setFile(null); + setError(''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + onClose(); + }; + + return ( + <div className="fixed inset-0 z-50 overflow-y-auto"> + {/* Backdrop */} + <div + className="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity" + onClick={handleClose} + /> + + {/* Modal */} + <div className="flex min-h-full items-center justify-center p-4"> + <div className="relative bg-slate-900 rounded-lg border border-slate-700 w-full max-w-md p-6 shadow-xl"> + {/* Header */} + <div className="mb-6"> + <h3 className="text-xl font-bold text-white mb-2">Import {game} Data</h3> + <p className="text-slate-400 text-sm">Upload your game data in JSON format</p> + </div> + + {/* File Upload Area */} + <div className="mb-6"> + <label + htmlFor="file-upload" + className="relative block w-full rounded-lg border-2 border-dashed border-slate-600 p-12 text-center hover:border-violet-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-violet-500 focus-within:ring-offset-2 focus-within:ring-offset-slate-900 transition-colors cursor-pointer" + > + <svg + className="mx-auto h-12 w-12 text-slate-400" + stroke="currentColor" + fill="none" + viewBox="0 0 48 48" + > + <path + d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + <span className="mt-2 block text-sm font-medium text-slate-300"> + {file ? file.name : 'Click to upload or drag and drop'} + </span> + <span className="mt-1 block text-xs text-slate-500">JSON files only</span> + <input + ref={fileInputRef} + id="file-upload" + name="file-upload" + type="file" + className="sr-only" + accept=".json" + onChange={handleFileChange} + /> + </label> + </div> + + {/* Error Message */} + {error && ( + <div className="mb-6 rounded-md bg-red-500/10 border border-red-500/20 p-3"> + <p className="text-sm text-red-400">{error}</p> + </div> + )} + + {/* Actions */} + <div className="flex space-x-3"> + <button + onClick={handleClose} + className="flex-1 border border-slate-600 hover:border-violet-500 text-slate-300 hover:text-white py-2 px-4 rounded-md font-medium transition-colors" + disabled={isLoading} + > + Cancel + </button> + <button + onClick={handleUpload} + className="flex-1 bg-violet-600 hover:bg-violet-700 text-white py-2 px-4 rounded-md font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + disabled={!file || isLoading} + > + {isLoading ? ( + <span className="flex items-center justify-center"> + <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"> + <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> + <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> + </svg> + Processing... + </span> + ) : ( + 'Upload' + )} + </button> + </div> + </div> + </div> + </div> + ); +}; + +export default JsonUploadModal; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 1d87c8a..e42a2b8 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -66,6 +66,12 @@ const Home = () => { <span className="text-white font-semibold text-lg">Mirage</span> </div> <div className="flex items-center space-x-4"> + <Link + to="/import" + className="text-slate-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors" + > + Import Data + </Link> <span className="text-slate-300 text-sm">Welcome back, {user.username}</span> <button onClick={handleLogout} diff --git a/frontend/src/pages/Import.tsx b/frontend/src/pages/Import.tsx index e69de29..877e0e4 100644 --- a/frontend/src/pages/Import.tsx +++ b/frontend/src/pages/Import.tsx @@ -0,0 +1,266 @@ +import { useState, useEffect } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { useAuth } from '../contexts/AuthContext'; +import JsonUploadModal from '../components/modals/JsonUploadModal'; + +interface SupportedGame { + internalName: string; + formattedName: string; + description: string; +} + +const Import = () => { + const { user, isLoading, logout } = useAuth(); + const navigate = useNavigate(); + const [selectedGame, setSelectedGame] = useState(''); + const [isJsonModalOpen, setIsJsonModalOpen] = useState(false); + const [supportedGames, setSupportedGames] = useState<SupportedGame[]>([]); + const [gamesLoading, setGamesLoading] = useState(true); + const [uploadStatus, setUploadStatus] = useState<{ + type: 'success' | 'error' | null; + message: string; + }>({ type: null, message: '' }); + + useEffect(() => { + const fetchSupportedGames = async () => { + try { + const response = await fetch(import.meta.env.VITE_API_URL+'/supportedGames'); + if (!response.ok) { + throw new Error('Failed to fetch supported games'); + } + const data = await response.json(); + setSupportedGames(data); + } catch (error) { + console.error('Failed to fetch supported games:', error); + setUploadStatus({ + type: 'error', + message: 'Failed to load supported games. Please refresh the page.' + }); + } finally { + setGamesLoading(false); + } + }; + + fetchSupportedGames(); + }, []); + + const handleLogout = async () => { + try { + await logout(); + navigate('/'); + } catch (error) { + console.error('Logout failed:', error); + alert('Network error during logout. Please try again.'); + } + }; + + const handleJsonUpload = async (data: any) => { + try { + console.log('Uploading data for game:', selectedGame, data); + const response = await fetch(`${import.meta.env.VITE_API_URL}/uploadScore`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + meta: { + game: data.meta.game, + service: data.meta.service, + playtype: data.meta.playtype + }, + scores: data.scores + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to upload scores'); + } + + const result = await response.json(); + + setUploadStatus({ + type: 'success', + message: `Successfully imported ${result.scoreCount} score(s) for ${supportedGames.find(g => g.internalName === data.meta.game)?.formattedName || data.meta.game}` + }); + + setTimeout(() => { + setUploadStatus({ type: null, message: '' }); + }, 5000); + } catch (error) { + console.error('Upload failed:', error); + setUploadStatus({ + type: 'error', + message: error instanceof Error ? error.message : 'Failed to import data. Please try again.' + }); + } + }; + + if (isLoading) { + return ( + <div className="min-h-screen bg-slate-950 flex items-center justify-center"> + <div className="text-center"> + <div className="w-8 h-8 border-2 border-violet-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div> + <p className="text-slate-300">Loading import page...</p> + </div> + </div> + ); + } + + if (!user) { + return ( + <div className="min-h-screen bg-slate-950 flex items-center justify-center"> + <div className="text-center max-w-md mx-auto px-6"> + <div className="bg-slate-900 rounded-lg p-8 border border-slate-700"> + <h2 className="text-2xl font-bold text-white mb-4">Session Expired</h2> + <p className="text-slate-300 mb-6">Please sign in to import your data.</p> + <div className="space-y-3"> + <Link + to="/login" + className="block w-full bg-violet-600 hover:bg-violet-700 text-white py-3 rounded-md font-medium transition-colors" + > + Sign In + </Link> + <Link + to="/" + className="block w-full border border-slate-600 hover:border-violet-500 text-slate-300 hover:text-white py-3 rounded-md font-medium transition-colors" + > + Back to Home + </Link> + </div> + </div> + </div> + </div> + ); + } + + return ( + <div className="min-h-screen bg-slate-950"> + {/* Navigation */} + <nav className="border-b border-slate-800 bg-slate-950/95 backdrop-blur-sm sticky top-0 z-50"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="flex items-center justify-between h-16"> + <div className="flex items-center space-x-3"> + <Link to="/home" className="flex items-center space-x-3 group"> + <div className="w-8 h-8 bg-violet-600 rounded-md flex items-center justify-center group-hover:bg-violet-700 transition-colors"> + <span className="text-white font-bold text-sm">M</span> + </div> + <span className="text-white font-semibold text-lg">Mirage</span> + </Link> + </div> + <div className="flex items-center space-x-4"> + <Link + to="/home" + className="text-slate-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors" + > + Dashboard + </Link> + <span className="text-slate-300 text-sm">Welcome back, {user.username}</span> + <button + onClick={handleLogout} + className="text-slate-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors" + > + Sign Out + </button> + </div> + </div> + </div> + </nav> + + {/* Main Content */} + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> + {/* Header */} + <div className="mb-8"> + <h1 className="text-3xl font-bold text-white mb-2">Import Data</h1> + <p className="text-slate-400">Import your game scores and progress from various sources</p> + </div> + + {/* Status Message */} + {uploadStatus.type && ( + <div className={`mb-6 rounded-md p-4 ${ + uploadStatus.type === 'success' + ? 'bg-green-500/10 border border-green-500/20' + : 'bg-red-500/10 border border-red-500/20' + }`}> + <p className={`text-sm ${ + uploadStatus.type === 'success' ? 'text-green-400' : 'text-red-400' + }`}> + {uploadStatus.message} + </p> + </div> + )} + + {/* Game Selection Card */} + <div className="bg-slate-900 rounded-lg border border-slate-700 p-8"> + <div className="mb-8"> + <h2 className="text-xl font-bold text-white mb-4">Select Game</h2> + <p className="text-slate-400 text-sm mb-6"> + Choose the game you want to import data for + </p> + + {gamesLoading ? ( + <div className="w-full md:w-96 bg-slate-800 border border-slate-600 rounded-md px-4 py-3"> + <div className="flex items-center"> + <div className="w-4 h-4 border-2 border-violet-500 border-t-transparent rounded-full animate-spin mr-3"></div> + <span className="text-slate-400">Loading games...</span> + </div> + </div> + ) : ( + <select + value={selectedGame} + onChange={(e) => setSelectedGame(e.target.value)} + className="w-full md:w-96 bg-slate-800 border border-slate-600 text-white rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-colors" + > + <option value="">Select a game</option> + {supportedGames.map((game) => ( + <option key={game.internalName} value={game.internalName} title={game.description}> + {game.formattedName} + </option> + ))} + </select> + )} + </div> + + {/* Import Options */} + {selectedGame && ( + <div className="space-y-6 mt-8"> + <h3 className="text-lg font-semibold text-white">Import Options</h3> + + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {/* JSON Upload Card */} + <div className="bg-slate-800 rounded-lg border border-slate-700 p-6 hover:border-violet-500 transition-colors"> + <div className="w-12 h-12 bg-violet-600/20 rounded-lg flex items-center justify-center mb-4"> + <svg className="w-6 h-6 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> + </svg> + </div> + <h4 className="text-white font-semibold mb-2">Batch-Manual Upload</h4> + <p className="text-slate-400 text-sm mb-4"> + Upload your game data from a Mirage compatible JSON file + </p> + <button + onClick={() => setIsJsonModalOpen(true)} + className="w-full bg-violet-600 hover:bg-violet-700 text-white py-2 px-4 rounded-md font-medium transition-colors" + > + Upload JSON + </button> + </div> + </div> + </div> + )} + </div> + </div> + + {/* JSON Upload Modal */} + <JsonUploadModal + isOpen={isJsonModalOpen} + onClose={() => setIsJsonModalOpen(false)} + onUpload={handleJsonUpload} + game={supportedGames.find(g => g.internalName === selectedGame)?.formattedName || ''} + /> + </div> + ); +}; + +export default Import; |
