Compare commits
8 Commits
b836ce2ed8
...
b778796195
| Author | SHA1 | Date |
|---|---|---|
|
|
b778796195 | |
|
|
3fe0141ed7 | |
|
|
461f8c062e | |
|
|
bce36b5ab4 | |
|
|
f2183f0d15 | |
|
|
53a3a5d44f | |
|
|
891dde40b6 | |
|
|
a00bdba56b |
|
|
@ -28,3 +28,4 @@ temp_dbs
|
||||||
|
|
||||||
deploy.sh
|
deploy.sh
|
||||||
sqlite.db
|
sqlite.db
|
||||||
|
coverage
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Business Minesweeper is a real-time multiplayer minesweeper game with expanding boards, user accounts, match history, spectating, and collectibles. Built with React frontend and Bun backend using WebSockets for real-time communication.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
```bash
|
||||||
|
# Initial setup (requires Bun installed)
|
||||||
|
echo "SECRET=SOME_RANDOM_STRING" > .env
|
||||||
|
bun install
|
||||||
|
bun run drizzle:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
bun dev # Start both frontend and backend in development mode
|
||||||
|
bun run dev:client # Start only frontend (Vite dev server)
|
||||||
|
bun run dev:backend # Start only backend with hot reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build & Quality
|
||||||
|
```bash
|
||||||
|
bun run build # Build for production (TypeScript compilation + Vite build)
|
||||||
|
bun run lint # Run ESLint
|
||||||
|
bun run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database
|
||||||
|
```bash
|
||||||
|
bun run drizzle:schema # Generate database migrations
|
||||||
|
bun run drizzle:migrate # Run database migrations
|
||||||
|
bun run nukedb # Delete and recreate database (removes sqlite.db)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Frontend (`src/`)
|
||||||
|
- **Framework**: React 18 + TypeScript with Vite build system
|
||||||
|
- **Routing**: Wouter for client-side routing
|
||||||
|
- **State Management**:
|
||||||
|
- Jotai for component state (atoms in `src/atoms.ts`)
|
||||||
|
- TanStack Query for server state and caching
|
||||||
|
- **Styling**: Tailwind CSS v4 with custom CSS variables
|
||||||
|
- **Animation**: Motion (Framer Motion) for UI animations
|
||||||
|
- **Game Rendering**: Pixi.js with PixiViewport for the minesweeper board
|
||||||
|
|
||||||
|
### Backend (`backend/`)
|
||||||
|
- **Runtime**: Bun with TypeScript
|
||||||
|
- **Architecture**: WebSocket-based real-time API with controller pattern
|
||||||
|
- **Database**: SQLite with Drizzle ORM
|
||||||
|
- **Structure**:
|
||||||
|
- `router.ts` - Main request handler and routing
|
||||||
|
- `controller/` - Business logic controllers (game, user, scoreboard)
|
||||||
|
- `repositories/` - Data access layer
|
||||||
|
- `database/` - DB connection and configuration
|
||||||
|
- `entities/` - Type definitions
|
||||||
|
- `events.ts` - Real-time event system
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
- **WebSocket Client**: `src/wsClient.ts` handles connection, reconnection, and type-safe API calls
|
||||||
|
- **Real-time Updates**: Server publishes events to all connected clients for live game updates
|
||||||
|
- **Type Safety**: Shared types between frontend/backend via `Routes` interface
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
#### Game Logic
|
||||||
|
- `src/views/endless/Endless.tsx` - Main game view with Pixi.js board
|
||||||
|
- `src/components/LazyBoard.tsx` - Game board rendering component
|
||||||
|
- `backend/controller/gameController.ts` - Server-side game logic
|
||||||
|
|
||||||
|
#### UI Architecture
|
||||||
|
- `src/Shell.tsx` - Main layout with responsive drawer navigation
|
||||||
|
- `src/main.tsx` - App entry point with routing setup
|
||||||
|
- `src/components/` - Reusable UI components using Radix UI primitives
|
||||||
|
|
||||||
|
#### Data Flow
|
||||||
|
- WebSocket mutations for game actions (reveal, flag, etc.)
|
||||||
|
- TanStack Query for caching user data, game state, leaderboards
|
||||||
|
- Jotai atoms for local UI state (current game ID, settings)
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### WebSocket API Usage
|
||||||
|
```typescript
|
||||||
|
const mutation = useWSMutation("game.reveal");
|
||||||
|
const { data } = useWSQuery("game.getGameState", gameId, !!gameId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Queries
|
||||||
|
Uses Drizzle ORM with repository pattern for data access. Each controller has corresponding repository in `backend/repositories/`.
|
||||||
|
|
||||||
|
### Real-time Events
|
||||||
|
Server publishes events via `backend/events.ts`, frontend handles via WebSocket message listeners in `wsClient.ts`.
|
||||||
|
|
@ -4,9 +4,14 @@ import { Game } from "../schema";
|
||||||
import {
|
import {
|
||||||
getCurrentGame,
|
getCurrentGame,
|
||||||
getGame,
|
getGame,
|
||||||
|
getGames,
|
||||||
getGamesCount,
|
getGamesCount,
|
||||||
|
getTotalGamesPlayed,
|
||||||
|
parseGameState,
|
||||||
upsertGame,
|
upsertGame,
|
||||||
|
upsertGameState,
|
||||||
} from "./gameRepository";
|
} from "./gameRepository";
|
||||||
|
import { encode } from "@msgpack/msgpack";
|
||||||
|
|
||||||
describe("GameRepository", () => {
|
describe("GameRepository", () => {
|
||||||
it("should get game by uuid", async () => {
|
it("should get game by uuid", async () => {
|
||||||
|
|
@ -136,4 +141,131 @@ describe("GameRepository", () => {
|
||||||
started: started + 1,
|
started: started + 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should get finished games for user", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
const started = Date.now();
|
||||||
|
await db.insert(Game).values({
|
||||||
|
uuid: "TestUuid1",
|
||||||
|
user: "TestUser",
|
||||||
|
stage: 1,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 1,
|
||||||
|
started,
|
||||||
|
});
|
||||||
|
await db.insert(Game).values({
|
||||||
|
uuid: "TestUuid2",
|
||||||
|
user: "TestUser",
|
||||||
|
stage: 2,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 0,
|
||||||
|
started: started + 1,
|
||||||
|
});
|
||||||
|
await db.insert(Game).values({
|
||||||
|
uuid: "TestUuid3",
|
||||||
|
user: "OtherUser",
|
||||||
|
stage: 1,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 1,
|
||||||
|
started: started + 2,
|
||||||
|
});
|
||||||
|
const games = await getGames(db, "TestUser");
|
||||||
|
expect(games).toHaveLength(1);
|
||||||
|
expect(games[0].uuid).toBe("TestUuid1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get total games played for user", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
const started = Date.now();
|
||||||
|
await db.insert(Game).values({
|
||||||
|
uuid: "TestUuid1",
|
||||||
|
user: "TestUser",
|
||||||
|
stage: 1,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 1,
|
||||||
|
started,
|
||||||
|
});
|
||||||
|
await db.insert(Game).values({
|
||||||
|
uuid: "TestUuid2",
|
||||||
|
user: "TestUser",
|
||||||
|
stage: 2,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 0,
|
||||||
|
started: started + 1,
|
||||||
|
});
|
||||||
|
const totalGames = await getTotalGamesPlayed(db, "TestUser");
|
||||||
|
expect(totalGames).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get total games played for all users", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
const started = Date.now();
|
||||||
|
await db.insert(Game).values({
|
||||||
|
uuid: "TestUuid1",
|
||||||
|
user: "TestUser",
|
||||||
|
stage: 1,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 1,
|
||||||
|
started,
|
||||||
|
});
|
||||||
|
await db.insert(Game).values({
|
||||||
|
uuid: "TestUuid2",
|
||||||
|
user: "OtherUser",
|
||||||
|
stage: 2,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 1,
|
||||||
|
started: started + 1,
|
||||||
|
});
|
||||||
|
const totalGames = await getTotalGamesPlayed(db);
|
||||||
|
expect(totalGames).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse game state", () => {
|
||||||
|
const gameData = {
|
||||||
|
uuid: "TestUuid",
|
||||||
|
user: "TestUser",
|
||||||
|
stage: 1,
|
||||||
|
finished: 1,
|
||||||
|
started: Date.now(),
|
||||||
|
// Other ServerGame properties don't matter for this test
|
||||||
|
mines: [[false]],
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
isRevealed: [[false]],
|
||||||
|
isFlagged: [[false]],
|
||||||
|
isQuestionMark: [[false]],
|
||||||
|
minesCount: 0,
|
||||||
|
lastClick: [0, 0] as [number, number],
|
||||||
|
theme: "default" as const,
|
||||||
|
};
|
||||||
|
const buffer = Buffer.from(encode(gameData));
|
||||||
|
const parsed = parseGameState(buffer);
|
||||||
|
expect(parsed).toEqual(gameData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upsert game state", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
const serverGame = {
|
||||||
|
uuid: "TestUuid",
|
||||||
|
user: "TestUser",
|
||||||
|
stage: 1,
|
||||||
|
finished: 1,
|
||||||
|
started: Date.now(),
|
||||||
|
// Other ServerGame properties don't matter for this test
|
||||||
|
mines: [[false]],
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
isRevealed: [[false]],
|
||||||
|
isFlagged: [[false]],
|
||||||
|
isQuestionMark: [[false]],
|
||||||
|
minesCount: 0,
|
||||||
|
lastClick: [0, 0] as [number, number],
|
||||||
|
theme: "default" as const,
|
||||||
|
};
|
||||||
|
await upsertGameState(db, serverGame);
|
||||||
|
const game = await getGame(db, "TestUuid");
|
||||||
|
expect(game?.uuid).toBe("TestUuid");
|
||||||
|
expect(game?.user).toBe("TestUser");
|
||||||
|
expect(game?.stage).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export const upsertGameState = async (
|
||||||
game: ServerGame,
|
game: ServerGame,
|
||||||
) => {
|
) => {
|
||||||
const { uuid, user, stage, finished, started } = game;
|
const { uuid, user, stage, finished, started } = game;
|
||||||
upsertGame(db, {
|
await upsertGame(db, {
|
||||||
uuid,
|
uuid,
|
||||||
user,
|
user,
|
||||||
stage,
|
stage,
|
||||||
|
|
|
||||||
|
|
@ -37,4 +37,55 @@ describe("ScoreRepository", () => {
|
||||||
const result = await getScoreBoard(db);
|
const result = await getScoreBoard(db);
|
||||||
expect(result).toEqual([{ stage: 10, user: "TestUser" }]);
|
expect(result).toEqual([{ stage: 10, user: "TestUser" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return empty array when no finished games exist", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
await db.insert(User).values({
|
||||||
|
name: "TestUser2",
|
||||||
|
password: "test",
|
||||||
|
});
|
||||||
|
await db.insert(Game).values({
|
||||||
|
user: "TestUser2",
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
stage: 5,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 0,
|
||||||
|
started: Date.now(),
|
||||||
|
});
|
||||||
|
const result = await getScoreBoard(db);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple users and sort by highest stage", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
await db.insert(User).values({
|
||||||
|
name: "User1",
|
||||||
|
password: "test",
|
||||||
|
});
|
||||||
|
await db.insert(User).values({
|
||||||
|
name: "User2",
|
||||||
|
password: "test",
|
||||||
|
});
|
||||||
|
await db.insert(Game).values({
|
||||||
|
user: "User1",
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
stage: 15,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 1,
|
||||||
|
started: Date.now(),
|
||||||
|
});
|
||||||
|
await db.insert(Game).values({
|
||||||
|
user: "User2",
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
stage: 25,
|
||||||
|
gameState: Buffer.from("ANY"),
|
||||||
|
finished: 1,
|
||||||
|
started: Date.now(),
|
||||||
|
});
|
||||||
|
const result = await getScoreBoard(db);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ stage: 25, user: "User2" },
|
||||||
|
{ stage: 15, user: "User1" }
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { getTestDb } from "../database/getTestDb";
|
import { getTestDb } from "../database/getTestDb";
|
||||||
import { getUser, loginUser, registerUser } from "./userRepository";
|
import {
|
||||||
|
getUser,
|
||||||
|
getUserCount,
|
||||||
|
getUserSettings,
|
||||||
|
loginUser,
|
||||||
|
registerUser,
|
||||||
|
upsertUserSettings,
|
||||||
|
} from "./userRepository";
|
||||||
|
|
||||||
describe("UserRepository", () => {
|
describe("UserRepository", () => {
|
||||||
it("should register a user", async () => {
|
it("should register a user", async () => {
|
||||||
|
|
@ -45,4 +52,72 @@ describe("UserRepository", () => {
|
||||||
password: undefined,
|
password: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should throw error if password is incorrect", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
await registerUser(db, "TestUser", "test");
|
||||||
|
expect(loginUser(db, "TestUser", "wrongpassword")).rejects.toThrow(
|
||||||
|
"Incorrect password",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle getUser for nonexistent user", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
const user = await getUser(db, "NonexistentUser");
|
||||||
|
expect(user?.name).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get user count", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
await registerUser(db, "TestUser1", "test");
|
||||||
|
await registerUser(db, "TestUser2", "test");
|
||||||
|
const count = await getUserCount(db);
|
||||||
|
expect(count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get default user settings", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
const settings = await getUserSettings(db, "TestUser");
|
||||||
|
expect(settings).toEqual({
|
||||||
|
placeQuestionMark: false,
|
||||||
|
longPressOnDesktop: false,
|
||||||
|
showRevealAnimation: true,
|
||||||
|
soundEnabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upsert user settings - insert", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
const newSettings = {
|
||||||
|
placeQuestionMark: true,
|
||||||
|
longPressOnDesktop: true,
|
||||||
|
showRevealAnimation: false,
|
||||||
|
soundEnabled: false,
|
||||||
|
};
|
||||||
|
await upsertUserSettings(db, "TestUser", newSettings);
|
||||||
|
const settings = await getUserSettings(db, "TestUser");
|
||||||
|
expect(settings).toEqual(newSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upsert user settings - update", async () => {
|
||||||
|
const db = getTestDb();
|
||||||
|
const initialSettings = {
|
||||||
|
placeQuestionMark: false,
|
||||||
|
longPressOnDesktop: false,
|
||||||
|
showRevealAnimation: true,
|
||||||
|
soundEnabled: true,
|
||||||
|
};
|
||||||
|
await upsertUserSettings(db, "TestUser", initialSettings);
|
||||||
|
|
||||||
|
const updatedSettings = {
|
||||||
|
placeQuestionMark: true,
|
||||||
|
longPressOnDesktop: true,
|
||||||
|
showRevealAnimation: false,
|
||||||
|
soundEnabled: false,
|
||||||
|
};
|
||||||
|
await upsertUserSettings(db, "TestUser", updatedSettings);
|
||||||
|
|
||||||
|
const settings = await getUserSettings(db, "TestUser");
|
||||||
|
expect(settings).toEqual(updatedSettings);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,20 @@ export const handleRequest = async (
|
||||||
!message ||
|
!message ||
|
||||||
!(typeof message === "object") ||
|
!(typeof message === "object") ||
|
||||||
!("type" in message) ||
|
!("type" in message) ||
|
||||||
!("payload" in message) ||
|
|
||||||
!("id" in message)
|
!("id" in message)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const { type, payload, id } = message;
|
const { type, id } = message;
|
||||||
if (!(typeof type === "string")) return;
|
if (!(typeof type === "string")) return;
|
||||||
|
|
||||||
|
// Handle ping message
|
||||||
|
if (type === 'ping') {
|
||||||
|
ws.send(JSON.stringify({ type: 'pong', id }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("payload" in message)) return;
|
||||||
|
const { payload } = message;
|
||||||
const [controllerName, action] = type.split(".");
|
const [controllerName, action] = type.split(".");
|
||||||
if (!(controllerName in controllers)) return;
|
if (!(controllerName in controllers)) return;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@
|
||||||
"react-confetti-boom": "^1.0.0",
|
"react-confetti-boom": "^1.0.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tailwind-merge": "^2.5.3",
|
"tailwind-merge": "^2.5.3",
|
||||||
"use-sound": "^4.0.3",
|
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { getValue, ServerGame, serverToClientGame } from "./game";
|
import { getValue, isServerGame, isClientGame, ServerGame, ClientGame, serverToClientGame } from "./game";
|
||||||
|
|
||||||
describe("Game", () => {
|
describe("Game", () => {
|
||||||
it("should get value", () => {
|
it("should get value", () => {
|
||||||
|
|
@ -15,6 +15,46 @@ describe("Game", () => {
|
||||||
expect(getValue(mines, 1, 3)).toEqual(8);
|
expect(getValue(mines, 1, 3)).toEqual(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should identify server game", () => {
|
||||||
|
const serverGame: ServerGame = {
|
||||||
|
theme: "default",
|
||||||
|
mines: [[false]],
|
||||||
|
minesCount: 0,
|
||||||
|
isRevealed: [[false]],
|
||||||
|
isFlagged: [[false]],
|
||||||
|
isQuestionMark: [[false]],
|
||||||
|
started: Date.now(),
|
||||||
|
finished: 0,
|
||||||
|
lastClick: [0, 0],
|
||||||
|
uuid: "test-uuid",
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
user: "TestUser",
|
||||||
|
stage: 1,
|
||||||
|
};
|
||||||
|
expect(isServerGame(serverGame)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should identify client game", () => {
|
||||||
|
const clientGame: ClientGame = {
|
||||||
|
theme: "default",
|
||||||
|
minesCount: 0,
|
||||||
|
isRevealed: [[false]],
|
||||||
|
isFlagged: [[false]],
|
||||||
|
isQuestionMark: [[false]],
|
||||||
|
values: [[0]],
|
||||||
|
started: Date.now(),
|
||||||
|
lastClick: [0, 0],
|
||||||
|
uuid: "test-uuid",
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
user: "TestUser",
|
||||||
|
stage: 1,
|
||||||
|
};
|
||||||
|
expect(isClientGame(clientGame)).toBe(true);
|
||||||
|
expect(isServerGame(clientGame)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("should convert server to client game", () => {
|
it("should convert server to client game", () => {
|
||||||
const serverGame: ServerGame = {
|
const serverGame: ServerGame = {
|
||||||
theme: "default",
|
theme: "default",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export const userSettings = z.object({
|
||||||
placeQuestionMark: z.boolean().default(false),
|
placeQuestionMark: z.boolean().default(false),
|
||||||
longPressOnDesktop: z.boolean().default(false),
|
longPressOnDesktop: z.boolean().default(false),
|
||||||
showRevealAnimation: z.boolean().default(true),
|
showRevealAnimation: z.boolean().default(true),
|
||||||
|
soundEnabled: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserSettings = z.infer<typeof userSettings>;
|
export type UserSettings = z.infer<typeof userSettings>;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { useMediaQuery } from "@uidotdev/usehooks";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import { Tag } from "./components/Tag";
|
import { Tag } from "./components/Tag";
|
||||||
import Feed from "./components/Feed/Feed";
|
import Feed from "./components/Feed/Feed";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { loginTokenAtom } from "./atoms";
|
||||||
|
|
||||||
const drawerWidth = 256;
|
const drawerWidth = 256;
|
||||||
const drawerWidthWithPadding = drawerWidth;
|
const drawerWidthWithPadding = drawerWidth;
|
||||||
|
|
@ -24,6 +26,7 @@ const drawerWidthWithPadding = drawerWidth;
|
||||||
const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const drawerRef = useRef<HTMLDivElement>(null);
|
const drawerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [loginToken] = useAtom(loginTokenAtom);
|
||||||
|
|
||||||
const x = isOpen ? 0 : -drawerWidthWithPadding;
|
const x = isOpen ? 0 : -drawerWidthWithPadding;
|
||||||
const width = isOpen ? drawerWidthWithPadding : 0;
|
const width = isOpen ? drawerWidthWithPadding : 0;
|
||||||
|
|
@ -79,22 +82,28 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
<Play />
|
<Play />
|
||||||
Play
|
Play
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/history">
|
{loginToken && (
|
||||||
<History />
|
<NavLink href="/history">
|
||||||
History
|
<History />
|
||||||
</NavLink>
|
History
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
<NavLink href="/store">
|
<NavLink href="/store">
|
||||||
<Store />
|
<Store />
|
||||||
Store
|
Store
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink href="/collection">
|
{loginToken && (
|
||||||
<Library />
|
<NavLink href="/collection">
|
||||||
Collection <Tag size="sm">NEW</Tag>
|
<Library />
|
||||||
</NavLink>
|
Collection <Tag size="sm">NEW</Tag>
|
||||||
<NavLink href="/settings">
|
</NavLink>
|
||||||
<Settings />
|
)}
|
||||||
Settings
|
{loginToken && (
|
||||||
</NavLink>
|
<NavLink href="/settings">
|
||||||
|
<Settings />
|
||||||
|
Settings
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
<Hr />
|
<Hr />
|
||||||
<Feed />
|
<Feed />
|
||||||
{/* <Hr /> */}
|
{/* <Hr /> */}
|
||||||
|
|
@ -125,7 +134,7 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
transition={{ type: "tween" }}
|
transition={{ type: "tween" }}
|
||||||
layout
|
layout
|
||||||
/>
|
/>
|
||||||
<motion.div className="flex flex-col gap-4 grow max-w-7xl mx-auto w-[calc(100vw-256px)]">
|
<motion.div className="flex flex-col gap-4 grow max-w-7xl mx-auto w-full md:w-[calc(100vw-256px)]">
|
||||||
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2">
|
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2">
|
||||||
<Header />
|
<Header />
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,6 @@ interface LootboxResult {
|
||||||
lootbox: string;
|
lootbox: string;
|
||||||
}
|
}
|
||||||
export const lootboxResultAtom = atom<LootboxResult | undefined>();
|
export const lootboxResultAtom = atom<LootboxResult | undefined>();
|
||||||
|
|
||||||
|
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
||||||
|
export const connectionStatusAtom = atom<ConnectionStatus>('connecting');
|
||||||
|
|
|
||||||
|
|
@ -17,26 +17,31 @@ import { wsClient } from "../../wsClient";
|
||||||
|
|
||||||
const RegisterButton = () => {
|
const RegisterButton = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoginMode, setIsLoginMode] = useState(false);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const register = useWSMutation("user.register");
|
const register = useWSMutation("user.register");
|
||||||
|
const login = useWSMutation("user.login");
|
||||||
const [, setToken] = useAtom(loginTokenAtom);
|
const [, setToken] = useAtom(loginTokenAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUsername("");
|
setUsername("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
|
setError("");
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="primary">Register</Button>
|
<Button variant="primary" className="self-start">
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Register</DialogTitle>
|
<DialogTitle>{isLoginMode ? "Login" : "Register"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<label className="text-white/70 font-bold">Username</label>
|
<label className="text-white/70 font-bold">Username</label>
|
||||||
|
|
@ -49,6 +54,18 @@ const RegisterButton = () => {
|
||||||
<PasswordInput value={password} onChange={setPassword} />
|
<PasswordInput value={password} onChange={setPassword} />
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-500">{error}</p>}
|
{error && <p className="text-red-500">{error}</p>}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setIsLoginMode(!isLoginMode);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
className="text-sm text-white/60 hover:text-white/80"
|
||||||
|
>
|
||||||
|
{isLoginMode ? "Need an account? Register" : "Already have an account? Login"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => setIsOpen(false)}>
|
<Button variant="ghost" onClick={() => setIsOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
@ -56,7 +73,8 @@ const RegisterButton = () => {
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
register
|
const mutation = isLoginMode ? login : register;
|
||||||
|
mutation
|
||||||
.mutateAsync({ username, password })
|
.mutateAsync({ username, password })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
setToken(res.token);
|
setToken(res.token);
|
||||||
|
|
@ -64,13 +82,14 @@ const RegisterButton = () => {
|
||||||
token: res.token,
|
token: res.token,
|
||||||
});
|
});
|
||||||
await queryClient.resetQueries();
|
await queryClient.resetQueries();
|
||||||
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setError(e);
|
setError(e);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Register
|
{isLoginMode ? "Login" : "Register"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import Coords from "./Coords";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Maximize2, Minimize2, RotateCcw } from "lucide-react";
|
import { Maximize2, Minimize2, RotateCcw } from "lucide-react";
|
||||||
import useSound from "use-sound";
|
import { useAudio } from "../hooks/useAudio";
|
||||||
import explosion from "../sound/explosion.mp3";
|
import explosion from "../sound/explosion.mp3";
|
||||||
import "@pixi/canvas-display";
|
import "@pixi/canvas-display";
|
||||||
import "@pixi/canvas-extract";
|
import "@pixi/canvas-extract";
|
||||||
|
|
@ -80,7 +80,7 @@ const Board: React.FC<BoardProps> = (props) => {
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
const [height, setHeight] = useState(0);
|
const [height, setHeight] = useState(0);
|
||||||
const showLastPos = game.user !== user || isServerGame(game);
|
const showLastPos = game.user !== user || isServerGame(game);
|
||||||
const [playSound] = useSound(explosion, {
|
const [playSound] = useAudio(explosion, {
|
||||||
volume: 0.5,
|
volume: 0.5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { connectionStatusAtom, type ConnectionStatus as ConnectionStatusType } from "../atoms";
|
||||||
|
import { Wifi, WifiOff } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
|
||||||
|
const statusConfig: Record<ConnectionStatusType, { color: string; icon: React.ReactNode; label: string }> = {
|
||||||
|
connecting: { color: "text-yellow-400", icon: <Wifi className="w-3 h-3" />, label: "Connecting..." },
|
||||||
|
connected: { color: "text-green-400", icon: <Wifi className="w-3 h-3" />, label: "Connected" },
|
||||||
|
disconnected: { color: "text-red-400", icon: <WifiOff className="w-3 h-3" />, label: "Disconnected" },
|
||||||
|
reconnecting: { color: "text-yellow-400", icon: <Wifi className="w-3 h-3" />, label: "Reconnecting..." },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConnectionStatus = () => {
|
||||||
|
const [status] = useAtom(connectionStatusAtom);
|
||||||
|
const config = statusConfig[status];
|
||||||
|
|
||||||
|
// Only show when not connected
|
||||||
|
const shouldShow = status !== 'connected';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{shouldShow && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className={`flex items-center gap-1.5 text-xs ${config.color} bg-white/5 px-2 py-1 rounded-md backdrop-blur-sm`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={status === 'connecting' || status === 'reconnecting' ? { rotate: 360 } : {}}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
</motion.div>
|
||||||
|
<span className="font-medium">{config.label}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -15,6 +15,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { loginTokenAtom } from "../atoms";
|
import { loginTokenAtom } from "../atoms";
|
||||||
import Gems from "./Gems";
|
import Gems from "./Gems";
|
||||||
|
import { ConnectionStatus } from "./ConnectionStatus";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
|
@ -28,7 +29,8 @@ const Header = () => {
|
||||||
const { data: gems } = useWSQuery("user.getOwnGems", null);
|
const { data: gems } = useWSQuery("user.getOwnGems", null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex gap-4">
|
<div className="w-full flex gap-4 items-center">
|
||||||
|
<ConnectionStatus />
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
|
|
||||||
{username ? (
|
{username ? (
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ interface PastMatchProps {
|
||||||
const PastMatch = ({ game }: PastMatchProps) => {
|
const PastMatch = ({ game }: PastMatchProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 items-center w-full @container">
|
<div className="flex flex-col gap-4 items-center w-full @container">
|
||||||
<div className="w-full bg-white/10 border-white/20 border-1 rounded-md grid justify-center grid-cols-4 @max-xl:grid-cols-3 p-4">
|
<div className="w-full bg-white/10 border-white/20 border-1 rounded-md grid justify-center grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
||||||
<div className="flex-col flex">
|
<div className="flex-col flex">
|
||||||
<div className="text-white/90 text-lg">Endless</div>
|
<div className="text-white/90 text-lg">Endless</div>
|
||||||
<div className="text-white/50 text-lg">
|
<div className="text-white/50 text-lg">
|
||||||
|
|
@ -24,10 +24,10 @@ const PastMatch = ({ game }: PastMatchProps) => {
|
||||||
{game.minesCount - game.isFlagged.flat().filter((f) => f).length}
|
{game.minesCount - game.isFlagged.flat().filter((f) => f).length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/80 text-lg @max-xl:hidden">
|
<div className="text-white/80 text-lg hidden lg:block">
|
||||||
<div>Duration: {formatTimeSpan(game.finished - game.started)}</div>
|
<div>Duration: {formatTimeSpan(game.finished - game.started)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-center sm:justify-end col-span-full sm:col-span-1">
|
||||||
{/* @ts-expect-error as is cheaply typed */}
|
{/* @ts-expect-error as is cheaply typed */}
|
||||||
<Button as={Link} href={`/play/${game.uuid}`} variant="outline">
|
<Button as={Link} href={`/play/${game.uuid}`} variant="outline">
|
||||||
Show Board
|
Show Board
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ import { cn } from "../lib/utils";
|
||||||
|
|
||||||
function TooltipProvider({
|
function TooltipProvider({
|
||||||
delayDuration = 0,
|
delayDuration = 0,
|
||||||
|
disableHoverableContent = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
return (
|
return (
|
||||||
<TooltipPrimitive.Provider
|
<TooltipPrimitive.Provider
|
||||||
data-slot="tooltip-provider"
|
data-slot="tooltip-provider"
|
||||||
delayDuration={delayDuration}
|
delayDuration={delayDuration}
|
||||||
|
disableHoverableContent={disableHoverableContent}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useWSQuery } from '../hooks';
|
||||||
|
|
||||||
|
interface AudioOptions {
|
||||||
|
volume?: number;
|
||||||
|
loop?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAudio = (src: string, options: AudioOptions = {}) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const { data: settings } = useWSQuery("user.getSettings", null);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
// Check if sound is disabled in settings
|
||||||
|
if (settings && settings.soundEnabled === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioRef.current) {
|
||||||
|
audioRef.current = new Audio(src);
|
||||||
|
audioRef.current.volume = options.volume ?? 1;
|
||||||
|
audioRef.current.loop = options.loop ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to beginning and play
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
audioRef.current.play().catch((error) => {
|
||||||
|
console.warn('Audio play failed:', error);
|
||||||
|
});
|
||||||
|
}, [src, options.volume, options.loop, settings]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [play, { pause, stop }] as const;
|
||||||
|
};
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useWSMutation, useWSQuery } from "../../hooks";
|
import { useWSMutation, useWSQuery } from "../../hooks";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { gameIdAtom } from "../../atoms";
|
import { gameIdAtom, loginTokenAtom } from "../../atoms";
|
||||||
import { Button } from "../../components/Button";
|
import { Button } from "../../components/Button";
|
||||||
import LeaderboardButton from "../../components/LeaderboardButton";
|
import LeaderboardButton from "../../components/LeaderboardButton";
|
||||||
import { Fragment, startTransition, Suspense, useEffect } from "react";
|
import { Fragment, startTransition, Suspense, useEffect } from "react";
|
||||||
import { Board } from "../../components/LazyBoard";
|
import { Board } from "../../components/LazyBoard";
|
||||||
|
import RegisterButton from "../../components/Auth/RegisterButton";
|
||||||
|
|
||||||
interface EndlessProps {
|
interface EndlessProps {
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
|
|
@ -12,6 +13,7 @@ interface EndlessProps {
|
||||||
|
|
||||||
const Endless: React.FC<EndlessProps> = (props) => {
|
const Endless: React.FC<EndlessProps> = (props) => {
|
||||||
const [gameId, setGameId] = useAtom(gameIdAtom);
|
const [gameId, setGameId] = useAtom(gameIdAtom);
|
||||||
|
const [loginToken] = useAtom(loginTokenAtom);
|
||||||
const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId);
|
const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId);
|
||||||
const { data: settings } = useWSQuery("user.getSettings", null);
|
const { data: settings } = useWSQuery("user.getSettings", null);
|
||||||
const startGame = useWSMutation("game.createGame");
|
const startGame = useWSMutation("game.createGame");
|
||||||
|
|
@ -73,18 +75,22 @@ const Endless: React.FC<EndlessProps> = (props) => {
|
||||||
<div className="w-full grid md:grid-cols-[350px_1fr]">
|
<div className="w-full grid md:grid-cols-[350px_1fr]">
|
||||||
<div className="flex flex-col md:border-r-white/10 md:border-r-1 gap-8 pr-12">
|
<div className="flex flex-col md:border-r-white/10 md:border-r-1 gap-8 pr-12">
|
||||||
<h2 className="text-white/90 text-xl">Minesweeper Endless</h2>
|
<h2 className="text-white/90 text-xl">Minesweeper Endless</h2>
|
||||||
<Button
|
{loginToken ? (
|
||||||
className="w-fit"
|
<Button
|
||||||
variant="primary"
|
className="w-fit"
|
||||||
onClick={async () => {
|
variant="primary"
|
||||||
const gameId = await startGame.mutateAsync(null);
|
onClick={async () => {
|
||||||
startTransition(() => {
|
const gameId = await startGame.mutateAsync(null);
|
||||||
setGameId(gameId.uuid);
|
startTransition(() => {
|
||||||
});
|
setGameId(gameId.uuid);
|
||||||
}}
|
});
|
||||||
>
|
}}
|
||||||
Start Game
|
>
|
||||||
</Button>
|
Start Game
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<RegisterButton />
|
||||||
|
)}
|
||||||
<h2 className="text-white/80 text-lg mt-8">How to play</h2>
|
<h2 className="text-white/80 text-lg mt-8">How to play</h2>
|
||||||
<p className="text-white/90">
|
<p className="text-white/90">
|
||||||
Endless minesweeper is just like regular minesweeper but you
|
Endless minesweeper is just like regular minesweeper but you
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo, useRef, useEffect, useState } from "react";
|
||||||
import { useWSQuery } from "../../hooks";
|
import { useWSQuery } from "../../hooks";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,7 +9,72 @@ import {
|
||||||
import PastMatch from "../../components/PastMatch";
|
import PastMatch from "../../components/PastMatch";
|
||||||
import GemsIcon from "../../components/GemIcon";
|
import GemsIcon from "../../components/GemIcon";
|
||||||
|
|
||||||
|
const TouchTooltip = ({
|
||||||
|
children,
|
||||||
|
date,
|
||||||
|
games,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
date: string;
|
||||||
|
games: number;
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||||
|
const timeoutRef = useRef<Timer>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Detect if device supports touch
|
||||||
|
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouch = () => {
|
||||||
|
if (isTouchDevice) {
|
||||||
|
setIsOpen(true);
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = setTimeout(() => setIsOpen(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// For touch devices, use controlled tooltip
|
||||||
|
if (isTouchDevice) {
|
||||||
|
return (
|
||||||
|
<Tooltip open={isOpen}>
|
||||||
|
<TooltipTrigger
|
||||||
|
asChild
|
||||||
|
onClick={handleTouch}
|
||||||
|
onTouchStart={handleTouch}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{date}</p>
|
||||||
|
<p>{games} Games Played</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-touch devices, use default hover behavior
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{date}</p>
|
||||||
|
<p>{games} Games Played</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Profile: React.FC = () => {
|
const Profile: React.FC = () => {
|
||||||
|
const [availableWeeks, setAvailableWeeks] = useState(16); // Default to 4 months
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { data: username } = useWSQuery("user.getSelf", null);
|
const { data: username } = useWSQuery("user.getSelf", null);
|
||||||
const { data: heatmap } = useWSQuery(
|
const { data: heatmap } = useWSQuery(
|
||||||
"user.getHeatmap",
|
"user.getHeatmap",
|
||||||
|
|
@ -30,15 +95,72 @@ const Profile: React.FC = () => {
|
||||||
!!username,
|
!!username,
|
||||||
);
|
);
|
||||||
const now = useMemo(() => dayjs(), []);
|
const now = useMemo(() => dayjs(), []);
|
||||||
const firstOfYear = useMemo(() => now.startOf("year"), [now]);
|
|
||||||
const weeks = now.diff(firstOfYear, "weeks") + 1;
|
|
||||||
const maxHeat = heatmap ? Math.max(...heatmap) : 0;
|
const maxHeat = heatmap ? Math.max(...heatmap) : 0;
|
||||||
|
|
||||||
|
// Calculate available space and adjust weeks accordingly
|
||||||
|
useEffect(() => {
|
||||||
|
const calculateAvailableWeeks = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
const isMobile = window.innerWidth < 640; // sm breakpoint
|
||||||
|
|
||||||
|
// Calculate dot and gap sizes based on breakpoint
|
||||||
|
const dotWidth = isMobile ? 16 : 24; // w-4 = 16px, w-6 = 24px
|
||||||
|
const gapSize = isMobile ? 4 : 8; // gap-1 = 4px, gap-2 = 8px
|
||||||
|
|
||||||
|
// Each week takes: dotWidth + gapSize
|
||||||
|
const weekWidth = dotWidth + gapSize;
|
||||||
|
|
||||||
|
// Calculate max weeks that fit, with some padding
|
||||||
|
const maxWeeks = Math.floor((containerWidth - 32) / weekWidth); // 32px for padding
|
||||||
|
|
||||||
|
// Limit between 4 weeks (1 month) and 26 weeks (6 months)
|
||||||
|
const weeks = Math.max(4, Math.min(26, maxWeeks));
|
||||||
|
|
||||||
|
setAvailableWeeks(weeks);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use setTimeout to ensure DOM is fully rendered
|
||||||
|
const timer = setTimeout(calculateAvailableWeeks, 0);
|
||||||
|
|
||||||
|
window.addEventListener("resize", calculateAvailableWeeks);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
window.removeEventListener("resize", calculateAvailableWeeks);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Additional effect to recalculate when heatmap data is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (heatmap && containerRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
const isMobile = window.innerWidth < 640;
|
||||||
|
|
||||||
|
const dotWidth = isMobile ? 16 : 24;
|
||||||
|
const gapSize = isMobile ? 4 : 8;
|
||||||
|
const weekWidth = dotWidth + gapSize;
|
||||||
|
const maxWeeks = Math.floor((containerWidth - 32) / weekWidth);
|
||||||
|
const weeks = Math.max(4, Math.min(26, maxWeeks));
|
||||||
|
|
||||||
|
setAvailableWeeks(weeks);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [heatmap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6">
|
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6 px-2 sm:px-0">
|
||||||
<div className="m-8 text-white flex self-center">
|
<div className="mx-4 my-8 md:m-8 text-white flex flex-col sm:flex-row self-center">
|
||||||
<div className="p-2 flex items-center text-2xl">{username}</div>
|
<div className="p-2 flex items-center text-2xl mb-2 sm:mb-0">
|
||||||
<div className="border-l-white border-l p-2 text-lg">
|
{username}
|
||||||
|
</div>
|
||||||
|
<div className="border-l-0 sm:border-l-white sm:border-l p-2 text-lg">
|
||||||
<p>Total Games: {profile?.totalGames}</p>
|
<p>Total Games: {profile?.totalGames}</p>
|
||||||
<p>Highest Stage: {profile?.highestStage}</p>
|
<p>Highest Stage: {profile?.highestStage}</p>
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -57,40 +179,42 @@ const Profile: React.FC = () => {
|
||||||
.map((game) => <PastMatch key={game.uuid} game={game} />)}
|
.map((game) => <PastMatch key={game.uuid} game={game} />)}
|
||||||
</div>
|
</div>
|
||||||
{heatmap && (
|
{heatmap && (
|
||||||
<div className="col-span-full">
|
<div className="col-span-full mb-8">
|
||||||
<h2 className="text-white text-2xl font-semibold mb-4">Activity</h2>
|
<h2 className="text-white text-2xl font-semibold mb-4">Activity</h2>
|
||||||
<div className="flex gap-2 ">
|
<div className="overflow-x-auto" ref={containerRef}>
|
||||||
{Array.from({ length: weeks }).map((_, w) => (
|
<div className="flex gap-1 sm:gap-2 min-w-fit">
|
||||||
<div key={w} className="w-6 flex gap-2 flex-col">
|
{Array.from({ length: availableWeeks }).map((_, w) => (
|
||||||
{Array.from({ length: 7 }).map((_, d) => {
|
<div
|
||||||
const index = w * 7 + d;
|
key={w}
|
||||||
if (index >= heatmap.length) return;
|
className="w-4 sm:w-6 flex gap-1 sm:gap-2 flex-col"
|
||||||
return (
|
>
|
||||||
<Tooltip key={d}>
|
{Array.from({ length: 7 }).map((_, d) => {
|
||||||
<TooltipTrigger>
|
const index =
|
||||||
<div className="w-5 h-5 border border-white">
|
heatmap.length - availableWeeks * 7 + (w * 7 + d);
|
||||||
|
if (index < 0 || index >= heatmap.length) return;
|
||||||
|
const date = now
|
||||||
|
.clone()
|
||||||
|
.subtract(availableWeeks * 7 - 1 - (w * 7 + d), "days")
|
||||||
|
.format("DD/MM/YYYY");
|
||||||
|
return (
|
||||||
|
<TouchTooltip key={d} date={date} games={heatmap[index]}>
|
||||||
|
<button
|
||||||
|
className="w-3 h-3 sm:w-5 sm:h-5 border border-white touch-manipulation"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="w-5 h-5 bg-brand -m-px"
|
className="w-3 h-3 sm:w-5 sm:h-5 bg-brand -m-px pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
opacity: heatmap[index] / maxHeat,
|
opacity: heatmap[index] / maxHeat,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
</TooltipTrigger>
|
</TouchTooltip>
|
||||||
<TooltipContent>
|
);
|
||||||
<p>
|
})}
|
||||||
{firstOfYear
|
</div>
|
||||||
.clone()
|
))}
|
||||||
.add(index, "days")
|
</div>
|
||||||
.format("DD/MM/YYYY")}
|
|
||||||
</p>
|
|
||||||
<p>{heatmap[index]} Games Played</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ const Settings = () => {
|
||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<BoolSetting
|
||||||
|
label="Sound Effects"
|
||||||
|
description={<>Enable or disable sound effects in the game.</>}
|
||||||
|
value={settings?.soundEnabled ?? true}
|
||||||
|
onChange={async (value) => {
|
||||||
|
await updateSettings.mutateAsync({ soundEnabled: value });
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { Routes } from "../backend/router";
|
import type { Routes } from "../backend/router";
|
||||||
import type { Events } from "../shared/events";
|
import type { Events } from "../shared/events";
|
||||||
import { queryClient } from "./queryClient";
|
import { queryClient } from "./queryClient";
|
||||||
|
import { connectionStatusAtom } from "./atoms";
|
||||||
|
import { getDefaultStore } from "jotai";
|
||||||
|
|
||||||
const connectionString = import.meta.env.DEV
|
const connectionString = import.meta.env.DEV
|
||||||
? "ws://localhost:8072/ws"
|
? "ws://localhost:8072/ws"
|
||||||
|
|
@ -21,11 +23,43 @@ const emitMessage = (event: MessageEvent) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const createWSClient = () => {
|
const createWSClient = () => {
|
||||||
|
const store = getDefaultStore();
|
||||||
let ws = new WebSocket(connectionString);
|
let ws = new WebSocket(connectionString);
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
const maxReconnectAttempts = 5;
|
const maxReconnectAttempts = 5;
|
||||||
|
let heartbeatInterval: number | null = null;
|
||||||
|
let heartbeatTimeoutId: number | null = null;
|
||||||
|
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
||||||
|
const HEARTBEAT_TIMEOUT = 5000; // 5 seconds to wait for pong
|
||||||
|
|
||||||
|
const startHeartbeat = () => {
|
||||||
|
stopHeartbeat();
|
||||||
|
heartbeatInterval = window.setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "ping", id: crypto.randomUUID() }));
|
||||||
|
|
||||||
|
heartbeatTimeoutId = window.setTimeout(() => {
|
||||||
|
console.warn("Heartbeat timeout - closing connection");
|
||||||
|
ws.close();
|
||||||
|
}, HEARTBEAT_TIMEOUT);
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHeartbeat = () => {
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
if (heartbeatTimeoutId) {
|
||||||
|
clearTimeout(heartbeatTimeoutId);
|
||||||
|
heartbeatTimeoutId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const tryReconnect = () => {
|
const tryReconnect = () => {
|
||||||
|
store.set(connectionStatusAtom, "reconnecting");
|
||||||
|
stopHeartbeat();
|
||||||
if (reconnectAttempts < maxReconnectAttempts) {
|
if (reconnectAttempts < maxReconnectAttempts) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
|
|
@ -33,14 +67,18 @@ const createWSClient = () => {
|
||||||
}, 1000 * reconnectAttempts);
|
}, 1000 * reconnectAttempts);
|
||||||
} else {
|
} else {
|
||||||
console.error("Max reconnect attempts reached");
|
console.error("Max reconnect attempts reached");
|
||||||
|
store.set(connectionStatusAtom, "disconnected");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
|
store.set(connectionStatusAtom, "connecting");
|
||||||
ws = new WebSocket(connectionString);
|
ws = new WebSocket(connectionString);
|
||||||
|
|
||||||
ws.onopen = async () => {
|
ws.onopen = async () => {
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
|
store.set(connectionStatusAtom, "connected");
|
||||||
|
startHeartbeat();
|
||||||
const token = localStorage.getItem("loginToken");
|
const token = localStorage.getItem("loginToken");
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -69,25 +107,36 @@ const createWSClient = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
addMessageListener((event: MessageEvent) => {
|
addMessageListener((event: MessageEvent) => {
|
||||||
const data = JSON.parse(event.data) as Events;
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === "updateGame") {
|
|
||||||
|
// Handle pong response
|
||||||
|
if (data.type === "pong") {
|
||||||
|
if (heartbeatTimeoutId) {
|
||||||
|
clearTimeout(heartbeatTimeoutId);
|
||||||
|
heartbeatTimeoutId = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = data as Events;
|
||||||
|
if (eventData.type === "updateGame") {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["game.getGameState", data.game],
|
["game.getGameState", eventData.game],
|
||||||
data.gameState,
|
eventData.gameState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (data.type === "loss") {
|
if (eventData.type === "loss") {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["scoreboard.getScoreBoard", 10],
|
queryKey: ["scoreboard.getScoreBoard", 10],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.type === "gemsRewarded") {
|
if (eventData.type === "gemsRewarded") {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["user.getOwnGems", null],
|
queryKey: ["user.getOwnGems", null],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log("Received message", data);
|
console.log("Received message", eventData);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue