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:
2026-04-03 13:10:33 -04:00
parent 7402f18bcf
commit 5aba7b5aa1
24 changed files with 5269 additions and 881 deletions

View 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": {}
}

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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;
}

View 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;
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View 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>
);
}

View 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 }

View 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,
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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", {

View File

@@ -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;
}
}

View 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))
}

View File

@@ -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>,

View File

@@ -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;

View 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;

View 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"]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}

View File

@@ -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",