├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── bun.lockb ├── components.json ├── docker-compose.yml ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── placeholder.svg └── robots.txt ├── src ├── App.css ├── App.tsx ├── components │ ├── GameBoard.tsx │ ├── GameCell.tsx │ ├── GameControls.tsx │ ├── GameLobby.tsx │ ├── Header.tsx │ ├── WaitingRoom.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── hooks │ ├── use-mobile.tsx │ ├── use-toast.ts │ └── useGame.ts ├── index.css ├── lib │ ├── firebase.ts │ ├── gameUtils.ts │ └── utils.ts ├── main.tsx ├── pages │ ├── Game.tsx │ ├── Index.tsx │ └── NotFound.tsx └── vite-env.d.ts ├── tailwind.config.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules 3 | dist 4 | .git 5 | .github 6 | .gitignore 7 | .DS_Store 8 | .env 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | # Build stage 3 | FROM node:20-alpine AS build 4 | 5 | WORKDIR /app 6 | 7 | # Copy package files 8 | COPY package.json package-lock.json ./ 9 | 10 | # Install dependencies 11 | RUN npm ci 12 | 13 | # Copy source code 14 | COPY . . 15 | 16 | # Build the app 17 | RUN npm run build 18 | 19 | # Production stage 20 | FROM nginx:alpine 21 | 22 | # Copy built assets from build stage 23 | COPY --from=build /app/dist /usr/share/nginx/html 24 | 25 | # Copy nginx configuration if needed 26 | # COPY nginx.conf /etc/nginx/conf.d/default.conf 27 | 28 | # Expose port 29 | EXPOSE 80 30 | 31 | # Command to run the server 32 | CMD ["nginx", "-g", "daemon off;"] 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Lovable project 2 | 3 | ## Project info 4 | 5 | **URL**: https://lovable.dev/projects/f43bef07-7d26-4259-8c5c-43252eab4180 6 | 7 | ## How can I edit this code? 8 | 9 | There are several ways of editing your application. 10 | 11 | **Use Lovable** 12 | 13 | Simply visit the [Lovable Project](https://lovable.dev/projects/f43bef07-7d26-4259-8c5c-43252eab4180) and start prompting. 14 | 15 | Changes made via Lovable will be committed automatically to this repo. 16 | 17 | **Use your preferred IDE** 18 | 19 | If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable. 20 | 21 | The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating) 22 | 23 | Follow these steps: 24 | 25 | ```sh 26 | # Step 1: Clone the repository using the project's Git URL. 27 | git clone 28 | 29 | # Step 2: Navigate to the project directory. 30 | cd 31 | 32 | # Step 3: Install the necessary dependencies. 33 | npm i 34 | 35 | # Step 4: Start the development server with auto-reloading and an instant preview. 36 | npm run dev 37 | ``` 38 | 39 | **Edit a file directly in GitHub** 40 | 41 | - Navigate to the desired file(s). 42 | - Click the "Edit" button (pencil icon) at the top right of the file view. 43 | - Make your changes and commit the changes. 44 | 45 | **Use GitHub Codespaces** 46 | 47 | - Navigate to the main page of your repository. 48 | - Click on the "Code" button (green button) near the top right. 49 | - Select the "Codespaces" tab. 50 | - Click on "New codespace" to launch a new Codespace environment. 51 | - Edit files directly within the Codespace and commit and push your changes once you're done. 52 | 53 | ## What technologies are used for this project? 54 | 55 | This project is built with . 56 | 57 | - Vite 58 | - TypeScript 59 | - React 60 | - shadcn-ui 61 | - Tailwind CSS 62 | 63 | ## How can I deploy this project? 64 | 65 | Simply open [Lovable](https://lovable.dev/projects/f43bef07-7d26-4259-8c5c-43252eab4180) and click on Share -> Publish. 66 | 67 | ## I want to use a custom domain - is that possible? 68 | 69 | We don't support custom domains (yet). If you want to deploy your project under your own domain then we recommend using Netlify. Visit our docs for more details: [Custom domains](https://docs.lovable.dev/tips-tricks/custom-domain/) 70 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullfunc/cloud-tic-tac-toe-gather/17f0e3108f325e5a5b9d8ad98b24d2a3af5b2887/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | version: '3.8' 3 | 4 | services: 5 | tic-tac-toe: 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | ports: 10 | - "8080:80" 11 | restart: unless-stopped 12 | # If you need environment variables, add them here 13 | # environment: 14 | # - NODE_ENV=production 15 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | { allowConstantExport: true }, 25 | ], 26 | "@typescript-eslint/no-unused-vars": "off", 27 | }, 28 | } 29 | ); 30 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | cloud-tic-tac-toe-gather 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite_react_shadcn_ts", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "build:dev": "vite build --mode development", 10 | "lint": "eslint .", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@hookform/resolvers": "^3.9.0", 15 | "@radix-ui/react-accordion": "^1.2.0", 16 | "@radix-ui/react-alert-dialog": "^1.1.1", 17 | "@radix-ui/react-aspect-ratio": "^1.1.0", 18 | "@radix-ui/react-avatar": "^1.1.0", 19 | "@radix-ui/react-checkbox": "^1.1.1", 20 | "@radix-ui/react-collapsible": "^1.1.0", 21 | "@radix-ui/react-context-menu": "^2.2.1", 22 | "@radix-ui/react-dialog": "^1.1.2", 23 | "@radix-ui/react-dropdown-menu": "^2.1.1", 24 | "@radix-ui/react-hover-card": "^1.1.1", 25 | "@radix-ui/react-label": "^2.1.0", 26 | "@radix-ui/react-menubar": "^1.1.1", 27 | "@radix-ui/react-navigation-menu": "^1.2.0", 28 | "@radix-ui/react-popover": "^1.1.1", 29 | "@radix-ui/react-progress": "^1.1.0", 30 | "@radix-ui/react-radio-group": "^1.2.0", 31 | "@radix-ui/react-scroll-area": "^1.1.0", 32 | "@radix-ui/react-select": "^2.1.1", 33 | "@radix-ui/react-separator": "^1.1.0", 34 | "@radix-ui/react-slider": "^1.2.0", 35 | "@radix-ui/react-slot": "^1.1.0", 36 | "@radix-ui/react-switch": "^1.1.0", 37 | "@radix-ui/react-tabs": "^1.1.0", 38 | "@radix-ui/react-toast": "^1.2.1", 39 | "@radix-ui/react-toggle": "^1.1.0", 40 | "@radix-ui/react-toggle-group": "^1.1.0", 41 | "@radix-ui/react-tooltip": "^1.1.4", 42 | "@tanstack/react-query": "^5.56.2", 43 | "class-variance-authority": "^0.7.1", 44 | "clsx": "^2.1.1", 45 | "cmdk": "^1.0.0", 46 | "date-fns": "^3.6.0", 47 | "embla-carousel-react": "^8.3.0", 48 | "firebase": "^10.9.0", 49 | "input-otp": "^1.2.4", 50 | "lucide-react": "^0.462.0", 51 | "next-themes": "^0.3.0", 52 | "react": "^18.3.1", 53 | "react-day-picker": "^8.10.1", 54 | "react-dom": "^18.3.1", 55 | "react-hook-form": "^7.53.0", 56 | "react-resizable-panels": "^2.1.3", 57 | "react-router-dom": "^6.26.2", 58 | "recharts": "^2.12.7", 59 | "sonner": "^1.5.0", 60 | "tailwind-merge": "^2.5.2", 61 | "tailwindcss-animate": "^1.0.7", 62 | "vaul": "^0.9.3", 63 | "zod": "^3.23.8" 64 | }, 65 | "devDependencies": { 66 | "@eslint/js": "^9.9.0", 67 | "@tailwindcss/typography": "^0.5.15", 68 | "@types/node": "^22.5.5", 69 | "@types/react": "^18.3.3", 70 | "@types/react-dom": "^18.3.0", 71 | "@vitejs/plugin-react-swc": "^3.5.0", 72 | "autoprefixer": "^10.4.20", 73 | "eslint": "^9.9.0", 74 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 75 | "eslint-plugin-react-refresh": "^0.4.9", 76 | "globals": "^15.9.0", 77 | "lovable-tagger": "^1.1.7", 78 | "postcss": "^8.4.47", 79 | "tailwindcss": "^3.4.11", 80 | "typescript": "^5.5.3", 81 | "typescript-eslint": "^8.0.1", 82 | "vite": "^5.4.1" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullfunc/cloud-tic-tac-toe-gather/17f0e3108f325e5a5b9d8ad98b24d2a3af5b2887/public/favicon.ico -------------------------------------------------------------------------------- /public/placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: Googlebot 2 | Allow: / 3 | 4 | User-agent: Bingbot 5 | Allow: / 6 | 7 | User-agent: Twitterbot 8 | Allow: / 9 | 10 | User-agent: facebookexternalhit 11 | Allow: / 12 | 13 | User-agent: * 14 | Allow: / 15 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Toaster } from "@/components/ui/toaster"; 3 | import { Toaster as Sonner } from "@/components/ui/sonner"; 4 | import { TooltipProvider } from "@/components/ui/tooltip"; 5 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 6 | import { BrowserRouter, Routes, Route } from "react-router-dom"; 7 | import Index from "./pages/Index"; 8 | import Game from "./pages/Game"; 9 | import NotFound from "./pages/NotFound"; 10 | 11 | const queryClient = new QueryClient(); 12 | 13 | const App = () => ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | } /> 21 | } /> 22 | } /> 23 | 24 | 25 | 26 | 27 | ); 28 | 29 | export default App; 30 | -------------------------------------------------------------------------------- /src/components/GameBoard.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useMemo } from 'react'; 3 | import { Player, GameState, getWinningLine, getPlayerSymbol } from '@/lib/gameUtils'; 4 | import GameCell from './GameCell'; 5 | import { cn } from '@/lib/utils'; 6 | 7 | interface GameBoardProps { 8 | game: GameState; 9 | playerId: string; 10 | onCellClick: (index: number) => void; 11 | } 12 | 13 | const GameBoard = ({ game, playerId, onCellClick }: GameBoardProps) => { 14 | const { board, status, currentTurn, winner } = game; 15 | 16 | const winningLine = useMemo(() => { 17 | return getWinningLine(board); 18 | }, [board]); 19 | 20 | const playerSymbol = getPlayerSymbol(playerId, game); 21 | const isCurrentPlayerTurn = playerSymbol === currentTurn; 22 | const gameEnded = status === 'finished'; 23 | 24 | return ( 25 |
26 |
34 | {board.map((cell, index) => ( 35 | onCellClick(index)} 40 | isWinningCell={winningLine ? winningLine.includes(index) : false} 41 | isCurrentPlayerTurn={isCurrentPlayerTurn} 42 | disabled={gameEnded} 43 | /> 44 | ))} 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default GameBoard; 51 | -------------------------------------------------------------------------------- /src/components/GameCell.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useId } from 'react'; 3 | import { Player } from '@/lib/gameUtils'; 4 | import { cn } from '@/lib/utils'; 5 | 6 | interface GameCellProps { 7 | value: Player; 8 | onClick: () => void; 9 | index: number; 10 | isWinningCell: boolean; 11 | isCurrentPlayerTurn: boolean; 12 | disabled?: boolean; 13 | } 14 | 15 | const GameCell = ({ 16 | value, 17 | onClick, 18 | index, 19 | isWinningCell, 20 | isCurrentPlayerTurn, 21 | disabled = false 22 | }: GameCellProps) => { 23 | const id = useId(); 24 | 25 | return ( 26 |
35 | {value === 'X' && ( 36 | 47 | 62 | 78 | 79 | )} 80 | 81 | {value === 'O' && ( 82 | 93 | 106 | 107 | )} 108 | 109 | {!value && isCurrentPlayerTurn && !disabled && ( 110 |
111 | {isCurrentPlayerTurn === true && !disabled && ( 112 |
113 | )} 114 |
115 | )} 116 |
117 | ); 118 | }; 119 | 120 | export default GameCell; 121 | -------------------------------------------------------------------------------- /src/components/GameControls.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useState } from 'react'; 3 | import { Button } from '@/components/ui/button'; 4 | import { Input } from '@/components/ui/input'; 5 | import { GameState, formatGameId } from '@/lib/gameUtils'; 6 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; 7 | import { Check, Copy, RefreshCw } from 'lucide-react'; 8 | import { cn } from '@/lib/utils'; 9 | 10 | interface GameControlsProps { 11 | game: GameState | null; 12 | playerId: string; 13 | onRestart: () => void; 14 | onShareGame: () => void; 15 | } 16 | 17 | const GameControls = ({ game, playerId, onRestart, onShareGame }: GameControlsProps) => { 18 | const [copied, setCopied] = useState(false); 19 | 20 | if (!game) return null; 21 | 22 | const { id, status, playerX, playerO, winner, currentTurn, isSinglePlayer } = game; 23 | const isActivePlayer = playerId === playerX || playerId === playerO; 24 | const isPlayerX = playerId === playerX; 25 | const isPlayerO = playerId === playerO; 26 | const isGameFull = playerX && playerO; 27 | 28 | const handleCopyGameId = () => { 29 | navigator.clipboard.writeText(id); 30 | setCopied(true); 31 | setTimeout(() => setCopied(false), 2000); 32 | }; 33 | 34 | return ( 35 |
36 | 37 | 38 | Game Status 39 | 40 | {status === 'waiting' && 'Waiting for opponent to join...'} 41 | {status === 'in-progress' && !winner && `Current turn: ${currentTurn}`} 42 | {status === 'finished' && winner && `Winner: ${winner}`} 43 | {status === 'finished' && !winner && 'Game ended in a draw'} 44 | 45 | 46 | 47 | {!isGameFull && status === 'waiting' && ( 48 |
49 | 54 | 62 |
63 | )} 64 | 65 | {/* Player info */} 66 |
67 |
72 |
Player X
73 |
74 | {isSinglePlayer ? "You" : isPlayerX ? "You" : playerX ? "Opponent" : "Waiting..."} 75 |
76 |
77 |
82 |
Player O
83 |
84 | {isSinglePlayer ? "You" : isPlayerO ? "You" : playerO ? "Opponent" : "Waiting..."} 85 |
86 |
87 |
88 |
89 | 90 | {status === 'finished' && ( 91 | 99 | )} 100 | 101 | {status === 'waiting' && !isSinglePlayer && ( 102 | 109 | )} 110 | 111 |
112 |
113 | ); 114 | }; 115 | 116 | export default GameControls; 117 | -------------------------------------------------------------------------------- /src/components/GameLobby.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useEffect, useState } from 'react'; 3 | import { useToast } from '@/components/ui/use-toast'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import WaitingRoom from './WaitingRoom'; 6 | import useGame from '@/hooks/useGame'; 7 | import { GameState, generateRandomName } from '@/lib/gameUtils'; 8 | 9 | const GameLobby = () => { 10 | const [playerId, setPlayerId] = useState(''); 11 | const [recentGames, setRecentGames] = useState([]); 12 | const [isLoading, setIsLoading] = useState(true); 13 | const { toast } = useToast(); 14 | const navigate = useNavigate(); 15 | 16 | // Generate or retrieve player ID 17 | useEffect(() => { 18 | const storedPlayerId = localStorage.getItem('playerId'); 19 | const storedPlayerName = localStorage.getItem('playerName'); 20 | 21 | if (storedPlayerId) { 22 | setPlayerId(storedPlayerId); 23 | } else { 24 | const newPlayerId = Math.random().toString(36).substring(2, 15); 25 | localStorage.setItem('playerId', newPlayerId); 26 | setPlayerId(newPlayerId); 27 | } 28 | 29 | if (!storedPlayerName) { 30 | const newPlayerName = generateRandomName(); 31 | localStorage.setItem('playerName', newPlayerName); 32 | } 33 | 34 | setIsLoading(false); 35 | }, []); 36 | 37 | const { createGame, joinGame, findRecentGames, createSoloGame } = useGame(playerId); 38 | 39 | // Load recent games with better error handling and loading states 40 | useEffect(() => { 41 | const loadGames = async () => { 42 | if (!playerId) return; 43 | 44 | try { 45 | setIsLoading(true); 46 | const games = await findRecentGames(); 47 | setRecentGames(games); 48 | } catch (error) { 49 | console.error('Failed to load recent games:', error); 50 | toast({ 51 | title: 'Connection issue', 52 | description: 'Could not load recent games. Please try again.', 53 | variant: 'destructive', 54 | }); 55 | } finally { 56 | setIsLoading(false); 57 | } 58 | }; 59 | 60 | if (playerId) { 61 | loadGames(); 62 | } 63 | }, [playerId, findRecentGames, toast]); 64 | 65 | const handleCreateGame = async () => { 66 | if (!playerId) { 67 | toast({ 68 | title: 'Error', 69 | description: 'Could not create player profile. Please refresh the page.', 70 | variant: 'destructive', 71 | }); 72 | return; 73 | } 74 | 75 | try { 76 | setIsLoading(true); 77 | const gameId = await createGame(); 78 | navigate(`/game/${gameId}`); 79 | toast({ 80 | title: 'Game created', 81 | description: 'Share the game ID with a friend to play.', 82 | }); 83 | } catch (error) { 84 | console.error('Error in handleCreateGame:', error); 85 | toast({ 86 | title: 'Connection issue', 87 | description: 'Failed to create game. Please check your connection and try again.', 88 | variant: 'destructive', 89 | }); 90 | setIsLoading(false); 91 | } 92 | }; 93 | 94 | const handleJoinGame = async (gameId: string) => { 95 | if (!playerId) { 96 | toast({ 97 | title: 'Error', 98 | description: 'Could not create player profile. Please refresh the page.', 99 | variant: 'destructive', 100 | }); 101 | return; 102 | } 103 | 104 | try { 105 | setIsLoading(true); 106 | await joinGame(gameId); 107 | } catch (error) { 108 | console.error('Error in handleJoinGame:', error); 109 | setIsLoading(false); 110 | } 111 | }; 112 | 113 | const handlePlaySolo = async () => { 114 | if (!playerId) { 115 | toast({ 116 | title: 'Error', 117 | description: 'Could not create player profile. Please refresh the page.', 118 | variant: 'destructive', 119 | }); 120 | return; 121 | } 122 | 123 | try { 124 | setIsLoading(true); 125 | const gameId = await createSoloGame(); 126 | navigate(`/game/${gameId}`); 127 | toast({ 128 | title: 'Solo game started', 129 | description: 'You can now play against yourself!', 130 | }); 131 | } catch (error) { 132 | console.error('Error in handlePlaySolo:', error); 133 | toast({ 134 | title: 'Connection issue', 135 | description: 'Failed to create solo game. Please check your connection and try again.', 136 | variant: 'destructive', 137 | }); 138 | setIsLoading(false); 139 | } 140 | }; 141 | 142 | return ( 143 | <> 144 | 151 | 152 | ); 153 | }; 154 | 155 | export default GameLobby; 156 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Link } from 'react-router-dom'; 3 | import { cn } from '@/lib/utils'; 4 | 5 | interface HeaderProps { 6 | className?: string; 7 | } 8 | 9 | const Header = ({ className }: HeaderProps) => { 10 | return ( 11 |
12 |
13 | 17 |
18 |
19 | X 20 | O 21 |
22 | TicTacToe 23 |
24 | 25 | 26 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default Header; 40 | -------------------------------------------------------------------------------- /src/components/WaitingRoom.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useState } from 'react'; 3 | import { Button } from '@/components/ui/button'; 4 | import { Input } from '@/components/ui/input'; 5 | import { useToast } from '@/components/ui/use-toast'; 6 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; 7 | import { GameState, formatGameId } from '@/lib/gameUtils'; 8 | import { cn } from '@/lib/utils'; 9 | 10 | interface WaitingRoomProps { 11 | onCreateGame: () => Promise; 12 | onJoinGame: (gameId: string) => Promise; 13 | onPlaySolo: () => Promise; 14 | recentGames: GameState[]; 15 | isLoading: boolean; 16 | } 17 | 18 | const WaitingRoom = ({ onCreateGame, onJoinGame, onPlaySolo, recentGames, isLoading }: WaitingRoomProps) => { 19 | const [gameId, setGameId] = useState(''); 20 | const [isCreating, setIsCreating] = useState(false); 21 | const [isJoining, setIsJoining] = useState(false); 22 | const [isPlayingSolo, setIsPlayingSolo] = useState(false); 23 | const { toast } = useToast(); 24 | 25 | const handleCreateGame = async () => { 26 | try { 27 | setIsCreating(true); 28 | await onCreateGame(); 29 | } catch (error) { 30 | console.error('Error creating game:', error); 31 | } finally { 32 | setIsCreating(false); 33 | } 34 | }; 35 | 36 | const handleJoinGame = async () => { 37 | if (!gameId.trim()) { 38 | toast({ 39 | title: 'Game ID required', 40 | description: 'Please enter a valid game ID', 41 | variant: 'destructive', 42 | }); 43 | return; 44 | } 45 | 46 | try { 47 | setIsJoining(true); 48 | await onJoinGame(gameId.trim().toLowerCase()); 49 | } catch (error) { 50 | console.error('Error joining game:', error); 51 | } finally { 52 | setIsJoining(false); 53 | } 54 | }; 55 | 56 | const handlePlaySolo = async () => { 57 | try { 58 | setIsPlayingSolo(true); 59 | await onPlaySolo(); 60 | } catch (error) { 61 | console.error('Error starting solo game:', error); 62 | } finally { 63 | setIsPlayingSolo(false); 64 | } 65 | }; 66 | 67 | const handleJoinRecentGame = async (id: string) => { 68 | try { 69 | setIsJoining(true); 70 | await onJoinGame(id); 71 | } catch (error) { 72 | console.error('Error joining recent game:', error); 73 | } finally { 74 | setIsJoining(false); 75 | } 76 | }; 77 | 78 | return ( 79 |
80 | 81 | 82 | Game Options 83 | 84 | Choose how you want to play 85 | 86 | 87 | 88 | 95 | 96 | 104 | 105 | 106 | 107 | 108 | 109 | Join Game 110 | 111 | Enter a game ID to join an existing game 112 | 113 | 114 | 115 |
116 | setGameId(e.target.value)} 120 | className="bg-background/80" 121 | autoComplete="off" 122 | disabled={isLoading} 123 | /> 124 | 131 |
132 |
133 |
134 | 135 | {recentGames.length > 0 && ( 136 | 137 | 138 | Open Games 139 | 140 | Join a game that's waiting for players 141 | 142 | 143 | 144 |
145 | {recentGames.map((game) => ( 146 |
handleJoinRecentGame(game.id)} 154 | > 155 |
156 |
157 | {formatGameId(game.id).slice(0, 2)} 158 |
159 |
160 |
{formatGameId(game.id)}
161 |
162 | Created {new Date(game.createdAt).toLocaleTimeString()} 163 |
164 |
165 |
166 | 174 |
175 | ))} 176 |
177 |
178 |
179 | )} 180 |
181 | ); 182 | }; 183 | 184 | export default WaitingRoom; 185 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 3 | import { ChevronDown } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Accordion = AccordionPrimitive.Root 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 18 | )) 19 | AccordionItem.displayName = "AccordionItem" 20 | 21 | const AccordionTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, children, ...props }, ref) => ( 25 | 26 | svg]:rotate-180", 30 | className 31 | )} 32 | {...props} 33 | > 34 | {children} 35 | 36 | 37 | 38 | )) 39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 40 | 41 | const AccordionContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 50 |
{children}
51 |
52 | )) 53 | 54 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 55 | 56 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 57 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 3 | 4 | import { cn } from "@/lib/utils" 5 | import { buttonVariants } from "@/components/ui/button" 6 | 7 | const AlertDialog = AlertDialogPrimitive.Root 8 | 9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 10 | 11 | const AlertDialogPortal = AlertDialogPrimitive.Portal 12 | 13 | const AlertDialogOverlay = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )) 26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 27 | 28 | const AlertDialogContent = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, ...props }, ref) => ( 32 | 33 | 34 | 42 | 43 | )) 44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 45 | 46 | const AlertDialogHeader = ({ 47 | className, 48 | ...props 49 | }: React.HTMLAttributes) => ( 50 |
57 | ) 58 | AlertDialogHeader.displayName = "AlertDialogHeader" 59 | 60 | const AlertDialogFooter = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ) 72 | AlertDialogFooter.displayName = "AlertDialogFooter" 73 | 74 | const AlertDialogTitle = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef 77 | >(({ className, ...props }, ref) => ( 78 | 83 | )) 84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 85 | 86 | const AlertDialogDescription = React.forwardRef< 87 | React.ElementRef, 88 | React.ComponentPropsWithoutRef 89 | >(({ className, ...props }, ref) => ( 90 | 95 | )) 96 | AlertDialogDescription.displayName = 97 | AlertDialogPrimitive.Description.displayName 98 | 99 | const AlertDialogAction = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 110 | 111 | const AlertDialogCancel = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 124 | )) 125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 126 | 127 | export { 128 | AlertDialog, 129 | AlertDialogPortal, 130 | AlertDialogOverlay, 131 | AlertDialogTrigger, 132 | AlertDialogContent, 133 | AlertDialogHeader, 134 | AlertDialogFooter, 135 | AlertDialogTitle, 136 | AlertDialogDescription, 137 | AlertDialogAction, 138 | AlertDialogCancel, 139 | } 140 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /src/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 2 | 3 | const AspectRatio = AspectRatioPrimitive.Root 4 | 5 | export { AspectRatio } 6 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>