add good looking lobby ui with websocket support with claude sonnet 3.7

This commit is contained in:
Pablu23
2025-10-07 14:55:30 +02:00
parent 81ad47960a
commit eb6e85a099
19 changed files with 1477 additions and 86 deletions

View 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>

View 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>

View 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
View 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;
}

View 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
};
}