add good looking lobby ui with websocket support with claude sonnet 3.7
This commit is contained in:
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user