major stuff
parent
c8961fce74
commit
44f2076fba
@ -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()],
|
||||
})
|
||||
|
@ -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} />
|
||||
|
||||
|
||||
|
||||
<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…
Reference in New Issue