Author SHA1 Message Date
RandyJC 82be8dee93 update README 2025-12-08 23:37:39 +01:00
RandyJC 656c4efddd fix points and username after restoring session 2025-12-08 23:25:26 +01:00
RandyJC 8890808751 fix 2025-12-08 23:19:07 +01:00
RandyJC 113950cc6f small fix when reconnecting to the game 2025-12-08 23:17:21 +01:00
RandyJC d8a9fb2dca fix rejoining the game for some clients 2025-12-08 23:12:05 +01:00
RandyJC 3317925d08 fix build error 2025-12-08 23:05:24 +01:00
RandyJC d66b03e797 adding new clients view for manager page 2025-12-08 23:03:46 +01:00
RandyJC c7d41cd7a5 adding more persistence for the client to be able to rejoin the session on clientId 2025-12-08 22:50:52 +01:00
RandyJC 42df8f5893 no-lock fix 2025-12-08 22:41:39 +01:00
RandyJC befe39d2fd adding persistance when client losing connections (adding redis db) 2025-12-08 22:37:44 +01:00
RandyJC 7129ec6984 fix build error 2025-12-08 22:12:25 +01:00
RandyJC e2df9c20cc fix build error 2025-12-08 22:10:29 +01:00
RandyJC 01b26c59f0 adding asset manager and other new features 2025-12-08 22:08:06 +01:00
RandyJC 03fdeb643a fix probing on wav 2025-12-08 21:51:49 +01:00
RandyJC 6420544f35 adding probe button in quizeditor and fix player size in cooldown 2025-12-08 21:44:01 +01:00
RandyJC 03b00b2499 adding skip in cooldown and probing for media files 2025-12-08 21:32:27 +01:00
RandyJC ea49971609 adding .env for max file upload 2025-12-08 21:18:32 +01:00
RandyJC e60818f93e fix 2025-12-08 20:56:17 +01:00
RandyJC 5499a83a9f fix media play on safari browser 2025-12-08 20:51:25 +01:00
RandyJC ce89a023c8 fix for safari browser handling media files 2025-12-08 16:00:54 +01:00
RandyJC fd3047ec04 fix build error 2025-12-08 15:52:05 +01:00
RandyJC 8403c6a7c3 fix build error 2025-12-08 15:49:34 +01:00
RandyJC 650e8f2366 fix build error 2025-12-08 15:48:06 +01:00
RandyJC a10cea357b fix build 2025-12-08 15:45:04 +01:00
RandyJC eefb5b01f1 fix build error 2025-12-08 15:43:32 +01:00
RandyJC e5fd5d52f0 remove youtube support and add local file handling 2025-12-08 15:38:23 +01:00
RandyJC df615dc720 youtube fix 2025-12-08 15:09:42 +01:00
RandyJC 1988eca947 socket fix 2025-12-08 13:40:41 +01:00
RandyJC 59ee57d995 another fix for npmn 2025-12-08 13:38:25 +01:00
RandyJC 87e9864290 fixed issue witn npmn 2025-12-08 13:35:52 +01:00
RandyJC 14ea9c75cd adding manager UI and audio and video (youtube) questions 2025-11-28 21:17:18 +01:00
Ralex c79d82c565 feat(icon): add SVG icon for github folder 2025-11-19 19:49:37 +01:00
Ralex 0abb38ced1 Merge pull request #26 from Ralex91/Ralex91-patch-1
Update Docker Pulls badge color in README
2025-10-29 22:03:10 +01:00
Ralex 4f8e69bcad Update Docker Pulls badge color in README 2025-10-29 22:02:54 +01:00
Ralex 91cc356564 Merge pull request #25 from Ralex91/v1.0.0
V1.0.0
2025-10-29 19:36:47 +01:00
Ralex b91531657b fix(game): increment current question on reconnect for manager and player 2025-10-26 18:55:56 +01:00
Ralex 16e0fc8006 fix(game): reset managerStatus before broadcasting prepared status 2025-10-26 18:39:06 +01:00
Ralex 0cd2e8d8b7 fix(game): fix leaderboard old state 2025-10-26 18:30:49 +01:00
Ralex 349c9337e6 feat(game): enhance leaderboard structure and animation in Leaderboard component 2025-10-26 17:41:04 +01:00
Ralex 2b499190a2 chore(README): add preview images 2025-10-26 12:17:06 +01:00
Ralex 8fe973369a fix(question): image size 2025-10-26 12:05:26 +01:00
Ralex e0850efe09 chore(assets): fix svg size 2025-10-26 11:46:31 +01:00
Ralex 2e645ae9d5 fix(game): fix kickPlayer event to include gameId and improve reset message on player kick 2025-10-26 11:32:01 +01:00
Ralex ba29466c69 refactor(hooks): move useScreenSize hook to the correct directory and update import paths 2025-10-26 10:56:48 +01:00
Ralex 74707749aa feat(game): enhance game reconnection logic and improve reset messages 2025-10-25 16:38:07 +02:00
Ralex dbefaa0557 refactor(components): use arrow function syntax and improve consistency 2025-10-25 15:04:03 +02:00
Ralex ac486b3d7d chore(packages): update 2025-10-25 13:43:09 +02:00
Ralex 46c43846e9 feat(docker): update Dockerfile and compose configuration, enhance README for deployment methods 2025-10-25 13:41:29 +02:00
Ralex 90555891f8 fix(docker): fix config files reading & add compose file 2025-10-19 12:32:46 +02:00
Ralex 96bff164c0 feat: improve reconnect, add ESLint configuration for common and socket 2025-10-19 00:37:26 +02:00
Ralex 8bdb8f47ef feat(reconnect): add reconnect for player & manager 2025-10-18 18:20:03 +02:00
Ralex edb7146d6d refactor: add typescript & pnpm workspace & docker file 2025-10-16 23:12:40 +02:00
Ralex 8f73241f34 Merge pull request #23 from Ralex91/chore/eslint-packages
Fix EsLint config & update packages
2025-09-28 13:03:19 +02:00
Ralex 89c39fac52 Fix EsLint config & update packages 2025-09-28 12:58:51 +02:00
Ralex dd841c813e Merge pull request #20 from raisramadhani/main
Emit total players count to clients in the room on player join and kick events & remove comments
2025-09-21 19:48:43 +02:00
Muhammad Rais Ramadhani bfd6810f16 Add Tailwind CSS configuration file 2025-09-21 17:24:55 +07:00
Muhammad Rais Ramadhani 8dc69b9b34 Remove comments 2025-09-21 16:26:20 +07:00
Muhammad Rais Ramadhani 93b1098e74 Emit total players count to clients in the room on player join and kick events 2025-09-13 18:14:56 +07:00
Ralex 0bdec93c21 Merge pull request #17 from Ralex91/dependabot/npm_and_yarn/next-14.2.32
Bump next from 14.2.30 to 14.2.32
2025-09-03 09:34:49 +02:00
dependabot[bot] 3065b18d93 Bump next from 14.2.30 to 14.2.32
Bumps [next](https://github.com/vercel/next.js) from 14.2.30 to 14.2.32.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.30...v14.2.32)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 14.2.32
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 00:39:35 +00:00
Ralex c1cea36c72 Merge pull request #15 from Ralex91/dependabot/npm_and_yarn/babel/runtime-7.27.6
Bump @babel/runtime from 7.25.6 to 7.27.6
2025-07-07 16:03:37 +02:00
dependabot[bot] 43cc6154ac Bump @babel/runtime from 7.25.6 to 7.27.6
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.25.6 to 7.27.6.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.6/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-version: 7.27.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-23 15:30:12 +00:00
Ralex 25da01c766 Merge pull request #14 from Ralex91/dependabot/npm_and_yarn/next-14.2.30
Bump next from 14.2.21 to 14.2.30
2025-06-23 16:00:46 +02:00
dependabot[bot] 66e30797d2 Bump next from 14.2.21 to 14.2.30
Bumps [next](https://github.com/vercel/next.js) from 14.2.21 to 14.2.30.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.21...v14.2.30)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 14.2.30
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-15 03:27:19 +00:00
Ralex 77f5e6aed1 Merge pull request #13 from Norse-IoT/typos
Fix username typo
2025-01-16 18:29:37 +01:00
Cassian Godsted 456020f77a Fix username 2025-01-15 14:43:56 -05:00
Ralex a2857cdd85 Merge pull request #12 from Ralex91/dependabot/npm_and_yarn/next-14.2.21
Bump next from 14.2.15 to 14.2.21
2025-01-10 09:27:08 +01:00
dependabot[bot] f63cf11764 Bump next from 14.2.15 to 14.2.21
Bumps [next](https://github.com/vercel/next.js) from 14.2.15 to 14.2.21.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.15...v14.2.21)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-10 08:26:24 +00:00
Ralex 87bdb0afd5 Merge pull request #11 from Ralex91/dependabot/npm_and_yarn/next-14.2.15
Bump next from 14.2.13 to 14.2.15
2024-12-19 08:39:57 +01:00
Ralex bf43c82a88 Merge pull request #10 from Ralex91/dependabot/npm_and_yarn/nanoid-3.3.8
Bump nanoid from 3.3.7 to 3.3.8
2024-12-19 08:39:30 +01:00
dependabot[bot] b012a7e776 Bump next from 14.2.13 to 14.2.15
Bumps [next](https://github.com/vercel/next.js) from 14.2.13 to 14.2.15.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.13...v14.2.15)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-18 19:10:35 +00:00
dependabot[bot] b6377b23d1 Bump nanoid from 3.3.7 to 3.3.8
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-14 15:18:05 +00:00
Ralex 91c725cbe1 Merge pull request #9 from ziv-airis/fix-game-subject
fix: game subject from the game attribute
2024-11-15 12:09:13 +01:00
Ziv Kalderon 11c5dfe423 fix: game subject from the game attribute 2024-11-15 10:57:25 +02:00
Ralex b03c5843f4 Merge pull request #8 from Ralex91/dependabot/npm_and_yarn/multi-c4b86170b3
Bump cookie and engine.io
2024-10-24 15:37:29 +02:00
dependabot[bot] da36d3c885 Bump cookie and engine.io
Bumps [cookie](https://github.com/jshttp/cookie) and [engine.io](https://github.com/socketio/socket.io). These dependencies needed to be updated together.

Updates `cookie` from 0.4.2 to 0.7.2
- [Release notes](https://github.com/jshttp/cookie/releases)
- [Commits](https://github.com/jshttp/cookie/compare/v0.4.2...v0.7.2)

Updates `engine.io` from 6.6.1 to 6.6.2
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/engine.io@6.6.1...engine.io@6.6.2)

---
updated-dependencies:
- dependency-name: cookie
  dependency-type: indirect
- dependency-name: engine.io
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-11 10:12:06 +00:00
Ralex e775763914 New config file and update README 2024-09-22 14:16:10 +02:00
Ralex b5abfbb724 Merge pull request #5 from Ralex91/dependabot/npm_and_yarn/next-14.2.10
Bump next from 14.2.3 to 14.2.10
2024-09-18 09:54:08 +02:00
dependabot[bot] fbb220b1a2 Bump next from 14.2.3 to 14.2.10
Bumps [next](https://github.com/vercel/next.js) from 14.2.3 to 14.2.10.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.3...v14.2.10)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-18 03:10:15 +00:00
Ralex 993fc57ddc Merge pull request #4 from Ralex91/dependabot/npm_and_yarn/multi-d143a95b26
Bump ws, engine.io, socket.io-adapter and engine.io-client
2024-09-03 15:55:12 +02:00
dependabot[bot] 04ad8a866e Bump ws, engine.io, socket.io-adapter and engine.io-client
Bumps [ws](https://github.com/websockets/ws), [engine.io](https://github.com/socketio/engine.io), [socket.io-adapter](https://github.com/socketio/socket.io-adapter) and [engine.io-client](https://github.com/socketio/engine.io-client). These dependencies needed to be updated together.

Updates `ws` from 8.11.0 to 8.17.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.11.0...8.17.1)

Updates `engine.io` from 6.5.4 to 6.5.5
- [Release notes](https://github.com/socketio/engine.io/releases)
- [Changelog](https://github.com/socketio/engine.io/blob/6.5.5/CHANGELOG.md)
- [Commits](https://github.com/socketio/engine.io/compare/6.5.4...6.5.5)

Updates `socket.io-adapter` from 2.5.4 to 2.5.5
- [Release notes](https://github.com/socketio/socket.io-adapter/releases)
- [Changelog](https://github.com/socketio/socket.io-adapter/blob/2.5.5/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-adapter/compare/2.5.4...2.5.5)

Updates `engine.io-client` from 6.5.3 to 6.5.4
- [Release notes](https://github.com/socketio/engine.io-client/releases)
- [Changelog](https://github.com/socketio/engine.io-client/blob/6.5.4/CHANGELOG.md)
- [Commits](https://github.com/socketio/engine.io-client/compare/6.5.3...6.5.4)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
- dependency-name: engine.io
  dependency-type: indirect
- dependency-name: socket.io-adapter
  dependency-type: indirect
- dependency-name: engine.io-client
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-03 13:47:09 +00:00
Ralex91 cd3f151738 Update dependencies 2024-05-21 11:45:00 +02:00
Ralex f2daa23d84 Merge pull request #2 from Ralex91/dev
Merge
2024-02-10 11:26:47 +01:00
Ralex91 324316bc38 Add Manager Skip button on quiz 2024-02-08 19:37:15 +01:00
Ralex91 8a9b8df7d2 Add concurrently 2024-02-08 16:32:05 +01:00
Ralex91 31122b9d2f Improve Login/Join/Manager Input 2024-02-08 16:03:44 +01:00
Ralex b5f7eb382a Merge pull request #1 from Ralex91/dev
Update Podium & Sound
2024-02-08 15:52:56 +01:00
Ralex91 8493987f8e Update Podium & Sound 2024-02-08 15:39:27 +01:00
Ralex91 a5adacfaef Update Podium & Statred to add sound 2024-02-07 16:09:03 +01:00
Ralex91 94600d1215 Update README 2024-02-04 19:51:40 +01:00
Ralex 6f53b6bbe3 Create LICENSE 2024-02-04 19:49:18 +01:00
Ralex91 b208e7e3c3 Fix README 2024-02-04 19:39:29 +01:00
Ralex91 6756f65f9f Update README a Small Fix 2024-02-04 19:35:26 +01:00
Ralex91 1e8bd44813 Large patch 2024-02-04 17:53:45 +01:00
Ralex91 8c57670742 Add Kick player & Validators 2024-02-03 00:14:04 +01:00
Ralex91 cd723f7efd Add Start cooldown & Patch socket event 2024-02-02 13:55:55 +01:00
Ralex91 6c7f31a6d4 Add prettierc/workspace config 2024-02-01 19:10:40 +01:00
Ralex91 c2db3810ac Add Prepared Animation 2024-02-01 16:35:10 +01:00
Ralex91 1839f31a21 Update Manager/Color/Logo 2024-02-01 15:50:31 +01:00
Ralex91 f63bf33c6f Update Round Result values 2024-02-01 11:43:50 +01:00
Ralex91 fb8c2aa1c3 Update Result rank text 2024-02-01 11:30:33 +01:00
Ralex91 6838f04828 Update Manager 2024-02-01 11:02:28 +01:00
Ralex91 0a075e564a Update Manager 2024-01-31 23:45:32 +01:00
Ralex91 91c7644c71 Add leaderboard & Manager some features 2024-01-31 23:27:52 +01:00
Ralex91 e122fe1078 First Commit 2024-01-31 20:06:10 +01:00
24 changed files with 72 additions and 801 deletions
-3
View File
@@ -35,7 +35,6 @@ export interface ServerToClientEvents {
"game:reset": (_message: string) => void "game:reset": (_message: string) => void
"game:updateQuestion": (_data: { current: number; total: number }) => void "game:updateQuestion": (_data: { current: number; total: number }) => void
"game:playerAnswer": (_count: number) => void "game:playerAnswer": (_count: number) => void
"game:break": (_active: boolean) => void
// Player events // Player events
"player:successReconnect": (_data: { "player:successReconnect": (_data: {
@@ -67,7 +66,6 @@ export interface ServerToClientEvents {
"manager:quizzLoaded": (_quizz: QuizzWithId) => void "manager:quizzLoaded": (_quizz: QuizzWithId) => void
"manager:quizzSaved": (_quizz: QuizzWithId) => void "manager:quizzSaved": (_quizz: QuizzWithId) => void
"manager:quizzDeleted": (_id: string) => void "manager:quizzDeleted": (_id: string) => void
"manager:break": (_active: boolean) => void
} }
export interface ClientToServerEvents { export interface ClientToServerEvents {
@@ -80,7 +78,6 @@ export interface ClientToServerEvents {
"manager:abortQuiz": (_message: MessageGameId) => void "manager:abortQuiz": (_message: MessageGameId) => void
"manager:pauseCooldown": (_message: MessageGameId) => void "manager:pauseCooldown": (_message: MessageGameId) => void
"manager:resumeCooldown": (_message: MessageGameId) => void "manager:resumeCooldown": (_message: MessageGameId) => void
"manager:setBreak": (_message: { gameId?: string; active: boolean }) => void
"manager:endGame": (_message: MessageGameId) => void "manager:endGame": (_message: MessageGameId) => void
"manager:skipQuestionIntro": (_message: MessageGameId) => void "manager:skipQuestionIntro": (_message: MessageGameId) => void
"manager:nextQuestion": (_message: MessageGameId) => void "manager:nextQuestion": (_message: MessageGameId) => void
-4
View File
@@ -234,10 +234,6 @@ io.on("connection", (socket) => {
withGame(gameId, socket, (game) => game.resumeCooldown(socket)) withGame(gameId, socket, (game) => game.resumeCooldown(socket))
) )
socket.on("manager:setBreak", ({ gameId, active }) =>
withGame(gameId, socket, (game) => game.setBreak(socket, active))
)
socket.on("manager:endGame", ({ gameId }) => socket.on("manager:endGame", ({ gameId }) =>
withGame(gameId, socket, (game) => game.endGame(socket, registry)) withGame(gameId, socket, (game) => game.endGame(socket, registry))
) )
-38
View File
@@ -37,22 +37,6 @@ class Config {
if (!isMediaExists) { if (!isMediaExists) {
fs.mkdirSync(getPath("media")) fs.mkdirSync(getPath("media"))
} }
const isThemeExists = fs.existsSync(getPath("theme.json"))
if (!isThemeExists) {
fs.writeFileSync(
getPath("theme.json"),
JSON.stringify(
{
brandName: "Rahoot",
backgroundUrl: null,
},
null,
2
)
)
}
} }
static init() { static init() {
@@ -167,17 +151,6 @@ class Config {
return {} return {}
} }
static theme() {
this.ensureBaseFolders()
try {
const raw = fs.readFileSync(getPath("theme.json"), "utf-8")
return JSON.parse(raw)
} catch (error) {
console.error("Failed to read theme config:", error)
return { brandName: "Rahoot", backgroundUrl: null }
}
}
static quizz() { static quizz() {
this.ensureBaseFolders() this.ensureBaseFolders()
@@ -258,17 +231,6 @@ class Config {
return getPath(fileName ? `media/${fileName}` : "media") return getPath(fileName ? `media/${fileName}` : "media")
} }
static saveTheme(theme: { brandName?: string; backgroundUrl?: string | null }) {
this.ensureBaseFolders()
const next = {
brandName: theme.brandName || "Rahoot",
backgroundUrl: theme.backgroundUrl ?? null,
}
fs.writeFileSync(getPath("theme.json"), JSON.stringify(next, null, 2))
return next
}
} }
export default Config export default Config
-32
View File
@@ -46,7 +46,6 @@ class Game {
timer: NodeJS.Timeout | null timer: NodeJS.Timeout | null
resolve: (() => void) | null resolve: (() => void) | null
} }
breakActive: boolean
constructor(io: Server, socket: Socket, quizz: Quizz) { constructor(io: Server, socket: Socket, quizz: Quizz) {
if (!io) { if (!io) {
@@ -85,7 +84,6 @@ class Game {
timer: null, timer: null,
resolve: null, resolve: null,
} }
this.breakActive = false
const roomInvite = createInviteCode() const roomInvite = createInviteCode()
this.inviteCode = roomInvite this.inviteCode = roomInvite
@@ -139,7 +137,6 @@ class Game {
timer: null, timer: null,
resolve: null, resolve: null,
} }
game.breakActive = snapshot.breakActive || false
if (game.cooldown.active && game.cooldown.remaining > 0 && !game.cooldown.paused) { if (game.cooldown.active && game.cooldown.remaining > 0 && !game.cooldown.paused) {
game.startCooldown(game.cooldown.remaining) game.startCooldown(game.cooldown.remaining)
@@ -196,7 +193,6 @@ class Game {
paused: this.cooldown.paused, paused: this.cooldown.paused,
remaining: this.cooldown.remaining, remaining: this.cooldown.remaining,
}, },
breakActive: this.breakActive,
} }
} }
@@ -314,10 +310,6 @@ class Game {
players: this.players, players: this.players,
}) })
socket.emit("game:totalPlayers", this.players.length) socket.emit("game:totalPlayers", this.players.length)
if (this.breakActive) {
socket.emit("manager:break", true)
socket.emit("game:break", true)
}
registry.reactivateGame(this.gameId) registry.reactivateGame(this.gameId)
console.log(`Manager reconnected to game ${this.inviteCode}`) console.log(`Manager reconnected to game ${this.inviteCode}`)
@@ -369,9 +361,6 @@ class Game {
}, },
}) })
socket.emit("game:totalPlayers", this.players.length) socket.emit("game:totalPlayers", this.players.length)
if (this.breakActive) {
socket.emit("game:break", true)
}
console.log( console.log(
`Player ${player.username} reconnected to game ${this.inviteCode}` `Player ${player.username} reconnected to game ${this.inviteCode}`
) )
@@ -464,27 +453,6 @@ class Game {
this.persist() this.persist()
} }
setBreak(socket: Socket, active: boolean) {
if (this.manager.id !== socket.id) {
return
}
this.breakActive = active
if (this.cooldown.active) {
if (active) {
this.cooldown.paused = true
} else {
this.cooldown.paused = false
}
this.io.to(this.gameId).emit("game:cooldownPause", this.cooldown.paused)
}
this.io.to(this.gameId).emit("game:break", active)
this.io.to(this.manager.id).emit("manager:break", active)
this.persist()
}
skipQuestionIntro(socket: Socket) { skipQuestionIntro(socket: Socket) {
if (this.manager.id !== socket.id) { if (this.manager.id !== socket.id) {
return return
+22 -21
View File
@@ -1,8 +1,9 @@
"use client" "use client"
import BrandHeading from "@rahoot/web/components/BrandHeading" import logo from "@rahoot/web/assets/logo.svg"
import Loader from "@rahoot/web/components/Loader" import Loader from "@rahoot/web/components/Loader"
import { useSocket } from "@rahoot/web/contexts/socketProvider" import { useSocket } from "@rahoot/web/contexts/socketProvider"
import Image from "next/image"
import { PropsWithChildren, useEffect } from "react" import { PropsWithChildren, useEffect } from "react"
const AuthLayout = ({ children }: PropsWithChildren) => { const AuthLayout = ({ children }: PropsWithChildren) => {
@@ -13,34 +14,34 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
} }
}, [connect, isConnected]) }, [connect, isConnected])
const Shell = ({ children: inner }: PropsWithChildren) => (
<section className="relative flex min-h-screen flex-col items-center justify-center px-4 text-center">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full" />
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45" />
</div>
<div className="z-10 flex w-full flex-col items-center gap-8 px-4">
<BrandHeading size="md" />
<div className="flex w-full justify-center">{inner}</div>
</div>
</section>
)
if (!isConnected) { if (!isConnected) {
return ( return (
<Shell> <section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="flex flex-col items-center gap-3"> <div className="pointer-events-none absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
<Loader className="h-23" /> <Loader className="h-23" />
<h2 className="text-2xl font-bold text-white drop-shadow-lg md:text-3xl"> <h2 className="mt-2 text-center text-2xl font-bold text-white drop-shadow-lg md:text-3xl">
Loading... Loading...
</h2> </h2>
</div> </section>
</Shell>
) )
} }
return <Shell>{children}</Shell> return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="pointer-events-none absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
{children}
</section>
)
} }
export default AuthLayout export default AuthLayout
+9 -52
View File
@@ -5,16 +5,14 @@ import { STATUS } from "@rahoot/common/types/game/status"
import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword" import ManagerPassword from "@rahoot/web/components/game/create/ManagerPassword"
import QuizEditor from "@rahoot/web/components/game/create/QuizEditor" import QuizEditor from "@rahoot/web/components/game/create/QuizEditor"
import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary" import MediaLibrary from "@rahoot/web/components/game/create/MediaLibrary"
import ThemeEditor from "@rahoot/web/components/game/create/ThemeEditor"
import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz" import SelectQuizz from "@rahoot/web/components/game/create/SelectQuizz"
import Button from "@rahoot/web/components/Button"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { useManagerStore } from "@rahoot/web/stores/manager" import { useManagerStore } from "@rahoot/web/stores/manager"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useEffect, useState } from "react" import { useState } from "react"
const Manager = () => { const Manager = () => {
const { gameId, setGameId, setStatus } = useManagerStore() const { setGameId, setStatus } = useManagerStore()
const router = useRouter() const router = useRouter()
const { socket } = useSocket() const { socket } = useSocket()
@@ -22,8 +20,6 @@ const Manager = () => {
const [quizzList, setQuizzList] = useState<QuizzWithId[]>([]) const [quizzList, setQuizzList] = useState<QuizzWithId[]>([])
const [showEditor, setShowEditor] = useState(false) const [showEditor, setShowEditor] = useState(false)
const [showMedia, setShowMedia] = useState(false) const [showMedia, setShowMedia] = useState(false)
const [showTheme, setShowTheme] = useState(false)
const [resumeGameId, setResumeGameId] = useState<string | null>(null)
useEvent("manager:quizzList", (quizzList) => { useEvent("manager:quizzList", (quizzList) => {
setIsAuth(true) setIsAuth(true)
@@ -33,44 +29,15 @@ const Manager = () => {
useEvent("manager:gameCreated", ({ gameId, inviteCode }) => { useEvent("manager:gameCreated", ({ gameId, inviteCode }) => {
setGameId(gameId) setGameId(gameId)
setStatus(STATUS.SHOW_ROOM, { text: "Waiting for the players", inviteCode }) setStatus(STATUS.SHOW_ROOM, { text: "Waiting for the players", inviteCode })
setResumeGameId(gameId)
router.push(`/game/manager/${gameId}`) router.push(`/game/manager/${gameId}`)
}) })
useEvent(
"manager:successReconnect",
({ gameId, status, players, currentQuestion }) => {
setGameId(gameId)
setStatus(status.name, status.data)
setResumeGameId(gameId)
router.push(`/game/manager/${gameId}`)
},
)
const handleAuth = (password: string) => { const handleAuth = (password: string) => {
socket?.emit("manager:auth", password) socket?.emit("manager:auth", password)
} }
const handleCreate = (quizzId: string) => { const handleCreate = (quizzId: string) => {
socket?.emit("game:create", quizzId) socket?.emit("game:create", quizzId)
} }
const handleBreakToggle = (active: boolean) => {
if (!gameId) return
socket?.emit("manager:setBreak", { gameId, active })
}
const handleResume = () => {
if (!resumeGameId) return
socket?.emit("manager:reconnect", { gameId: resumeGameId })
}
useEffect(() => {
try {
const stored = localStorage.getItem("last_manager_game_id")
if (stored) {
setResumeGameId(stored)
}
} catch {}
}, [])
if (!isAuth) { if (!isAuth) {
return <ManagerPassword onSubmit={handleAuth} /> return <ManagerPassword onSubmit={handleAuth} />
@@ -82,42 +49,32 @@ const Manager = () => {
quizzList={quizzList} quizzList={quizzList}
onBack={() => setShowEditor(false)} onBack={() => setShowEditor(false)}
onListUpdate={setQuizzList} onListUpdate={setQuizzList}
onBreakToggle={handleBreakToggle}
gameId={gameId}
/> />
) )
} }
if (showMedia) { if (showMedia) {
return ( return (
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm"> <div className="flex w-full flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex gap-2">
<Button onClick={() => setShowMedia(false)} className="bg-gray-700"> <button
onClick={() => setShowMedia(false)}
className="rounded-md bg-gray-700 px-3 py-2 text-white"
>
Back Back
</Button> </button>
<div className="flex flex-col leading-tight">
<h2 className="text-lg font-semibold text-gray-900">Media library</h2>
<p className="text-xs text-gray-500">Upload, view, and delete unused media.</p>
</div>
</div> </div>
<MediaLibrary /> <MediaLibrary />
</div> </div>
) )
} }
if (showTheme) {
return <ThemeEditor onBack={() => setShowTheme(false)} />
}
return ( return (
<SelectQuizz <SelectQuizz
quizzList={quizzList} quizzList={quizzList}
onSelect={handleCreate} onSelect={handleCreate}
onManage={() => setShowEditor(true)} onManage={() => setShowEditor(true)}
onMedia={() => setShowMedia(true)} onMedia={() => setShowMedia(true)}
onTheme={() => setShowTheme(true)}
resumeGameId={resumeGameId}
onResume={handleResume}
/> />
) )
} }
-30
View File
@@ -1,30 +0,0 @@
import { getTheme, saveTheme } from "@rahoot/web/server/theme"
import { NextResponse } from "next/server"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export async function GET() {
try {
const theme = getTheme()
return NextResponse.json({ theme })
} catch (error) {
console.error("Failed to load theme", error)
return NextResponse.json({ error: "Failed to load theme" }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const theme = saveTheme({
brandName: body.brandName,
backgroundUrl: body.backgroundUrl,
})
return NextResponse.json({ theme })
} catch (error) {
console.error("Failed to save theme", error)
const message = error instanceof Error ? error.message : "Failed to save theme"
return NextResponse.json({ error: message }, { status: 400 })
}
}
@@ -26,7 +26,6 @@ const ManagerGame = () => {
useManagerStore() useManagerStore()
const { setQuestionStates } = useQuestionStore() const { setQuestionStates } = useQuestionStore()
const [cooldownPaused, setCooldownPaused] = useState(false) const [cooldownPaused, setCooldownPaused] = useState(false)
const [breakActive, setBreakActive] = useState(false)
const { players } = useManagerStore() const { players } = useManagerStore()
useEvent("game:status", ({ name, data }) => { useEvent("game:status", ({ name, data }) => {
@@ -74,9 +73,6 @@ const ManagerGame = () => {
setCooldownPaused(isPaused) setCooldownPaused(isPaused)
}) })
useEvent("game:break", (active) => setBreakActive(active))
useEvent("manager:break", (active) => setBreakActive(active))
const handleSkip = () => { const handleSkip = () => {
if (!gameId) { if (!gameId) {
return return
@@ -119,11 +115,6 @@ const ManagerGame = () => {
} }
} }
const handleBreakToggle = () => {
if (!gameId) return
socket?.emit("manager:setBreak", { gameId, active: !breakActive })
}
const handleEndGame = () => { const handleEndGame = () => {
if (!gameId) return if (!gameId) return
socket?.emit("manager:endGame", { gameId }) socket?.emit("manager:endGame", { gameId })
@@ -182,8 +173,6 @@ const ManagerGame = () => {
showPause={ showPause={
status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER status?.name === STATUS.SHOW_QUESTION || status?.name === STATUS.SELECT_ANSWER
} }
onBreakToggle={handleBreakToggle}
breakActive={breakActive}
onEnd={handleEndGame} onEnd={handleEndGame}
players={players} players={players}
manager manager
+2 -10
View File
@@ -1,32 +1,24 @@
import Toaster from "@rahoot/web/components/Toaster" import Toaster from "@rahoot/web/components/Toaster"
import BrandingHelmet from "@rahoot/web/components/BrandingHelmet"
import ThemeHydrator from "@rahoot/web/components/ThemeHydrator"
import { SocketProvider } from "@rahoot/web/contexts/socketProvider" import { SocketProvider } from "@rahoot/web/contexts/socketProvider"
import type { Metadata } from "next" import type { Metadata } from "next"
import { Montserrat } from "next/font/google" import { Montserrat } from "next/font/google"
import { PropsWithChildren } from "react" import { PropsWithChildren } from "react"
import "./globals.css" import "./globals.css"
import { getTheme } from "@rahoot/web/server/theme"
const montserrat = Montserrat({ const montserrat = Montserrat({
variable: "--font-montserrat", variable: "--font-montserrat",
subsets: ["latin"], subsets: ["latin"],
}) })
export async function generateMetadata(): Promise<Metadata> { export const metadata: Metadata = {
const theme = getTheme() title: "Rahoot !",
return {
title: theme.brandName || "Rahoot",
icons: "/icon.svg", icons: "/icon.svg",
} }
}
const RootLayout = ({ children }: PropsWithChildren) => ( const RootLayout = ({ children }: PropsWithChildren) => (
<html lang="en" suppressHydrationWarning={true} data-lt-installed="true"> <html lang="en" suppressHydrationWarning={true} data-lt-installed="true">
<body className={`${montserrat.variable} bg-secondary antialiased`}> <body className={`${montserrat.variable} bg-secondary antialiased`}>
<SocketProvider> <SocketProvider>
<BrandingHelmet />
<ThemeHydrator />
<main className="text-base-[8px] flex flex-col">{children}</main> <main className="text-base-[8px] flex flex-col">{children}</main>
<Toaster /> <Toaster />
</SocketProvider> </SocketProvider>
@@ -1,33 +0,0 @@
"use client"
import { useThemeStore } from "@rahoot/web/stores/theme"
import clsx from "clsx"
type Props = {
className?: string
size?: "md" | "lg"
}
const BrandHeading = ({ className, size = "lg" }: Props) => {
const { brandName } = useThemeStore()
const label = brandName || "Rahoot!"
const sizeClass =
size === "lg"
? "text-4xl md:text-5xl lg:text-6xl"
: "text-2xl md:text-3xl"
return (
<div
className={clsx(
"text-center font-black tracking-tight text-[#f7931e] drop-shadow-lg leading-tight",
sizeClass,
className,
)}
>
{label}
</div>
)
}
export default BrandHeading
@@ -1,18 +0,0 @@
"use client"
import { useThemeStore } from "@rahoot/web/stores/theme"
import { useEffect } from "react"
const BrandingHelmet = () => {
const { brandName } = useThemeStore()
useEffect(() => {
if (brandName) {
document.title = brandName
}
}, [brandName])
return null
}
export default BrandingHelmet
+1 -1
View File
@@ -1,7 +1,7 @@
import { PropsWithChildren } from "react" import { PropsWithChildren } from "react"
const Form = ({ children }: PropsWithChildren) => ( const Form = ({ children }: PropsWithChildren) => (
<div className="z-10 mx-auto flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm"> <div className="z-10 flex w-full max-w-80 flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
{children} {children}
</div> </div>
) )
@@ -1,32 +0,0 @@
"use client"
import { useEffect } from "react"
import { useThemeStore } from "@rahoot/web/stores/theme"
const ThemeHydrator = () => {
const { setBackground, setBrandName } = useThemeStore()
useEffect(() => {
const load = async () => {
try {
const res = await fetch("/api/theme", { cache: "no-store" })
const data = await res.json()
if (res.ok && data.theme) {
if (typeof data.theme.backgroundUrl === "string") {
setBackground(data.theme.backgroundUrl || null)
}
if (typeof data.theme.brandName === "string") {
setBrandName(data.theme.brandName)
}
}
} catch (error) {
console.error("Failed to hydrate theme", error)
}
}
load()
}, [setBackground, setBrandName])
return null
}
export default ThemeHydrator
@@ -7,9 +7,9 @@ import Loader from "@rahoot/web/components/Loader"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player" import { usePlayerStore } from "@rahoot/web/stores/player"
import { useQuestionStore } from "@rahoot/web/stores/question" import { useQuestionStore } from "@rahoot/web/stores/question"
import { useThemeStore } from "@rahoot/web/stores/theme"
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants" import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
import clsx from "clsx" import clsx from "clsx"
import Image from "next/image"
import { PropsWithChildren, useEffect, useState } from "react" import { PropsWithChildren, useEffect, useState } from "react"
type Props = PropsWithChildren & { type Props = PropsWithChildren & {
@@ -21,8 +21,6 @@ type Props = PropsWithChildren & {
onEnd?: () => void onEnd?: () => void
players?: { id: string; username: string; connected: boolean }[] players?: { id: string; username: string; connected: boolean }[]
manager?: boolean manager?: boolean
onBreakToggle?: () => void
breakActive?: boolean
} }
const GameWrapper = ({ const GameWrapper = ({
@@ -35,15 +33,11 @@ const GameWrapper = ({
onEnd, onEnd,
players, players,
manager, manager,
onBreakToggle,
breakActive,
}: Props) => { }: Props) => {
const { isConnected } = useSocket() const { isConnected } = useSocket()
const { player } = usePlayerStore() const { player } = usePlayerStore()
const { questionStates, setQuestionStates } = useQuestionStore() const { questionStates, setQuestionStates } = useQuestionStore()
const { backgroundUrl, setBackground, setBrandName } = useThemeStore()
const [isDisabled, setIsDisabled] = useState(false) const [isDisabled, setIsDisabled] = useState(false)
const [onBreak, setOnBreak] = useState(false)
const next = statusName ? MANAGER_SKIP_BTN[statusName] : null const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
useEvent("game:updateQuestion", ({ current, total }) => { useEvent("game:updateQuestion", ({ current, total }) => {
@@ -57,50 +51,19 @@ const GameWrapper = ({
setIsDisabled(false) setIsDisabled(false)
}, [statusName]) }, [statusName])
useEvent("game:break", (active) => setOnBreak(active))
useEvent("manager:break", (active) => setOnBreak(active))
useEffect(() => {
const loadTheme = async () => {
try {
const res = await fetch("/api/theme", { cache: "no-store" })
const data = await res.json()
if (res.ok && data.theme) {
if (typeof data.theme.backgroundUrl === "string") {
setBackground(data.theme.backgroundUrl || null)
}
if (typeof data.theme.brandName === "string") {
setBrandName(data.theme.brandName)
}
}
} catch (error) {
console.error("Failed to load theme", error)
}
}
loadTheme()
}, [setBackground, setBrandName])
const handleNext = () => { const handleNext = () => {
setIsDisabled(true) setIsDisabled(true)
onNext?.() onNext?.()
} }
const resolvedBackground = backgroundUrl || background.src
return ( return (
<section className="relative flex min-h-screen w-full flex-col justify-between"> <section className="relative flex min-h-screen w-full flex-col justify-between">
<div <div className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70">
className="fixed top-0 left-0 -z-10 h-full w-full bg-orange-600 opacity-70" <Image
style={{ className="pointer-events-none h-full w-full object-cover opacity-60"
backgroundImage: `url(${resolvedBackground})`, src={background}
backgroundSize: "cover", alt="background"
backgroundPosition: "center", />
backgroundRepeat: "no-repeat",
opacity: 0.7,
}}
>
<div className="h-full w-full bg-black/10" />
</div> </div>
{!isConnected && !statusName ? ( {!isConnected && !statusName ? (
@@ -139,17 +102,6 @@ const GameWrapper = ({
</Button> </Button>
)} )}
{manager && onBreakToggle && (
<Button
className={clsx("self-end bg-white px-4 text-black!", {
"pointer-events-none": isDisabled,
})}
onClick={onBreakToggle}
>
{breakActive ? "Resume game" : "Break"}
</Button>
)}
{manager && onEnd && ( {manager && onEnd && (
<Button className="self-end bg-red-600 px-4" onClick={onEnd}> <Button className="self-end bg-red-600 px-4" onClick={onEnd}>
End game End game
@@ -190,15 +142,6 @@ const GameWrapper = ({
</div> </div>
</div> </div>
)} )}
{onBreak && (
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-black/60">
<div className="rounded-md bg-white/90 px-6 py-4 text-center shadow-lg">
<p className="text-lg font-semibold text-gray-800">Game paused for a break</p>
<p className="text-sm text-gray-600">We&apos;ll resume from the same spot.</p>
</div>
</div>
)}
</> </>
)} )}
</section> </section>
@@ -27,7 +27,6 @@ const ManagerPassword = ({ onSubmit }: Props) => {
}) })
return ( return (
<div className="w-full max-w-xl">
<Form> <Form>
<Input <Input
type="password" type="password"
@@ -37,7 +36,6 @@ const ManagerPassword = ({ onSubmit }: Props) => {
/> />
<Button onClick={handleSubmit}>Submit</Button> <Button onClick={handleSubmit}>Submit</Button>
</Form> </Form>
</div>
) )
} }
@@ -12,8 +12,6 @@ type Props = {
quizzList: QuizzWithId[] quizzList: QuizzWithId[]
onBack: () => void onBack: () => void
onListUpdate: (_quizz: QuizzWithId[]) => void onListUpdate: (_quizz: QuizzWithId[]) => void
onBreakToggle?: (_active: boolean) => void
gameId?: string | null
} }
type EditableQuestion = QuizzWithId["questions"][number] type EditableQuestion = QuizzWithId["questions"][number]
@@ -57,13 +55,7 @@ const formatBytes = (bytes: number) => {
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}` return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}`
} }
const QuizEditor = ({ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
quizzList,
onBack,
onListUpdate,
onBreakToggle,
gameId,
}: Props) => {
const { socket } = useSocket() const { socket } = useSocket()
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [draft, setDraft] = useState<QuizzWithId | null>(null) const [draft, setDraft] = useState<QuizzWithId | null>(null)
@@ -468,7 +460,7 @@ const QuizEditor = ({
}, [quizzList, selectedId]) }, [quizzList, selectedId])
return ( return (
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-7 rounded-md bg-white p-6 shadow-sm md:p-8"> <div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={onBack} className="bg-gray-700"> <Button onClick={onBack} className="bg-gray-700">
@@ -482,16 +474,6 @@ const QuizEditor = ({
Delete quiz Delete quiz
</Button> </Button>
)} )}
{onBreakToggle && gameId && (
<>
<Button className="bg-amber-500" onClick={() => onBreakToggle(true)}>
Break
</Button>
<Button className="bg-green-600" onClick={() => onBreakToggle(false)}>
Resume
</Button>
</>
)}
</div> </div>
<Button onClick={handleSave} disabled={saving || loading}> <Button onClick={handleSave} disabled={saving || loading}>
@@ -9,20 +9,9 @@ type Props = {
onSelect: (_id: string) => void onSelect: (_id: string) => void
onManage?: () => void onManage?: () => void
onMedia?: () => void onMedia?: () => void
onTheme?: () => void
resumeGameId?: string | null
onResume?: () => void
} }
const SelectQuizz = ({ const SelectQuizz = ({ quizzList, onSelect, onManage, onMedia }: Props) => {
quizzList,
onSelect,
onManage,
onMedia,
onTheme,
resumeGameId,
onResume,
}: Props) => {
const [selected, setSelected] = useState<string | null>(null) const [selected, setSelected] = useState<string | null>(null)
const handleSelect = (id: string) => () => { const handleSelect = (id: string) => () => {
@@ -48,14 +37,6 @@ const SelectQuizz = ({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Select a quizz</h1> <h1 className="text-2xl font-bold">Select a quizz</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{resumeGameId && onResume && (
<button
className="rounded-md bg-amber-500 px-3 py-1 text-sm font-semibold text-white shadow"
onClick={onResume}
>
Resume game
</button>
)}
{onMedia && ( {onMedia && (
<button <button
className="text-sm font-semibold text-gray-700 underline" className="text-sm font-semibold text-gray-700 underline"
@@ -64,14 +45,6 @@ const SelectQuizz = ({
Media Media
</button> </button>
)} )}
{onTheme && (
<button
className="text-sm font-semibold text-amber-700 underline"
onClick={onTheme}
>
Theme
</button>
)}
{onManage && ( {onManage && (
<button <button
className="text-sm font-semibold text-primary underline" className="text-sm font-semibold text-primary underline"
@@ -1,291 +0,0 @@
"use client"
import background from "@rahoot/web/assets/background.webp"
import Button from "@rahoot/web/components/Button"
import { useThemeStore } from "@rahoot/web/stores/theme"
import clsx from "clsx"
import Image from "next/image"
import { useEffect, useMemo, useState } from "react"
type MediaItem = {
fileName: string
url: string
size: number
mime: string
type: string
}
type Props = {
onBack: () => void
}
const ThemeEditor = ({ onBack }: Props) => {
const { backgroundUrl, brandName, setBackground, setBrandName, reset } = useThemeStore()
const [customUrl, setCustomUrl] = useState("")
const [items, setItems] = useState<MediaItem[]>([])
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [savingTheme, setSavingTheme] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [initializing, setInitializing] = useState(true)
const previewUrl = useMemo(
() => backgroundUrl || customUrl || background.src,
[backgroundUrl, customUrl],
)
const load = async () => {
setLoading(true)
try {
const [mediaRes, themeRes] = await Promise.all([
fetch("/api/media", { cache: "no-store" }),
fetch("/api/theme", { cache: "no-store" }),
])
const mediaData = await mediaRes.json()
const themeData = await themeRes.json()
if (!mediaRes.ok) throw new Error(mediaData.error || "Failed to load media")
if (!themeRes.ok) throw new Error(themeData.error || "Failed to load theme")
const onlyImages = (mediaData.media || []).filter(
(item: MediaItem) => item.mime?.startsWith("image/"),
)
setItems(onlyImages)
if (themeData.theme) {
if (typeof themeData.theme.backgroundUrl === "string") {
setBackground(themeData.theme.backgroundUrl || null)
setCustomUrl(themeData.theme.backgroundUrl || "")
}
if (typeof themeData.theme.brandName === "string") {
setBrandName(themeData.theme.brandName)
}
}
} catch (error) {
console.error(error)
} finally {
setLoading(false)
setInitializing(false)
}
}
useEffect(() => {
load()
}, [])
const persistTheme = async (next: { backgroundUrl?: string | null; brandName?: string }) => {
setSavingTheme(true)
setSaveError(null)
try {
const res = await fetch("/api/theme", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || "Failed to save theme")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save theme"
setSaveError(message)
} finally {
setSavingTheme(false)
}
}
const handleSet = (url: string) => {
if (!url) return
setBackground(url)
persistTheme({ backgroundUrl: url })
}
const handleApplyCustom = () => {
if (!customUrl.trim()) return
handleSet(customUrl.trim())
}
const handleUpload = async (file?: File | null) => {
if (!file) return
setUploading(true)
setUploadError(null)
try {
const form = new FormData()
form.append("file", file)
const res = await fetch("/api/media", {
method: "POST",
body: form,
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || "Upload failed")
}
if (data.media?.url) {
handleSet(data.media.url)
setCustomUrl(data.media.url)
}
load()
} catch (error) {
const message = error instanceof Error ? error.message : "Upload failed"
setUploadError(message)
} finally {
setUploading(false)
}
}
const handleReset = () => {
reset()
setCustomUrl("")
persistTheme({ backgroundUrl: null, brandName: "Rahoot" })
}
return (
<div className="flex w-full max-w-6xl flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button onClick={onBack} className="bg-gray-700">
Back
</Button>
<div className="flex flex-col leading-tight">
<h2 className="text-lg font-semibold text-gray-900">Theme editor</h2>
<p className="text-xs text-gray-500">
Set the game background from uploads or a custom URL.
</p>
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
<h3 className="text-lg font-semibold text-gray-800">Branding</h3>
<label className="flex flex-col gap-2">
<span className="text-sm font-semibold text-gray-600">
Brand name
</span>
<input
value={brandName}
onChange={(e) => {
setBrandName(e.target.value)
persistTheme({ brandName: e.target.value })
}}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
placeholder="e.g., Kwalitaria Pub Quiz"
/>
<span className="text-xs text-gray-500">
Appears in the browser title bar and elsewhere we surface the brand.
</span>
</label>
</div>
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
<h3 className="text-lg font-semibold text-gray-800">Preview</h3>
<div className="relative h-60 w-full overflow-hidden rounded-md border border-gray-100 bg-gray-50">
<Image
src={previewUrl}
alt="Background preview"
fill
className="object-cover"
sizes="100vw"
unoptimized
/>
<div className="absolute inset-0 bg-black/20" />
<div className="absolute inset-0 flex items-center justify-center text-white">
<span className="rounded bg-black/50 px-3 py-1 text-sm font-semibold">
Current background
</span>
</div>
</div>
<div className="flex gap-2">
<Button className="bg-gray-700" onClick={handleReset}>
Reset to default
</Button>
<Button className="bg-primary" onClick={handleApplyCustom}>
Apply custom URL
</Button>
</div>
<input
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
placeholder="https://example.com/background.webp or /media/your-file"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
<p className="text-sm text-gray-600">
Paste any reachable image URL (including your uploaded media path). Changes apply immediately to the game background.
</p>
</div>
<div className="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800">Images from media library</h3>
<p className="text-sm text-gray-500">Pick any uploaded image as the background.</p>
</div>
<Button className="bg-gray-700" onClick={load} disabled={loading}>
{loading ? "Refreshing..." : "Refresh"}
</Button>
</div>
<div className="rounded-md border border-dashed border-gray-300 p-3">
<label className="flex items-center gap-3 text-sm font-semibold text-gray-800">
<input
type="file"
accept="image/*"
className="text-sm"
onChange={(e) => handleUpload(e.target.files?.[0])}
disabled={uploading}
/>
{uploading ? "Uploading..." : "Upload image"}
</label>
<p className="mt-1 text-xs text-gray-500">
Upload a background image (webp/png/jpg). It will be available immediately and selected as the current background.
</p>
{uploadError && (
<p className="mt-1 text-xs font-semibold text-red-600">{uploadError}</p>
)}
{saveError && (
<p className="mt-1 text-xs font-semibold text-red-600">{saveError}</p>
)}
{(savingTheme || initializing) && !uploading && (
<p className="mt-1 text-xs text-gray-500">
{initializing ? "Loading theme…" : "Saving theme…"}
</p>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
{items.map((item) => (
<button
key={item.fileName}
className={clsx(
"relative h-32 overflow-hidden rounded-md border border-gray-200 bg-gray-50 text-left shadow-sm transition hover:border-primary",
backgroundUrl === item.url && "ring-2 ring-primary",
)}
onClick={() => handleSet(item.url)}
>
<Image
src={item.url}
alt={item.fileName}
fill
className="object-cover"
sizes="200px"
unoptimized
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/0" />
<div className="absolute bottom-2 left-2 right-2 text-xs font-semibold text-white drop-shadow">
{item.fileName}
</div>
</button>
))}
{!loading && items.length === 0 && (
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500">
No images uploaded yet. Upload an image in the Media page, then pick it here.
</div>
)}
</div>
</div>
</div>
</div>
)
}
export default ThemeEditor
@@ -1,11 +0,0 @@
"use client"
import { PropsWithChildren } from "react"
const BrandShell = ({ children }: PropsWithChildren) => (
<div className="flex w-full max-w-xl flex-col items-center justify-center gap-4">
{children}
</div>
)
export default BrandShell
@@ -3,7 +3,6 @@ import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input" import Input from "@rahoot/web/components/Input"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player" import { usePlayerStore } from "@rahoot/web/stores/player"
import BrandShell from "@rahoot/web/components/game/join/BrandShell"
import { KeyboardEvent, useState } from "react" import { KeyboardEvent, useState } from "react"
const Room = () => { const Room = () => {
@@ -26,7 +25,6 @@ const Room = () => {
}) })
return ( return (
<BrandShell>
<Form> <Form>
<Input <Input
onChange={(e) => setInvitation(e.target.value)} onChange={(e) => setInvitation(e.target.value)}
@@ -35,7 +33,6 @@ const Room = () => {
/> />
<Button onClick={handleJoin}>Submit</Button> <Button onClick={handleJoin}>Submit</Button>
</Form> </Form>
</BrandShell>
) )
} }
@@ -4,7 +4,6 @@ import { STATUS } from "@rahoot/common/types/game/status"
import Button from "@rahoot/web/components/Button" import Button from "@rahoot/web/components/Button"
import Form from "@rahoot/web/components/Form" import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input" import Input from "@rahoot/web/components/Input"
import BrandShell from "@rahoot/web/components/game/join/BrandShell"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player" import { usePlayerStore } from "@rahoot/web/stores/player"
@@ -43,7 +42,6 @@ const Username = () => {
}) })
return ( return (
<BrandShell>
<Form> <Form>
<Input <Input
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
@@ -52,7 +50,6 @@ const Username = () => {
/> />
<Button onClick={handleLogin}>Submit</Button> <Button onClick={handleLogin}>Submit</Button>
</Form> </Form>
</BrandShell>
) )
} }
-29
View File
@@ -1,29 +0,0 @@
import Config from "@rahoot/socket/services/config"
export type ThemeSettings = {
brandName: string
backgroundUrl: string | null
}
export const getTheme = (): ThemeSettings => {
const theme = Config.theme()
return {
brandName: theme.brandName || "Rahoot",
backgroundUrl:
typeof theme.backgroundUrl === "string" && theme.backgroundUrl.length > 0
? theme.backgroundUrl
: null,
}
}
export const saveTheme = (payload: Partial<ThemeSettings>): ThemeSettings => {
const current = getTheme()
const merged = {
brandName: payload.brandName ?? current.brandName,
backgroundUrl:
payload.backgroundUrl === undefined
? current.backgroundUrl
: payload.backgroundUrl,
}
return Config.saveTheme(merged)
}
+2 -16
View File
@@ -25,16 +25,7 @@ const initialState = {
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({ export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
...initialState, ...initialState,
setGameId: (gameId) => { setGameId: (gameId) => set({ gameId }),
try {
if (gameId) {
localStorage.setItem("last_manager_game_id", gameId)
} else {
localStorage.removeItem("last_manager_game_id")
}
} catch {}
set({ gameId })
},
setStatus: (name, data) => set({ status: createStatus(name, data) }), setStatus: (name, data) => set({ status: createStatus(name, data) }),
resetStatus: () => set({ status: null }), resetStatus: () => set({ status: null }),
@@ -44,10 +35,5 @@ export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
players: typeof players === "function" ? players(state.players) : players, players: typeof players === "function" ? players(state.players) : players,
})), })),
reset: () => { reset: () => set(initialState),
try {
localStorage.removeItem("last_manager_game_id")
} catch {}
set(initialState)
},
})) }))
-23
View File
@@ -1,23 +0,0 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
type ThemeState = {
backgroundUrl: string | null
brandName: string
setBackground: (_url: string | null) => void
setBrandName: (_name: string) => void
reset: () => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
backgroundUrl: null,
brandName: "Rahoot",
setBackground: (backgroundUrl) => set({ backgroundUrl }),
setBrandName: (brandName) => set({ brandName }),
reset: () => set({ backgroundUrl: null, brandName: "Rahoot" }),
}),
{ name: "theme-preferences" },
),
)