diff --git a/.gitignore b/.gitignore index 16d54bb..b467b70 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,11 @@ pnpm-debug.log* # jetbrains setting folder .idea/ + +# local files +*.local* + +# database +*.db +*.db-shm +*.db-wal diff --git a/astro.config.mjs b/astro.config.mjs index e762ba5..ba0b1a5 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,5 +1,17 @@ // @ts-check -import { defineConfig } from 'astro/config'; +import { defineConfig } from 'astro/config' + +import node from '@astrojs/node' + +import preact from '@astrojs/preact' // https://astro.build/config -export default defineConfig({}); +export default defineConfig({ + output: 'server', + + adapter: node({ + mode: 'standalone', + }), + + integrations: [preact()], +}) diff --git a/bun.lockb b/bun.lockb index cf342b8..902a4be 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index e3a564a..83a3c82 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,21 @@ "astro": "astro" }, "dependencies": { - "astro": "^4.16.13", "@astrojs/check": "^0.9.4", + "@astrojs/node": "^8.3.4", + "@astrojs/preact": "^3.5.3", + "@fontsource-variable/material-symbols-outlined": "^5.1.3", + "@fontsource-variable/source-code-pro": "^5.1.0", + "@fontsource/inria-sans": "^5.1.0", + "@paralleldrive/cuid2": "^2.2.2", + "astro": "^4.16.13", + "better-sqlite3": "^11.5.0", + "nanoid": "^5.0.8", + "preact": "^10.24.3", "typescript": "^5.6.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/bun": "^1.1.13" } } diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index f157bd1..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/public/regolamento.pdf b/public/regolamento.pdf new file mode 100644 index 0000000..5a66dfd Binary files /dev/null and b/public/regolamento.pdf differ diff --git a/src/client/utils.ts b/src/client/utils.ts new file mode 100644 index 0000000..7c833cd --- /dev/null +++ b/src/client/utils.ts @@ -0,0 +1,29 @@ +export async function requestJSON(url: string) { + const res = await fetch(url) + return await res.json() +} + +type Result = { status: 'ok'; value: T } | { status: 'error'; value: E } + +export async function sendJSON(url: string, data: any): Promise> { + try { + const res = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + + if (!res.ok) { + console.error(res) + return { status: 'error', value: await res.text() } + } + + return { status: 'ok', value: await res.json() } + } catch (e) { + console.error(e) + return { status: 'error', value: e!.toString() } + } +} diff --git a/src/components/ActionRegistry.tsx b/src/components/ActionRegistry.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/AdminLogin.tsx b/src/components/AdminLogin.tsx new file mode 100644 index 0000000..be2b631 --- /dev/null +++ b/src/components/AdminLogin.tsx @@ -0,0 +1,30 @@ +import { sendJSON } from '@/client/utils' +import { useState } from 'preact/hooks' + +export const AdminLogin = ({}) => { + const [password, setPassword] = useState('') + + const handleLogin = async () => { + const { status, value } = await sendJSON('/api/login', { password }) + if (status === 'ok') { + console.log(value.room) + const { room } = value + location.href = `/${room}/admin` + return + } + } + + return ( +
+

Accedi al pannello di controllo di una stanza

+ setPassword(e.currentTarget.value)} + /> + +
+ ) +} diff --git a/src/components/Leaderboard.tsx b/src/components/Leaderboard.tsx new file mode 100644 index 0000000..514ea6a --- /dev/null +++ b/src/components/Leaderboard.tsx @@ -0,0 +1,45 @@ +import type { Question, Scoreboard } from '@/ggwp' + +type Props = { + questions: Question[] + scoreboard: Scoreboard + + onQuestionClick?: (team: string, questionId: string) => void +} + +export const Leaderboard = ({ questions, scoreboard, onQuestionClick }: Props) => { + return ( +
+
+ {questions.map(({ id }, i) => ( +
+ {id} +
+ ))} +
+
+ {scoreboard.map(({ team, totalScore, questionScores }, i) => ( +
+
+
{team}
+
{totalScore}pt
+
+
+ {questions.map((question, j) => ( +
onQuestionClick?.(team, question.id)}> + {questionScores[question.id]} +
+ ))} +
+
+ ))} +
+
+ ) +} diff --git a/src/components/LiveLeaderboard.tsx b/src/components/LiveLeaderboard.tsx new file mode 100644 index 0000000..7b8fce7 --- /dev/null +++ b/src/components/LiveLeaderboard.tsx @@ -0,0 +1,34 @@ +import type { RoomData } from '@/db/model' +import { Leaderboard } from './Leaderboard' +import { useEffect, useState } from 'preact/hooks' +import { requestJSON, sendJSON } from '@/client/utils' +import { computeScoreboardState } from '@/ggwp' + +type Props = { + roomId: string +} + +export const LiveLeaderboard = ({ roomId }: Props) => { + const [room, setRoom] = useState(null) + useEffect(() => { + requestJSON(`/api/room/${roomId}`).then(room => { + setRoom(room) + }) + }, []) + + console.log(room) + + if (!room) { + return
Loading...
+ } + + const scoreboard = computeScoreboardState( + { + teamIds: room.teams, + questions: room.questions, + }, + room.actions + ) + + return +} diff --git a/src/components/NewRoom.tsx b/src/components/NewRoom.tsx new file mode 100644 index 0000000..c1fc53b --- /dev/null +++ b/src/components/NewRoom.tsx @@ -0,0 +1,196 @@ +import { sendJSON } from '@/client/utils' +import { type Question } from '@/ggwp' +import { useState } from 'preact/hooks' + +const handleCreateRoom = async (id: string, teams: string[], questions: Question[]) => { + const { status, value } = await sendJSON('/api/rooms', { + id, + teams, + questions, + }) + + console.log(status, value) + + const { password } = value + + if (status === 'ok') { + alert(`Stanza creata con successo!\n\nPassword Admin: ${password}\n(non la perdere)`) + return + } + + if (status === 'error') { + alert(value) + return + } +} + +export const NewRoom = ({}) => { + const [roomName, setRoomName] = useState('') + const [teams, setTeams] = useState(['']) + // const [questions, setQuestions] = useState<{ group: string; name: string }[]>([{ group: '', name: '' }]) + const [questions, setQuestions] = useState<{ group: string; name: string }[]>( + Array.from({ length: 18 }, (_, i) => ({ + group: `P${Math.floor(i / 6) + 1}`, + name: `${(i % 6) + 1}`, + })) + ) + + return ( +
+

Crea una nuova stanza

+ setRoomName(e.currentTarget.value)} + /> + +

Squadre

+
+
+ + +
+ {teams.map((team, i) => ( +
+ { + const newTeams = [...teams] + newTeams[i] = e.currentTarget.value + setTeams(newTeams) + }} + /> + +
+ ))} + +

Domande

+
+
+ + +
+ {/*
+
+ + +
*/} + {questions.map((question, i) => ( +
+ { + const newQuestions = [...questions] + newQuestions[i].group = e.currentTarget.value + setQuestions(newQuestions) + }} + /> + { + const newQuestions = [...questions] + newQuestions[i].name = e.currentTarget.value + setQuestions(newQuestions) + }} + /> + +
+ ))} + + +
+ ) +} diff --git a/src/components/Rooms.tsx b/src/components/Rooms.tsx new file mode 100644 index 0000000..775e7a6 --- /dev/null +++ b/src/components/Rooms.tsx @@ -0,0 +1,30 @@ +import { requestJSON } from '@/client/utils' +import type { Room } from '@/db/model' +import { useEffect, useState } from 'preact/hooks' + +export const Rooms = () => { + const [rooms, setRooms] = useState(null) + useEffect(() => { + requestJSON('/api/rooms').then(rooms => setRooms(rooms)) + }, []) + + if (!rooms) { + return
Loading...
+ } + + return rooms.map((room, i) => ( +
+
+ {room.id} +
+ +
+
+ )) +} diff --git a/src/components/SubmitAction.tsx b/src/components/SubmitAction.tsx new file mode 100644 index 0000000..8eace19 --- /dev/null +++ b/src/components/SubmitAction.tsx @@ -0,0 +1,115 @@ +import { requestJSON } from '@/client/utils' +import type { RoomData } from '@/db/model' +import { type Action, type ActionAnswer, type ActionJolly } from '@/ggwp' +import { useEffect, useState } from 'preact/hooks' + +type Outcome = 'correct' | 'partial' | 'wrong' + +const handleSendAnswer = (action: Action) => { + console.log(action) +} + +export const SubmitActionAnswer = ({ room }: { room: RoomData }) => { + const [answer, setAnswer] = useState({ + question: '', + team: '', + outcome: 'correct', + }) + + return ( + <> + setAnswer({ ...answer, question: e.currentTarget.value })} + /> + setAnswer({ ...answer, team: e.currentTarget.value })} + /> + + + + ) +} + +export const SubmitActionJolly = ({ room }: { room: RoomData }) => { + const [answer, setAnswer] = useState({ + team: '', + groupId: '', + }) + + return ( + <> + setAnswer({ ...answer, team: e.currentTarget.value })} + /> + setAnswer({ ...answer, groupId: e.currentTarget.value })} + /> + + + ) +} + +export const SubmitAction = ({ roomId }: { roomId: string }) => { + const [room, setRoom] = useState(null) + useEffect(() => { + requestJSON(`/api/room/${roomId}`).then(room => { + setRoom(room) + }) + }, []) + + if (!room) { + return
Loading...
+ } + + return ( +
+

Risposta

+ + +

Jolly

+ +
+ ) +} diff --git a/src/db/events.ts b/src/db/events.ts new file mode 100644 index 0000000..26d63bd --- /dev/null +++ b/src/db/events.ts @@ -0,0 +1,28 @@ +import { EventEmitter } from 'events' +import type { RoomData } from './model' + +const rooms = new Map>() + +export function addRoomUpdateListener(roomId: string, cb: (room: RoomData) => void) { + if (!rooms.has(roomId)) { + rooms.set(roomId, new EventEmitter()) + } + + rooms.get(roomId)!.on('update', cb) +} + +export function removeRoomUpdateListener(roomId: string, cb: (room: RoomData) => void) { + if (!rooms.has(roomId)) { + return + } + + rooms.get(roomId)!.off('update', cb) +} + +export function emitRoomUpdate(roomId: string, room: RoomData) { + if (!rooms.has(roomId)) { + return + } + + rooms.get(roomId)!.emit('update', room) +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..126fd34 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,105 @@ +import cuid2 from '@paralleldrive/cuid2' +import Database from 'better-sqlite3' +import type { Room, RoomData } from './model' +import type { Question } from '@/ggwp' + +const db = new Database('ggwp.db') +db.pragma('journal_mode = WAL') +db.pragma('foreign_keys = ON') + +db.exec(` + CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + + password TEXT NOT NULL, + created_at TEXT NOT NULL, + + data TEXT NOT NULL + ); +`) + +// db.exec(` +// CREATE TABLE IF NOT EXISTS game_actions ( +// id INTEGER PRIMARY KEY, +// room_id TEXT NOT NULL, +// created_at TEXT NOT NULL, + +// data TEXT NOT NULL, + +// FOREIGN KEY (room_id) REFERENCES rooms (id) +// ); +// `) + +export function databaseStatus(): string { + return 'ok' +} + +export function createRoom(id: string, teams: string[], questions: Question[]): string { + const password = cuid2.createId() + + const data: RoomData = { + teams, + questions, + actions: [], + } + + db.prepare<[string, string, string, string]>( + ` + INSERT INTO rooms (id, password, created_at, data) + VALUES (?, ?, ?, ?); + ` + ).run(id, password, new Date().toISOString(), JSON.stringify(data)) + + return password +} + +export function getRoom(id: string): RoomData | null { + const row = db + .prepare< + [string], + { + id: string + created_at: string + data: string + } + >('SELECT * FROM rooms WHERE id = ?') + .get(id) + if (!row) { + return null + } + + return JSON.parse(row.data) +} + +export function getRoomByPassword(password: string): string | null { + const row = db + .prepare< + [string], + { + id: string + } + >('SELECT id FROM rooms WHERE password = ?') + .get(password) + if (!row) { + return null + } + + return row.id +} + +export function getRooms(): Room[] { + const rows = db + .prepare< + [], + { + id: string + data: string + } + >('SELECT id, data FROM rooms') + .all() + + return rows.map(row => ({ + id: row.id, + ...JSON.parse(row.data), + })) +} diff --git a/src/db/model.ts b/src/db/model.ts new file mode 100644 index 0000000..3d22421 --- /dev/null +++ b/src/db/model.ts @@ -0,0 +1,12 @@ +import type { Action } from '@/ggwp' + +export type Room = { + id: string + + teams: string[] + questions: { id: string; group: string }[] + + actions: Action[] +} + +export type RoomData = Omit diff --git a/src/db/sessions.ts b/src/db/sessions.ts new file mode 100644 index 0000000..5c1c0d8 --- /dev/null +++ b/src/db/sessions.ts @@ -0,0 +1,18 @@ +import cuid2 from '@paralleldrive/cuid2' + +// sessions is a map of session IDs to room IDs. +const sessions = new Map() + +export const createSession = (room: string) => { + const session = cuid2.createId() + sessions.set(session, room) + return session +} + +export const getSession = (sid: string) => { + return sessions.get(sid) +} + +export const deleteSession = (sid: string) => { + sessions.delete(sid) +} diff --git a/src/ggwp/index.test.ts b/src/ggwp/index.test.ts new file mode 100644 index 0000000..3c8a791 --- /dev/null +++ b/src/ggwp/index.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'bun:test' +import { computeScoreboardState, type Action, type Game } from '.' + +describe('ggwp simple game', () => { + it('should compute the scoreboard correctly', () => { + const game = { + teamIds: ['A', 'B', 'C', 'D'], + questions: [ + { id: 'Q1-1', group: 'P1' }, + { id: 'Q1-2', group: 'P1' }, + { id: 'Q2-1', group: 'P2' }, + { id: 'Q2-2', group: 'P2' }, + ], + } satisfies Game + + const actions = [ + { type: 'answer', team: 'B', question: 'Q1-1', outcome: 'partial' }, + { type: 'answer', team: 'B', question: 'Q2-2', outcome: 'partial' }, + { type: 'answer', team: 'A', question: 'Q1-1', outcome: 'correct' }, + { type: 'answer', team: 'A', question: 'Q1-2', outcome: 'correct' }, + { type: 'jolly', team: 'A', groupId: 'P1' }, + { type: 'answer', team: 'C', question: 'Q1-1', outcome: 'correct' }, + { type: 'answer', team: 'D', question: 'Q1-1', outcome: 'correct' }, + ] satisfies Action[] + + const scoreboard = computeScoreboardState(game, actions) + + expect(scoreboard).toEqual([ + { team: 'A', totalScore: 31, questionScores: { 'Q1-1': 13, 'Q1-2': 13, 'Q2-1': 0, 'Q2-2': 0 } }, + { team: 'B', totalScore: 13, questionScores: { 'Q1-1': 5, 'Q1-2': 0, 'Q2-1': 0, 'Q2-2': 8 } }, + { team: 'C', totalScore: 12, questionScores: { 'Q1-1': 12, 'Q1-2': 0, 'Q2-1': 0, 'Q2-2': 0 } }, + { team: 'D', totalScore: 11, questionScores: { 'Q1-1': 11, 'Q1-2': 0, 'Q2-1': 0, 'Q2-2': 0 } }, + ]) + // expect(scoreboard).toEqual({ + // ['A']: 10 + 3 + 10 + 3 + 5, // = 31 + // ['B']: 5 + 5 + 3, // = 13 + // ['C']: 10 + 2, // = 12 + // ['D']: 10 + 1, // = 11 + // }) + }) +}) diff --git a/src/ggwp/index.ts b/src/ggwp/index.ts new file mode 100644 index 0000000..185aa9e --- /dev/null +++ b/src/ggwp/index.ts @@ -0,0 +1,179 @@ +function sortByKey(array: T[], extractor: (item: T) => number): T[] { + return array.toSorted((a, b) => { + return extractor(a) - extractor(b) + }) +} + +export type Question = { id: string; group: string } + +export type Game = { + teamIds: string[] + questions: Question[] +} + +export type Scoreboard = { + team: string + totalScore: number + questionScores: { [id: string]: number } +}[] + +function getCorrectnessMultiplier(outcome: 'correct' | 'partial' | 'wrong'): number { + switch (outcome) { + case 'correct': + return 1.0 + case 'partial': + return 0.5 + case 'wrong': + return 0.0 + default: + throw new Error(`Unknown outcome: ${outcome}`) + } +} + +function getTimeBonus(index: number): number { + switch (index) { + case 0: + return 3.0 + case 1: + return 2.0 + case 2: + return 1.0 + default: + return 0.0 + } +} + +export type ActionAnswer = { + team: string + question: string + outcome: 'correct' | 'partial' | 'wrong' +} + +export type ActionJolly = { + team: string + groupId: string +} + +export type Action = ({ type: 'answer' } & ActionAnswer) | ({ type: 'jolly' } & ActionJolly) + +export function computeScoreboardState(game: Game, rawActions: Action[]): Scoreboard { + // filter out actions for the same question by the same team, keeping only the last one + const keyer = (action: ActionAnswer) => `answer-${action.team}-${action.question}` + const lastAnswerIndexForQuestion: { [key: string]: number } = {} + + for (let i = 0; i < rawActions.length; i++) { + const action = rawActions[i] + if (action.type === 'answer') { + lastAnswerIndexForQuestion[keyer(action)] = i + } + } + + const actions = rawActions.filter((action, i) => { + if (action.type === 'answer') { + return i === lastAnswerIndexForQuestion[keyer(action)] + } + + return true + }) + + const actionsByQuestion: { [question: string]: ActionAnswer[] } = {} + + const questionGroups = Object.fromEntries(game.questions.map(question => [question.id, question.group])) + + for (const question of game.questions) { + if (!actionsByQuestion[question.id]) { + actionsByQuestion[question.id] = [] + } + + const questionActions: { + type: 'answer' + team: string + question: string + outcome: 'correct' | 'partial' | 'wrong' + }[] = actions + .filter(action => { + return action.type === 'answer' + }) + .filter(action => action.question === question.id) + + actionsByQuestion[question.id] = sortByKey(questionActions, item => { + return -getCorrectnessMultiplier(item.outcome) + }) + } + + console.log(actionsByQuestion) + + const scoreboardByTeam: { + [team: string]: { + totalScore: number + bonusJolly: number + questionScores: { [id: string]: number } + } + } = {} + + for (const id of game.teamIds) { + scoreboardByTeam[id] = { + totalScore: 0, + bonusJolly: 0, + questionScores: Object.fromEntries(game.questions.map(question => [question.id, 0])), + } + } + + for (const actions of Object.values(actionsByQuestion)) { + let index = 0 + for (const action of actions) { + scoreboardByTeam[action.team].totalScore += + 10 * getCorrectnessMultiplier(action.outcome) + getTimeBonus(index) + + scoreboardByTeam[action.team].questionScores[action.question] += + 10 * getCorrectnessMultiplier(action.outcome) + getTimeBonus(index) + + index++ + } + } + + // dict from team to jolly question group + const teamsJollyGroup = Object.fromEntries( + actions.filter(action => action.type === 'jolly').map(action => [action.team, action.groupId]) + ) + + for (const team of game.teamIds) { + let teamBonus: number = 0 + + const teamJollyQuestions = actions + .filter(action => action.type === 'answer') + .filter(action => action.team === team) + .filter(action => { + return questionGroups[action.question] === teamsJollyGroup[team] + }) + + const N = teamJollyQuestions + .map(question => getCorrectnessMultiplier(question.outcome)) + .reduce((a, b) => a + b, 0) + + if (2 <= N && N < 4) { + teamBonus = 5 + } else if (4 <= N && N < 6) { + teamBonus = 10 + } else if (6 <= N) { + teamBonus = 30 + } + + scoreboardByTeam[team].totalScore += teamBonus + scoreboardByTeam[team].bonusJolly = teamBonus + } + + const scoreboard: Scoreboard = [] + + for (const team of game.teamIds) { + scoreboard.push({ + team, + totalScore: scoreboardByTeam[team].totalScore, + questionScores: scoreboardByTeam[team].questionScores, + }) + } + + scoreboard.sort((a, b) => b.totalScore - a.totalScore) + + return scoreboard +} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro new file mode 100644 index 0000000..773ac08 --- /dev/null +++ b/src/layouts/Base.astro @@ -0,0 +1,26 @@ +--- +import '@fontsource/inria-sans/latin.css' +import '@fontsource/inria-sans/latin-italic.css' + +import '@fontsource-variable/source-code-pro/index.css' + +import '@fontsource-variable/material-symbols-outlined/full.css' + +import '../styles.css' + +import { databaseStatus } from '@/db' +databaseStatus() +--- + + + + + + + + GGWP Leaderboard + + + + + diff --git a/src/pages/[room]/admin.astro b/src/pages/[room]/admin.astro new file mode 100644 index 0000000..6adce6a --- /dev/null +++ b/src/pages/[room]/admin.astro @@ -0,0 +1,38 @@ +--- +import Base from '@/layouts/Base.astro' +import { LiveLeaderboard } from '@/components/LiveLeaderboard' + +import { getSession } from '@/db/sessions' +import { SubmitAction } from '@/components/SubmitAction' + +const { room } = Astro.params +if (!room) { + return Astro.redirect('/error?msg=' + encodeURIComponent(`Devi specificare una stanza`)) +} + +if (Astro.cookies.has('sid')) { + const sid = Astro.cookies.get('sid') + if (!sid) { + return Astro.redirect('/error?msg=' + encodeURIComponent(`Sessione non valida`)) + } + + const sessionRoom = getSession(sid.value) + + if (sessionRoom !== room) { + return Astro.redirect('/error?msg=' + encodeURIComponent(`Sei solo l'admin di "${sessionRoom}"`)) + } +} else { + return Astro.redirect('/error?msg=' + encodeURIComponent(`Devi essere loggato per accedere a questa pagina`)) +} +--- + + +

Admin

+

{room}

+ + +   + +

Azioni

+ + diff --git a/src/pages/[room]/index.astro b/src/pages/[room]/index.astro new file mode 100644 index 0000000..7058d31 --- /dev/null +++ b/src/pages/[room]/index.astro @@ -0,0 +1,14 @@ +--- +import Base from '@/layouts/Base.astro' +import { LiveLeaderboard } from '@/components/LiveLeaderboard' + +const { room } = Astro.params +if (!room) { + return Astro.redirect('/error?msg=' + encodeURIComponent(`Devi specificare una stanza`)) +} +--- + + +

{room}

+ + diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts new file mode 100644 index 0000000..8030b86 --- /dev/null +++ b/src/pages/api/login.ts @@ -0,0 +1,29 @@ +import { getRoomByPassword } from '@/db' +import { createSession } from '@/db/sessions' +import type { APIRoute } from 'astro' + +export const POST: APIRoute = async ({ request, cookies }) => { + const body = await request.json() + + console.log(body) + + let { password } = body + + const roomId = getRoomByPassword(password) + if (!roomId) { + return new Response('Invalid room password', { status: 400 }) + } + + const sid = createSession(roomId) + + console.log('Created session', roomId, sid) + cookies.set('sid', sid, { + path: '/', + expires: new Date(Date.now() + 1000 * 60 * 60 * 24), // 1 day + httpOnly: true, + }) + + return new Response(JSON.stringify({ room: roomId }), { + headers: { 'content-type': 'application/json' }, + }) +} diff --git a/src/pages/api/room/[id].ts b/src/pages/api/room/[id].ts new file mode 100644 index 0000000..e25e28b --- /dev/null +++ b/src/pages/api/room/[id].ts @@ -0,0 +1,29 @@ +import { getRoom } from '@/db' +import type { APIRoute } from 'astro' + +async function sseHandler() { + // https://github.com/MicroWebStacks/astro-examples/blob/main/03_sse-counter/src/pages/api/stream.js + return new Response('SSE not implemented', { status: 501 }) +} + +export const GET: APIRoute = async ({ params, url }) => { + const { id: roomId } = params + if (!roomId) { + return new Response('Invalid room id', { status: 400 }) + } + + if (url.searchParams.has('sse')) { + return await sseHandler() + } + + const room = getRoom(roomId) + if (!room) { + return new Response('Room not found', { status: 404 }) + } + + console.log(room) + + return new Response(JSON.stringify(room), { + headers: { 'content-type': 'application/json' }, + }) +} diff --git a/src/pages/api/rooms.ts b/src/pages/api/rooms.ts new file mode 100644 index 0000000..647afc8 --- /dev/null +++ b/src/pages/api/rooms.ts @@ -0,0 +1,44 @@ +import { createRoom, getRoom, getRooms } from '@/db' +import { createSession } from '@/db/sessions' +import type { APIRoute } from 'astro' + +export const POST: APIRoute = async ({ params, request, cookies }) => { + const body = await request.json() + + console.log(body) + + let { id: roomId, teams, questions } = body + if (!roomId.match(/^[a-zA-Z][a-zA-Z0-9-]*$/)) { + return new Response('Invalid room id', { status: 400 }) + } + + if (teams.length < 3) { + return new Response('At least 3 teams are required', { status: 400 }) + } + + // Save the room to the database + try { + const password = createRoom(roomId, teams, questions) + const sid = createSession(roomId) + + cookies.set('sid', sid, { + path: '/', + expires: new Date(Date.now() + 1000 * 60 * 60 * 24), // 1 day + httpOnly: true, + }) + + return new Response(JSON.stringify({ password }), { + headers: { 'content-type': 'application/json' }, + }) + } catch (e) { + return new Response(e!.toString(), { status: 500 }) + } +} + +export const GET: APIRoute = async ({}) => { + const rooms = getRooms() + + return new Response(JSON.stringify(rooms), { + headers: { 'content-type': 'application/json' }, + }) +} diff --git a/src/pages/error.astro b/src/pages/error.astro new file mode 100644 index 0000000..26592df --- /dev/null +++ b/src/pages/error.astro @@ -0,0 +1,14 @@ +--- +import Base from '@/layouts/Base.astro' + +const errorMessage = Astro.url.searchParams.get('msg') || 'Errore sconosciuto' +--- + + +

GGWP

+

Errore!

+
+

{errorMessage}

+ Torna alla home +
+ diff --git a/src/pages/index.astro b/src/pages/index.astro index 2d14107..1fecf9c 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,16 +1,19 @@ --- - +import { AdminLogin } from '@/components/AdminLogin' +import { NewRoom } from '@/components/NewRoom' +import { Rooms } from '@/components/Rooms' +import Base from '@/layouts/Base.astro' --- - - - - - - - Astro - - -

Astro

- - + +

GGWP

+ +

Gare

+ + +

Admin

+ + +

Crea

+ + diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..4636075 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,502 @@ +*, +*::before, +*::after { + font: inherit; + + margin: 0; + padding: 0; + box-sizing: border-box; + + flex-shrink: 0; +} + +html, +body { + min-height: 100vh; +} + +img { + display: block; +} + +/* Root */ + +:root { + font-size: 18px; + + --blue: oklch(from royalblue calc(l + 0.05) c h); + --red: oklch(63.11% 0.2387 32.0573); + --green: oklch(60.3% 0.3 134.48); + --orange: oklch(76.96% 0.1692 67.6368); +} + +body { + color: #333; + background: hsl(240, 100%, 98%); + + font-family: 'Inria Sans', sans-serif; + line-height: 1.5; + + display: grid; + justify-items: center; + align-content: start; + + padding: 6rem 1rem 12rem; + gap: 1rem; +} + +/* Typography */ + +h1, +h2 { + font-family: 'Source Code Pro Variable', monospace; + font-weight: 600; + line-height: 1.25; +} + +h3, +h4 { + font-family: 'Inria Sans', sans-serif; + font-weight: 700; + line-height: 1.25; + color: #000000c6; +} + +:root { + --font-size-base: 16px; + --line-height: 1; + --font-size-factor: 1.41; +} + +h1 { + font-size: calc(var(--font-size-base) * pow(var(--font-size-factor), 3)); +} + +h2 { + font-size: calc(var(--font-size-base) * pow(var(--font-size-factor), 2)); +} + +h3 { + font-size: calc(var(--font-size-base) * var(--font-size-factor)); +} + +h4 { + font-size: var(--font-size-base); +} + +strong { + font-weight: 700; +} + +em { + font-style: italic; +} + +code { + font-family: 'Source Code Pro', monospace; +} + +a { + color: oklch(from var(--tint, royalblue) 0.3 c h); + text-decoration: underline 2px solid; +} + +.text-large { + font-size: 20px; +} + +/* Components */ + +.card { + padding: 1rem; + border: 2px solid #333; + border-radius: 0.5rem; + + color: oklch(from var(--tint) 0.15 0.1 h); + background: linear-gradient(-15deg, var(--tint), oklch(from var(--tint) calc(l + 0.25) c h)); + box-shadow: 3px 3px 0 oklch(from var(--tint) l 0.1 h); + + min-width: 50ch; + + @media screen and (max-width: 768px) { + min-width: 100%; + } +} + +.tint-blue { + --tint: var(--blue); +} + +.tint-red { + --tint: var(--red); +} + +.tint-green { + --tint: var(--green); +} + +.tint-orange { + --tint: var(--orange); +} + +.stack-v { + display: flex; + flex-direction: column; + gap: 0.75rem; + + &.center { + align-items: center; + } + + > .fill { + flex-grow: 1; + } + + @media screen and (max-width: 768px) { + flex-wrap: wrap; + } +} + +.stack-h { + display: flex; + flex-direction: row; + gap: 0.75rem; + + &.center { + align-items: center; + } + + > .fill { + flex-grow: 1; + } + + @media screen and (max-width: 768px) { + flex-wrap: wrap; + } +} + +.fill-w { + width: 100%; +} + +.fill-h { + height: 100%; +} + +.icon { + font-family: 'Material Symbols Outlined Variable'; + font-weight: 400; + font-size: 20px !important; + + display: grid; + place-content: center; +} + +input[type='text'], +input[type='number'] { + max-width: unset; + height: 2rem; + + padding: 0 0.25rem; + border: 2px solid #333; + border-radius: 0.25rem; + + background: #fff; + color: #333; + box-shadow: 3px 3px 0 oklch(from var(--tint) calc(l - 0.2) 0.1 h); + + outline: none; + + &:focus { + background: oklch(from var(--tint) calc(l + 1) 0.05 h); + } +} + +select { + height: 2rem; + + padding: 0 0.25rem; + border: 2px solid #333; + border-radius: 0.25rem; + + background: #fff; + color: #333; + box-shadow: 3px 3px 0 oklch(from var(--tint) calc(l - 0.2) 0.1 h); + + outline: none; + + &:focus { + background: oklch(from var(--tint) calc(l + 1) 0.05 h); + } +} + +button { + height: 2rem; + + font-size: 16px; + font-weight: 700; + color: #444; + + padding: 0 0.75rem; + border: 2px solid #333; + border-radius: 0.25rem; + + color: oklch(from var(--tint) 0.15 0.1 h); + background: oklch(from var(--tint) calc(l + 0.3) c h); + cursor: pointer; + + box-shadow: 2px 2px 0 oklch(from var(--tint) calc(l - 0.2) 0.1 h); + + display: grid; + place-content: center; + place-items: center; + grid-auto-flow: column; + gap: 0.25rem; + + &:where(:has(.icon:first-child)) { + padding-left: 0.25rem; + } + + &.square { + padding: 0; + border-radius: 0.25rem; + + width: 2rem; + height: 2rem; + } + + &:hover { + background: oklch(from var(--tint) calc(l + 0.25) c h); + } + + &:active { + background: oklch(from var(--tint) calc(l + 0.2) c h); + + transform: translate(1px, 1px); + box-shadow: 1px 1px 0 oklch(from var(--tint) calc(l - 0.2) 0.1 h); + } + + &.primary { + background: var(--accent); + color: #fff; + + &:hover { + background: var(--accent-light); + } + + &:active { + background: var(--accent-dark); + } + } + + .icon { + font-size: 20px; + } +} + +.leaderboard { + display: grid; + + place-items: center; + + grid-template-columns: auto auto repeat(var(--question-count), 1fr); + grid-template-rows: auto auto auto; + + > .questions { + display: grid; + + grid-column: 3 / -1; + grid-row: span 2; + + grid-template-columns: subgrid; + grid-template-rows: subgrid; + + > .question-header { + display: grid; + + grid-template-columns: subgrid; + grid-template-rows: subgrid; + + grid-area: span 2 / span 1; + + border-top: 2px solid var(--orange); + border-right: 2px solid var(--orange); + + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + background: var(--orange); + + box-shadow: 2px 2px 0 #333; + + /* > .name { + grid-area: 1 / 1; + + } */ + + padding: 0.5rem 0.25rem; + + text-align: center; + font-size: 22px; + font-weight: 500; + /* + > .score { + grid-area: 2 / 1; + font-size: 16px; + + padding: 0.25rem 0.25rem; + } */ + + &:first-child { + border-left: 2px solid var(--orange); + } + + position: relative; + + &::after { + content: ''; + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + border-top: 2px solid #333; + border-right: 2px solid #333; + border-left: 2px solid #333; + } + } + } + + > .team-answers-container { + display: grid; + + grid-template-columns: subgrid; + grid-template-rows: repeat(var(--team-count), 1fr); + + grid-area: 3 / 1 / -1 / -1; + + > .row { + display: grid; + + grid-column: 1 / -1; + grid-row: span 1; + + grid-template-columns: subgrid; + grid-template-rows: subgrid; + + > .team { + display: grid; + + grid-template-columns: subgrid; + grid-template-rows: subgrid; + + grid-area: span 1 / span 2; + + border-left: 2px solid #333; + border-bottom: 2px solid #333; + + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + + padding: 0.25rem 0.5rem; + gap: 1rem; + + color: #fff; + background: var(--green); + + box-shadow: 2px 2px 0 #333; + + > .name { + grid-area: 1 / 1; + font-weight: 500; + } + + > .score { + grid-area: 1 / 2; + font-size: 16px; + } + } + + > .answers { + display: grid; + + grid-column: 3 / -1; + grid-row: span 1; + + grid-template-columns: subgrid; + grid-template-rows: subgrid; + + > .answer { + display: grid; + place-content: center; + + background: #fff; + + border-right: 2px solid #333; + border-bottom: 2px solid #333; + + &:first-child { + border-left: 2px solid #333; + } + + &:last-child { + box-shadow: 2px 2px 0 #333; + } + + position: relative; + cursor: pointer; + + &:hover { + &::after { + content: ''; + position: absolute; + + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + + border: 4px dashed var(--blue); + } + } + } + } + + &:first-child { + > .team { + border-top: 2px solid #333; + } + + > .answers { + > .answer { + border-top: 2px solid #333; + } + } + } + + &:last-child { + .answer { + box-shadow: 2px 2px 0 #333; + } + } + + &:hover { + .team { + background: oklch(from var(--green) calc(l + 0.1) c h); + } + + .answer { + background: #e0e0e0; + } + } + } + } +} + +/* Pages */ + +/* Misc */ diff --git a/tsconfig.json b/tsconfig.json index bcbf8b5..e265ee5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,11 @@ { - "extends": "astro/tsconfigs/strict" + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } }