Skip to content

Commit 10ecb23

Browse files
authored
feat: Lobby view (#43)
Closes #25
1 parent 626e216 commit 10ecb23

24 files changed

+327
-25
lines changed

packages/client/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,23 @@
2323
"lint:inspect": "eslint --inspect-config"
2424
},
2525
"dependencies": {
26+
"@dumber-dungeons/shared": "workspace:*",
2627
"iti": "^0.7.0",
2728
"iti-react": "^0.7.0",
29+
"qrcode": "^1.5.4",
2830
"react": "^19.0.0",
2931
"react-dom": "^19.0.0",
30-
"three": "^0.170.0",
31-
"@dumber-dungeons/shared": "workspace:*"
32+
"react-error-boundary": "^4.1.2",
33+
"react-router": "^7.0.2",
34+
"socket.io-client": "^4.8.1",
35+
"three": "^0.170.0"
3236
},
3337
"devDependencies": {
3438
"@eslint/js": "^9.14.0",
3539
"@types/eslint-config-prettier": "^6.11.3",
3640
"@types/eslint__js": "^8.42.3",
3741
"@types/react-dom": "^19.0.2",
42+
"@types/qrcode": "^1.5.5",
3843
"@types/three": "^0.170.0",
3944
"@typescript-eslint/eslint-plugin": "^8.0.0",
4045
"@typescript-eslint/parser": "^8.0.0",

packages/client/src/app.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { createContainer } from 'iti';
2+
import { DungeonClient } from './dungeon.client';
3+
import { io } from 'socket.io-client';
24

35
// Setup DI context
4-
export const app = createContainer();
6+
export const app = createContainer().add({
7+
dungeonClient: new DungeonClient(io()),
8+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useEffect, type JSX } from 'react';
2+
3+
/**
4+
* Component to include a stylesheet.
5+
*
6+
* Can be used from anywhere in the DOM tree, will always append to <head>.
7+
*/
8+
export function ExternalStyle(props: { href: string }): JSX.Element {
9+
useEffect(() => {
10+
const link = document.createElement('link');
11+
link.rel = 'stylesheet';
12+
link.href = props.href;
13+
document.head.appendChild(link);
14+
15+
return (): void => {
16+
link.remove();
17+
};
18+
}, [props.href]);
19+
20+
return <></>;
21+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { EventEmitter } from '@dumber-dungeons/shared/src/event.emitter';
2+
import {
3+
type Session,
4+
SessionStatus,
5+
} from '@dumber-dungeons/shared/src/api/session';
6+
import { type Participant } from '@dumber-dungeons/shared/src/api/participant';
7+
import {
8+
type ParticipantChangeEvent,
9+
type ParticipantJoinEvent,
10+
type ParticipantLeaveEvent,
11+
type SocketEventMap,
12+
} from '@dumber-dungeons/shared/src/api/socket.events';
13+
import type { Socket } from 'socket.io-client';
14+
15+
export class DungeonClient {
16+
public readonly onParticipantJoin = new EventEmitter<ParticipantJoinEvent>();
17+
public readonly onParticipantChange =
18+
new EventEmitter<ParticipantChangeEvent>();
19+
public readonly onParticipantLeave =
20+
new EventEmitter<ParticipantLeaveEvent>();
21+
private session: Session;
22+
private socket: Socket<SocketEventMap>;
23+
24+
constructor(socket: Socket) {
25+
this.socket = socket;
26+
27+
this.session = {
28+
id: '',
29+
status: SessionStatus.IN_LOBBY,
30+
participants: [],
31+
};
32+
33+
this.subscribeToSocket();
34+
}
35+
36+
public getParticipants(): Array<Participant> {
37+
return [...this.session.participants];
38+
}
39+
40+
private subscribeToSocket(): void {
41+
this.socket.on('participant/join', (participant: Participant) => {
42+
this.addParticipant(participant);
43+
this.onParticipantJoin.emit({ participant });
44+
});
45+
this.socket.on('participant/update', (participant: Participant) => {
46+
this.updateParticipant(participant);
47+
this.onParticipantChange.emit({ participant });
48+
});
49+
this.socket.on('participant/leave', (participant: Participant) => {
50+
this.removeParticipant(participant);
51+
this.onParticipantLeave.emit({ participant });
52+
});
53+
}
54+
55+
private addParticipant(participant: Participant): void {
56+
this.session.participants.push(participant);
57+
}
58+
59+
private removeParticipant(participant: Participant): void {
60+
this.session.participants = this.session.participants.filter(
61+
(p) => p.id != participant.id
62+
);
63+
}
64+
65+
private updateParticipant(participant: Participant): void {
66+
const idx = this.session.participants.findIndex(
67+
(p) => p.id == participant.id
68+
);
69+
70+
if (idx < 0) this.addParticipant(participant);
71+
else this.session.participants[idx] = participant;
72+
}
73+
}

packages/client/src/index.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
1-
import 'react';
21
import { createRoot } from 'react-dom/client';
3-
import { ThreeJS } from './views/threejs/threejs.tsx';
4-
import styleCss from './views/style.css';
2+
import { BrowserRouter, Route, Routes } from 'react-router';
3+
import { frontendRoutes } from '@dumber-dungeons/shared/src/api/frontend.routes';
4+
import { ThreeJS } from './views/threejs/threejs';
5+
import { LobbyView } from './views/lobby/lobby.view';
6+
import globalStyle from './views/style.css';
7+
import { ErrorBoundary } from 'react-error-boundary';
8+
import { ErrorView } from './views/error/error.view';
9+
import { ExternalStyle } from './components/external.style';
510

6-
const globalCssLink = document.createElement('link');
7-
globalCssLink.rel = 'stylesheet';
8-
globalCssLink.href = styleCss;
9-
document.head.appendChild(globalCssLink);
11+
function getRootContainer(): Element {
12+
const container = document.querySelector('#root');
13+
if (!container) throw new Error('Missing root!');
1014

11-
const root = createRoot(document.getElementById('root') as HTMLDivElement);
12-
root.render(<ThreeJS />);
15+
return container;
16+
}
17+
18+
createRoot(getRootContainer()).render(
19+
<>
20+
<ExternalStyle href={globalStyle} />
21+
<ErrorBoundary FallbackComponent={ErrorView}>
22+
<BrowserRouter>
23+
<Routes>
24+
<Route path={frontendRoutes.index} element={<ThreeJS />} />
25+
<Route path={frontendRoutes.lobby} element={<LobbyView />} />
26+
</Routes>
27+
</BrowserRouter>
28+
</ErrorBoundary>
29+
</>
30+
);

packages/client/src/links.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { generatePath } from 'react-router';
2+
3+
export function generateLink(path: string, params: object): string {
4+
const resultURL = new URL(window.location.href);
5+
resultURL.pathname = generatePath(path, params);
6+
7+
return resultURL.toString();
8+
}

packages/client/src/template.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>Dumber Dungeons</title>
7+
<base href="/" />
78
</head>
89
<body>
910
<div id="root"></div>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { JSX } from 'react';
2+
3+
export function ErrorView(props: { error: Error }): JSX.Element {
4+
return (
5+
<>
6+
<h1>{props.error.message || 'Error'}</h1>
7+
<p>Stack trace:</p>
8+
<pre>{String(props.error.stack)}</pre>
9+
</>
10+
);
11+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useEffect, useState, type JSX } from 'react';
2+
import * as QR from 'qrcode';
3+
import { frontendRoutes } from '@dumber-dungeons/shared/src/api/frontend.routes';
4+
import { generateLink } from '#src/links';
5+
6+
export function JoinLobby(props: { sessionId: string }): JSX.Element {
7+
const [link, setLink] = useState<string>();
8+
const [qrData, setQrData] = useState<string>();
9+
10+
useEffect(() => {
11+
const joinLink = generateLink(frontendRoutes.join, { id: props.sessionId });
12+
13+
setLink(joinLink);
14+
void QR.toDataURL(joinLink, { width: 512 }).then(setQrData);
15+
}, [props.sessionId]);
16+
17+
return (
18+
<div className="lobby panel">
19+
<p>
20+
<img src={qrData} />
21+
</p>
22+
<p>
23+
Join at: <i>{link}</i>
24+
</p>
25+
</div>
26+
);
27+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { Participant } from '@dumber-dungeons/shared/src/api/participant';
2+
import type { JSX } from 'react';
3+
4+
export function LobbyParticipants(props: {
5+
participants: Array<Participant>;
6+
}): JSX.Element {
7+
return (
8+
<div className="lobby panel">
9+
Participants
10+
<table>
11+
<thead>
12+
<tr>
13+
<td>#</td>
14+
<td>Name</td>
15+
<td>Ready?</td>
16+
</tr>
17+
</thead>
18+
<tbody>
19+
{props.participants.map((participant, idx) => (
20+
<tr key={participant.id}>
21+
<td>#{idx + 1}</td>
22+
<td>{participant.name}</td>
23+
<td>{participant.isReady ? '✅' : '❎'}</td>
24+
</tr>
25+
))}
26+
</tbody>
27+
</table>
28+
</div>
29+
);
30+
}

0 commit comments

Comments
 (0)