Two teams try and fill in any horizontal, vertical, or diagonal line on a bingo board by playing maps on osu! osu.bingo
osu

init

clxxiii 43b04890

+3197
+21
.gitignore
··· 1 + node_modules 2 + 3 + # Output 4 + .output 5 + .vercel 6 + /.svelte-kit 7 + /build 8 + 9 + # OS 10 + .DS_Store 11 + Thumbs.db 12 + 13 + # Env 14 + .env 15 + .env.* 16 + !.env.example 17 + !.env.test 18 + 19 + # Vite 20 + vite.config.js.timestamp-* 21 + vite.config.ts.timestamp-*
+1
.npmrc
··· 1 + engine-strict=true
+4
.prettierignore
··· 1 + # Package Managers 2 + package-lock.json 3 + pnpm-lock.yaml 4 + yarn.lock
+8
.prettierrc
··· 1 + { 2 + "useTabs": true, 3 + "singleQuote": true, 4 + "trailingComma": "none", 5 + "printWidth": 100, 6 + "plugins": ["prettier-plugin-svelte"], 7 + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 + }
+38
README.md
··· 1 + # create-svelte 2 + 3 + Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte). 4 + 5 + ## Creating a project 6 + 7 + If you're seeing this, you've probably already done this step. Congrats! 8 + 9 + ```bash 10 + # create a new project in the current directory 11 + npm create svelte@latest 12 + 13 + # create a new project in my-app 14 + npm create svelte@latest my-app 15 + ``` 16 + 17 + ## Developing 18 + 19 + Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 + 21 + ```bash 22 + npm run dev 23 + 24 + # or start the server and open the app in a new browser tab 25 + npm run dev -- --open 26 + ``` 27 + 28 + ## Building 29 + 30 + To create a production version of your app: 31 + 32 + ```bash 33 + npm run build 34 + ``` 35 + 36 + You can preview the production build with `npm run preview`. 37 + 38 + > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
bun.lockb

This is a binary file and will not be displayed.

db/dev.db

This is a binary file and will not be displayed.

+10
drizzle.config.ts
··· 1 + import { defineConfig } from 'drizzle-kit'; 2 + 3 + export default defineConfig({ 4 + dialect: 'sqlite', 5 + dbCredentials: { 6 + url: process.env.DATABASE_URL 7 + }, 8 + schema: ['./src/lib/drizzle/schema.ts', './src/lib/drizzle/relations.ts'], 9 + out: './src/lib/drizzle/migrations' 10 + });
+33
eslint.config.js
··· 1 + import js from '@eslint/js'; 2 + import ts from 'typescript-eslint'; 3 + import svelte from 'eslint-plugin-svelte'; 4 + import prettier from 'eslint-config-prettier'; 5 + import globals from 'globals'; 6 + 7 + /** @type {import('eslint').Linter.FlatConfig[]} */ 8 + export default [ 9 + js.configs.recommended, 10 + ...ts.configs.recommended, 11 + ...svelte.configs['flat/recommended'], 12 + prettier, 13 + ...svelte.configs['flat/prettier'], 14 + { 15 + languageOptions: { 16 + globals: { 17 + ...globals.browser, 18 + ...globals.node 19 + } 20 + } 21 + }, 22 + { 23 + files: ['**/*.svelte'], 24 + languageOptions: { 25 + parserOptions: { 26 + parser: ts.parser 27 + } 28 + } 29 + }, 30 + { 31 + ignores: ['build/', '.svelte-kit/', 'dist/'] 32 + } 33 + ];
+48
package.json
··· 1 + { 2 + "name": "bingo", 3 + "version": "0.0.1", 4 + "private": true, 5 + "scripts": { 6 + "dev": "vite dev", 7 + "build": "vite build", 8 + "preview": "vite preview", 9 + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 + "generate": "drizzle-kit generate", 12 + "migrate": "drizzle-kit migrate", 13 + "studio": "drizzle-kit studio", 14 + "lint": "prettier --check . && eslint .", 15 + "format": "prettier --write ." 16 + }, 17 + "devDependencies": { 18 + "@sveltejs/adapter-auto": "^3.0.0", 19 + "@sveltejs/kit": "^2.0.0", 20 + "@sveltejs/vite-plugin-svelte": "^3.0.0", 21 + "@types/better-sqlite3": "^7.6.10", 22 + "@types/bun": "^1.1.4", 23 + "@types/eslint": "^8.56.7", 24 + "autoprefixer": "^10.4.19", 25 + "drizzle-kit": "^0.22.7", 26 + "eslint": "^9.0.0", 27 + "eslint-config-prettier": "^9.1.0", 28 + "eslint-plugin-svelte": "^2.36.0", 29 + "globals": "^15.0.0", 30 + "postcss": "^8.4.38", 31 + "prettier": "^3.1.1", 32 + "prettier-plugin-svelte": "^3.1.2", 33 + "svelte": "^4.2.7", 34 + "svelte-check": "^3.6.0", 35 + "tailwindcss": "^3.4.4", 36 + "tslib": "^2.4.1", 37 + "typescript": "^5.0.0", 38 + "typescript-eslint": "^8.0.0-alpha.20", 39 + "vite": "^5.0.3" 40 + }, 41 + "type": "module", 42 + "dependencies": { 43 + "@libsql/client": "^0.6.2", 44 + "drizzle-orm": "^0.31.2", 45 + "jose": "^5.4.0", 46 + "lucide-svelte": "^0.395.0" 47 + } 48 + }
+6
postcss.config.js
··· 1 + export default { 2 + plugins: { 3 + tailwindcss: {}, 4 + autoprefixer: {} 5 + } 6 + };
+23
src/app.css
··· 1 + @tailwind base; 2 + @tailwind components; 3 + @tailwind utilities; 4 + 5 + @font-face { 6 + font-family: "Roundor"; 7 + src: url("/fonts/RoundorNonCommercial.otf") 8 + } 9 + 10 + @font-face { 11 + font-family: "Comfortaa"; 12 + src: url("/fonts/Comfortaa-VariableFont_wght.ttf") 13 + } 14 + 15 + @font-face { 16 + font-family: "Montserrat"; 17 + font-weight: 400; 18 + src: url("/fonts/montserrat/Montserrat-Regular.ttf") 19 + } 20 + 21 + body { 22 + @apply bg-zinc-900 text-purple-300 font-sans 23 + }
+48
src/app.d.ts
··· 1 + // See https://kit.svelte.dev/docs/types#app 2 + 3 + import type { BingoGame, BingoGame, BingoSquare, Chat, GameUser, Map, MapStats, OauthToken, Score, Session, TimeEvent, User, User } from '$lib/drizzle/schema'; 4 + 5 + // for information about these interfaces 6 + declare global { 7 + namespace App { 8 + // interface Error {} 9 + interface Locals { 10 + user?: typeof User.$inferSelect; 11 + } 12 + // interface PageData {} 13 + // interface PageState {} 14 + // interface Platform {} 15 + } 16 + namespace Bingo { 17 + type BingoGame = (typeof BingoGame.$inferSelect) 18 + type BingoSquare = (typeof BingoSquare.$inferSelect) 19 + type BingoSquare = (typeof Chat.$inferSelect) 20 + type GameUser = (typeof GameUser.$inferSelect) 21 + type Map = (typeof Map.$inferSelect) 22 + type MapStats = (typeof MapStats.$inferSelect) 23 + type OauthToken = (typeof OauthToken.$inferSelect) 24 + type Score = (typeof Score.$inferSelect) 25 + type Session = (typeof Session.$inferSelect) 26 + type TimeEvent = (typeof TimeEvent.$inferSelect) 27 + type User = (typeof User.$inferSelect) 28 + 29 + namespace Card { 30 + type FullSquare = BingoSquare & { 31 + data: FullMap, 32 + scores: Score[], 33 + } 34 + type FullUser = (GameUser & User) 35 + type FullMap = Map & { 36 + stats: MapStats 37 + } 38 + } 39 + type Card = BingoGame & { 40 + squares: Card.FullSquare[] | null, // Null if game state is 0 (to hide squares) 41 + events: TimeEvent[], 42 + users: FullUser[], 43 + chats: Chat[] 44 + } 45 + } 46 + } 47 + 48 + export { };
+13
src/app.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <link rel="icon" href="%sveltekit.assets%/favicon-32x32.png" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + %sveltekit.head% 8 + </head> 9 + 10 + <body data-sveltekit-preload-data="hover"> 11 + <div style="display: contents">%sveltekit.body%</div> 12 + </body> 13 + </html>
+43
src/hooks.server.ts
··· 1 + import { redirect, type Handle } from '@sveltejs/kit'; 2 + import * as jose from 'jose'; 3 + import { JWT_SECRET } from '$env/static/private'; 4 + import q from '$lib/drizzle/queries'; 5 + import { StatusCodes } from '$lib/StatusCodes'; 6 + 7 + const jwt_secret = new TextEncoder().encode(JWT_SECRET); 8 + export const handle: Handle = async ({ event, resolve }) => { 9 + const sessionToken = event.cookies.get('osu_bingo_token'); 10 + 11 + // Redirect to game page 12 + if (event.route.id == null) { 13 + const match = event.url.pathname.match(/^\/(?:\D{4})$/g); 14 + if (match != null) { 15 + const link = match[0].slice(1, 5); 16 + const gameId = await q.gameLinkToId(link); 17 + if (gameId != null) { 18 + redirect(StatusCodes.TEMPORARY_REDIRECT, `/game/${link}`); 19 + } 20 + } 21 + } 22 + 23 + // Login Stuff 24 + if (sessionToken) { 25 + try { 26 + const { payload } = await jose.jwtVerify(sessionToken, jwt_secret); 27 + const userId = payload.user_id; 28 + if (!userId || typeof userId != 'number') throw 'No User ID in token'; 29 + 30 + // Get user from database 31 + const user = await q.getUserFromSessionToken(sessionToken); 32 + 33 + if (!user || user.id != userId) throw 'Session is invalid!'; 34 + 35 + event.locals.user = user; 36 + } catch (error) { 37 + console.log(error); 38 + event.cookies.delete('osu_bingo_token', { path: '/' }); 39 + } 40 + } 41 + 42 + return await resolve(event); 43 + };
+58
src/lib/StatusCodes.ts
··· 1 + export enum StatusCodes { 2 + CONTINUE = 100, 3 + SWITCHING_PROTOCOLS = 101, 4 + PROCESSING = 102, 5 + OK = 200, 6 + CREATED = 201, 7 + ACCEPTED = 202, 8 + NON_AUTHORITATIVE_INFORMATION = 203, 9 + NO_CONTENT = 204, 10 + RESET_CONTENT = 205, 11 + PARTIAL_CONTENT = 206, 12 + MULTI_STATUS = 207, 13 + MULTIPLE_CHOICES = 300, 14 + MOVED_PERMANENTLY = 301, 15 + MOVED_TEMPORARILY = 302, 16 + SEE_OTHER = 303, 17 + NOT_MODIFIED = 304, 18 + USE_PROXY = 305, 19 + TEMPORARY_REDIRECT = 307, 20 + PERMANENT_REDIRECT = 308, 21 + BAD_REQUEST = 400, 22 + UNAUTHORIZED = 401, 23 + PAYMENT_REQUIRED = 402, 24 + FORBIDDEN = 403, 25 + NOT_FOUND = 404, 26 + METHOD_NOT_ALLOWED = 405, 27 + NOT_ACCEPTABLE = 406, 28 + PROXY_AUTHENTICATION_REQUIRED = 407, 29 + REQUEST_TIMEOUT = 408, 30 + CONFLICT = 409, 31 + GONE = 410, 32 + LENGTH_REQUIRED = 411, 33 + PRECONDITION_FAILED = 412, 34 + REQUEST_TOO_LONG = 413, 35 + REQUEST_URI_TOO_LONG = 414, 36 + UNSUPPORTED_MEDIA_TYPE = 415, 37 + REQUESTED_RANGE_NOT_SATISFIABLE = 416, 38 + EXPECTATION_FAILED = 417, 39 + IM_A_TEAPOT = 418, 40 + INSUFFICIENT_SPACE_ON_RESOURCE = 419, 41 + METHOD_FAILURE = 420, 42 + MISDIRECTED_REQUEST = 421, 43 + UNPROCESSABLE_ENTITY = 422, 44 + LOCKED = 423, 45 + FAILED_DEPENDENCY = 424, 46 + PRECONDITION_REQUIRED = 428, 47 + TOO_MANY_REQUESTS = 429, 48 + REQUEST_HEADER_FIELDS_TOO_LARGE = 431, 49 + UNAVAILABLE_FOR_LEGAL_REASONS = 451, 50 + INTERNAL_SERVER_ERROR = 500, 51 + NOT_IMPLEMENTED = 501, 52 + BAD_GATEWAY = 502, 53 + SERVICE_UNAVAILABLE = 503, 54 + GATEWAY_TIMEOUT = 504, 55 + HTTP_VERSION_NOT_SUPPORTED = 505, 56 + INSUFFICIENT_STORAGE = 507, 57 + NETWORK_AUTHENTICATION_REQUIRED = 511 58 + }
+44
src/lib/components/Announcer.svelte
··· 1 + <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 4 + export let game: Bingo.Card; 5 + export let user: Bingo.User | undefined; 6 + 7 + let buttonType = ''; 8 + const announcerText = () => { 9 + // Before Game 10 + if (game.state == 0) { 11 + // User in game 12 + if (user && !game.users.map((x) => x.id).includes(user.id)) { 13 + buttonType = 'JOIN'; 14 + return 'Join'; 15 + } 16 + // Start Timer 17 + buttonType = 'LEAVE'; 18 + const timer = game.events.find((x) => x.action == 'start'); 19 + if (timer) { 20 + return `The game will start at ${timer.time.getMinutes()}:${timer.time.getSeconds()}`; 21 + } 22 + return 'The game will start when the host starts it'; 23 + } 24 + return ''; 25 + }; 26 + 27 + const leave = () => { 28 + if (browser) { 29 + fetch(`/api/leave_game?id=${game.id}`); 30 + window.location.reload(); 31 + } 32 + }; 33 + </script> 34 + 35 + <div 36 + class="min-w-[300px] px-2 font-rounded font-bold flex items-center justify-center rounded-full h-12 bg-zinc-900" 37 + > 38 + {announcerText()} 39 + {#if buttonType == 'JOIN'} 40 + <button class="bg-green-600 p-1 rounded-full text-sm px-2 ml-2">JOIN</button> 41 + {:else if buttonType == 'LEAVE'} 42 + <button on:click={leave} class="bg-amber-600 p-1 rounded-full text-sm px-2 ml-2">LEAVE</button> 43 + {/if} 44 + </div>
+39
src/lib/components/BingoCard.svelte
··· 1 + <script lang="ts"> 2 + import { createEventDispatcher } from 'svelte'; 3 + import BingoSquare from './BingoSquare.svelte'; 4 + 5 + export let card: Bingo.Card; 6 + const squares = card.squares; 7 + let yMax: number; 8 + let xMax: number; 9 + if (squares) { 10 + yMax = Math.max(...squares.map((x) => x.y_pos)); 11 + xMax = Math.max(...squares.map((x) => x.x_pos)); 12 + } 13 + 14 + const dispatch = createEventDispatcher(); 15 + const click = (square: Bingo.Card.FullSquare) => { 16 + dispatch('squareclick', square); 17 + }; 18 + </script> 19 + 20 + <div 21 + class="grid p-2 bg-base-950 w-full max-w-[500px] rounded" 22 + style=" 23 + grid-template-columns: repeat({xMax + 1}, {xMax + 1}fr); 24 + grid-template-rows: repeat({yMax + 1}, {yMax + 1}fr); 25 + aspect-ratio: {xMax + 1} / {yMax + 1} 26 + " 27 + > 28 + {#if squares} 29 + {#each squares as square} 30 + <div 31 + class="p-2" 32 + style="grid-area: {square.y_pos + 1} / {square.x_pos + 1} / {square.y_pos + 33 + 2} / {square.x_pos + 2}" 34 + > 35 + <BingoSquare on:click={() => click(square)} {square} /> 36 + </div> 37 + {/each} 38 + {/if} 39 + </div>
+54
src/lib/components/BingoSquare.svelte
··· 1 + <script lang="ts"> 2 + import { createEventDispatcher } from 'svelte'; 3 + 4 + export let square: Bingo.Card.FullSquare; 5 + 6 + const dispatch = createEventDispatcher(); 7 + 8 + const difficulty = square.data.stats.star_rating.toFixed(2); 9 + const claimed = square.claimed_by ?? 'UNCLAIMED'; 10 + 11 + const click = () => { 12 + dispatch('click'); 13 + }; 14 + </script> 15 + 16 + <button 17 + style="--url: url({square.data.cover_url})" 18 + class="block transition bg-black relative square z-0 w-full h-full overflow-hidden rounded" 19 + on:click={click} 20 + > 21 + <div class="background absolute top-0 size-full -z-10 transition"> 22 + {#if claimed === 'RED'} 23 + <div class="absolute top-0 size-full bg-amber-600 z-10 opacity-70"></div> 24 + <div class="absolute bg-img top-0 size-full z-0 grayscale"></div> 25 + {:else if claimed === 'BLUE'} 26 + <div class="absolute top-0 size-full bg-blue-600 z-10 opacity-70"></div> 27 + <div class="absolute bg-img top-0 size-full z-0 grayscale"></div> 28 + {:else} 29 + <div class="absolute bg-img top-0 size-full z-0"></div> 30 + {/if} 31 + </div> 32 + <div class="popout-box font-display"> 33 + <div 34 + class="font-rounded font-bold text-xs rounded-tr absolute w-10 bottom-0 left-0 bg-zinc-900 z-20 pointer-events-none" 35 + > 36 + {difficulty} 37 + </div> 38 + </div> 39 + </button> 40 + 41 + <style> 42 + div.bg-img { 43 + background: var(--url); 44 + background-size: cover; 45 + background-position: 50% 50%; 46 + } 47 + button:hover div.background { 48 + filter: blur(2px); 49 + } 50 + button:hover { 51 + transform: translateY(-5px); 52 + box-shadow: 0px 10px 8px rgba(0, 0, 0, 0.3); 53 + } 54 + </style>
+1
src/lib/components/Chatbox.svelte
··· 1 + <div class="bg-zinc-800 rounded-xl size-full"></div>
src/lib/components/Footer.svelte

This is a binary file and will not be displayed.

+17
src/lib/components/Header.svelte
··· 1 + <script lang="ts"> 2 + import User from './User.svelte'; 3 + import VR from './VerticalRule.svelte'; 4 + 5 + export let user: Bingo.User | undefined; 6 + </script> 7 + 8 + <nav class="relative h-12 w-full flex items-center p-1"> 9 + <a href="/" class="h-10 p-2 hover:bg-zinc-900 flex gap-1 rounded transition"> 10 + <img src="/icon.svg" class="h-6" alt="" /> 11 + <div class="flex items-center font-display h-6 text-lg text-nowrap">bingo</div> 12 + </a> 13 + <VR /> 14 + <div class="absolute right-1"> 15 + <User {user} /> 16 + </div> 17 + </nav>
+55
src/lib/components/MapCard.svelte
··· 1 + <script lang="ts"> 2 + import { Clock, Star } from 'lucide-svelte'; 3 + import MapStat from './MapStat.svelte'; 4 + 5 + export let map: Bingo.Card.FullMap; 6 + 7 + const seconds = (time: number) => { 8 + const seconds = time % 60; 9 + return seconds > 9 ? seconds : `0${seconds}`; 10 + }; 11 + 12 + const timeString = `${Math.floor(map.stats.length / 60)}:${seconds(map.stats.length)}`; 13 + </script> 14 + 15 + <div class="grid top bg-zinc-900"> 16 + <img 17 + class="rounded-l-lg row-start-1 row-end-1 col-start-1 col-end-2" 18 + src={map.cover_url} 19 + alt="" 20 + /> 21 + <div 22 + class="bg-zinc-900 -translate-x-2 pl-2 rounded-l-lg flex flex-col justify-center font-rounded" 23 + > 24 + <div class="text-xl font-extrabold">{map.title}</div> 25 + <div class="font-bold">by {map.artist}</div> 26 + <div>[{map.difficulty_name}]</div> 27 + <div class="text-xs pt-2"> 28 + <MapStat>{map.stats.star_rating} <Star class="inline" size={14} /></MapStat> 29 + <MapStat>{timeString} <Clock class="inline" size={14} /></MapStat> 30 + <MapStat>{map.stats.bpm} BPM</MapStat> 31 + <MapStat>CS{map.stats.cs}</MapStat> 32 + <MapStat>AR{map.stats.ar}</MapStat> 33 + <MapStat>OD{map.stats.od}</MapStat> 34 + </div> 35 + </div> 36 + <div 37 + class="mt-2 p-2 w-full row-start-3 row-end-4 col-start-1 col-end-3 bg-zinc-700 flex justify-around" 38 + > 39 + <a 40 + class="font-rounded block bg-pink-800 rounded w-40 text-center font-bold hover:bg-pink-700 active:bg-pink-900 transition" 41 + href="osu://dl/{map.beatmapset_id}">osu!Direct</a 42 + > 43 + <a 44 + class="font-rounded block bg-pink-800 rounded w-40 text-center font-bold hover:bg-pink-700 active:bg-pink-900 transition" 45 + href="https://osu.ppy.sh/b/{map.id}">Open on osu!web</a 46 + > 47 + </div> 48 + </div> 49 + 50 + <style> 51 + div.top { 52 + grid-template-columns: fit-content(120px) 1fr; 53 + grid-template-rows: fit-content(120px) 1fr; 54 + } 55 + </style>
+1
src/lib/components/MapStat.svelte
··· 1 + <span class="bg-zinc-700 p-0.5 rounded"><slot /></span>
src/lib/components/ScoreList.svelte

This is a binary file and will not be displayed.

+45
src/lib/components/SquareSidebar.svelte
··· 1 + <script lang="ts"> 2 + import { fade, slide, blur } from 'svelte/transition'; 3 + import { createEventDispatcher } from 'svelte'; 4 + import { X } from 'lucide-svelte'; 5 + import MapCard from './MapCard.svelte'; 6 + export let square: Bingo.Card.FullSquare | null; 7 + 8 + let sidebar: HTMLDivElement; 9 + 10 + const dispatch = createEventDispatcher(); 11 + 12 + const close = () => { 13 + dispatch('close'); 14 + }; 15 + </script> 16 + 17 + <div 18 + transition:slide={{ axis: 'x' }} 19 + bind:this={sidebar} 20 + class="group bg-zinc-800 overflow-hidden relative w-[500px] transition rounded-xl size-full" 21 + data-claimer={square?.claimed_by ?? 'UNCLAIMED'} 22 + > 23 + {#if square} 24 + <div 25 + transition:fade={{ duration: 150 }} 26 + class="absolute transition h-14 top-0 w-full p-2 bg-gradient-to-b group-data-[claimer=RED]:from-amber-600 group-data-[claimer=UNCLAIMED]:from-zinc-600 group-data-[claimer=BLUE]:from-blue-600 to-zinc-800" 27 + > 28 + <button 29 + on:click={close} 30 + class="p-1 flex justify-center items-center rounded size-10 hover:bg-[rgba(0,0,0,0.5)]" 31 + > 32 + <X /> 33 + </button> 34 + </div> 35 + 36 + <div transition:blur={{ duration: 150 }} class="absolute top-14 w-full"> 37 + <MapCard map={square.data} /> 38 + </div> 39 + 40 + <div class="w-full absolute top-56 p-2 pt-4"> 41 + <h2 class="text-xl font-bold font-rounded">Scores</h2> 42 + <hr class="border-zinc-600" /> 43 + </div> 44 + {/if} 45 + </div>
+16
src/lib/components/TeamList.svelte
··· 1 + <div class="p-4 bg-zinc-900 flex gap-4 rounded-lg"> 2 + <div class="relative h-[500px] w-[400px] border-blue-700 border-2 rounded-xl"> 3 + <h1 4 + class="bg-zinc-900 font-semibold font-rounded text-blue-700 absolute top-0 left-3 w-fit px-2 -translate-y-3.5" 5 + > 6 + Blue Team 7 + </h1> 8 + </div> 9 + <div class="relative h-[500px] w-[400px] border-amber-700 border-2 rounded-xl"> 10 + <h1 11 + class="bg-zinc-900 font-semibold font-rounded text-amber-700 absolute top-0 left-3 w-fit px-2 -translate-y-3.5" 12 + > 13 + Red Team 14 + </h1> 15 + </div> 16 + </div>
+30
src/lib/components/User.svelte
··· 1 + <script lang="ts"> 2 + export let user: Bingo.User | undefined; 3 + </script> 4 + 5 + {#if user != undefined} 6 + <button 7 + class="text-display grid gap-x-1 items-center px-1 h-10 hover:bg-zinc-900 rounded transition" 8 + > 9 + <img 10 + class="row-start-1 row-end-3 col-start-1 col-end-2 h-8 rounded-full" 11 + src={user.avatar_url} 12 + alt="" 13 + /> 14 + <div class="text-left col-start-2 col-end-3 row-start-1 row-end 2">{user.username}</div> 15 + <div class="text-[8px] text-left col-start-2 col-end-3 row-start-2 row-end-3"> 16 + #{user.global_rank?.toLocaleString()} 17 + </div> 18 + </button> 19 + {:else} 20 + <a 21 + class="font-rounded px-10 hover:bg-pink-900 transition active:bg-pink-950 font-bold bg-pink-800 border-pink-200 rounded-full p-1 w-40" 22 + href="/auth/login/osu">login with osu!</a 23 + > 24 + {/if} 25 + 26 + <style> 27 + button.grid { 28 + grid-template-columns: 2rem calc(100% - 2rem); 29 + } 30 + </style>
+11
src/lib/components/VerticalRule.svelte
··· 1 + <script> 2 + export let width = 1.5; 3 + </script> 4 + 5 + <div class="h-full mx-1" style="--w: {width}px"></div> 6 + 7 + <style> 8 + div { 9 + border-left: solid var(--w) rgba(255, 255, 255, 0.3); 10 + } 11 + </style>
+6
src/lib/drizzle/index.ts
··· 1 + import { createClient } from '@libsql/client'; 2 + import { DATABASE_URL } from '$env/static/private'; 3 + import { drizzle } from 'drizzle-orm/libsql'; 4 + 5 + const sqlite = createClient({ url: DATABASE_URL }); 6 + export const db = drizzle(sqlite);
+134
src/lib/drizzle/migrations/0000_low_genesis.sql
··· 1 + CREATE TABLE `BingoGame` ( 2 + `id` text PRIMARY KEY NOT NULL, 3 + `link_id` text, 4 + `state` integer DEFAULT 0 NOT NULL, 5 + `allow_team_switching` integer DEFAULT true 6 + ); 7 + --> statement-breakpoint 8 + CREATE TABLE `BingoSquare` ( 9 + `id` text PRIMARY KEY NOT NULL, 10 + `game_id` text NOT NULL, 11 + `map_id` integer NOT NULL, 12 + `mod_string` text DEFAULT '', 13 + `x_pos` integer NOT NULL, 14 + `y_pos` integer NOT NULL, 15 + `claimed_by` text, 16 + FOREIGN KEY (`game_id`) REFERENCES `BingoGame`(`id`) ON UPDATE no action ON DELETE no action, 17 + FOREIGN KEY (`map_id`) REFERENCES `Map`(`id`) ON UPDATE no action ON DELETE no action 18 + ); 19 + --> statement-breakpoint 20 + CREATE TABLE `Chat` ( 21 + `id` text PRIMARY KEY NOT NULL, 22 + `time` integer NOT NULL, 23 + `text` text, 24 + `game_id` text NOT NULL, 25 + `user_id` integer NOT NULL, 26 + FOREIGN KEY (`game_id`) REFERENCES `BingoGame`(`id`) ON UPDATE cascade ON DELETE cascade, 27 + FOREIGN KEY (`user_id`) REFERENCES `GameUser`(`user_id`) ON UPDATE no action ON DELETE no action 28 + ); 29 + --> statement-breakpoint 30 + CREATE TABLE `GameUser` ( 31 + `game_id` text NOT NULL, 32 + `user_id` integer NOT NULL, 33 + `team_name` text NOT NULL, 34 + `host` integer DEFAULT false NOT NULL, 35 + PRIMARY KEY(`game_id`, `user_id`) 36 + ); 37 + --> statement-breakpoint 38 + CREATE TABLE `Map` ( 39 + `id` integer PRIMARY KEY NOT NULL, 40 + `beatmapset_id` integer NOT NULL, 41 + `fetch_time` integer NOT NULL, 42 + `title` text NOT NULL, 43 + `artist` text NOT NULL, 44 + `difficulty_name` text NOT NULL, 45 + `url` text NOT NULL, 46 + `cover_url` text NOT NULL, 47 + `status` text NOT NULL, 48 + `max_combo` integer NOT NULL, 49 + `last_updated` integer NOT NULL, 50 + `available` integer NOT NULL 51 + ); 52 + --> statement-breakpoint 53 + CREATE TABLE `MapStats` ( 54 + `map_id` integer NOT NULL, 55 + `mod_string` text DEFAULT '' NOT NULL, 56 + `star_rating` real NOT NULL, 57 + `bpm` real NOT NULL, 58 + `length` integer NOT NULL, 59 + `cs` real NOT NULL, 60 + `ar` real NOT NULL, 61 + `od` real NOT NULL, 62 + `hp` real NOT NULL, 63 + PRIMARY KEY(`map_id`, `mod_string`), 64 + FOREIGN KEY (`map_id`) REFERENCES `Map`(`id`) ON UPDATE cascade ON DELETE cascade 65 + ); 66 + --> statement-breakpoint 67 + CREATE TABLE `OauthToken` ( 68 + `id` text PRIMARY KEY NOT NULL, 69 + `service` text NOT NULL, 70 + `access_token` text NOT NULL, 71 + `expires_at` integer NOT NULL, 72 + `refresh_token` text NOT NULL, 73 + `token_type` text NOT NULL, 74 + `user_id` integer, 75 + FOREIGN KEY (`user_id`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade 76 + ); 77 + --> statement-breakpoint 78 + CREATE TABLE `Score` ( 79 + `id` text PRIMARY KEY NOT NULL, 80 + `is_fc` integer DEFAULT false NOT NULL, 81 + `score` real NOT NULL, 82 + `grade` text NOT NULL, 83 + `accuracy` real NOT NULL, 84 + `mods` text DEFAULT '', 85 + `important` integer DEFAULT false, 86 + `square_id` text NOT NULL, 87 + `user_id` integer NOT NULL, 88 + FOREIGN KEY (`square_id`) REFERENCES `BingoSquare`(`id`) ON UPDATE cascade ON DELETE cascade, 89 + FOREIGN KEY (`user_id`) REFERENCES `GameUser`(`user_id`) ON UPDATE no action ON DELETE no action 90 + ); 91 + --> statement-breakpoint 92 + CREATE TABLE `Session` ( 93 + `id` text PRIMARY KEY NOT NULL, 94 + `user_id` integer NOT NULL, 95 + `token` text NOT NULL, 96 + `created_at` integer, 97 + `last_used` integer, 98 + `device` text, 99 + `browser` text, 100 + `os` text, 101 + FOREIGN KEY (`user_id`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE no action 102 + ); 103 + --> statement-breakpoint 104 + CREATE TABLE `TimeEvent` ( 105 + `id` text PRIMARY KEY NOT NULL, 106 + `time` integer NOT NULL, 107 + `action` text NOT NULL, 108 + `fulfilled` integer DEFAULT false, 109 + `game_id` text NOT NULL, 110 + FOREIGN KEY (`game_id`) REFERENCES `BingoGame`(`id`) ON UPDATE no action ON DELETE cascade 111 + ); 112 + --> statement-breakpoint 113 + CREATE TABLE `User` ( 114 + `id` integer PRIMARY KEY NOT NULL, 115 + `username` text NOT NULL, 116 + `country_code` text NOT NULL, 117 + `country_name` text NOT NULL, 118 + `cover_url` text NOT NULL, 119 + `avatar_url` text NOT NULL, 120 + `pp` real, 121 + `global_rank` integer, 122 + `country_rank` integer, 123 + `total_score` integer, 124 + `ranked_score` integer, 125 + `hit_accuracy` real, 126 + `play_count` integer, 127 + `level` integer, 128 + `level_progress` integer, 129 + `last_refreshed` integer 130 + ); 131 + --> statement-breakpoint 132 + CREATE UNIQUE INDEX `OauthToken_access_token_unique` ON `OauthToken` (`access_token`);--> statement-breakpoint 133 + CREATE UNIQUE INDEX `OauthToken_refresh_token_unique` ON `OauthToken` (`refresh_token`);--> statement-breakpoint 134 + CREATE UNIQUE INDEX `Session_token_unique` ON `Session` (`token`);
+903
src/lib/drizzle/migrations/meta/0000_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "2f012e20-fe39-463f-b22e-84b52edbfd0b", 5 + "prevId": "00000000-0000-0000-0000-000000000000", 6 + "tables": { 7 + "BingoGame": { 8 + "name": "BingoGame", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "link_id": { 18 + "name": "link_id", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": false, 22 + "autoincrement": false 23 + }, 24 + "state": { 25 + "name": "state", 26 + "type": "integer", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false, 30 + "default": 0 31 + }, 32 + "allow_team_switching": { 33 + "name": "allow_team_switching", 34 + "type": "integer", 35 + "primaryKey": false, 36 + "notNull": false, 37 + "autoincrement": false, 38 + "default": true 39 + } 40 + }, 41 + "indexes": {}, 42 + "foreignKeys": {}, 43 + "compositePrimaryKeys": {}, 44 + "uniqueConstraints": {} 45 + }, 46 + "BingoSquare": { 47 + "name": "BingoSquare", 48 + "columns": { 49 + "id": { 50 + "name": "id", 51 + "type": "text", 52 + "primaryKey": true, 53 + "notNull": true, 54 + "autoincrement": false 55 + }, 56 + "game_id": { 57 + "name": "game_id", 58 + "type": "text", 59 + "primaryKey": false, 60 + "notNull": true, 61 + "autoincrement": false 62 + }, 63 + "map_id": { 64 + "name": "map_id", 65 + "type": "integer", 66 + "primaryKey": false, 67 + "notNull": true, 68 + "autoincrement": false 69 + }, 70 + "mod_string": { 71 + "name": "mod_string", 72 + "type": "text", 73 + "primaryKey": false, 74 + "notNull": false, 75 + "autoincrement": false, 76 + "default": "''" 77 + }, 78 + "x_pos": { 79 + "name": "x_pos", 80 + "type": "integer", 81 + "primaryKey": false, 82 + "notNull": true, 83 + "autoincrement": false 84 + }, 85 + "y_pos": { 86 + "name": "y_pos", 87 + "type": "integer", 88 + "primaryKey": false, 89 + "notNull": true, 90 + "autoincrement": false 91 + }, 92 + "claimed_by": { 93 + "name": "claimed_by", 94 + "type": "text", 95 + "primaryKey": false, 96 + "notNull": false, 97 + "autoincrement": false 98 + } 99 + }, 100 + "indexes": {}, 101 + "foreignKeys": { 102 + "BingoSquare_game_id_BingoGame_id_fk": { 103 + "name": "BingoSquare_game_id_BingoGame_id_fk", 104 + "tableFrom": "BingoSquare", 105 + "tableTo": "BingoGame", 106 + "columnsFrom": [ 107 + "game_id" 108 + ], 109 + "columnsTo": [ 110 + "id" 111 + ], 112 + "onDelete": "no action", 113 + "onUpdate": "no action" 114 + }, 115 + "BingoSquare_map_id_Map_id_fk": { 116 + "name": "BingoSquare_map_id_Map_id_fk", 117 + "tableFrom": "BingoSquare", 118 + "tableTo": "Map", 119 + "columnsFrom": [ 120 + "map_id" 121 + ], 122 + "columnsTo": [ 123 + "id" 124 + ], 125 + "onDelete": "no action", 126 + "onUpdate": "no action" 127 + } 128 + }, 129 + "compositePrimaryKeys": {}, 130 + "uniqueConstraints": {} 131 + }, 132 + "Chat": { 133 + "name": "Chat", 134 + "columns": { 135 + "id": { 136 + "name": "id", 137 + "type": "text", 138 + "primaryKey": true, 139 + "notNull": true, 140 + "autoincrement": false 141 + }, 142 + "time": { 143 + "name": "time", 144 + "type": "integer", 145 + "primaryKey": false, 146 + "notNull": true, 147 + "autoincrement": false 148 + }, 149 + "text": { 150 + "name": "text", 151 + "type": "text", 152 + "primaryKey": false, 153 + "notNull": false, 154 + "autoincrement": false 155 + }, 156 + "game_id": { 157 + "name": "game_id", 158 + "type": "text", 159 + "primaryKey": false, 160 + "notNull": true, 161 + "autoincrement": false 162 + }, 163 + "user_id": { 164 + "name": "user_id", 165 + "type": "integer", 166 + "primaryKey": false, 167 + "notNull": true, 168 + "autoincrement": false 169 + } 170 + }, 171 + "indexes": {}, 172 + "foreignKeys": { 173 + "Chat_game_id_BingoGame_id_fk": { 174 + "name": "Chat_game_id_BingoGame_id_fk", 175 + "tableFrom": "Chat", 176 + "tableTo": "BingoGame", 177 + "columnsFrom": [ 178 + "game_id" 179 + ], 180 + "columnsTo": [ 181 + "id" 182 + ], 183 + "onDelete": "cascade", 184 + "onUpdate": "cascade" 185 + }, 186 + "Chat_user_id_GameUser_user_id_fk": { 187 + "name": "Chat_user_id_GameUser_user_id_fk", 188 + "tableFrom": "Chat", 189 + "tableTo": "GameUser", 190 + "columnsFrom": [ 191 + "user_id" 192 + ], 193 + "columnsTo": [ 194 + "user_id" 195 + ], 196 + "onDelete": "no action", 197 + "onUpdate": "no action" 198 + } 199 + }, 200 + "compositePrimaryKeys": {}, 201 + "uniqueConstraints": {} 202 + }, 203 + "GameUser": { 204 + "name": "GameUser", 205 + "columns": { 206 + "game_id": { 207 + "name": "game_id", 208 + "type": "text", 209 + "primaryKey": false, 210 + "notNull": true, 211 + "autoincrement": false 212 + }, 213 + "user_id": { 214 + "name": "user_id", 215 + "type": "integer", 216 + "primaryKey": false, 217 + "notNull": true, 218 + "autoincrement": false 219 + }, 220 + "team_name": { 221 + "name": "team_name", 222 + "type": "text", 223 + "primaryKey": false, 224 + "notNull": true, 225 + "autoincrement": false 226 + }, 227 + "host": { 228 + "name": "host", 229 + "type": "integer", 230 + "primaryKey": false, 231 + "notNull": true, 232 + "autoincrement": false, 233 + "default": false 234 + } 235 + }, 236 + "indexes": {}, 237 + "foreignKeys": {}, 238 + "compositePrimaryKeys": { 239 + "GameUser_game_id_user_id_pk": { 240 + "columns": [ 241 + "game_id", 242 + "user_id" 243 + ], 244 + "name": "GameUser_game_id_user_id_pk" 245 + } 246 + }, 247 + "uniqueConstraints": {} 248 + }, 249 + "Map": { 250 + "name": "Map", 251 + "columns": { 252 + "id": { 253 + "name": "id", 254 + "type": "integer", 255 + "primaryKey": true, 256 + "notNull": true, 257 + "autoincrement": false 258 + }, 259 + "beatmapset_id": { 260 + "name": "beatmapset_id", 261 + "type": "integer", 262 + "primaryKey": false, 263 + "notNull": true, 264 + "autoincrement": false 265 + }, 266 + "fetch_time": { 267 + "name": "fetch_time", 268 + "type": "integer", 269 + "primaryKey": false, 270 + "notNull": true, 271 + "autoincrement": false 272 + }, 273 + "title": { 274 + "name": "title", 275 + "type": "text", 276 + "primaryKey": false, 277 + "notNull": true, 278 + "autoincrement": false 279 + }, 280 + "artist": { 281 + "name": "artist", 282 + "type": "text", 283 + "primaryKey": false, 284 + "notNull": true, 285 + "autoincrement": false 286 + }, 287 + "difficulty_name": { 288 + "name": "difficulty_name", 289 + "type": "text", 290 + "primaryKey": false, 291 + "notNull": true, 292 + "autoincrement": false 293 + }, 294 + "url": { 295 + "name": "url", 296 + "type": "text", 297 + "primaryKey": false, 298 + "notNull": true, 299 + "autoincrement": false 300 + }, 301 + "cover_url": { 302 + "name": "cover_url", 303 + "type": "text", 304 + "primaryKey": false, 305 + "notNull": true, 306 + "autoincrement": false 307 + }, 308 + "status": { 309 + "name": "status", 310 + "type": "text", 311 + "primaryKey": false, 312 + "notNull": true, 313 + "autoincrement": false 314 + }, 315 + "max_combo": { 316 + "name": "max_combo", 317 + "type": "integer", 318 + "primaryKey": false, 319 + "notNull": true, 320 + "autoincrement": false 321 + }, 322 + "last_updated": { 323 + "name": "last_updated", 324 + "type": "integer", 325 + "primaryKey": false, 326 + "notNull": true, 327 + "autoincrement": false 328 + }, 329 + "available": { 330 + "name": "available", 331 + "type": "integer", 332 + "primaryKey": false, 333 + "notNull": true, 334 + "autoincrement": false 335 + } 336 + }, 337 + "indexes": {}, 338 + "foreignKeys": {}, 339 + "compositePrimaryKeys": {}, 340 + "uniqueConstraints": {} 341 + }, 342 + "MapStats": { 343 + "name": "MapStats", 344 + "columns": { 345 + "map_id": { 346 + "name": "map_id", 347 + "type": "integer", 348 + "primaryKey": false, 349 + "notNull": true, 350 + "autoincrement": false 351 + }, 352 + "mod_string": { 353 + "name": "mod_string", 354 + "type": "text", 355 + "primaryKey": false, 356 + "notNull": true, 357 + "autoincrement": false, 358 + "default": "''" 359 + }, 360 + "star_rating": { 361 + "name": "star_rating", 362 + "type": "real", 363 + "primaryKey": false, 364 + "notNull": true, 365 + "autoincrement": false 366 + }, 367 + "bpm": { 368 + "name": "bpm", 369 + "type": "real", 370 + "primaryKey": false, 371 + "notNull": true, 372 + "autoincrement": false 373 + }, 374 + "length": { 375 + "name": "length", 376 + "type": "integer", 377 + "primaryKey": false, 378 + "notNull": true, 379 + "autoincrement": false 380 + }, 381 + "cs": { 382 + "name": "cs", 383 + "type": "real", 384 + "primaryKey": false, 385 + "notNull": true, 386 + "autoincrement": false 387 + }, 388 + "ar": { 389 + "name": "ar", 390 + "type": "real", 391 + "primaryKey": false, 392 + "notNull": true, 393 + "autoincrement": false 394 + }, 395 + "od": { 396 + "name": "od", 397 + "type": "real", 398 + "primaryKey": false, 399 + "notNull": true, 400 + "autoincrement": false 401 + }, 402 + "hp": { 403 + "name": "hp", 404 + "type": "real", 405 + "primaryKey": false, 406 + "notNull": true, 407 + "autoincrement": false 408 + } 409 + }, 410 + "indexes": {}, 411 + "foreignKeys": { 412 + "MapStats_map_id_Map_id_fk": { 413 + "name": "MapStats_map_id_Map_id_fk", 414 + "tableFrom": "MapStats", 415 + "tableTo": "Map", 416 + "columnsFrom": [ 417 + "map_id" 418 + ], 419 + "columnsTo": [ 420 + "id" 421 + ], 422 + "onDelete": "cascade", 423 + "onUpdate": "cascade" 424 + } 425 + }, 426 + "compositePrimaryKeys": { 427 + "MapStats_map_id_mod_string_pk": { 428 + "columns": [ 429 + "map_id", 430 + "mod_string" 431 + ], 432 + "name": "MapStats_map_id_mod_string_pk" 433 + } 434 + }, 435 + "uniqueConstraints": {} 436 + }, 437 + "OauthToken": { 438 + "name": "OauthToken", 439 + "columns": { 440 + "id": { 441 + "name": "id", 442 + "type": "text", 443 + "primaryKey": true, 444 + "notNull": true, 445 + "autoincrement": false 446 + }, 447 + "service": { 448 + "name": "service", 449 + "type": "text", 450 + "primaryKey": false, 451 + "notNull": true, 452 + "autoincrement": false 453 + }, 454 + "access_token": { 455 + "name": "access_token", 456 + "type": "text", 457 + "primaryKey": false, 458 + "notNull": true, 459 + "autoincrement": false 460 + }, 461 + "expires_at": { 462 + "name": "expires_at", 463 + "type": "integer", 464 + "primaryKey": false, 465 + "notNull": true, 466 + "autoincrement": false 467 + }, 468 + "refresh_token": { 469 + "name": "refresh_token", 470 + "type": "text", 471 + "primaryKey": false, 472 + "notNull": true, 473 + "autoincrement": false 474 + }, 475 + "token_type": { 476 + "name": "token_type", 477 + "type": "text", 478 + "primaryKey": false, 479 + "notNull": true, 480 + "autoincrement": false 481 + }, 482 + "user_id": { 483 + "name": "user_id", 484 + "type": "integer", 485 + "primaryKey": false, 486 + "notNull": false, 487 + "autoincrement": false 488 + } 489 + }, 490 + "indexes": { 491 + "OauthToken_access_token_unique": { 492 + "name": "OauthToken_access_token_unique", 493 + "columns": [ 494 + "access_token" 495 + ], 496 + "isUnique": true 497 + }, 498 + "OauthToken_refresh_token_unique": { 499 + "name": "OauthToken_refresh_token_unique", 500 + "columns": [ 501 + "refresh_token" 502 + ], 503 + "isUnique": true 504 + } 505 + }, 506 + "foreignKeys": { 507 + "OauthToken_user_id_User_id_fk": { 508 + "name": "OauthToken_user_id_User_id_fk", 509 + "tableFrom": "OauthToken", 510 + "tableTo": "User", 511 + "columnsFrom": [ 512 + "user_id" 513 + ], 514 + "columnsTo": [ 515 + "id" 516 + ], 517 + "onDelete": "cascade", 518 + "onUpdate": "no action" 519 + } 520 + }, 521 + "compositePrimaryKeys": {}, 522 + "uniqueConstraints": {} 523 + }, 524 + "Score": { 525 + "name": "Score", 526 + "columns": { 527 + "id": { 528 + "name": "id", 529 + "type": "text", 530 + "primaryKey": true, 531 + "notNull": true, 532 + "autoincrement": false 533 + }, 534 + "is_fc": { 535 + "name": "is_fc", 536 + "type": "integer", 537 + "primaryKey": false, 538 + "notNull": true, 539 + "autoincrement": false, 540 + "default": false 541 + }, 542 + "score": { 543 + "name": "score", 544 + "type": "real", 545 + "primaryKey": false, 546 + "notNull": true, 547 + "autoincrement": false 548 + }, 549 + "grade": { 550 + "name": "grade", 551 + "type": "text", 552 + "primaryKey": false, 553 + "notNull": true, 554 + "autoincrement": false 555 + }, 556 + "accuracy": { 557 + "name": "accuracy", 558 + "type": "real", 559 + "primaryKey": false, 560 + "notNull": true, 561 + "autoincrement": false 562 + }, 563 + "mods": { 564 + "name": "mods", 565 + "type": "text", 566 + "primaryKey": false, 567 + "notNull": false, 568 + "autoincrement": false, 569 + "default": "''" 570 + }, 571 + "important": { 572 + "name": "important", 573 + "type": "integer", 574 + "primaryKey": false, 575 + "notNull": false, 576 + "autoincrement": false, 577 + "default": false 578 + }, 579 + "square_id": { 580 + "name": "square_id", 581 + "type": "text", 582 + "primaryKey": false, 583 + "notNull": true, 584 + "autoincrement": false 585 + }, 586 + "user_id": { 587 + "name": "user_id", 588 + "type": "integer", 589 + "primaryKey": false, 590 + "notNull": true, 591 + "autoincrement": false 592 + } 593 + }, 594 + "indexes": {}, 595 + "foreignKeys": { 596 + "Score_square_id_BingoSquare_id_fk": { 597 + "name": "Score_square_id_BingoSquare_id_fk", 598 + "tableFrom": "Score", 599 + "tableTo": "BingoSquare", 600 + "columnsFrom": [ 601 + "square_id" 602 + ], 603 + "columnsTo": [ 604 + "id" 605 + ], 606 + "onDelete": "cascade", 607 + "onUpdate": "cascade" 608 + }, 609 + "Score_user_id_GameUser_user_id_fk": { 610 + "name": "Score_user_id_GameUser_user_id_fk", 611 + "tableFrom": "Score", 612 + "tableTo": "GameUser", 613 + "columnsFrom": [ 614 + "user_id" 615 + ], 616 + "columnsTo": [ 617 + "user_id" 618 + ], 619 + "onDelete": "no action", 620 + "onUpdate": "no action" 621 + } 622 + }, 623 + "compositePrimaryKeys": {}, 624 + "uniqueConstraints": {} 625 + }, 626 + "Session": { 627 + "name": "Session", 628 + "columns": { 629 + "id": { 630 + "name": "id", 631 + "type": "text", 632 + "primaryKey": true, 633 + "notNull": true, 634 + "autoincrement": false 635 + }, 636 + "user_id": { 637 + "name": "user_id", 638 + "type": "integer", 639 + "primaryKey": false, 640 + "notNull": true, 641 + "autoincrement": false 642 + }, 643 + "token": { 644 + "name": "token", 645 + "type": "text", 646 + "primaryKey": false, 647 + "notNull": true, 648 + "autoincrement": false 649 + }, 650 + "created_at": { 651 + "name": "created_at", 652 + "type": "integer", 653 + "primaryKey": false, 654 + "notNull": false, 655 + "autoincrement": false 656 + }, 657 + "last_used": { 658 + "name": "last_used", 659 + "type": "integer", 660 + "primaryKey": false, 661 + "notNull": false, 662 + "autoincrement": false 663 + }, 664 + "device": { 665 + "name": "device", 666 + "type": "text", 667 + "primaryKey": false, 668 + "notNull": false, 669 + "autoincrement": false 670 + }, 671 + "browser": { 672 + "name": "browser", 673 + "type": "text", 674 + "primaryKey": false, 675 + "notNull": false, 676 + "autoincrement": false 677 + }, 678 + "os": { 679 + "name": "os", 680 + "type": "text", 681 + "primaryKey": false, 682 + "notNull": false, 683 + "autoincrement": false 684 + } 685 + }, 686 + "indexes": { 687 + "Session_token_unique": { 688 + "name": "Session_token_unique", 689 + "columns": [ 690 + "token" 691 + ], 692 + "isUnique": true 693 + } 694 + }, 695 + "foreignKeys": { 696 + "Session_user_id_User_id_fk": { 697 + "name": "Session_user_id_User_id_fk", 698 + "tableFrom": "Session", 699 + "tableTo": "User", 700 + "columnsFrom": [ 701 + "user_id" 702 + ], 703 + "columnsTo": [ 704 + "id" 705 + ], 706 + "onDelete": "no action", 707 + "onUpdate": "no action" 708 + } 709 + }, 710 + "compositePrimaryKeys": {}, 711 + "uniqueConstraints": {} 712 + }, 713 + "TimeEvent": { 714 + "name": "TimeEvent", 715 + "columns": { 716 + "id": { 717 + "name": "id", 718 + "type": "text", 719 + "primaryKey": true, 720 + "notNull": true, 721 + "autoincrement": false 722 + }, 723 + "time": { 724 + "name": "time", 725 + "type": "integer", 726 + "primaryKey": false, 727 + "notNull": true, 728 + "autoincrement": false 729 + }, 730 + "action": { 731 + "name": "action", 732 + "type": "text", 733 + "primaryKey": false, 734 + "notNull": true, 735 + "autoincrement": false 736 + }, 737 + "fulfilled": { 738 + "name": "fulfilled", 739 + "type": "integer", 740 + "primaryKey": false, 741 + "notNull": false, 742 + "autoincrement": false, 743 + "default": false 744 + }, 745 + "game_id": { 746 + "name": "game_id", 747 + "type": "text", 748 + "primaryKey": false, 749 + "notNull": true, 750 + "autoincrement": false 751 + } 752 + }, 753 + "indexes": {}, 754 + "foreignKeys": { 755 + "TimeEvent_game_id_BingoGame_id_fk": { 756 + "name": "TimeEvent_game_id_BingoGame_id_fk", 757 + "tableFrom": "TimeEvent", 758 + "tableTo": "BingoGame", 759 + "columnsFrom": [ 760 + "game_id" 761 + ], 762 + "columnsTo": [ 763 + "id" 764 + ], 765 + "onDelete": "cascade", 766 + "onUpdate": "no action" 767 + } 768 + }, 769 + "compositePrimaryKeys": {}, 770 + "uniqueConstraints": {} 771 + }, 772 + "User": { 773 + "name": "User", 774 + "columns": { 775 + "id": { 776 + "name": "id", 777 + "type": "integer", 778 + "primaryKey": true, 779 + "notNull": true, 780 + "autoincrement": false 781 + }, 782 + "username": { 783 + "name": "username", 784 + "type": "text", 785 + "primaryKey": false, 786 + "notNull": true, 787 + "autoincrement": false 788 + }, 789 + "country_code": { 790 + "name": "country_code", 791 + "type": "text", 792 + "primaryKey": false, 793 + "notNull": true, 794 + "autoincrement": false 795 + }, 796 + "country_name": { 797 + "name": "country_name", 798 + "type": "text", 799 + "primaryKey": false, 800 + "notNull": true, 801 + "autoincrement": false 802 + }, 803 + "cover_url": { 804 + "name": "cover_url", 805 + "type": "text", 806 + "primaryKey": false, 807 + "notNull": true, 808 + "autoincrement": false 809 + }, 810 + "avatar_url": { 811 + "name": "avatar_url", 812 + "type": "text", 813 + "primaryKey": false, 814 + "notNull": true, 815 + "autoincrement": false 816 + }, 817 + "pp": { 818 + "name": "pp", 819 + "type": "real", 820 + "primaryKey": false, 821 + "notNull": false, 822 + "autoincrement": false 823 + }, 824 + "global_rank": { 825 + "name": "global_rank", 826 + "type": "integer", 827 + "primaryKey": false, 828 + "notNull": false, 829 + "autoincrement": false 830 + }, 831 + "country_rank": { 832 + "name": "country_rank", 833 + "type": "integer", 834 + "primaryKey": false, 835 + "notNull": false, 836 + "autoincrement": false 837 + }, 838 + "total_score": { 839 + "name": "total_score", 840 + "type": "integer", 841 + "primaryKey": false, 842 + "notNull": false, 843 + "autoincrement": false 844 + }, 845 + "ranked_score": { 846 + "name": "ranked_score", 847 + "type": "integer", 848 + "primaryKey": false, 849 + "notNull": false, 850 + "autoincrement": false 851 + }, 852 + "hit_accuracy": { 853 + "name": "hit_accuracy", 854 + "type": "real", 855 + "primaryKey": false, 856 + "notNull": false, 857 + "autoincrement": false 858 + }, 859 + "play_count": { 860 + "name": "play_count", 861 + "type": "integer", 862 + "primaryKey": false, 863 + "notNull": false, 864 + "autoincrement": false 865 + }, 866 + "level": { 867 + "name": "level", 868 + "type": "integer", 869 + "primaryKey": false, 870 + "notNull": false, 871 + "autoincrement": false 872 + }, 873 + "level_progress": { 874 + "name": "level_progress", 875 + "type": "integer", 876 + "primaryKey": false, 877 + "notNull": false, 878 + "autoincrement": false 879 + }, 880 + "last_refreshed": { 881 + "name": "last_refreshed", 882 + "type": "integer", 883 + "primaryKey": false, 884 + "notNull": false, 885 + "autoincrement": false 886 + } 887 + }, 888 + "indexes": {}, 889 + "foreignKeys": {}, 890 + "compositePrimaryKeys": {}, 891 + "uniqueConstraints": {} 892 + } 893 + }, 894 + "enums": {}, 895 + "_meta": { 896 + "schemas": {}, 897 + "tables": {}, 898 + "columns": {} 899 + }, 900 + "internal": { 901 + "indexes": {} 902 + } 903 + }
+13
src/lib/drizzle/migrations/meta/_journal.json
··· 1 + { 2 + "version": "7", 3 + "dialect": "sqlite", 4 + "entries": [ 5 + { 6 + "idx": 0, 7 + "version": "6", 8 + "when": 1718592239988, 9 + "tag": "0000_low_genesis", 10 + "breakpoints": true 11 + } 12 + ] 13 + }
+144
src/lib/drizzle/queries/game.ts
··· 1 + import { and, eq, or } from 'drizzle-orm'; 2 + import { db } from '..'; 3 + import { BingoGame, BingoSquare, Chat, GameUser, Map, MapStats, Score, TimeEvent, User } from '../schema'; 4 + import { getBeatmaps } from '$lib/server/osu'; 5 + import type { Osu } from '$lib/osu'; 6 + import { addMap } from './map'; 7 + 8 + export const newGame = async () => { 9 + const randomLetter = () => { 10 + const A = 65; 11 + const random = Math.floor(Math.random() * 26); 12 + return String.fromCharCode(A + random); 13 + }; 14 + let link_id = ''; 15 + // Check for duplicate link IDs 16 + while (true) { 17 + link_id = `${randomLetter()}${randomLetter()}${randomLetter()}${randomLetter()}`; 18 + const duplicate = await db 19 + .select() 20 + .from(BingoGame) 21 + .where(and(eq(BingoGame.link_id, link_id), eq(BingoGame.state, 0))); 22 + if (duplicate.length == 0) break; 23 + } 24 + 25 + return ( 26 + await db 27 + .insert(BingoGame) 28 + .values({ 29 + link_id 30 + }) 31 + .returning() 32 + )[0]; 33 + }; 34 + 35 + export const fillSquares = async ( 36 + game_id: string, 37 + min_sr: number, 38 + max_sr: number, 39 + access_token: string 40 + ) => { 41 + // Currently, the app just gets the 25 most recent beatmaps. 42 + // In the future, some better solution should be used for this. 43 + const sets = await getBeatmaps(min_sr, max_sr, access_token); 44 + 45 + const beatmaps: { map: Osu.BeatmapExtended, set: Osu.Beatmapset }[] = []; 46 + outer_loop: while (beatmaps.length < 25) { 47 + for (const set of sets) { 48 + if (!set.beatmaps) continue; 49 + 50 + for (const map of set?.beatmaps) { 51 + if (map.difficulty_rating < min_sr || map.difficulty_rating > max_sr) continue; 52 + 53 + const newSet = structuredClone(set); 54 + delete newSet.beatmaps; 55 + 56 + beatmaps.push({ map: map as Osu.BeatmapExtended, set: newSet }); 57 + if (beatmaps.length >= 25) break outer_loop; 58 + } 59 + } 60 + } 61 + 62 + for (const map of beatmaps) { 63 + await addMap(map.map, map.set) 64 + } 65 + 66 + for (let y = 0; y < 5; y++) { 67 + for (let x = 0; x < 5; x++) { 68 + const beatmap = beatmaps[y * 5 + x]; 69 + 70 + await db.insert(BingoSquare).values({ 71 + game_id, 72 + map_id: beatmap.map.id, 73 + x_pos: x, 74 + y_pos: y 75 + }); 76 + } 77 + } 78 + }; 79 + 80 + export const getGame = async (game_id: string) => { 81 + const game = (await db.select().from(BingoGame).where(eq(BingoGame.id, game_id)))[0]; 82 + 83 + 84 + //@ts-ignore 85 + const users: Bingo.Card.FullUser[] = await db 86 + //@ts-ignore 87 + .select({ ...GameUser, ...User }) 88 + .from(GameUser) 89 + .where(eq(GameUser.game_id, game_id)) 90 + .innerJoin(User, eq(GameUser.user_id, User.id)); 91 + 92 + const events = await db.select().from(TimeEvent).where(eq(TimeEvent.game_id, game_id)); 93 + const chats = await db.select().from(Chat).where(eq(Chat.game_id, game_id)); 94 + 95 + if (game.state == 0) return { ...game, users, events, chats, squares: null } 96 + 97 + const squares: Bingo.Card.FullSquare[] = []; 98 + const dbSquares = await db 99 + .select() 100 + .from(BingoSquare) 101 + .where(eq(BingoSquare.game_id, game_id)) 102 + for (const square of dbSquares) { 103 + const map = (await db 104 + .select() 105 + .from(Map) 106 + .innerJoin( 107 + MapStats, 108 + eq(MapStats.map_id, Map.id)) 109 + .where(and( 110 + eq(Map.id, square.map_id), 111 + eq(MapStats.mod_string, square.mod_string ?? "") 112 + )) 113 + )[0]; 114 + const scores = await db.select().from(Score).where(eq(Score.square_id, square.id)); 115 + squares.push({ 116 + ...square, 117 + data: { 118 + ...map.Map, 119 + stats: map.MapStats 120 + }, 121 + scores 122 + }) 123 + } 124 + 125 + return { ...game, users, events, squares, chats }; 126 + }; 127 + 128 + export const gameLinkToId = async (link: string) => { 129 + const query = ( 130 + await db 131 + .select({ game_id: BingoGame.id }) 132 + .from(BingoGame) 133 + .where(and(eq(BingoGame.link_id, link), or(eq(BingoGame.state, 0), eq(BingoGame.state, 1)))) 134 + )[0]; 135 + if (query == undefined) return null; 136 + 137 + return query.game_id; 138 + }; 139 + 140 + export const getGameFromLinkId = async (link: string) => { 141 + const game_id = await gameLinkToId(link); 142 + if (!game_id) return null; 143 + return await getGame(game_id); 144 + };
+31
src/lib/drizzle/queries/gameuser.ts
··· 1 + import { and, eq } from 'drizzle-orm'; 2 + import { db } from '..'; 3 + import { GameUser } from '../schema'; 4 + 5 + export const joinGame = async (game_id: string, user_id: number, team: string) => { 6 + const test = await db.select().from(GameUser).where(and( 7 + eq(GameUser.game_id, game_id), 8 + eq(GameUser.user_id, user_id) 9 + )) 10 + if (test.length != 0) { 11 + return (await db.update(GameUser).set({ team_name: team }).where(and( 12 + eq(GameUser.game_id, game_id), 13 + eq(GameUser.user_id, user_id) 14 + )).returning())[0] 15 + } 16 + 17 + return (await db 18 + .insert(GameUser) 19 + .values({ 20 + game_id, 21 + user_id, 22 + team_name: team 23 + }).returning())[0] 24 + } 25 + 26 + export const leaveGame = async (game_id: string, user_id: number) => { 27 + await db.delete(GameUser).where(and( 28 + eq(GameUser.game_id, game_id), 29 + eq(GameUser.user_id, user_id) 30 + )) 31 + }
+13
src/lib/drizzle/queries/index.ts
··· 1 + import * as session from './session'; 2 + import * as user from './user'; 3 + import * as token from './token'; 4 + import * as game from './game'; 5 + import * as gameuser from './gameuser'; 6 + 7 + export default { 8 + ...session, 9 + ...user, 10 + ...token, 11 + ...game, 12 + ...gameuser 13 + };
+59
src/lib/drizzle/queries/map.ts
··· 1 + import { and, eq } from 'drizzle-orm'; 2 + import { db } from '..'; 3 + import { Map, MapStats } from '../schema'; 4 + import type { Osu } from '$lib/osu'; 5 + 6 + export const addMap = async (map: Osu.BeatmapExtended, set: Osu.Beatmapset, mods?: string) => { 7 + const obj: typeof Map.$inferInsert = { 8 + id: map.id, 9 + beatmapset_id: map.beatmapset_id, 10 + title: set.title, 11 + artist: set.artist, 12 + difficulty_name: map.version, 13 + url: map.url, 14 + cover_url: set.covers['list@2x'], 15 + status: set.status, 16 + max_combo: map.max_combo, 17 + last_updated: new Date(map.last_updated), 18 + available: map.is_scoreable, 19 + fetch_time: new Date() 20 + }; 21 + 22 + const stats: typeof MapStats.$inferInsert = { 23 + map_id: map.id, 24 + length: map.hit_length, 25 + bpm: map.bpm, 26 + star_rating: map.difficulty_rating, 27 + cs: map.cs, 28 + od: map.accuracy, 29 + ar: map.ar, 30 + hp: map.drain, 31 + mod_string: mods ?? '' 32 + }; 33 + 34 + const mapObj = await db.select({ id: Map.id }).from(Map).where(eq(Map.id, map.id)); 35 + if (mapObj.length == 0) { 36 + await db.insert(Map).values(obj); 37 + } else { 38 + await db.update(Map).set(obj).where(eq(Map.id, map.id)); 39 + } 40 + 41 + const statsObj = await db 42 + .select({ sr: MapStats.star_rating }) 43 + .from(MapStats) 44 + .where(and(eq(MapStats.map_id, map.id), eq(MapStats.mod_string, mods ?? ''))); 45 + if (statsObj.length == 0) { 46 + await db.insert(MapStats).values(stats); 47 + } else { 48 + await db 49 + .update(MapStats) 50 + .set(stats) 51 + .where(and(eq(MapStats.map_id, map.id), eq(MapStats.mod_string, mods ?? ''))); 52 + } 53 + 54 + return db 55 + .select() 56 + .from(Map) 57 + .where(eq(Map.id, map.id)) 58 + .innerJoin(MapStats, eq(MapStats.map_id, map.id)); 59 + };
+36
src/lib/drizzle/queries/session.ts
··· 1 + import { eq } from 'drizzle-orm'; 2 + import { db } from '..'; 3 + import { Session, User } from '../schema'; 4 + import * as jose from 'jose'; 5 + import { JWT_SECRET } from '$env/static/private'; 6 + 7 + const jwt_alg = 'HS256'; 8 + const jwt_secret = new TextEncoder().encode(JWT_SECRET); 9 + 10 + export const createSession = async (user_id: number) => { 11 + const userCheck = await db.select({ id: User.id }).from(User).where(eq(User.id, user_id)); 12 + if (userCheck.length == 0) { 13 + return null; 14 + } 15 + 16 + const token = await new jose.SignJWT({ 17 + user_id: user_id 18 + }) 19 + .setIssuedAt() 20 + .setProtectedHeader({ alg: jwt_alg }) 21 + .sign(jwt_secret); 22 + 23 + return ( 24 + await db 25 + .insert(Session) 26 + .values({ 27 + user_id: user_id, 28 + token 29 + }) 30 + .returning() 31 + )[0]; 32 + }; 33 + 34 + export const deleteSession = async (token: string) => { 35 + await db.delete(Session).where(eq(Session.token, token)); 36 + };
+19
src/lib/drizzle/queries/token.ts
··· 1 + import { eq } from 'drizzle-orm'; 2 + import { db } from '..'; 3 + import { OauthToken } from '../schema'; 4 + 5 + export const setToken = async (token: typeof OauthToken.$inferInsert) => { 6 + if (!token.service) return; 7 + if (!token.user_id) return; 8 + 9 + const dbToken = await db.select().from(OauthToken).where(eq(OauthToken.user_id, token.user_id)); 10 + if (dbToken.length != 0) { 11 + await db.update(OauthToken).set(token).where(eq(OauthToken.user_id, token.user_id)); 12 + } else { 13 + await db.insert(OauthToken).values(token); 14 + } 15 + }; 16 + 17 + export const getToken = async (user_id: number) => { 18 + return (await db.select().from(OauthToken).where(eq(OauthToken.user_id, user_id)))[0]; 19 + };
+40
src/lib/drizzle/queries/user.ts
··· 1 + import { eq } from 'drizzle-orm'; 2 + import { db } from '..'; 3 + import { Session, User } from '../schema'; 4 + 5 + export const setUser = async (user: typeof User.$inferInsert) => { 6 + if (user.id == null) { 7 + return; 8 + } 9 + 10 + const userObj = await db.select({ id: User.id }).from(User).where(eq(User.id, user.id)); 11 + if (userObj.length == 0) { 12 + return (await db.insert(User).values(user).returning())[0]; 13 + } 14 + 15 + const newUser = { 16 + last_refreshed: new Date(), 17 + ...user 18 + }; 19 + 20 + return (await db.update(User).set(newUser).where(eq(User.id, user.id)).returning())[0]; 21 + }; 22 + 23 + export const getUser = async (id: number) => { 24 + return (await db.select().from(User).where(eq(User.id, id)))[0]; 25 + }; 26 + 27 + export const getUserFromSessionToken = async (token: string) => { 28 + const tokens = await db 29 + .select() 30 + .from(Session) 31 + .where(eq(Session.token, token)) 32 + .innerJoin(User, eq(User.id, Session.user_id)); 33 + 34 + // If there is no session with that token, token is invalid. 35 + if (tokens.length == 0) { 36 + return null; 37 + } 38 + //There should only be one 39 + return tokens[0].User; 40 + };
+104
src/lib/drizzle/relations.ts
··· 1 + import { relations } from 'drizzle-orm'; 2 + import { 3 + BingoGame, 4 + BingoSquare, 5 + GameUser, 6 + User, 7 + OauthToken, 8 + TimeEvent, 9 + Map, 10 + MapStats, 11 + Score, 12 + Session, 13 + Chat 14 + } from './schema'; 15 + 16 + export const BingoGameRelations = relations(BingoGame, ({ many }) => ({ 17 + users: many(GameUser), 18 + events: many(TimeEvent), 19 + squares: many(BingoSquare) 20 + })); 21 + 22 + export const BingoSquareRelations = relations(BingoSquare, ({ one, many }) => ({ 23 + scores: many(Score), 24 + game: one(BingoGame, { 25 + fields: [BingoSquare.game_id], 26 + references: [BingoGame.id] 27 + }), 28 + map: one(Map, { 29 + fields: [BingoSquare.map_id], 30 + references: [Map.id] 31 + }) 32 + })); 33 + 34 + export const GameUserRelations = relations(GameUser, ({ one }) => ({ 35 + game: one(BingoGame, { 36 + fields: [GameUser.game_id], 37 + references: [BingoGame.id] 38 + }), 39 + user: one(User, { 40 + fields: [GameUser.user_id], 41 + references: [User.id] 42 + }) 43 + })); 44 + 45 + export const UserRelations = relations(User, ({ many }) => ({ 46 + game_list: many(GameUser), 47 + tokens: many(OauthToken), 48 + sessions: many(Session) 49 + })); 50 + 51 + export const TokenRelations = relations(OauthToken, ({ one }) => ({ 52 + user: one(User, { 53 + fields: [OauthToken.user_id], 54 + references: [User.id] 55 + }) 56 + })); 57 + 58 + export const SessionRelations = relations(Session, ({ one }) => ({ 59 + user: one(User, { 60 + fields: [Session.user_id], 61 + references: [User.id] 62 + }) 63 + })); 64 + 65 + export const EventRelations = relations(TimeEvent, ({ one }) => ({ 66 + game: one(BingoGame, { 67 + fields: [TimeEvent.game_id], 68 + references: [BingoGame.id] 69 + }) 70 + })); 71 + 72 + export const MapRelations = relations(Map, ({ many }) => ({ 73 + in_squares: many(BingoSquare), 74 + stats: many(MapStats) 75 + })); 76 + 77 + export const MapStatsRelations = relations(MapStats, ({ one }) => ({ 78 + map: one(Map, { 79 + fields: [MapStats.map_id], 80 + references: [Map.id] 81 + }) 82 + })); 83 + 84 + export const ScoreRelations = relations(Score, ({ one }) => ({ 85 + square: one(BingoSquare, { 86 + fields: [Score.square_id], 87 + references: [BingoSquare.id] 88 + }), 89 + game_user: one(GameUser, { 90 + fields: [Score.user_id], 91 + references: [GameUser.user_id] 92 + }) 93 + })); 94 + 95 + export const ChatRelations = relations(Chat, ({ one }) => ({ 96 + game: one(BingoSquare, { 97 + fields: [Chat.game_id], 98 + references: [BingoSquare.id] 99 + }), 100 + user: one(User, { 101 + fields: [Chat.user_id], 102 + references: [User.id] 103 + }) 104 + }));
+235
src/lib/drizzle/schema.ts
··· 1 + /** 2 + * DRIZZLE SCHEMA 3 + * 4 + * - All of the property definitions for the database schema 5 + * - All relations are specified in `./relations.ts` 6 + */ 7 + 8 + import { text, integer, real, sqliteTable, primaryKey } from 'drizzle-orm/sqlite-core'; 9 + import { randomUUID } from 'node:crypto'; 10 + 11 + /** 12 + * Represents a game of Bingo 13 + * 14 + * Contains references to multiple users, multiple maps (bingo squares), and multiple scores. 15 + */ 16 + export const BingoGame = sqliteTable('BingoGame', { 17 + id: text('id').primaryKey().$defaultFn(randomUUID), 18 + link_id: text('link_id'), // Four letters, similar to Jackbox 19 + 20 + // Settings 21 + state: integer('state').default(0).notNull(), // 0: Before starting, 1: In game, 2: Finished 22 + allow_team_switching: integer('allow_team_switching', { mode: 'boolean' }).default(true) // Only takes effect when game state is 0. 23 + }); 24 + 25 + export const BingoSquare = sqliteTable('BingoSquare', { 26 + id: text('id').primaryKey().$defaultFn(randomUUID), 27 + 28 + // References 29 + game_id: text('game_id') 30 + .references(() => BingoGame.id) 31 + .notNull(), 32 + map_id: integer('map_id') 33 + .references(() => Map.id) 34 + .notNull(), 35 + mod_string: text('mod_string').default(''), 36 + 37 + // Position 38 + x_pos: integer('x_pos').notNull(), 39 + y_pos: integer('y_pos').notNull(), 40 + 41 + claimed_by: text('claimed_by') 42 + }); 43 + 44 + /** 45 + * Linker table between a game and a user 46 + */ 47 + export const GameUser = sqliteTable( 48 + 'GameUser', 49 + { 50 + game_id: text('game_id').notNull(), 51 + user_id: integer('user_id').notNull(), 52 + 53 + team_name: text('team_name').notNull(), 54 + host: integer('host', { mode: 'boolean' }).notNull().default(false) 55 + }, 56 + (table) => ({ 57 + composite_key: primaryKey({ columns: [table.game_id, table.user_id] }) 58 + }) 59 + ); 60 + 61 + /** 62 + * Represents an osu! user 63 + * 64 + * id column is shared with osu! id 65 + */ 66 + export const User = sqliteTable('User', { 67 + id: integer('id').primaryKey().notNull(), 68 + 69 + username: text('username').notNull(), 70 + country_code: text('country_code').notNull(), 71 + country_name: text('country_name').notNull(), 72 + 73 + cover_url: text('cover_url').notNull(), 74 + avatar_url: text('avatar_url').notNull(), 75 + 76 + pp: real('pp'), 77 + global_rank: integer('global_rank'), 78 + country_rank: integer('country_rank'), 79 + 80 + total_score: integer('total_score'), 81 + ranked_score: integer('ranked_score'), 82 + hit_accuracy: real('hit_accuracy'), 83 + play_count: integer('play_count'), 84 + level: integer('level'), 85 + level_progress: integer('level_progress'), 86 + 87 + last_refreshed: integer('last_refreshed', { mode: 'timestamp' }).$defaultFn(() => new Date()) 88 + }); 89 + 90 + /** 91 + * Represents an Oauth token for a user, 92 + */ 93 + export const OauthToken = sqliteTable('OauthToken', { 94 + id: text('id').primaryKey().$defaultFn(randomUUID), 95 + service: text('service').notNull(), 96 + access_token: text('access_token').unique().notNull(), 97 + expires_at: integer('expires_at', { mode: 'timestamp' }).notNull(), 98 + refresh_token: text('refresh_token').unique().notNull(), 99 + token_type: text('token_type').notNull(), 100 + 101 + user_id: integer('user_id').references(() => User.id, { onDelete: 'cascade' }) 102 + }); 103 + 104 + /** 105 + * Represents a user's login session. 106 + */ 107 + export const Session = sqliteTable('Session', { 108 + id: text('id').primaryKey().$defaultFn(randomUUID), 109 + user_id: integer('user_id') 110 + .notNull() 111 + .references(() => User.id), 112 + 113 + token: text('token').notNull().unique(), // JWT stored in the browser 114 + 115 + created_at: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), 116 + last_used: integer('last_used', { mode: 'timestamp' }), 117 + 118 + // Session Info 119 + device: text('device'), 120 + browser: text('browser'), 121 + os: text('os') 122 + }); 123 + 124 + /** 125 + * Represents an event that is scheduled to happen at a certain time. 126 + * 127 + */ 128 + export const TimeEvent = sqliteTable('TimeEvent', { 129 + id: text('id').primaryKey().$defaultFn(randomUUID), 130 + time: integer('time', { mode: 'timestamp' }).notNull(), 131 + action: text('action').notNull(), // Some string that represents what to do when the time hits 132 + fulfilled: integer('fulfilled', { mode: 'boolean' }).default(false), 133 + 134 + game_id: text('game_id') 135 + .notNull() 136 + .references(() => BingoGame.id, { onDelete: 'cascade', onUpdate: 'cascade' }) 137 + }); 138 + 139 + /** 140 + * Represents a trimmed-down version of a osu! map 141 + */ 142 + export const Map = sqliteTable('Map', { 143 + id: integer('id').primaryKey(), 144 + beatmapset_id: integer('beatmapset_id').notNull(), 145 + fetch_time: integer('fetch_time', { mode: 'timestamp' }) 146 + .$defaultFn(() => new Date()) 147 + .notNull(), 148 + 149 + // Rendered as "title - artist [difficulty_name]" in chat 150 + title: text('title').notNull(), 151 + artist: text('artist').notNull(), 152 + difficulty_name: text('difficulty_name').notNull(), 153 + 154 + // Additional Fields for displaying nicely in a Web format. 155 + url: text('url').notNull(), 156 + cover_url: text('cover_url').notNull(), 157 + status: text('status').notNull(), 158 + max_combo: integer('max_combo').notNull(), 159 + last_updated: integer('last_updated', { mode: 'timestamp_ms' }).notNull(), 160 + 161 + // Whether a user is able to download 162 + // using osu!direct/official servers 163 + available: integer('available', { mode: 'boolean' }).notNull() 164 + }); 165 + 166 + /** 167 + * Represents stats about a map 168 + * 169 + * Linked to a Map, with some mod_string. For displaying NM stats, DT stats, etc. 170 + * mod_string should match the string of the MapInPool object ("" means nomod) 171 + */ 172 + export const MapStats = sqliteTable( 173 + 'MapStats', 174 + { 175 + map_id: integer('map_id') 176 + .notNull() 177 + .references(() => Map.id, { onDelete: 'cascade', onUpdate: 'cascade' }), 178 + mod_string: text('mod_string').default('').notNull(), 179 + 180 + star_rating: real('star_rating').notNull(), 181 + bpm: real('bpm').notNull(), 182 + length: integer('length').notNull(), // Length of map in seconds 183 + 184 + cs: real('cs').notNull(), 185 + ar: real('ar').notNull(), 186 + od: real('od').notNull(), 187 + hp: real('hp').notNull() 188 + }, 189 + (table) => ({ 190 + composite_key: primaryKey({ columns: [table.map_id, table.mod_string] }) 191 + }) 192 + ); 193 + 194 + /** 195 + * A user's score on a map (MatchBlock) 196 + * 197 + * Contains references to a team score (for a team's score) 198 + */ 199 + export const Score = sqliteTable('Score', { 200 + id: text('id').primaryKey().$defaultFn(randomUUID), 201 + 202 + is_fc: integer('is_fc', { mode: 'boolean' }).notNull().default(false), 203 + score: real('score').notNull(), 204 + grade: text('grade').notNull(), 205 + accuracy: real('accuracy').notNull(), 206 + mods: text('mods').default(''), 207 + 208 + important: integer('important', { mode: 'boolean' }).default(false), // Whether this score appears as a notification 209 + 210 + square_id: text('square_id') 211 + .notNull() 212 + .references(() => BingoSquare.id, { onDelete: 'cascade', onUpdate: 'cascade' }), 213 + user_id: integer('user_id') 214 + .notNull() 215 + .references(() => GameUser.user_id, { onDelete: 'cascade', onUpdate: 'cascade' }) 216 + }); 217 + 218 + /** 219 + * A chat message in a lobby 220 + */ 221 + export const Chat = sqliteTable('Chat', { 222 + id: text('id').primaryKey().$defaultFn(randomUUID), 223 + 224 + time: integer('time', { mode: 'timestamp' }) 225 + .$defaultFn(() => new Date()) 226 + .notNull(), 227 + text: text('text'), 228 + 229 + game_id: text('game_id') 230 + .notNull() 231 + .references(() => BingoGame.id, { onDelete: 'cascade', onUpdate: 'cascade' }), 232 + user_id: integer('user_id') 233 + .notNull() 234 + .references(() => GameUser.user_id, { onDelete: 'cascade', onUpdate: 'cascade' }) 235 + });
+1
src/lib/index.ts
··· 1 + // place files you want to import through the `$lib` alias in this folder.
+274
src/lib/osu.d.ts
··· 1 + import type { IntegerConfig } from 'drizzle-orm/sqlite-core'; 2 + 3 + export namespace Osu { 4 + type AuthScope = 5 + | 'chat.read' 6 + | 'chat.write' 7 + | 'chat.write_manage' 8 + | 'delegate' 9 + | 'forum.write' 10 + | 'friends.read' 11 + | 'identify' 12 + | 'public'; 13 + type Ruleset = 'fruits' | 'mania' | 'osu' | 'taiko'; 14 + 15 + type TokenResponse = AuthorizationCodeTokenResponse | ClientCredentialsTokenResponse; 16 + 17 + interface AuthorizationCodeTokenResponse { 18 + access_token: string; 19 + expires_in: number; 20 + refresh_token: string; 21 + token_type: string; 22 + } 23 + interface ClientCredentialsTokenResponse { 24 + access_token: string; 25 + expires_in: number; 26 + token_type: string; 27 + } 28 + 29 + interface User { 30 + avatar_url: string; 31 + country_code: string; 32 + default_group?: string; 33 + id: number; 34 + is_active: boolean; 35 + is_bot: boolean; 36 + is_deleted: boolean; 37 + is_online: boolean; 38 + is_supporter: boolean; 39 + last_visit?: string; 40 + pm_friends_only: boolean; 41 + profile_colour?: string; 42 + username: string; 43 + // Optional Attributes 44 + is_restricted?: boolean; 45 + kudosu?: User.Kudosu; 46 + account_history?: User.UserAccountHistory[]; 47 + active_tournament_banners?: User.ProfileBanner[]; 48 + badges?: User.UserBadge[]; 49 + beatmap_playcounts_count?: number; 50 + country?: { 51 + code: string; 52 + name: string; 53 + }; 54 + cover?: { 55 + custom_url: string; 56 + url: string; 57 + id: any; 58 + }; 59 + comments_count: number; 60 + favourite_beatmapset_count: number; 61 + follower_count: number; 62 + graveyard_beatmapset_count: number; 63 + groups: any[]; 64 + guest_beatmapset_count: number; 65 + loved_beatmapset_count: number; 66 + mapping_follower_count: number; 67 + monthly_playcounts: Count[]; 68 + nominated_beatmapset_count: number; 69 + page: { 70 + html: string; 71 + raw: string; 72 + }; 73 + pending_beatmapset_count: number; 74 + previous_usernames: string[]; 75 + rank_highest: { 76 + rank: number; 77 + updated_at: string; 78 + }; 79 + ranked_beatmapset_count: number; 80 + replays_watched_counts: Count[]; 81 + scores_best_count: number; 82 + scores_first_count: number; 83 + scores_pinned_count: number; 84 + scores_recent_count: number; 85 + session_verified: boolean; 86 + statistics: UserStatistics; 87 + statistics_rulesets: { 88 + osu: UserStatistics; 89 + taiko: UserStatistics; 90 + fruits: UserStatistics; 91 + mania: UserStatistics; 92 + }; 93 + support_level: number; 94 + user_achievements: { 95 + achieved_at: string; 96 + achievement_id: number; 97 + }[]; 98 + rank_history: { 99 + mode: string; 100 + data: number[]; 101 + }; 102 + rankHistory: { 103 + mode: string; 104 + data: number[]; 105 + }; 106 + ranked_and_approved_beatmapset_count: number; 107 + unranked_beatmapset_count: number; 108 + } 109 + 110 + namespace User { 111 + interface Kudosu { 112 + available: number; 113 + total: number; 114 + } 115 + interface ProfileBanner { 116 + id: number; 117 + tournament_id: number; 118 + image?: string; 119 + 'image@2x'?: string; 120 + } 121 + type ProfilePage = 122 + | 'me' 123 + | 'recent_activity' 124 + | 'beatmaps' 125 + | 'historical' 126 + | 'kudosu' 127 + | 'top_ranks' 128 + | 'medals'; 129 + interface RankHighest { 130 + rank: number; 131 + updated_at: string; 132 + } 133 + interface UserAccountHistory { 134 + description?: string; 135 + id: number; 136 + length: number; 137 + permanent: boolean; 138 + timestamp: string; 139 + type: 'note' | 'restriction' | 'silence'; 140 + } 141 + 142 + interface UserBadge { 143 + awarded_at: string; 144 + description: string; 145 + 'image@2x_url': string; 146 + image_url: string; 147 + url: string; 148 + } 149 + } 150 + 151 + interface UserExtended extends User { 152 + discord?: string; 153 + has_supported: boolean; 154 + interests?: string; 155 + join_date: string; 156 + location?: string; 157 + max_blocks: number; 158 + max_friends: number; 159 + occupation?: string; 160 + playmode: string; 161 + playstyle: string[]; 162 + post_count: number; 163 + profile_order: ProfilePage[]; 164 + title?: string; 165 + title_url?: string; 166 + twitter?: string; 167 + website?: string; 168 + } 169 + 170 + type Count = { 171 + start_date: string; 172 + count: number; 173 + }; 174 + 175 + interface UserStatistics { 176 + count_100: number; 177 + count_300: number; 178 + count_50: number; 179 + count_miss: number; 180 + level: { 181 + current: number; 182 + progress: number; 183 + }; 184 + global_rank?: number; 185 + global_rank_exp?: number; 186 + pp: number; 187 + pp_exp: number; 188 + ranked_score: number; 189 + hit_accuracy: number; 190 + play_count: number; 191 + play_time: number; 192 + total_score: number; 193 + total_hits: number; 194 + maximum_combo: number; 195 + replays_watched_by_others: number; 196 + is_ranked: boolean; 197 + grade_counts: { 198 + ss: number; 199 + ssh: number; 200 + s: number; 201 + sh: number; 202 + a: number; 203 + }; 204 + country_rank?: number; 205 + rank: { 206 + country: number; 207 + }; 208 + } 209 + 210 + interface Beatmapset { 211 + artist: string; 212 + artist_unicode: string; 213 + covers: Covers; 214 + creator: string; 215 + favourite_count: number; 216 + id: number; 217 + nsfw: boolean; 218 + offset: number; 219 + play_count: number; 220 + preview_url: string; 221 + source: string; 222 + status: string; 223 + spotlight: boolean; 224 + title: string; 225 + title_unicode: string; 226 + user_id: string; 227 + // optional 228 + beatmaps?: (Beatmap | BeatmapExtended)[]; 229 + } 230 + interface Covers { 231 + cover: string; 232 + 'cover@2x': string; 233 + card: string; 234 + 'card@2x': string; 235 + list: string; 236 + 'list@2x': string; 237 + slimcover: string; 238 + 'slimcover@2x': string; 239 + } 240 + 241 + interface Beatmap { 242 + beatmapset_id: number; 243 + difficulty_rating: number; 244 + id: number; 245 + mode: Ruleset; 246 + status: string; 247 + total_length: number; 248 + user_id: number; 249 + version: string; 250 + beatmapset?: BeatmapSet; 251 + } 252 + 253 + interface BeatmapExtended extends Beatmap { 254 + accuracy: number; // OD 255 + ar: number; 256 + bpm: number; 257 + convert: boolean; 258 + count_circles: number; 259 + count_sliders: number; 260 + count_spinners: number; 261 + cs: number; 262 + deleted_at?: string; 263 + drain: number; 264 + hit_length: number; 265 + is_scoreable: boolean; 266 + last_updated: string; 267 + mode_int: number; 268 + passcount: number; 269 + playcount: number; 270 + status: number; 271 + url: string; 272 + max_combo: number; 273 + } 274 + }
+80
src/lib/server/osu.ts
··· 1 + import type { Osu } from '../osu'; 2 + 3 + const BASE_URL = 'https://osu.ppy.sh/api/v2'; 4 + const AUTH_URL = 'https://osu.ppy.sh/oauth/authorize'; 5 + const TOKEN_URL = 'https://osu.ppy.sh/oauth/token'; 6 + 7 + export const createAuthUrl = ( 8 + client_id: string, 9 + redirect_uri: string, 10 + scopes: Osu.AuthScope[], 11 + state?: string 12 + ) => { 13 + const params = new URLSearchParams(); 14 + params.set('client_id', client_id); 15 + params.set('scope', scopes.join(' ')); 16 + params.set('response_type', 'code'); 17 + params.set('redirect_uri', redirect_uri); 18 + if (state) { 19 + params.set('state', state); 20 + } 21 + 22 + return `${AUTH_URL}?${params.toString()}`; 23 + }; 24 + 25 + export const exchangeAuthCode = async ( 26 + code: string, 27 + client_id: string, 28 + client_secret: string, 29 + redirect_uri: string 30 + ): Promise<Osu.AuthorizationCodeTokenResponse> => { 31 + const body = { 32 + client_id, 33 + client_secret, 34 + code, 35 + grant_type: 'authorization_code', 36 + redirect_uri 37 + }; 38 + 39 + const tokenReqUrl = `${TOKEN_URL}`; 40 + const tokenReq = await fetch(tokenReqUrl, { 41 + headers: { 42 + Accept: 'application/json', 43 + 'Content-Type': 'application/json' 44 + }, 45 + method: 'POST', 46 + body: JSON.stringify(body) 47 + }); 48 + return tokenReq.json(); 49 + }; 50 + 51 + export const getMe = async (access_token: string): Promise<Osu.User> => { 52 + const userReq = await fetch(`${BASE_URL}/me`, { 53 + headers: { 54 + 'Content-Type': 'application/json', 55 + Accept: 'application/json', 56 + Authorization: `Bearer ${access_token}` 57 + } 58 + }); 59 + return userReq.json(); 60 + }; 61 + 62 + // This is a very basic beatmap fetcher 63 + // Something else should be used in the future to increase 64 + // The quality of the maps. 65 + export const getBeatmaps = async (min_sr: number, max_sr: number, access_token: string) => { 66 + const params = new URLSearchParams(); 67 + params.set('q', `stars>${min_sr} stars<${max_sr}`); 68 + 69 + const beatmapSets = await fetch(`${BASE_URL}/beatmapsets/search?${params.toString()}`, { 70 + headers: { 71 + 'Content-Type': 'application/json', 72 + Accept: 'application/json', 73 + Authorization: `Bearer ${access_token}` 74 + } 75 + }); 76 + const response = await beatmapSets.json(); 77 + const beatmapsets: Osu.Beatmapset[] = response.beatmapsets; 78 + 79 + return beatmapsets; 80 + };
+5
src/params/redirect.ts
··· 1 + import type { ParamMatcher } from '@sveltejs/kit'; 2 + 3 + export const match: ParamMatcher = (param: string) => { 4 + return false; 5 + };
+11
src/routes/(api)/api/get_game/+server.ts
··· 1 + import { getGame } from '$lib/drizzle/queries/game'; 2 + import { error, json } from '@sveltejs/kit'; 3 + import type { RequestHandler } from './$types'; 4 + import { StatusCodes } from '$lib/StatusCodes'; 5 + 6 + export const GET: RequestHandler = async ({ url }) => { 7 + const id = url.searchParams.get('id'); 8 + if (!id) error(StatusCodes.BAD_REQUEST); 9 + const game = await getGame(id); 10 + return json(game); 11 + };
+14
src/routes/(api)/api/join_game/+server.ts
··· 1 + import { error, json } from "@sveltejs/kit"; 2 + import type { RequestHandler } from "./$types"; 3 + import { StatusCodes } from "$lib/StatusCodes"; 4 + import { joinGame } from "$lib/drizzle/queries/gameuser"; 5 + 6 + 7 + export const GET: RequestHandler = async ({ locals, url }) => { 8 + if (!locals.user) error(StatusCodes.UNAUTHORIZED); 9 + const game_id = url.searchParams.get('id') 10 + const team = url.searchParams.get('team') 11 + 12 + if (!game_id || !team) error(StatusCodes.BAD_REQUEST); 13 + return json(await joinGame(game_id, locals.user.id, team)) 14 + }
+14
src/routes/(api)/api/leave_game/+server.ts
··· 1 + import { error, json } from "@sveltejs/kit"; 2 + import type { RequestHandler } from "./$types"; 3 + import { StatusCodes } from "$lib/StatusCodes"; 4 + import { leaveGame } from "$lib/drizzle/queries/gameuser"; 5 + 6 + 7 + export const GET: RequestHandler = async ({ locals, url }) => { 8 + if (!locals.user) error(StatusCodes.UNAUTHORIZED); 9 + const game_id = url.searchParams.get('id') 10 + 11 + if (!game_id) error(StatusCodes.BAD_REQUEST); 12 + const gameUser = await leaveGame(game_id, locals.user.id); 13 + return json(gameUser); 14 + }
+3
src/routes/+layout.server.ts
··· 1 + export const load = ({ locals }) => { 2 + return { user: locals.user }; 3 + };
+19
src/routes/+layout.svelte
··· 1 + <script lang="ts"> 2 + import '../app.css'; 3 + import Footer from '$lib/components/Footer.svelte'; 4 + import Header from '$lib/components/Header.svelte'; 5 + import type { PageData } from './$types'; 6 + 7 + export let data: PageData; 8 + const user = data.user; 9 + </script> 10 + 11 + <header class="bg-zinc-800 shadow"> 12 + <Header {user} /> 13 + </header> 14 + <main class="relative min-h-full p-4"> 15 + <slot /> 16 + </main> 17 + <footer class="bg-zinc-800"> 18 + <Footer /> 19 + </footer>
+5
src/routes/+page.svelte
··· 1 + <script lang="ts"> 2 + </script> 3 + 4 + <h1>Welcome to SvelteKit</h1> 5 + <p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
+60
src/routes/auth/callback/osu/+server.ts
··· 1 + import { redirect } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { StatusCodes } from '$lib/StatusCodes'; 4 + import { PUBLIC_OSU_CLIENT_ID } from '$env/static/public'; 5 + import { OSU_CLIENT_SECRET } from '$env/static/private'; 6 + import q from '$lib/drizzle/queries'; 7 + import { exchangeAuthCode, getMe } from '$lib/server/osu'; 8 + 9 + export const GET: RequestHandler = async ({ url, cookies }) => { 10 + const code = url.searchParams.get('code'); 11 + if (code == null) { 12 + redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); 13 + } 14 + 15 + const token = await exchangeAuthCode( 16 + code, 17 + PUBLIC_OSU_CLIENT_ID, 18 + OSU_CLIENT_SECRET, 19 + `${url.origin}/auth/callback/osu` 20 + ); 21 + const user = await getMe(token.access_token); 22 + await q.setUser({ 23 + id: user.id, 24 + 25 + username: user.username, 26 + country_code: user.country_code, 27 + country_name: user.country?.name ?? user.country_code, 28 + 29 + cover_url: user.cover?.url ?? '', 30 + avatar_url: user.avatar_url, 31 + 32 + pp: user.statistics.pp, 33 + 34 + global_rank: user.statistics.global_rank, 35 + country_rank: user.statistics.country_rank, 36 + 37 + total_score: user.statistics.total_score, 38 + ranked_score: user.statistics.ranked_score, 39 + hit_accuracy: user.statistics.hit_accuracy, 40 + play_count: user.statistics.play_count, 41 + level: user.statistics.level.current, 42 + level_progress: user.statistics.level.progress 43 + }); 44 + 45 + await q.setToken({ 46 + user_id: user.id, 47 + service: 'osu', 48 + expires_at: new Date(Date.now() + 86400 * 1000), 49 + ...token 50 + }); 51 + 52 + const session = await q.createSession(user.id); 53 + if (session != null) 54 + cookies.set('osu_bingo_token', session.token, { 55 + path: '/', 56 + expires: new Date(Date.now() + 60 * 60 * 24 * 1000) 57 + }); 58 + 59 + redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); 60 + };
+13
src/routes/auth/login/osu/+server.ts
··· 1 + import { redirect } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { StatusCodes } from '$lib/StatusCodes'; 4 + import { PUBLIC_OSU_CLIENT_ID } from '$env/static/public'; 5 + import { createAuthUrl } from '$lib/server/osu'; 6 + 7 + export const GET: RequestHandler = ({ url }) => { 8 + const redirectUrl = createAuthUrl(PUBLIC_OSU_CLIENT_ID, `${url.origin}/auth/callback/osu`, [ 9 + 'public', 10 + 'identify' 11 + ]); 12 + redirect(StatusCodes.TEMPORARY_REDIRECT, redirectUrl); 13 + };
+14
src/routes/auth/logout/+server.ts
··· 1 + import { redirect } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { StatusCodes } from '$lib/StatusCodes'; 4 + import q from '$lib/drizzle/queries'; 5 + 6 + export const GET: RequestHandler = ({ cookies }) => { 7 + const token = cookies.get('osu_bingo_token'); 8 + if (!token) { 9 + redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); 10 + } 11 + q.deleteSession(token); 12 + cookies.delete('osu_bingo_token', { path: '/' }); 13 + redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); 14 + };
+13
src/routes/beatmapsets/+server.ts
··· 1 + import q from '$lib/drizzle/queries'; 2 + import { getBeatmaps } from '$lib/server/osu'; 3 + import { error, json } from '@sveltejs/kit'; 4 + import type { RequestHandler } from './$types'; 5 + import { StatusCodes } from '$lib/StatusCodes'; 6 + 7 + export const GET: RequestHandler = async ({ locals }) => { 8 + if (!locals.user) return error(StatusCodes.UNAUTHORIZED); 9 + 10 + const token = await q.getToken(locals.user.id); 11 + const beatmaps = await getBeatmaps(4.0, 5.0, token.access_token); 12 + return json(beatmaps); 13 + };
+10
src/routes/game/[id]/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + import q from '$lib/drizzle/queries'; 3 + import { error } from '@sveltejs/kit'; 4 + import { StatusCodes } from '$lib/StatusCodes'; 5 + 6 + export const load: PageServerLoad = async ({ params }) => { 7 + const game = await q.getGameFromLinkId(params.id); 8 + if (!game) error(StatusCodes.NOT_FOUND); 9 + return { game }; 10 + };
+54
src/routes/game/[id]/+page.svelte
··· 1 + <script lang="ts"> 2 + import Announcer from '$lib/components/Announcer.svelte'; 3 + import BingoCard from '$lib/components/BingoCard.svelte'; 4 + import Chatbox from '$lib/components/Chatbox.svelte'; 5 + import SquareSidebar from '$lib/components/SquareSidebar.svelte'; 6 + import TeamList from '$lib/components/TeamList.svelte'; 7 + import type { PageData } from './$types'; 8 + 9 + export let data: PageData; 10 + 11 + let sidebar = false; 12 + let selectedSquare: Bingo.Card.FullSquare | null; 13 + 14 + const squareclick = (square: CustomEvent<Bingo.Card.FullSquare>) => { 15 + if (!sidebar) { 16 + selectedSquare = square.detail; 17 + sidebar = true; 18 + return; 19 + } 20 + selectedSquare = null; 21 + setTimeout(() => (selectedSquare = square.detail), 300); 22 + }; 23 + </script> 24 + 25 + <section class="grid"> 26 + <article 27 + class="row-start-1 flex flex-col items-center rounded-xl p-4 gap-y-2 row-end-2 col-start-1 col-end-2 bg-[rgba(0,0,0,0.5)]" 28 + > 29 + <Announcer game={data.game} user={data.user} /> 30 + {#if data.game.squares} 31 + <BingoCard on:squareclick={squareclick} card={data.game} /> 32 + {:else} 33 + <TeamList /> 34 + {/if} 35 + </article> 36 + {#if sidebar} 37 + <article class="pl-4 relative row-start-1 row-end-3 col-start-2 col-end-3"> 38 + <SquareSidebar on:close={() => (sidebar = false)} square={selectedSquare} /> 39 + </article> 40 + {/if} 41 + <article class="pt-4 row-start-2 row-end-3 col-start-1 col-end-2"> 42 + <Chatbox /> 43 + </article> 44 + </section> 45 + 46 + <style> 47 + section.grid { 48 + /* screen height - heading - padding */ 49 + height: calc(100vh - 3rem - 2rem); 50 + max-width: calc(100vw - 2rem); 51 + grid-template-columns: 2fr fit-content(500px); 52 + grid-template-rows: repeat(2, 2fr); 53 + } 54 + </style>
+13
src/routes/new/+server.ts
··· 1 + import q from '$lib/drizzle/queries'; 2 + import { json, redirect } from '@sveltejs/kit'; 3 + import type { RequestHandler } from './$types'; 4 + import { StatusCodes } from '$lib/StatusCodes'; 5 + 6 + export const GET: RequestHandler = async ({ locals }) => { 7 + if (!locals.user) redirect(StatusCodes.UNAUTHORIZED, '/'); 8 + 9 + const token = await q.getToken(locals.user.id); 10 + const game = await q.newGame(); 11 + await q.fillSquares(game.id, 4.5, 5.5, token.access_token); 12 + return json(await q.getGame(game.id)); 13 + };
static/android-chrome-192x192.png

This is a binary file and will not be displayed.

static/android-chrome-512x512.png

This is a binary file and will not be displayed.

static/apple-touch-icon.png

This is a binary file and will not be displayed.

static/favicon-16x16.png

This is a binary file and will not be displayed.

static/favicon-32x32.png

This is a binary file and will not be displayed.

static/favicon.ico

This is a binary file and will not be displayed.

static/favicon.png

This is a binary file and will not be displayed.

static/fonts/Comfortaa-VariableFont_wght.ttf

This is a binary file and will not be displayed.

static/fonts/RoundorNonCommercial.otf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-Black.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-BlackItalic.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-Bold.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-BoldItalic.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-ExtraBold.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-ExtraBoldItalic.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-ExtraLight.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-ExtraLightItalic.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-Italic.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-Light.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-LightItalic.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-Medium.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-MediumItalic.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-Regular.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-SemiBold.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-SemiBoldItalic.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-Thin.ttf

This is a binary file and will not be displayed.

static/fonts/montserrat/Montserrat-ThinItalic.ttf

This is a binary file and will not be displayed.

+31
static/icon.svg
··· 1 + <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 + <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> 3 + <svg width="100%" height="100%" viewBox="0 0 200 200" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> 4 + <g transform="matrix(0.829326,0,0,0.829326,2.85895,5.16828)"> 5 + <path d="M74.587,25.953C74.587,17.52 67.741,10.673 59.307,10.673L28.748,10.673C20.315,10.673 13.469,17.52 13.469,25.953L13.469,56.512C13.469,64.945 20.315,71.792 28.748,71.792L59.307,71.792C67.741,71.792 74.587,64.945 74.587,56.512L74.587,25.953Z" style="fill:rgb(245,194,231);"/> 6 + </g> 7 + <g transform="matrix(0.829326,0,0,0.829326,63.4865,5.16828)"> 8 + <path d="M74.587,25.953C74.587,17.52 67.741,10.673 59.307,10.673L28.748,10.673C20.315,10.673 13.469,17.52 13.469,25.953L13.469,56.512C13.469,64.945 20.315,71.792 28.748,71.792L59.307,71.792C67.741,71.792 74.587,64.945 74.587,56.512L74.587,25.953Z" style="fill:rgb(245,194,231);"/> 9 + </g> 10 + <g transform="matrix(0.829326,0,0,0.829326,124.114,5.16828)"> 11 + <path d="M74.587,25.953C74.587,17.52 67.741,10.673 59.307,10.673L28.748,10.673C20.315,10.673 13.469,17.52 13.469,25.953L13.469,56.512C13.469,64.945 20.315,71.792 28.748,71.792L59.307,71.792C67.741,71.792 74.587,64.945 74.587,56.512L74.587,25.953Z" style="fill:rgb(245,194,231);"/> 12 + </g> 13 + <g transform="matrix(0.829326,0,0,0.829326,2.85896,65.7975)"> 14 + <path d="M74.587,25.953C74.587,17.52 67.741,10.673 59.307,10.673L28.748,10.673C20.315,10.673 13.469,17.52 13.469,25.953L13.469,56.512C13.469,64.945 20.315,71.792 28.748,71.792L59.307,71.792C67.741,71.792 74.587,64.945 74.587,56.512L74.587,25.953Z" style="fill:rgb(245,194,231);"/> 15 + </g> 16 + <g transform="matrix(0.829326,0,0,0.829326,63.4865,65.7975)"> 17 + <path d="M74.587,25.953C74.587,17.52 67.741,10.673 59.307,10.673L28.748,10.673C20.315,10.673 13.469,17.52 13.469,25.953L13.469,56.512C13.469,64.945 20.315,71.792 28.748,71.792L59.307,71.792C67.741,71.792 74.587,64.945 74.587,56.512L74.587,25.953Z" style="fill:white;"/> 18 + </g> 19 + <g transform="matrix(0.829326,0,0,0.829326,124.114,65.7975)"> 20 + <path d="M74.587,25.953C74.587,17.52 67.741,10.673 59.307,10.673L28.748,10.673C20.315,10.673 13.469,17.52 13.469,25.953L13.469,56.512C13.469,64.945 20.315,71.792 28.748,71.792L59.307,71.792C67.741,71.792 74.587,64.945 74.587,56.512L74.587,25.953Z" style="fill:rgb(245,194,231);"/> 21 + </g> 22 + <g transform="matrix(0.829326,0,0,0.829326,2.85896,126.441)"> 23 + <path d="M74.587,25.953C74.587,17.52 67.741,10.673 59.307,10.673L28.748,10.673C20.315,10.673 13.469,17.52 13.469,25.953L13.469,56.512C13.469,64.945 20.315,71.792 28.748,71.792L59.307,71.792C67.741,71.792 74.587,64.945 74.587,56.512L74.587,25.953Z" style="fill:rgb(245,194,231);"/> 24 + </g> 25 + <g transform="matrix(0.829326,0,0,0.829326,63.4865,126.441)"> 26 + <path d="M74.587,25.953C74.587,17.52 67.741,10.673 59.307,10.673L28.748,10.673C20.315,10.673 13.469,17.52 13.469,25.953L13.469,56.512C13.469,64.945 20.315,71.792 28.748,71.792L59.307,71.792C67.741,71.792 74.587,64.945 74.587,56.512L74.587,25.953Z" style="fill:rgb(245,194,231);"/> 27 + </g> 28 + <g transform="matrix(0.829326,0,0,0.829326,124.114,123.686)"> 29 + <path d="M74.587,25.953C74.587,17.52 67.741,10.673 59.307,10.673L28.748,10.673C20.315,10.673 13.469,17.52 13.469,25.953L13.469,56.512C13.469,64.945 20.315,71.792 28.748,71.792L59.307,71.792C67.741,71.792 74.587,64.945 74.587,56.512L74.587,25.953Z" style="fill:rgb(245,194,231);"/> 30 + </g> 31 + </svg>
+11
static/site.webmanifest
··· 1 + { 2 + "name": "", 3 + "short_name": "", 4 + "icons": [ 5 + { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, 6 + { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } 7 + ], 8 + "theme_color": "#ffffff", 9 + "background_color": "#ffffff", 10 + "display": "standalone" 11 + }
+18
svelte.config.js
··· 1 + import adapter from '@sveltejs/adapter-auto'; 2 + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 + 4 + /** @type {import('@sveltejs/kit').Config} */ 5 + const config = { 6 + // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 + // for more information about preprocessors 8 + preprocess: vitePreprocess(), 9 + 10 + kit: { 11 + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 + // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 + adapter: adapter() 15 + } 16 + }; 17 + 18 + export default config;
+94
tailwind.config.js
··· 1 + /** @type {import('tailwindcss').Config} */ 2 + export default { 3 + content: ["./src/**/*.{html,js,ts,svelte}"], 4 + theme: { 5 + extend: { 6 + fontFamily: { 7 + sans: ['Montserrat', 'sans-serif'], 8 + display: [ 'Roundor', 'display', 'sans-serif'], 9 + rounded: [ 'Comfortaa', 'sans-serif'] 10 + }, 11 + colors: { 12 + base: { 13 + 50: '#f6f5f5', 14 + 100: '#e7e6e6', 15 + 200: '#d1d0d0', 16 + 300: '#b1afb0', 17 + 400: '#898788', 18 + 500: '#6e6c6d', 19 + 600: '#5e5c5d', 20 + 700: '#504e4e', 21 + 800: '#464444', 22 + 900: '#3d3c3c', 23 + 950: '#313030' 24 + }, 25 + highlight: { 26 + 50: '#f0fdfc', 27 + 100: '#cafdf8', 28 + 200: '#96f9f1', 29 + 300: '#59efe8', 30 + 400: '#27dad8', 31 + 500: '#0eb8b9', 32 + 600: '#089599', 33 + 700: '#0b767a', 34 + 800: '#0e5d61', 35 + 900: '#114d50', 36 + 950: '#032c30' 37 + }, 38 + pink: { 39 + 50: '#fcf3f8', 40 + 100: '#fae9f3', 41 + 200: '#f8d2e8', 42 + 300: '#f3aed3', 43 + 400: '#ee92c2', 44 + 500: '#e05499', 45 + 600: '#ce3478', 46 + 700: '#b2245f', 47 + 800: '#93214f', 48 + 900: '#7b2045', 49 + 950: '#4b0c25' 50 + }, 51 + amber: { 52 + 50: '#fdf5f3', 53 + 100: '#fde8e3', 54 + 200: '#fbd5cd', 55 + 300: '#f7b8aa', 56 + 400: '#f18e78', 57 + 500: '#e6694d', 58 + 600: '#d5573b', 59 + 700: '#b13e24', 60 + 800: '#923622', 61 + 900: '#7a3222', 62 + 950: '#42170d' 63 + }, 64 + purple: { 65 + 50: '#f9f7fb', 66 + 100: '#f4f1f6', 67 + 200: '#e9e5ef', 68 + 300: '#d5c9df', 69 + 400: '#c5b4d1', 70 + 500: '#af94be', 71 + 600: '#9e7bac', 72 + 700: '#8b6998', 73 + 800: '#755780', 74 + 900: '#604969', 75 + 950: '#3f2f46' 76 + }, 77 + blue: { 78 + 50: '#eef3ff', 79 + 100: '#dfe8ff', 80 + 200: '#c6d4ff', 81 + 300: '#a3b7fe', 82 + 400: '#7f90fa', 83 + 500: '#7079f5', 84 + 600: '#4443e8', 85 + 700: '#3935cd', 86 + 800: '#2f2ea5', 87 + 900: '#2c2d83', 88 + 950: '#1b1a4c' 89 + } 90 + } 91 + } 92 + }, 93 + plugins: [] 94 + };
+19
tsconfig.json
··· 1 + { 2 + "extends": "./.svelte-kit/tsconfig.json", 3 + "compilerOptions": { 4 + "allowJs": true, 5 + "checkJs": true, 6 + "esModuleInterop": true, 7 + "forceConsistentCasingInFileNames": true, 8 + "resolveJsonModule": true, 9 + "skipLibCheck": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "moduleResolution": "bundler" 13 + } 14 + // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 + // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 16 + // 17 + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 + // from the referenced tsconfig.json - TypeScript does not merge them in 19 + }
+6
vite.config.ts
··· 1 + import { sveltekit } from '@sveltejs/kit/vite'; 2 + import { defineConfig } from 'vite'; 3 + 4 + export default defineConfig({ 5 + plugins: [sveltekit()] 6 + });