Set up Tailwind CSS v4 + shadcn/ui + TypeScript foundation
Install Tailwind v4 with @tailwindcss/vite plugin, TypeScript, and shadcn/ui (button + sheet components). Convert shared layout files (App, AdminNavbar, AuthContext, ProtectedRoute, useSocket, api) from JSX/JS to TSX/TS with type annotations. Rebuild AdminNavbar using shadcn Button and Sheet components with Tailwind classes, replacing custom CSS. Page components remain as JSX for incremental migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
25
frontend/frontend/components.json
Normal file
25
frontend/frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -12,6 +12,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4420
frontend/frontend/package-lock.json
generated
4420
frontend/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,17 +10,27 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
"shadcn": "^4.1.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.10",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
@@ -28,6 +38,8 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
#root {
|
||||
/*max-width: 1280px;*/
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
/*padding: 2rem;*/
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { ProtectedRoute } from "./components/auth/ProtectedRoute";
|
||||
@@ -12,7 +17,6 @@ import CategoryManagementView from "./components/categories/CategoryManagementVi
|
||||
import TemplatesView from "./components/templates/TemplatesView";
|
||||
import AdminNavbar from "./components/common/AdminNavbar";
|
||||
import { setAuthTokenGetter } from "./services/api";
|
||||
import "./App.css";
|
||||
|
||||
function HomePage() {
|
||||
const { user } = useAuth();
|
||||
@@ -20,24 +24,22 @@ function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<AdminNavbar />
|
||||
<div style={{ padding: "1rem 2rem", minHeight: "calc(100vh - 60px)" }}>
|
||||
<h1 style={{ margin: "0 0 1.5rem 0" }}>
|
||||
Trivia Game - Welcome {user?.profile?.name || user?.profile?.email}
|
||||
<div className="min-h-[calc(100vh-60px)] px-8 py-4">
|
||||
<h1 className="m-0 mb-6 text-3xl font-bold">
|
||||
Trivia Game - Welcome{" "}
|
||||
{user?.profile?.name || user?.profile?.email}
|
||||
</h1>
|
||||
<div
|
||||
style={{
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "8px",
|
||||
padding: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<h3>Quick Start</h3>
|
||||
<ol>
|
||||
<div className="rounded-lg bg-muted p-6">
|
||||
<h3 className="mt-0">Quick Start</h3>
|
||||
<ol className="space-y-1">
|
||||
<li>Create questions in the Question Bank</li>
|
||||
<li>Create a new game and select questions</li>
|
||||
<li>Add teams to your game</li>
|
||||
<li>Open the Contestant View on your TV</li>
|
||||
<li>Open the Admin View on your laptop to control the game</li>
|
||||
<li>
|
||||
Open the Admin View on your laptop to control the
|
||||
game
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +51,6 @@ function AppRoutes() {
|
||||
const { getTokenSilently } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// Set up token getter for API client
|
||||
setAuthTokenGetter(getTokenSilently);
|
||||
}, [getTokenSilently]);
|
||||
|
||||
@@ -58,7 +59,6 @@ function AppRoutes() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -104,7 +104,6 @@ function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Contestant view - all authenticated users */}
|
||||
<Route
|
||||
path="/games/:gameId/contestant"
|
||||
element={
|
||||
@@ -114,7 +113,6 @@ function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin view */}
|
||||
<Route
|
||||
path="/games/:gameId/admin"
|
||||
element={
|
||||
@@ -124,7 +122,6 @@ function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch all - redirect to home */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
border: '4px solid #f3f3f3',
|
||||
borderTop: '4px solid #3498db',
|
||||
borderRadius: '50%',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<p>Loading...</p>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
22
frontend/frontend/src/components/auth/ProtectedRoute.tsx
Normal file
22
frontend/frontend/src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-muted border-t-primary" />
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/* AdminNavbar Styles */
|
||||
|
||||
.admin-navbar {
|
||||
background: black;
|
||||
padding: 1rem 2rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-link {
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: white;
|
||||
font-weight: normal;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar-link:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.navbar-link.active {
|
||||
background: white;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.navbar-link.active:hover {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.navbar-user {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-left: 1rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 1px solid #444;
|
||||
}
|
||||
|
||||
.navbar-user-name {
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.navbar-logout {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar-logout:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* Mobile menu button - hidden by default */
|
||||
.navbar-mobile-toggle {
|
||||
display: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 768px) {
|
||||
.admin-navbar {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.navbar-mobile-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: black;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.navbar-menu.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navbar-links {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-link {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.navbar-user {
|
||||
flex-direction: column;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
padding-top: 0.75rem;
|
||||
border-left: none;
|
||||
border-top: 1px solid #444;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-user-name {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.navbar-logout {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens */
|
||||
@media (max-width: 480px) {
|
||||
.navbar-brand {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import "./AdminNavbar.css";
|
||||
|
||||
export default function AdminNavbar() {
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "Home" },
|
||||
{ path: "/questions", label: "Questions" },
|
||||
{ path: "/categories", label: "Categories" },
|
||||
{ path: "/templates", label: "Templates" },
|
||||
{ path: "/games/setup", label: "New Game" },
|
||||
];
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === "/") {
|
||||
return location.pathname === "/";
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
const handleLinkClick = () => {
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="admin-navbar">
|
||||
<div className="navbar-container">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<span className="navbar-brand">Trivia Admin</span>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu toggle */}
|
||||
<button
|
||||
className="navbar-mobile-toggle"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? "✕" : "☰"}
|
||||
</button>
|
||||
|
||||
<div className={`navbar-menu ${mobileMenuOpen ? "open" : ""}`}>
|
||||
<div className="navbar-links">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`navbar-link ${isActive(item.path) ? "active" : ""}`}
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* User info and logout */}
|
||||
<div className="navbar-user">
|
||||
<span className="navbar-user-name">
|
||||
{user?.profile?.name || user?.profile?.email}
|
||||
</span>
|
||||
<button onClick={logout} className="navbar-logout">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
135
frontend/frontend/src/components/common/AdminNavbar.tsx
Normal file
135
frontend/frontend/src/components/common/AdminNavbar.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Menu } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "Home" },
|
||||
{ path: "/questions", label: "Questions" },
|
||||
{ path: "/categories", label: "Categories" },
|
||||
{ path: "/templates", label: "Templates" },
|
||||
{ path: "/games/setup", label: "New Game" },
|
||||
];
|
||||
|
||||
export default function AdminNavbar() {
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === "/") return location.pathname === "/";
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 bg-black px-4 py-3 md:px-8 md:py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-white md:text-2xl">
|
||||
Trivia Admin
|
||||
</span>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className="hidden items-center gap-4 md:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm font-normal text-white no-underline transition-colors hover:bg-white/10",
|
||||
isActive(item.path) &&
|
||||
"bg-white font-bold text-black hover:bg-white",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-3 border-l border-white/20 pl-4">
|
||||
<span className="text-sm text-white">
|
||||
{user?.profile?.name || user?.profile?.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<Sheet
|
||||
open={mobileMenuOpen}
|
||||
onOpenChange={setMobileMenuOpen}
|
||||
>
|
||||
<SheetTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/10 md:hidden"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-64 bg-black text-white"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-white">
|
||||
Menu
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-1 px-4">
|
||||
{navItems.map((item) => (
|
||||
<SheetClose
|
||||
key={item.path}
|
||||
render={
|
||||
<Link
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm font-normal text-white no-underline transition-colors hover:bg-white/10",
|
||||
isActive(item.path) &&
|
||||
"bg-white font-bold text-black hover:bg-white",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-2 border-t border-white/20 p-4">
|
||||
<span className="text-sm text-white/70">
|
||||
{user?.profile?.name || user?.profile?.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
className="w-full"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
58
frontend/frontend/src/components/ui/button.tsx
Normal file
58
frontend/frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
138
frontend/frontend/src/components/ui/sheet.tsx
Normal file
138
frontend/frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [dbUser, setDbUser] = useState(null); // User record from DB (has .id for ownership checks)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [accessToken, setAccessToken] = useState(null);
|
||||
|
||||
// Fetch the DB user record (includes numeric id for ownership comparisons)
|
||||
const fetchDbUser = useCallback(async (idToken) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${idToken}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDbUser(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch DB user:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is already logged in (token in localStorage)
|
||||
const storedToken = localStorage.getItem('access_token');
|
||||
const storedIdToken = localStorage.getItem('id_token');
|
||||
|
||||
console.log('AuthContext: Checking stored tokens', {
|
||||
hasAccessToken: !!storedToken,
|
||||
hasIdToken: !!storedIdToken
|
||||
});
|
||||
|
||||
if (storedToken && storedIdToken) {
|
||||
try {
|
||||
const decoded = jwtDecode(storedIdToken);
|
||||
// Check if token is expired
|
||||
if (decoded.exp * 1000 > Date.now()) {
|
||||
console.log('AuthContext: Tokens valid, setting authenticated', decoded);
|
||||
setAccessToken(storedToken);
|
||||
setUser({ profile: decoded });
|
||||
setIsAuthenticated(true);
|
||||
fetchDbUser(storedIdToken);
|
||||
} else {
|
||||
// Token expired, clear storage
|
||||
console.log('AuthContext: Tokens expired');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('id_token');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding token:', error);
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('id_token');
|
||||
}
|
||||
} else {
|
||||
console.log('AuthContext: No tokens found in storage');
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [fetchDbUser]);
|
||||
|
||||
const login = () => {
|
||||
// Redirect to backend login endpoint
|
||||
window.location.href = '/api/auth/login';
|
||||
};
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// Extract tokens from URL hash (set by backend redirect)
|
||||
const hash = window.location.hash.substring(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
console.log('handleCallback: Hash params', hash);
|
||||
|
||||
const access_token = params.get('access_token');
|
||||
const id_token = params.get('id_token');
|
||||
|
||||
console.log('handleCallback: Tokens extracted', {
|
||||
hasAccessToken: !!access_token,
|
||||
hasIdToken: !!id_token
|
||||
});
|
||||
|
||||
if (!access_token || !id_token) {
|
||||
throw new Error('No tokens found in callback');
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('id_token', id_token);
|
||||
|
||||
console.log('handleCallback: Tokens stored in localStorage');
|
||||
|
||||
// Decode ID token to get user info
|
||||
const decoded = jwtDecode(id_token);
|
||||
|
||||
console.log('handleCallback: Decoded user', decoded);
|
||||
|
||||
setAccessToken(access_token);
|
||||
setUser({ profile: decoded });
|
||||
setIsAuthenticated(true);
|
||||
fetchDbUser(id_token);
|
||||
|
||||
console.log('handleCallback: Auth state updated, isAuthenticated=true');
|
||||
|
||||
// Clear hash from URL
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
|
||||
return { profile: decoded };
|
||||
} catch (error) {
|
||||
console.error('Callback error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
// Clear local storage
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('id_token');
|
||||
|
||||
setAccessToken(null);
|
||||
setUser(null);
|
||||
setDbUser(null);
|
||||
setIsAuthenticated(false);
|
||||
|
||||
// Redirect to backend logout to clear cookies and get Authelia logout URL
|
||||
window.location.href = '/api/auth/logout';
|
||||
};
|
||||
|
||||
const getTokenSilently = async () => {
|
||||
// Return ID token (JWT) for backend authentication
|
||||
// Note: Authelia's access_token is opaque and can't be validated by backend
|
||||
// We use id_token instead which is a proper JWT
|
||||
const storedIdToken = localStorage.getItem('id_token');
|
||||
if (storedIdToken) {
|
||||
try {
|
||||
const decoded = jwtDecode(storedIdToken);
|
||||
// Check if token is expired
|
||||
if (decoded.exp * 1000 > Date.now()) {
|
||||
return storedIdToken;
|
||||
} else {
|
||||
console.log('ID token expired');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('id_token');
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding ID token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// No valid token available
|
||||
return null;
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
dbUser,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
accessToken,
|
||||
login,
|
||||
logout,
|
||||
handleCallback,
|
||||
getTokenSilently,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
206
frontend/frontend/src/contexts/AuthContext.tsx
Normal file
206
frontend/frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
interface UserProfile {
|
||||
name?: string;
|
||||
email?: string;
|
||||
sub?: string;
|
||||
exp?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface User {
|
||||
profile: UserProfile;
|
||||
}
|
||||
|
||||
interface DbUser {
|
||||
id: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
dbUser: DbUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
accessToken: string | null;
|
||||
login: () => void;
|
||||
logout: () => Promise<void>;
|
||||
handleCallback: () => Promise<User>;
|
||||
getTokenSilently: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [dbUser, setDbUser] = useState<DbUser | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
|
||||
const fetchDbUser = useCallback(async (idToken: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${idToken}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDbUser(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch DB user:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem("access_token");
|
||||
const storedIdToken = localStorage.getItem("id_token");
|
||||
|
||||
console.log("AuthContext: Checking stored tokens", {
|
||||
hasAccessToken: !!storedToken,
|
||||
hasIdToken: !!storedIdToken,
|
||||
});
|
||||
|
||||
if (storedToken && storedIdToken) {
|
||||
try {
|
||||
const decoded = jwtDecode<UserProfile>(storedIdToken);
|
||||
if (decoded.exp && decoded.exp * 1000 > Date.now()) {
|
||||
console.log(
|
||||
"AuthContext: Tokens valid, setting authenticated",
|
||||
decoded,
|
||||
);
|
||||
setAccessToken(storedToken);
|
||||
setUser({ profile: decoded });
|
||||
setIsAuthenticated(true);
|
||||
fetchDbUser(storedIdToken);
|
||||
} else {
|
||||
console.log("AuthContext: Tokens expired");
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("id_token");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decoding token:", error);
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("id_token");
|
||||
}
|
||||
} else {
|
||||
console.log("AuthContext: No tokens found in storage");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [fetchDbUser]);
|
||||
|
||||
const login = () => {
|
||||
window.location.href = "/api/auth/login";
|
||||
};
|
||||
|
||||
const handleCallback = async (): Promise<User> => {
|
||||
try {
|
||||
const hash = window.location.hash.substring(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
console.log("handleCallback: Hash params", hash);
|
||||
|
||||
const access_token = params.get("access_token");
|
||||
const id_token = params.get("id_token");
|
||||
|
||||
console.log("handleCallback: Tokens extracted", {
|
||||
hasAccessToken: !!access_token,
|
||||
hasIdToken: !!id_token,
|
||||
});
|
||||
|
||||
if (!access_token || !id_token) {
|
||||
throw new Error("No tokens found in callback");
|
||||
}
|
||||
|
||||
localStorage.setItem("access_token", access_token);
|
||||
localStorage.setItem("id_token", id_token);
|
||||
|
||||
console.log("handleCallback: Tokens stored in localStorage");
|
||||
|
||||
const decoded = jwtDecode<UserProfile>(id_token);
|
||||
|
||||
console.log("handleCallback: Decoded user", decoded);
|
||||
|
||||
setAccessToken(access_token);
|
||||
const userObj: User = { profile: decoded };
|
||||
setUser(userObj);
|
||||
setIsAuthenticated(true);
|
||||
fetchDbUser(id_token);
|
||||
|
||||
console.log(
|
||||
"handleCallback: Auth state updated, isAuthenticated=true",
|
||||
);
|
||||
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
|
||||
return userObj;
|
||||
} catch (error) {
|
||||
console.error("Callback error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("id_token");
|
||||
|
||||
setAccessToken(null);
|
||||
setUser(null);
|
||||
setDbUser(null);
|
||||
setIsAuthenticated(false);
|
||||
|
||||
window.location.href = "/api/auth/logout";
|
||||
};
|
||||
|
||||
const getTokenSilently = async (): Promise<string | null> => {
|
||||
const storedIdToken = localStorage.getItem("id_token");
|
||||
if (storedIdToken) {
|
||||
try {
|
||||
const decoded = jwtDecode<UserProfile>(storedIdToken);
|
||||
if (decoded.exp && decoded.exp * 1000 > Date.now()) {
|
||||
return storedIdToken;
|
||||
} else {
|
||||
console.log("ID token expired");
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("id_token");
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decoding ID token:", error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const value: AuthContextValue = {
|
||||
user,
|
||||
dbUser,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
accessToken,
|
||||
login,
|
||||
logout,
|
||||
handleCallback,
|
||||
getTokenSilently,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,32 +1,30 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export function useSocket(gameId, role = "contestant") {
|
||||
const [socket, setSocket] = useState(null);
|
||||
export function useSocket(gameId: string | undefined, role = "contestant") {
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const socketRef = useRef(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const { getTokenSilently, isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameId || !isAuthenticated) return;
|
||||
|
||||
// Get ID token for authentication
|
||||
const initSocket = async () => {
|
||||
const token = await getTokenSilently();
|
||||
if (!token) {
|
||||
console.error('No token available for WebSocket connection');
|
||||
console.error("No token available for WebSocket connection");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create socket connection with auth token
|
||||
const newSocket = io({
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 5,
|
||||
auth: {
|
||||
token, // Send ID token (JWT) in connection auth
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,11 +33,10 @@ export function useSocket(gameId, role = "contestant") {
|
||||
newSocket.on("connect", () => {
|
||||
console.log("Socket connected");
|
||||
setIsConnected(true);
|
||||
// Join the game room with token
|
||||
newSocket.emit("join_game", {
|
||||
game_id: parseInt(gameId),
|
||||
role,
|
||||
token, // Send ID token (JWT) in join_game event
|
||||
token,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,14 +45,16 @@ export function useSocket(gameId, role = "contestant") {
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
newSocket.on("joined", (data) => {
|
||||
newSocket.on("joined", (data: unknown) => {
|
||||
console.log("Joined game room:", data);
|
||||
});
|
||||
|
||||
newSocket.on("error", (error) => {
|
||||
newSocket.on("error", (error: { message?: string }) => {
|
||||
console.error("Socket error:", error);
|
||||
// If error is auth-related, disconnect
|
||||
if (error.message?.includes('token') || error.message?.includes('auth')) {
|
||||
if (
|
||||
error.message?.includes("token") ||
|
||||
error.message?.includes("auth")
|
||||
) {
|
||||
newSocket.disconnect();
|
||||
}
|
||||
});
|
||||
@@ -65,7 +64,6 @@ export function useSocket(gameId, role = "contestant") {
|
||||
|
||||
initSocket();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.emit("leave_game", {
|
||||
@@ -1,59 +1,9 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
color-scheme: light;
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #f9f9f9;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Turn indicator pulse animation */
|
||||
@keyframes pulse {
|
||||
@@ -71,3 +21,127 @@ button:focus-visible {
|
||||
.turn-indicator {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
6
frontend/frontend/src/lib/utils.ts
Normal file
6
frontend/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.jsx";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
@@ -1,149 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
// Base URL will use proxy in development, direct path in production
|
||||
const API_BASE_URL = "/api";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Auth token getter (set by App.jsx)
|
||||
let getAuthToken = null;
|
||||
|
||||
export function setAuthTokenGetter(tokenGetter) {
|
||||
getAuthToken = tokenGetter;
|
||||
}
|
||||
|
||||
// Request interceptor to add JWT token
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
if (getAuthToken) {
|
||||
const token = await getAuthToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle 401 errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
console.error('Authentication error - logging out');
|
||||
// Dispatch custom event for auth context to handle
|
||||
window.dispatchEvent(new Event('auth:unauthorized'));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Questions API
|
||||
export const questionsAPI = {
|
||||
getAll: (params = {}) => api.get("/questions", { params }),
|
||||
getOne: (id) => api.get(`/questions/${id}`),
|
||||
create: (data) => api.post("/questions", data),
|
||||
createWithImage: (formData) =>
|
||||
api.post("/questions", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}),
|
||||
bulkCreate: (questions) => api.post("/questions/bulk", { questions }),
|
||||
getRandomByCategory: (category, count) =>
|
||||
api.get("/questions/random", {
|
||||
params: { category, count },
|
||||
}),
|
||||
update: (id, data) => api.put(`/questions/${id}`, data),
|
||||
updateWithImage: (id, formData) =>
|
||||
api.put(`/questions/${id}`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}),
|
||||
delete: (id) => api.delete(`/questions/${id}`),
|
||||
// Sharing
|
||||
getShares: (id) => api.get(`/questions/${id}/shares`),
|
||||
share: (id, userId) => api.post(`/questions/${id}/share`, { user_id: userId }),
|
||||
unshare: (id, userId) => api.delete(`/questions/${id}/share/${userId}`),
|
||||
bulkShare: (questionIds, userId) =>
|
||||
api.post("/questions/bulk-share", { question_ids: questionIds, user_id: userId }),
|
||||
};
|
||||
|
||||
// Games API
|
||||
export const gamesAPI = {
|
||||
getAll: () => api.get("/games"),
|
||||
getOne: (id) => api.get(`/games/${id}`),
|
||||
create: (data) => api.post("/games", data),
|
||||
delete: (id) => api.delete(`/games/${id}`),
|
||||
addQuestions: (id, questionIds) =>
|
||||
api.post(`/games/${id}/questions`, { question_ids: questionIds }),
|
||||
addTeam: (id, teamName) => api.post(`/games/${id}/teams`, { name: teamName }),
|
||||
saveAsTemplate: (id) => api.post(`/games/${id}/save-template`),
|
||||
getTemplates: () => api.get("/games/templates"),
|
||||
cloneTemplate: (id, name) => api.post(`/games/${id}/clone`, { name }),
|
||||
};
|
||||
|
||||
// Teams API
|
||||
export const teamsAPI = {
|
||||
delete: (id) => api.delete(`/teams/${id}`),
|
||||
getPastNames: () => api.get("/teams/past-names"),
|
||||
};
|
||||
|
||||
// Admin API
|
||||
export const adminAPI = {
|
||||
startGame: (id) => api.post(`/admin/game/${id}/start`),
|
||||
endGame: (id) => api.post(`/admin/game/${id}/end`),
|
||||
restartGame: (id) => api.post(`/admin/game/${id}/restart`),
|
||||
nextQuestion: (id) => api.post(`/admin/game/${id}/next`),
|
||||
prevQuestion: (id) => api.post(`/admin/game/${id}/prev`),
|
||||
awardPoints: (id, teamId, points) =>
|
||||
api.post(`/admin/game/${id}/award`, { team_id: teamId, points }),
|
||||
getCurrentState: (id) => api.get(`/admin/game/${id}/current`),
|
||||
toggleAnswer: (id, showAnswer) =>
|
||||
api.post(`/admin/game/${id}/toggle-answer`, { show_answer: showAnswer }),
|
||||
pauseTimer: (id, paused) =>
|
||||
api.post(`/admin/game/${id}/pause-timer`, { paused }),
|
||||
resetTimer: (id) => api.post(`/admin/game/${id}/reset-timer`),
|
||||
useLifeline: (gameId, teamId) =>
|
||||
api.post(`/admin/game/${gameId}/team/${teamId}/use-lifeline`),
|
||||
addLifeline: (gameId, teamId) =>
|
||||
api.post(`/admin/game/${gameId}/team/${teamId}/add-lifeline`),
|
||||
advanceTurn: (id) => api.post(`/admin/game/${id}/advance-turn`),
|
||||
};
|
||||
|
||||
// Categories API
|
||||
export const categoriesAPI = {
|
||||
getAll: () => api.get("/categories"),
|
||||
getOne: (id) => api.get(`/categories/${id}`),
|
||||
create: (data) => api.post("/categories", data),
|
||||
update: (id, data) => api.put(`/categories/${id}`, data),
|
||||
delete: (id) => api.delete(`/categories/${id}`),
|
||||
};
|
||||
|
||||
// Download Jobs API
|
||||
export const downloadJobsAPI = {
|
||||
getStatus: (jobId) => api.get(`/download-jobs/${jobId}`),
|
||||
getByQuestion: (questionId) =>
|
||||
api.get(`/download-jobs/question/${questionId}`),
|
||||
};
|
||||
|
||||
// Audio Control API
|
||||
export const audioControlAPI = {
|
||||
play: (gameId) => api.post(`/admin/game/${gameId}/audio/play`),
|
||||
pause: (gameId) => api.post(`/admin/game/${gameId}/audio/pause`),
|
||||
stop: (gameId) => api.post(`/admin/game/${gameId}/audio/stop`),
|
||||
seek: (gameId, position) =>
|
||||
api.post(`/admin/game/${gameId}/audio/seek`, { position }),
|
||||
};
|
||||
|
||||
// Users API (for sharing)
|
||||
export const usersAPI = {
|
||||
search: (query) => api.get("/auth/users/search", { params: { q: query } }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
158
frontend/frontend/src/services/api.ts
Normal file
158
frontend/frontend/src/services/api.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
const API_BASE_URL = "/api";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
let getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
export function setAuthTokenGetter(
|
||||
tokenGetter: () => Promise<string | null>,
|
||||
) {
|
||||
getAuthToken = tokenGetter;
|
||||
}
|
||||
|
||||
api.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
if (getAuthToken) {
|
||||
const token = await getAuthToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
console.error("Authentication error - logging out");
|
||||
window.dispatchEvent(new Event("auth:unauthorized"));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// Questions API
|
||||
export const questionsAPI = {
|
||||
getAll: (params = {}) => api.get("/questions", { params }),
|
||||
getOne: (id: number) => api.get(`/questions/${id}`),
|
||||
create: (data: unknown) => api.post("/questions", data),
|
||||
createWithImage: (formData: FormData) =>
|
||||
api.post("/questions", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}),
|
||||
bulkCreate: (questions: unknown[]) =>
|
||||
api.post("/questions/bulk", { questions }),
|
||||
getRandomByCategory: (category: string, count: number) =>
|
||||
api.get("/questions/random", {
|
||||
params: { category, count },
|
||||
}),
|
||||
update: (id: number, data: unknown) => api.put(`/questions/${id}`, data),
|
||||
updateWithImage: (id: number, formData: FormData) =>
|
||||
api.put(`/questions/${id}`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}),
|
||||
delete: (id: number) => api.delete(`/questions/${id}`),
|
||||
getShares: (id: number) => api.get(`/questions/${id}/shares`),
|
||||
share: (id: number, userId: number) =>
|
||||
api.post(`/questions/${id}/share`, { user_id: userId }),
|
||||
unshare: (id: number, userId: number) =>
|
||||
api.delete(`/questions/${id}/share/${userId}`),
|
||||
bulkShare: (questionIds: number[], userId: number) =>
|
||||
api.post("/questions/bulk-share", {
|
||||
question_ids: questionIds,
|
||||
user_id: userId,
|
||||
}),
|
||||
};
|
||||
|
||||
// Games API
|
||||
export const gamesAPI = {
|
||||
getAll: () => api.get("/games"),
|
||||
getOne: (id: number) => api.get(`/games/${id}`),
|
||||
create: (data: unknown) => api.post("/games", data),
|
||||
delete: (id: number) => api.delete(`/games/${id}`),
|
||||
addQuestions: (id: number, questionIds: number[]) =>
|
||||
api.post(`/games/${id}/questions`, { question_ids: questionIds }),
|
||||
addTeam: (id: number, teamName: string) =>
|
||||
api.post(`/games/${id}/teams`, { name: teamName }),
|
||||
saveAsTemplate: (id: number) => api.post(`/games/${id}/save-template`),
|
||||
getTemplates: () => api.get("/games/templates"),
|
||||
cloneTemplate: (id: number, name: string) =>
|
||||
api.post(`/games/${id}/clone`, { name }),
|
||||
};
|
||||
|
||||
// Teams API
|
||||
export const teamsAPI = {
|
||||
delete: (id: number) => api.delete(`/teams/${id}`),
|
||||
getPastNames: () => api.get("/teams/past-names"),
|
||||
};
|
||||
|
||||
// Admin API
|
||||
export const adminAPI = {
|
||||
startGame: (id: number) => api.post(`/admin/game/${id}/start`),
|
||||
endGame: (id: number) => api.post(`/admin/game/${id}/end`),
|
||||
restartGame: (id: number) => api.post(`/admin/game/${id}/restart`),
|
||||
nextQuestion: (id: number) => api.post(`/admin/game/${id}/next`),
|
||||
prevQuestion: (id: number) => api.post(`/admin/game/${id}/prev`),
|
||||
awardPoints: (id: number, teamId: number, points: number) =>
|
||||
api.post(`/admin/game/${id}/award`, { team_id: teamId, points }),
|
||||
getCurrentState: (id: number) => api.get(`/admin/game/${id}/current`),
|
||||
toggleAnswer: (id: number, showAnswer: boolean) =>
|
||||
api.post(`/admin/game/${id}/toggle-answer`, {
|
||||
show_answer: showAnswer,
|
||||
}),
|
||||
pauseTimer: (id: number, paused: boolean) =>
|
||||
api.post(`/admin/game/${id}/pause-timer`, { paused }),
|
||||
resetTimer: (id: number) => api.post(`/admin/game/${id}/reset-timer`),
|
||||
useLifeline: (gameId: number, teamId: number) =>
|
||||
api.post(`/admin/game/${gameId}/team/${teamId}/use-lifeline`),
|
||||
addLifeline: (gameId: number, teamId: number) =>
|
||||
api.post(`/admin/game/${gameId}/team/${teamId}/add-lifeline`),
|
||||
advanceTurn: (id: number) => api.post(`/admin/game/${id}/advance-turn`),
|
||||
};
|
||||
|
||||
// Categories API
|
||||
export const categoriesAPI = {
|
||||
getAll: () => api.get("/categories"),
|
||||
getOne: (id: number) => api.get(`/categories/${id}`),
|
||||
create: (data: unknown) => api.post("/categories", data),
|
||||
update: (id: number, data: unknown) =>
|
||||
api.put(`/categories/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/categories/${id}`),
|
||||
};
|
||||
|
||||
// Download Jobs API
|
||||
export const downloadJobsAPI = {
|
||||
getStatus: (jobId: string) => api.get(`/download-jobs/${jobId}`),
|
||||
getByQuestion: (questionId: number) =>
|
||||
api.get(`/download-jobs/question/${questionId}`),
|
||||
};
|
||||
|
||||
// Audio Control API
|
||||
export const audioControlAPI = {
|
||||
play: (gameId: number) => api.post(`/admin/game/${gameId}/audio/play`),
|
||||
pause: (gameId: number) =>
|
||||
api.post(`/admin/game/${gameId}/audio/pause`),
|
||||
stop: (gameId: number) => api.post(`/admin/game/${gameId}/audio/stop`),
|
||||
seek: (gameId: number, position: number) =>
|
||||
api.post(`/admin/game/${gameId}/audio/seek`, { position }),
|
||||
};
|
||||
|
||||
// Users API (for sharing)
|
||||
export const usersAPI = {
|
||||
search: (query: string) =>
|
||||
api.get("/auth/users/search", { params: { q: query } }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
30
frontend/frontend/tsconfig.app.json
Normal file
30
frontend/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
frontend/frontend/tsconfig.json
Normal file
10
frontend/frontend/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
// Use environment variable for backend URL, default to localhost for local dev
|
||||
const backendUrl = process.env.VITE_BACKEND_URL || "http://localhost:5001";
|
||||
|
||||
// Use environment variable for frontend port, default to 3000
|
||||
const frontendPort = parseInt(process.env.PORT || process.env.VITE_PORT || "3000", 10);
|
||||
const frontendPort = parseInt(
|
||||
process.env.PORT || process.env.VITE_PORT || "3000",
|
||||
10,
|
||||
);
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: frontendPort,
|
||||
host: "0.0.0.0",
|
||||
Reference in New Issue
Block a user