Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
VITE_FIREBASE_API_KEY="AIzaSyDOi4Xf4N_Qx3lug78PBUfu5UO9JgubRfY"
VITE_FIREBASE_AUTH_DOMAIN="supersonic-scholars.firebaseapp.com"
VITE_FIREBASE_PROJECT_ID="supersonic-scholars"
VITE_FIREBASE_STORAGE_BUCKET="supersonic-scholars.firebasestorage.app"
VITE_FIREBASE_MESSAGING_SENDER_ID="742656923274"
VITE_FIREBASE_APP_ID="1:742656923274:web:2f2eeb963e04f85bd77f73"
23 changes: 23 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
35 changes: 35 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import { HashRouter as Router, Routes, Route, useLocation } from 'react-router-dom';
import { Layout } from './components/Layout';
import { Home } from './pages/Home';
import { TopicPage } from './pages/TopicPage';

// Scroll to top on route change wrapper
const ScrollToTop = () => {
const { pathname } = useLocation();
React.useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
};

const App: React.FC = () => {
return (
<Router>
<ScrollToTop />
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/fundamentals" element={<TopicPage pageId="fundamentals" />} />
<Route path="/stats" element={<TopicPage pageId="stats" />} />
<Route path="/cars" element={<TopicPage pageId="cars" />} />
<Route path="/competitive" element={<TopicPage pageId="competitive" />} />
<Route path="/modes" element={<TopicPage pageId="modes" />} />
<Route path="/mechanics" element={<TopicPage pageId="mechanics" />} />
</Routes>
</Layout>
</Router>
);
};

export default App;
138 changes: 138 additions & 0 deletions components/CommentsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useState, useEffect } from 'react';
import { Comment } from '../types';
import { getComments, postComment, getUserName } from '../services/db';
import { IdentityModal } from './IdentityModal';
import { MessageSquare, Send, User } from 'lucide-react';

interface Props {
pageId: string;
}

export const CommentsSection: React.FC<Props> = ({ pageId }) => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
let active = true;
(async () => {
setLoading(true);
setError(null);
try {
const data = await getComments(pageId);
if (active) setComments(data);
} catch (e: any) {
if (active) setError(e.message || 'Failed to load comments');
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, [pageId]);

const loadComments = async () => {
try {
const data = await getComments(pageId);
setComments(data);
} catch (e: any) {
setError(e.message || 'Failed to refresh comments');
}
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim()) return;

const user = getUserName();
if (!user) {
setShowIdentityModal(true);
return;
}

setIsSubmitting(true);
try {
setError(null);
await postComment(pageId, newComment);
setNewComment('');
await loadComments();
} catch (e: any) {
setError(e.message || 'Failed to post comment');
} finally {
setIsSubmitting(false);
}
};

return (
<div className="mt-16 bg-slate-900/50 border border-slate-700 rounded-2xl p-6 sm:p-8">
<IdentityModal
isOpen={showIdentityModal}
onComplete={() => {
setShowIdentityModal(false);
// Auto re-submit not needed here, user can click send again, simpler UX flow
}}
onCancel={() => setShowIdentityModal(false)}
/>

<div className="flex items-center gap-3 mb-8">
<div className="p-2 bg-blue-600 rounded-lg">
<MessageSquare className="text-white" size={24} />
</div>
<h3 className="text-2xl font-bold text-white brand-font">Class Discussion</h3>
</div>

{/* Input */}
<form onSubmit={handleSubmit} className="mb-8 flex gap-4">
<div className="flex-grow">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Ask a question or share a tip..."
className="w-full bg-slate-800 border border-slate-600 rounded-xl px-4 py-3 text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all"
/>
</div>
<button
type="submit"
disabled={!newComment.trim() || isSubmitting}
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-6 py-3 rounded-xl font-bold flex items-center gap-2 transition-colors"
>
{isSubmitting ? 'Posting...' : <>Post <Send size={18} /></>}
</button>
</form>

{/* List / States */}
<div className="space-y-4">
{loading && (
<div className="animate-pulse h-24 bg-slate-800 rounded-xl" />
)}
{!loading && error && (
<div className="p-4 rounded-xl bg-red-900/30 border border-red-700 text-red-300 text-sm">
{error}
</div>
)}
{!loading && !error && comments.length === 0 && (
<p className="text-slate-500 text-center py-8 italic">No comments yet. Be the first to start the discussion!</p>
)}
{!loading && !error && comments.length > 0 && comments.map((comment) => (
<div key={comment.id} className="flex gap-4 p-4 bg-slate-800 rounded-xl border border-slate-700/50">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center border border-slate-600">
<User size={20} className="text-slate-400" />
</div>
</div>
<div>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-bold text-blue-400">{comment.userName}</span>
<span className="text-xs text-slate-500">{new Date(comment.timestamp).toLocaleDateString()}</span>
</div>
<p className="text-slate-300 leading-relaxed">{comment.text}</p>
</div>
</div>
))}
</div>
</div>
);
};
75 changes: 75 additions & 0 deletions components/IdentityModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { UserCircle } from 'lucide-react';
import { setUserName as saveToStorage } from '../services/db';

interface Props {
isOpen: boolean;
onComplete: () => void;
onCancel: () => void;
}

export const IdentityModal: React.FC<Props> = ({ isOpen, onComplete, onCancel }) => {
const [name, setName] = useState('');
const [error, setError] = useState('');

if (!isOpen) return null;

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Please enter a name to participate.');
return;
}
saveToStorage(name.trim());
onComplete();
};

return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-xl max-w-md w-full p-6 shadow-2xl shadow-blue-900/20 animate-in zoom-in-95 duration-200">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-blue-600/20 rounded-full">
<UserCircle size={32} className="text-blue-400" />
</div>
<div>
<h2 className="text-2xl font-bold text-white brand-font">Identify Yourself</h2>
<p className="text-slate-400 text-sm">Join the class to vote and comment.</p>
</div>
</div>

<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">
Your Name (Visible to others)
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all placeholder-slate-500"
placeholder="e.g. TurboGarrett"
autoFocus
/>
{error && <p className="text-red-400 text-sm mt-2">{error}</p>}
</div>

<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2 rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors font-medium"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-600/25 transition-all font-bold"
>
Start Learning
</button>
</div>
</form>
</div>
</div>
);
};
105 changes: 105 additions & 0 deletions components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Menu, X, Rocket, Trophy, Car, BookOpen, Activity, PlayCircle } from 'lucide-react';

const NavItem: React.FC<{ to: string; label: string; icon: React.ReactNode; onClick?: () => void }> = ({ to, label, icon, onClick }) => {
const location = useLocation();
const isActive = location.pathname === to;
return (
<Link
to={to}
onClick={onClick}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 ${
isActive
? 'bg-blue-600 text-white shadow-lg shadow-blue-500/50'
: 'text-slate-300 hover:text-white hover:bg-slate-800'
}`}
>
{icon}
<span className="font-semibold tracking-wide">{label}</span>
</Link>
);
};

export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);

const navLinks = [
{ to: '/', label: 'Home', icon: <Rocket size={20} /> },
{ to: '/fundamentals', label: 'Basics', icon: <BookOpen size={20} /> },
{ to: '/cars', label: 'Cars', icon: <Car size={20} /> },
{ to: '/stats', label: 'Stats', icon: <Activity size={20} /> },
{ to: '/competitive', label: 'Ranked', icon: <Trophy size={20} /> },
{ to: '/mechanics', label: 'Mechanics', icon: <PlayCircle size={20} /> },
];

return (
<div className="min-h-screen flex flex-col bg-slate-900 text-slate-100">
{/* Navigation */}
<nav className="sticky top-0 z-50 bg-slate-900/95 backdrop-blur-md border-b border-slate-700 shadow-xl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 group">
<div className="bg-orange-500 p-2 rounded-lg shadow-lg shadow-orange-500/20 group-hover:scale-105 transition-transform">
<Rocket className="text-white" size={24} />
</div>
<span className="text-xl font-bold brand-font tracking-wider bg-clip-text text-transparent bg-gradient-to-r from-white to-slate-400">
SS<span className="text-blue-500">SCHOLARS</span>
</span>
</Link>

{/* Desktop Nav */}
<div className="hidden md:flex items-center gap-2">
{navLinks.map((link) => (
<NavItem key={link.to} {...link} />
))}
</div>

{/* Mobile Menu Button */}
<div className="md:hidden">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="p-2 text-slate-300 hover:text-white"
>
{isMobileMenuOpen ? <X size={28} /> : <Menu size={28} />}
</button>
</div>
</div>
</div>

{/* Mobile Dropdown */}
{isMobileMenuOpen && (
<div className="md:hidden bg-slate-800 border-b border-slate-700 animate-in slide-in-from-top-2">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
{navLinks.map((link) => (
<NavItem
key={link.to}
{...link}
onClick={() => setIsMobileMenuOpen(false)}
/>
))}
</div>
</div>
)}
</nav>

{/* Main Content */}
<main className="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>

{/* Footer */}
<footer className="bg-slate-950 border-t border-slate-800 py-8 mt-12">
<div className="max-w-7xl mx-auto px-4 text-center">
<p className="text-slate-500 font-medium">
&copy; {new Date().getFullYear()} Supersonic Scholars. Built for Hack4Impact IdeaCon.
</p>
<p className="text-slate-600 text-sm mt-2">
Not affiliated with Psyonix or Epic Games. Educational use only.
</p>
</div>
</footer>
</div>
);
};
Loading