add good looking lobby ui with websocket support with claude sonnet 3.7
This commit is contained in:
31
drizzle/0000_numerous_chronomancer.sql
Normal file
31
drizzle/0000_numerous_chronomancer.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
CREATE TABLE `lobbys` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`host_email` text,
|
||||||
|
FOREIGN KEY (`host_email`) REFERENCES `users`(`email`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `sessions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`access_token` text,
|
||||||
|
`refresh_token` text,
|
||||||
|
`user_email` text,
|
||||||
|
FOREIGN KEY (`user_email`) REFERENCES `users`(`email`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `auth_states` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`code_verifier` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_in_lobby` (
|
||||||
|
`user_email` text NOT NULL,
|
||||||
|
`lobby_id` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`user_email`, `lobby_id`),
|
||||||
|
FOREIGN KEY (`user_email`) REFERENCES `users`(`email`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`lobby_id`) REFERENCES `lobbys`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`email` text PRIMARY KEY NOT NULL,
|
||||||
|
`username` text
|
||||||
|
);
|
||||||
215
drizzle/meta/0000_snapshot.json
Normal file
215
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "6bf15c3f-ef71-4979-b04a-1a93b3274a23",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"lobbys": {
|
||||||
|
"name": "lobbys",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"host_email": {
|
||||||
|
"name": "host_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"lobbys_host_email_users_email_fk": {
|
||||||
|
"name": "lobbys_host_email_users_email_fk",
|
||||||
|
"tableFrom": "lobbys",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"host_email"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_email": {
|
||||||
|
"name": "user_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_email_users_email_fk": {
|
||||||
|
"name": "sessions_user_email_users_email_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_email"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"auth_states": {
|
||||||
|
"name": "auth_states",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"code_verifier": {
|
||||||
|
"name": "code_verifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_in_lobby": {
|
||||||
|
"name": "user_in_lobby",
|
||||||
|
"columns": {
|
||||||
|
"user_email": {
|
||||||
|
"name": "user_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lobby_id": {
|
||||||
|
"name": "lobby_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_in_lobby_user_email_users_email_fk": {
|
||||||
|
"name": "user_in_lobby_user_email_users_email_fk",
|
||||||
|
"tableFrom": "user_in_lobby",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_email"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_in_lobby_lobby_id_lobbys_id_fk": {
|
||||||
|
"name": "user_in_lobby_lobby_id_lobbys_id_fk",
|
||||||
|
"tableFrom": "user_in_lobby",
|
||||||
|
"tableTo": "lobbys",
|
||||||
|
"columnsFrom": [
|
||||||
|
"lobby_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"user_in_lobby_user_email_lobby_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"user_email",
|
||||||
|
"lobby_id"
|
||||||
|
],
|
||||||
|
"name": "user_in_lobby_user_email_lobby_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1758887320368,
|
||||||
|
"tag": "0000_numerous_chronomancer",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
694
package-lock.json
generated
694
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,9 @@
|
|||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"better-sqlite3": "^11.8.0",
|
"better-sqlite3": "^11.8.0",
|
||||||
"drizzle-orm": "^0.40.0"
|
"drizzle-orm": "^0.40.0",
|
||||||
|
"tailwindcss": "^4.1.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/app.css
Normal file
1
src/app.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
33
src/app.d.ts
vendored
33
src/app.d.ts
vendored
@@ -18,4 +18,35 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { };
|
export interface Player {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
isHost: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameMode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Playlist {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameSettings {
|
||||||
|
maxPlayers: number;
|
||||||
|
gameMode: string;
|
||||||
|
selectedPlaylist: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebSocketMessage = {
|
||||||
|
type: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
|
||||||
|
};
|
||||||
|
|||||||
158
src/lib/components/GameSettings.svelte
Normal file
158
src/lib/components/GameSettings.svelte
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { GameMode, Playlist, Settings } from '$lib/types';
|
||||||
|
import PlaylistSelector from './PlaylistSelector.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
settings,
|
||||||
|
gameModes,
|
||||||
|
playlists,
|
||||||
|
isHost,
|
||||||
|
onUpdate
|
||||||
|
}: {
|
||||||
|
settings: Settings;
|
||||||
|
gameModes: GameMode[];
|
||||||
|
playlists: Playlist[];
|
||||||
|
isHost: boolean;
|
||||||
|
onUpdate: (settings: Settings) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let localSettings = $state<Settings>({
|
||||||
|
maxPlayers: settings.maxPlayers,
|
||||||
|
gameMode: settings.gameMode,
|
||||||
|
selectedPlaylistId: settings.selectedPlaylistId
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Update local settings when change
|
||||||
|
localSettings = {
|
||||||
|
maxPlayers: settings.maxPlayers,
|
||||||
|
gameMode: settings.gameMode,
|
||||||
|
selectedPlaylistId: settings.selectedPlaylistId
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateMaxPlayers(value: number) {
|
||||||
|
if (value >= 2 && value <= 16) {
|
||||||
|
localSettings.maxPlayers = value;
|
||||||
|
onUpdate(localSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGameMode(modeId: string) {
|
||||||
|
localSettings.gameMode = modeId;
|
||||||
|
onUpdate(localSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlaylist(playlistId: number) {
|
||||||
|
localSettings.selectedPlaylistId = playlistId;
|
||||||
|
onUpdate(localSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedPlaylist = $derived(
|
||||||
|
playlists.find((p: Playlist) => p.id === localSettings.selectedPlaylistId)
|
||||||
|
);
|
||||||
|
let selectedGameMode = $derived(gameModes.find((m: GameMode) => m.id === localSettings.gameMode));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-5 border border-gray-100">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-800 mb-4">Game Settings</h2>
|
||||||
|
|
||||||
|
{#if isHost}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Max Players</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
|
<button
|
||||||
|
class="flex-shrink-0 bg-gray-200 hover:bg-gray-300 text-gray-600 h-10 w-10 rounded-l flex items-center justify-center"
|
||||||
|
onclick={() => updateMaxPlayers(localSettings.maxPlayers - 1)}
|
||||||
|
disabled={localSettings.maxPlayers <= 2}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="h-10 bg-gray-100 text-gray-800 font-medium px-4 flex items-center justify-center min-w-[50px]"
|
||||||
|
>
|
||||||
|
{localSettings.maxPlayers}
|
||||||
|
</span>
|
||||||
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
|
<button
|
||||||
|
class="flex-shrink-0 bg-gray-200 hover:bg-gray-300 text-gray-600 h-10 w-10 rounded-r flex items-center justify-center"
|
||||||
|
onclick={() => updateMaxPlayers(localSettings.maxPlayers + 1)}
|
||||||
|
disabled={localSettings.maxPlayers >= 16}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http:/www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Game Mode</label>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
{#each gameModes as mode (mode.id)}
|
||||||
|
<button
|
||||||
|
class="p-3 rounded-lg text-center transition-colors {localSettings.gameMode ===
|
||||||
|
mode.id
|
||||||
|
? 'bg-blue-100 border-blue-200 text-blue-700'
|
||||||
|
: 'bg-gray-50 border-gray-100 text-gray-700'} border"
|
||||||
|
onclick={() => updateGameMode(mode.id)}
|
||||||
|
>
|
||||||
|
{mode.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Playlist</label>
|
||||||
|
<PlaylistSelector
|
||||||
|
{playlists}
|
||||||
|
selectedPlaylistId={localSettings.selectedPlaylistId}
|
||||||
|
onSelect={updatePlaylist}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-600 italic mb-4">The host is configuring game settings...</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-gray-50 p-3 rounded-md">
|
||||||
|
<p class="text-sm text-gray-500 mb-1">Max Players</p>
|
||||||
|
<p class="font-medium">{settings.maxPlayers}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 p-3 rounded-md">
|
||||||
|
<p class="text-sm text-gray-500 mb-1">Game Mode</p>
|
||||||
|
<p class="font-medium">{selectedGameMode?.name || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 p-3 rounded-md">
|
||||||
|
<p class="text-sm text-gray-500 mb-1">Playlist</p>
|
||||||
|
<p class="font-medium">{selectedPlaylist?.name || 'Unknown'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
41
src/lib/components/PlayerList.svelte
Normal file
41
src/lib/components/PlayerList.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Player } from '$lib/types';
|
||||||
|
|
||||||
|
let {players = [], maxPlayers = 8 }: { players: Player[], maxPlayers: number} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-5 border border-gray-100">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-800">Players</h2>
|
||||||
|
<span class="text-sm text-gray-500">{players.length}/{maxPlayers}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each players as player (player.id)}
|
||||||
|
<li class="flex items-center p-2 rounded-md {player.isHost ? 'bg-blue-50' : 'bg-gray-50'}">
|
||||||
|
<div class="bg-gradient-to-br from-blue-500 to-indigo-600 h-8 w-8 rounded-full flex items-center justify-center text-white font-medium text-sm">
|
||||||
|
{player.name.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span class="ml-2 text-gray-800 font-medium">{player.name}</span>
|
||||||
|
|
||||||
|
{#if player.isHost}
|
||||||
|
<span class="ml-auto flex items-center text-blue-600 text-sm font-medium">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M9.504 5.071a.5.5 0 01.992 0l1.5 10a.5.5 0 01-.992 0l-1.5-10zM8 9a1 1 0 100 2h4a1 1 0 100-2H8z" />
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm0-2a6 6 0 100-12 6 6 0 000 12z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Host
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if players.length < maxPlayers}
|
||||||
|
{#each Array(maxPlayers - players.length) as _}
|
||||||
|
<li class="p-2 rounded-md border border-dashed border-gray-200 text-center text-gray-400 text-sm">
|
||||||
|
Waiting for player...
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
55
src/lib/components/PlaylistSelector.svelte
Normal file
55
src/lib/components/PlaylistSelector.svelte
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Playlist } from '$lib/types';
|
||||||
|
|
||||||
|
const props = $props<{
|
||||||
|
playlists: Playlist[];
|
||||||
|
selectedPlaylistId: number;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
|
||||||
|
let filteredPlaylists = $derived(props.playlists.filter((playlist: Playlist) =>
|
||||||
|
playlist.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-5 border border-gray-100">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-800 mb-4">Select Playlist</h2>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search playlists..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 max-h-64 overflow-y-auto p-1">
|
||||||
|
{#each filteredPlaylists as playlist (playlist.id)}
|
||||||
|
<button
|
||||||
|
class="flex flex-col items-center text-left p-3 rounded-lg transition-all {props.selectedPlaylistId === playlist.id ? 'bg-blue-50 border-blue-200 border' : 'bg-gray-50 hover:bg-gray-100 border-gray-100 border'}"
|
||||||
|
onclick={() => props.onSelect(playlist.id)}
|
||||||
|
>
|
||||||
|
<div class="w-full aspect-square mb-2 rounded-md overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={playlist.imageUrl}
|
||||||
|
alt={playlist.name}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<h3 class="font-medium text-gray-900 truncate">{playlist.name}</h3>
|
||||||
|
<p class="text-sm text-gray-500">{playlist.songCount} songs</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if filteredPlaylists.length === 0}
|
||||||
|
<div class="col-span-3 py-8 text-center text-gray-500">
|
||||||
|
No playlists found matching "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
28
src/lib/types.ts
Normal file
28
src/lib/types.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface Player {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
isHost: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameMode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Playlist {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
maxPlayers: number;
|
||||||
|
gameMode: string;
|
||||||
|
selectedPlaylistId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketMessage {
|
||||||
|
type: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
83
src/lib/websocketClient.ts
Normal file
83
src/lib/websocketClient.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Player, Settings, WebSocketMessage } from './types';
|
||||||
|
|
||||||
|
export function createWebSocketClient() {
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
|
||||||
|
let connected = false;
|
||||||
|
let players: Player[] = [];
|
||||||
|
let gameSettings: Settings = {
|
||||||
|
maxPlayers: 8,
|
||||||
|
gameMode: 'classic',
|
||||||
|
selectedPlaylistId: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
function connect(url: string): void {
|
||||||
|
if (socket) socket.close();
|
||||||
|
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
connected = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
connected = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data) as WebSocketMessage;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'playerJoin':
|
||||||
|
players = [...players, message.player];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'playerLeave':
|
||||||
|
players = players.filter(p => p.id !== message.playerId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'playerList':
|
||||||
|
players = message.players;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'settingsUpdate':
|
||||||
|
gameSettings = message.settings;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(message: WebSocketMessage): void {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify(message));
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot send message, WebSocket is not connected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect(): void {
|
||||||
|
if (socket) {
|
||||||
|
socket.close();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
connected = false;
|
||||||
|
players = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
players,
|
||||||
|
gameSettings,
|
||||||
|
connect,
|
||||||
|
sendMessage,
|
||||||
|
disconnect
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import "../app.css";
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<form method="POST" action="?/deleteUsers">
|
<form method="POST" action="?/deleteUsers">
|
||||||
<button type="submit">Delete all Users</button>
|
<button type="submit">Delete all Users</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<button type="submit" onclick={async () => await goto("/lobby/create")}>Create Lobby</button>
|
||||||
{/if}
|
{/if}
|
||||||
<!--
|
<!--
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { lobbysRelations, lobbysTable, usersInLobby, usersTable } from '$lib/server/db/schema';
|
import { lobbysTable, usersInLobby } from '$lib/server/db/schema';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
|
||||||
export async function POST({ request }) {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const userReq = await request.json();
|
const userReq = await request.json();
|
||||||
|
|
||||||
const userInLobby = (await db.$count(usersInLobby, eq(usersInLobby.userEmail, userReq.email))) > 0
|
const userInLobby = (await db.$count(usersInLobby, eq(usersInLobby.userEmail, userReq.email))) > 0
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { db } from "$lib/server/db";
|
import { db } from "$lib/server/db";
|
||||||
import { usersTable } from "$lib/server/db/schema";
|
import { usersTable } from "$lib/server/db/schema";
|
||||||
import { json } from "@sveltejs/kit";
|
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||||
|
|
||||||
export async function POST({ request }) {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const user = await request.json();
|
const user = await request.json();
|
||||||
const u: typeof usersTable.$inferInsert = {
|
const u: typeof usersTable.$inferInsert = {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
168
src/routes/lobby/+page.svelte
Normal file
168
src/routes/lobby/+page.svelte
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { createWebSocketClient } from '$lib/websocketClient';
|
||||||
|
import type { Settings, GameMode, Playlist } from '$lib/types';
|
||||||
|
import PlayerList from '$lib/components/PlayerList.svelte';
|
||||||
|
import GameSettings from '$lib/components/GameSettings.svelte';
|
||||||
|
|
||||||
|
// Create WebSocket client
|
||||||
|
let wsClient = $state(createWebSocketClient());
|
||||||
|
|
||||||
|
// Current user state
|
||||||
|
let isHost = $state(true); // Assume current user is host for this example
|
||||||
|
let lobbyCode = $state('GAME123');
|
||||||
|
|
||||||
|
// Available options for settings
|
||||||
|
let gameModes = $state<GameMode[]>([
|
||||||
|
{ id: 'classic', name: 'Classic Mode' },
|
||||||
|
{ id: 'team', name: 'Team Battle' },
|
||||||
|
{ id: 'speed', name: 'Speed Round' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
let playlists = $state<Playlist[]>([
|
||||||
|
{ id: 1, name: 'Pop Hits 2023', imageUrl: 'https://example.com/images/pop2023.jpg', songCount: 25 },
|
||||||
|
{ id: 2, name: 'Rock Classics', imageUrl: 'https://example.com/images/rock.jpg', songCount: 30 },
|
||||||
|
{ id: 3, name: '80s Mixtape', imageUrl: 'https://example.com/images/80s.jpg', songCount: 20 },
|
||||||
|
{ id: 4, name: 'Movie Soundtracks', imageUrl: 'https://example.com/images/movies.jpg', songCount: 15 },
|
||||||
|
{ id: 5, name: 'Hip Hop Essentials', imageUrl: 'https://example.com/images/hiphop.jpg', songCount: 40 },
|
||||||
|
{ id: 6, name: 'Indie Discoveries', imageUrl: 'https://example.com/images/indie.jpg', songCount: 35 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
function handleSettingsUpdate(settings: Settings) {
|
||||||
|
// Send updated settings to other players via websocket
|
||||||
|
wsClient.sendMessage({
|
||||||
|
type: 'settingsUpdate',
|
||||||
|
settings: settings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGame() {
|
||||||
|
wsClient.sendMessage({
|
||||||
|
type: 'startGame',
|
||||||
|
settings: wsClient.gameSettings
|
||||||
|
});
|
||||||
|
// In a real app, this would navigate to the game screen
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLobbyCode() {
|
||||||
|
navigator.clipboard.writeText(lobbyCode);
|
||||||
|
// Would show a toast notification in a real app
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveLobby() {
|
||||||
|
wsClient.disconnect();
|
||||||
|
// In a real app, you'd likely redirect to another page here
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize mock data for demo
|
||||||
|
function initializeMockData() {
|
||||||
|
// Simulate WebSocket connection and receiving initial player data
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log("Init mock data");
|
||||||
|
wsClient.players = [
|
||||||
|
{ id: 1, name: 'YourName', isHost: true },
|
||||||
|
{ id: 2, name: 'Player2', isHost: false },
|
||||||
|
{ id: 3, name: 'GamerX', isHost: false }
|
||||||
|
];
|
||||||
|
wsClient.connected = true;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// In a real app, connect to WebSocket server
|
||||||
|
// wsClient.connect('ws://example.com/game-lobby');
|
||||||
|
|
||||||
|
// For demo purposes, we'll simulate a connection
|
||||||
|
initializeMockData();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
// Clean up WebSocket connection when component is destroyed
|
||||||
|
wsClient.disconnect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-50 py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Game Lobby</h1>
|
||||||
|
<div class="mt-3 flex items-center justify-center">
|
||||||
|
<span class="text-gray-600 mr-2">Invite your friends using code:</span>
|
||||||
|
<span class="font-mono bg-white px-3 py-1.5 rounded-md border border-gray-200 text-blue-600 font-semibold">{lobbyCode}</span>
|
||||||
|
<button
|
||||||
|
class="ml-2 p-1.5 text-gray-500 hover:text-blue-600 transition-colors"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
on:click={copyLobbyCode}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
|
||||||
|
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Left column: Player list -->
|
||||||
|
<div class="md:col-span-1">
|
||||||
|
{#if wsClient.connected}
|
||||||
|
<PlayerList
|
||||||
|
players={wsClient.players}
|
||||||
|
maxPlayers={wsClient.gameSettings.maxPlayers}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-5 flex items-center justify-center">
|
||||||
|
<div class="flex items-center text-amber-600">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Connecting to lobby...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: Game settings and controls -->
|
||||||
|
<div class="md:col-span-2 space-y-6">
|
||||||
|
<GameSettings
|
||||||
|
settings={wsClient.gameSettings}
|
||||||
|
gameModes={gameModes}
|
||||||
|
playlists={playlists}
|
||||||
|
isHost={isHost}
|
||||||
|
onUpdate={handleSettingsUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2.5 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
on:click={leaveLobby}
|
||||||
|
>
|
||||||
|
Leave Lobby
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isHost}
|
||||||
|
<button
|
||||||
|
class="px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 flex items-center"
|
||||||
|
on:click={startGame}
|
||||||
|
>
|
||||||
|
<span>Start Game</span>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-1.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L12.586 11H5a1 1 0 110-2h7.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="text-gray-500 italic self-center flex items-center">
|
||||||
|
<svg class="animate-pulse h-5 w-5 mr-2 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
</svg>
|
||||||
|
Waiting for host to start...
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
onDestroy(() => console.log('left site'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>You are in lobby with ID: {data.lobby?.id}</h1>
|
<h1>You are in lobby with ID: {data.lobby?.id}</h1>
|
||||||
<h2>Youre username is: {data.username}</h2>
|
<h2>Your username is: {data.username}</h2>
|
||||||
|
|
||||||
<h2>Players in Lobby: </h2>
|
<h2>Players in Lobby:</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{#each data.lobby.usersInLobby as player (player.userEmail)}
|
{#each data.lobby.usersInLobby as player (player.userEmail)}
|
||||||
<li>{player.user.username}</li>
|
<li>{player.user.username}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()]
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
sveltekit()
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user