major stuff

main
Antonio De Lucreziis 3 months ago
parent c8961fce74
commit 44f2076fba

8
.gitignore vendored

@ -22,3 +22,11 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
# local files
*.local*
# database
*.db
*.db-shm
*.db-wal

@ -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()],
})

Binary file not shown.

@ -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"
}
}

@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

@ -0,0 +1,29 @@
export async function requestJSON(url: string) {
const res = await fetch(url)
return await res.json()
}
type Result<T, E> = { status: 'ok'; value: T } | { status: 'error'; value: E }
export async function sendJSON(url: string, data: any): Promise<Result<any, string>> {
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() }
}
}

@ -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 (
<div class="card tint-blue stack-v center">
<h3>Accedi al pannello di controllo di una stanza</h3>
<input
type="text"
class="fill-w"
placeholder="Chiave di accesso..."
value={password}
onInput={e => setPassword(e.currentTarget.value)}
/>
<button onClick={() => handleLogin()}>Accedi</button>
</div>
)
}

@ -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 (
<div
class="leaderboard"
style={{
'--team-count': scoreboard.length,
'--question-count': questions.length,
}}
>
<div class="questions">
{questions.map(({ id }, i) => (
<div class="question-header" key={i}>
{id}
</div>
))}
</div>
<div class="team-answers-container">
{scoreboard.map(({ team, totalScore, questionScores }, i) => (
<div class="row" key={i}>
<div class="team">
<div class="name">{team}</div>
<div class="score">{totalScore}pt</div>
</div>
<div class="answers">
{questions.map((question, j) => (
<div class="answer" key={j} onClick={() => onQuestionClick?.(team, question.id)}>
{questionScores[question.id]}
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}

@ -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<RoomData | null>(null)
useEffect(() => {
requestJSON(`/api/room/${roomId}`).then(room => {
setRoom(room)
})
}, [])
console.log(room)
if (!room) {
return <div>Loading...</div>
}
const scoreboard = computeScoreboardState(
{
teamIds: room.teams,
questions: room.questions,
},
room.actions
)
return <Leaderboard questions={room.questions} scoreboard={scoreboard} />
}

@ -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<string[]>([''])
// 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 (
<div class="card tint-red stack-v center">
<h3>Crea una nuova stanza</h3>
<input
type="text"
class="fill-w"
placeholder="Nome stanza, e.g. ggwp-2024..."
value={roomName}
onInput={e => setRoomName(e.currentTarget.value)}
/>
<h4>Squadre</h4>
<div class="fill-w stack-h">
<div class="fill"></div>
<button onClick={() => setTeams([...teams, ''])}>
<div class="icon">add</div>
Squadra
</button>
<button
class="square"
onClick={() => {
const teamsSyntax = prompt(`Aggiungi squadre separate da virgola, e.g. "Team 1, Team 2"`)
if (!teamsSyntax) return
setTeams(teams => {
return [...teams, ...teamsSyntax.split(',')]
})
}}
>
<div class="icon">more_vert</div>
</button>
</div>
{teams.map((team, i) => (
<div class="fill-w stack-h" key={i}>
<input
type="text"
class="fill"
placeholder={`Squadra ${i + 1}...`}
value={team}
onInput={e => {
const newTeams = [...teams]
newTeams[i] = e.currentTarget.value
setTeams(newTeams)
}}
/>
<button
class="square"
onClick={() => {
setTeams(teams.filter((_, j) => j !== i))
}}
>
<div class="icon">delete</div>
</button>
</div>
))}
<h4>Domande</h4>
<div class="fill-w stack-h">
<div class="fill"></div>
<button
onClick={() =>
setQuestions([
...questions,
{
group: '',
name: '',
},
])
}
>
<div class="icon">add</div>
Domanda
</button>
<button
class="square"
onClick={() => {
const questionsSyntax = prompt(`Aggiungi domande separate da virgola, e.g. "P1*6, P2*4"`)
if (!questionsSyntax) return
setQuestions(questions => {
return [
...questions,
...questionsSyntax.split(',').flatMap(q => {
const [group, count] = q.split('*')
return Array.from({ length: Number(count) }, (_, i) => ({
group,
name: `${i + 1}`,
}))
}),
]
})
}}
>
<div class="icon">more_vert</div>
</button>
</div>
{/* <div class="fill-w stack-h">
<div class="fill"></div>
<input type="text" placeholder="P1*6, P2*4, ..." />
<button>
<div class="icon">add</div>
Bluk
</button>
</div> */}
{questions.map((question, i) => (
<div class="fill-w stack-h" key={i}>
<input
type="text"
class="fill"
placeholder={`Gruppo, e.g. "P1"...`}
value={question.group}
onInput={e => {
const newQuestions = [...questions]
newQuestions[i].group = e.currentTarget.value
setQuestions(newQuestions)
}}
/>
<input
type="text"
class="fill"
placeholder={`Domanda, e.g. "1"...`}
value={question.name}
onInput={e => {
const newQuestions = [...questions]
newQuestions[i].name = e.currentTarget.value
setQuestions(newQuestions)
}}
/>
<button
class="square"
onClick={() => {
setQuestions(questions.filter((_, j) => j !== i))
}}
>
<div class="icon">delete</div>
</button>
</div>
))}
<button
onClick={() =>
handleCreateRoom(
roomName,
teams,
questions.map(q => ({
id: `${q.group}.${q.name}`,
group: q.group,
}))
)
}
>
Crea Stanza
</button>
</div>
)
}

@ -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<Room[] | null>(null)
useEffect(() => {
requestJSON('/api/rooms').then(rooms => setRooms(rooms))
}, [])
if (!rooms) {
return <div>Loading...</div>
}
return rooms.map((room, i) => (
<div class="card tint-green stack-v center">
<div class="fill-w stack-h center" key={i}>
<code>{room.id}</code>
<div class="fill"></div>
<button
onClick={() => {
location.href = `/${room.id}`
}}
>
Classifica
</button>
</div>
</div>
))
}

@ -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<ActionAnswer>({
question: '',
team: '',
outcome: 'correct',
})
return (
<>
<input
type="text"
class="fill-w"
placeholder="Domanda..."
value={answer.question}
onInput={e => setAnswer({ ...answer, question: e.currentTarget.value })}
/>
<input
type="text"
class="fill-w"
placeholder="Squadra..."
value={answer.team}
onInput={e => setAnswer({ ...answer, team: e.currentTarget.value })}
/>
<select
class="fill-w"
value={answer.outcome}
onInput={e => setAnswer({ ...answer, outcome: e.currentTarget.value as Outcome })}
>
<option value="correct">Corretta</option>
<option value="partial">Parziale</option>
<option value="wrong">Sbagliata</option>
</select>
<button
onClick={() =>
handleSendAnswer({
type: 'answer',
...answer,
})
}
>
Invia risposta
</button>
</>
)
}
export const SubmitActionJolly = ({ room }: { room: RoomData }) => {
const [answer, setAnswer] = useState<ActionJolly>({
team: '',
groupId: '',
})
return (
<>
<input
type="text"
class="fill-w"
placeholder="Squadra..."
value={answer.team}
onInput={e => setAnswer({ ...answer, team: e.currentTarget.value })}
/>
<input
type="text"
class="fill-w"
placeholder="Gruppo..."
value={answer.groupId}
onInput={e => setAnswer({ ...answer, groupId: e.currentTarget.value })}
/>
<button
onClick={() =>
handleSendAnswer({
type: 'jolly',
...answer,
})
}
>
Imposta jolly
</button>
</>
)
}
export const SubmitAction = ({ roomId }: { roomId: string }) => {
const [room, setRoom] = useState<RoomData | null>(null)
useEffect(() => {
requestJSON(`/api/room/${roomId}`).then(room => {
setRoom(room)
})
}, [])
if (!room) {
return <div>Loading...</div>
}
return (
<div class="card tint-red stack-v center">
<h3>Risposta</h3>
<SubmitActionAnswer room={room} />
<h3>Jolly</h3>
<SubmitActionJolly room={room} />
</div>
)
}

@ -0,0 +1,28 @@
import { EventEmitter } from 'events'
import type { RoomData } from './model'
const rooms = new Map<string, EventEmitter<{ update: [RoomData] }>>()
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)
}

@ -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),
}))
}

@ -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<Room, 'id'>

@ -0,0 +1,18 @@
import cuid2 from '@paralleldrive/cuid2'
// sessions is a map of session IDs to room IDs.
const sessions = new Map<string, string>()
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)
}

@ -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
// })
})
})

@ -0,0 +1,179 @@
function sortByKey<T>(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
}

@ -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()
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GGWP Leaderboard</title>
</head>
<body>
<slot />
</body>
</html>

@ -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`))
}
---
<Base>
<h1>Admin</h1>
<h2>{room}</h2>
<LiveLeaderboard client:load roomId={room} />
&nbsp;
<h2>Azioni</h2>
<SubmitAction client:load roomId={room} />
</Base>

@ -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`))
}
---
<Base>
<h1>{room}</h1>
<LiveLeaderboard client:load roomId={room} />
</Base>

@ -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' },
})
}

@ -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' },
})
}

@ -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' },
})
}

@ -0,0 +1,14 @@
---
import Base from '@/layouts/Base.astro'
const errorMessage = Astro.url.searchParams.get('msg') || 'Errore sconosciuto'
---
<Base>
<h1>GGWP</h1>
<h2>Errore!</h2>
<div class="card tint-red stack-v center">
<h3>{errorMessage}</h3>
<a href="/">Torna alla home</a>
</div>
</Base>

@ -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'
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<h1>Astro</h1>
</body>
</html>
<Base>
<h1>GGWP</h1>
<h2>Gare</h2>
<Rooms client:load />
<h2>Admin</h2>
<AdminLogin client:load />
<h2>Crea</h2>
<NewRoom client:load />
</Base>

@ -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 */

@ -1,3 +1,11 @@
{
"extends": "astro/tsconfigs/strict"
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

Loading…
Cancel
Save