mirror of
https://github.com/HexCardGames/HexDeck.git
synced 2025-09-09 04:38:38 +02:00
🎉first frontend commit (WIP)
This commit is contained in:
398
frontend/src/components/ConnectRoom.svelte
Normal file
398
frontend/src/components/ConnectRoom.svelte
Normal file
@ -0,0 +1,398 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
Button,
|
||||
Spinner,
|
||||
InputAddon,
|
||||
ButtonGroup,
|
||||
Helper,
|
||||
} from "flowbite-svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { sessionStore } from "/stores/sessionStore";
|
||||
|
||||
let loading: "join" | "create" | false = false;
|
||||
let rejoinRoomCode = "";
|
||||
let rejoinRoomSessionData = {
|
||||
sessionToken: "",
|
||||
userId: "",
|
||||
};
|
||||
let joinRoomId = "";
|
||||
let join_error: string | false = false;
|
||||
let create_error: string | false = false;
|
||||
let inputRef: HTMLInputElement | null = null;
|
||||
|
||||
function formatInput(event: any) {
|
||||
let rawValue = event.target.value.replace(/\D/g, "");
|
||||
join_error = false;
|
||||
|
||||
if (rawValue.length > 6) {
|
||||
rawValue = rawValue.slice(0, 6);
|
||||
}
|
||||
|
||||
let formattedValue = rawValue
|
||||
.replace(/(\d{3})(\d{0,3})/, "$1-$2")
|
||||
.trim();
|
||||
|
||||
joinRoomId = formattedValue;
|
||||
|
||||
if (joinRoomId.length > 6) {
|
||||
requestJoinRoom();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: any) {
|
||||
if (event.key === "Backspace") {
|
||||
join_error = false;
|
||||
|
||||
let cursorPosition = event.target.selectionStart;
|
||||
|
||||
if (cursorPosition === 4) {
|
||||
// If cursor is at "-" position, delete the number before it
|
||||
joinRoomId = joinRoomId.slice(0, 2);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJoinRoom(joinCode = joinRoomId) {
|
||||
if (loading) return;
|
||||
loading = "join";
|
||||
join_error = false;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
// 5s timeout
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(`/api/room/join`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
JoinCode: joinCode.replaceAll("-", ""),
|
||||
UsernameProposal: "UsernameProposal",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const data: { StatusCode: string; Message: string } =
|
||||
await response.json();
|
||||
// TODO i18n here on StatusCode if not use Message
|
||||
if (["invalid_join_code"].includes(data?.StatusCode)) {
|
||||
join_error = "no_room_found";
|
||||
} else if (data?.Message) {
|
||||
join_error = data?.Message;
|
||||
} else {
|
||||
throw new Error("Server error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data: {
|
||||
SessionToken: string;
|
||||
PlayerId: string;
|
||||
Username: string;
|
||||
Permissions: any;
|
||||
} = await response.json();
|
||||
const SessionToken = data.SessionToken;
|
||||
const UserId = data.PlayerId;
|
||||
joinSession(SessionToken, UserId);
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
join_error = "timeout";
|
||||
} else {
|
||||
join_error = "request_failed";
|
||||
}
|
||||
console.error("Error joining room: ", error);
|
||||
} finally {
|
||||
loading = false;
|
||||
setTimeout(() => {
|
||||
focusInput();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestCreateRoom() {
|
||||
if (loading) return;
|
||||
loading = "create";
|
||||
create_error = false;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
// 5s timeout
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(`/api/room/create`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
UsernameProposal: "UsernameProposal",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Server error");
|
||||
}
|
||||
|
||||
const data:
|
||||
| {
|
||||
SessionToken: string;
|
||||
PlayerId: string;
|
||||
Username: string;
|
||||
Permissions: any;
|
||||
}
|
||||
| { error: string } = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const SessionToken = data.SessionToken;
|
||||
const UserId = data.PlayerId;
|
||||
sessionStore.connect(SessionToken, UserId);
|
||||
} else {
|
||||
create_error = data.error || "room_creation_failed";
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
create_error = "timeout";
|
||||
} else {
|
||||
create_error = String(error);
|
||||
}
|
||||
console.error("Error creating room:", error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
inputRef?.focus();
|
||||
}
|
||||
|
||||
function joinSession(sessionToken: string, userId: string) {
|
||||
try {
|
||||
sessionStore.connect(sessionToken, userId);
|
||||
} catch (error: any) {
|
||||
join_error = "request_failed";
|
||||
console.error("Error joining room session: ", error);
|
||||
} finally {
|
||||
loading = false;
|
||||
setTimeout(() => {
|
||||
focusInput();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSessionToken(
|
||||
sessionToken: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!sessionToken) return false;
|
||||
const params = new URLSearchParams({
|
||||
sessionToken: sessionToken,
|
||||
});
|
||||
const res = await fetch(`/api/check/session?${params}`);
|
||||
return res.status == 200;
|
||||
}
|
||||
|
||||
async function checkJoinCode(
|
||||
joinCode: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!joinCode) return false;
|
||||
const params = new URLSearchParams({
|
||||
JoinCode: joinCode,
|
||||
});
|
||||
const res = await fetch(`/api/check/joinCode?${params}`);
|
||||
return res.status == 200;
|
||||
}
|
||||
|
||||
async function checkSessionData() {
|
||||
const currentSessionData: {
|
||||
sessionToken?: string;
|
||||
userId?: string;
|
||||
joinCode?: string;
|
||||
} = JSON.parse(localStorage.getItem("currentSessionIds") || "{}");
|
||||
if (await checkSessionToken(currentSessionData.sessionToken)) {
|
||||
// Session is still valid
|
||||
rejoinRoomSessionData = currentSessionData as any;
|
||||
return;
|
||||
}
|
||||
if (await checkJoinCode(currentSessionData.joinCode)) {
|
||||
// joinCode is still valid
|
||||
rejoinRoomCode = currentSessionData.joinCode as string;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSessionData: {
|
||||
sessionToken?: string;
|
||||
userId?: string;
|
||||
joinCode?: string;
|
||||
} = JSON.parse(localStorage.getItem("lastSessionIds") || "{}");
|
||||
|
||||
if (await checkSessionToken(lastSessionData.sessionToken)) {
|
||||
// Session is still valid
|
||||
rejoinRoomSessionData = lastSessionData as any;
|
||||
return;
|
||||
}
|
||||
if (await checkJoinCode(lastSessionData.joinCode)) {
|
||||
// joinCode is still valid
|
||||
rejoinRoomCode = lastSessionData.joinCode as string;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// focus room code input on mount
|
||||
onMount(() => {
|
||||
focusInput();
|
||||
checkSessionData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
{#if rejoinRoomSessionData?.sessionToken}
|
||||
<div class="mb-6">
|
||||
<Button
|
||||
class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800"
|
||||
size="lg"
|
||||
disabled={loading && loading != "create"}
|
||||
on:click={() =>
|
||||
joinSession(
|
||||
rejoinRoomSessionData?.sessionToken,
|
||||
rejoinRoomSessionData?.userId,
|
||||
)}
|
||||
>
|
||||
{#if loading == "create"}
|
||||
<Spinner
|
||||
class="text-primary-350 dark:text-primary-200 me-3"
|
||||
size="4"
|
||||
/>
|
||||
{/if}
|
||||
{$_("landing_page.connect_room.rejoin_last_room")}
|
||||
</Button>
|
||||
{#if create_error}
|
||||
<Helper class="mt-2">
|
||||
<span class="text-red-900 dark:text-red-300 text-sm">
|
||||
{$_(`error_messages.${create_error}`, {
|
||||
default: $_("error_messages.error_message", {
|
||||
values: { error_message: create_error },
|
||||
}),
|
||||
})}
|
||||
</span>
|
||||
</Helper>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full text-center mb-4">
|
||||
{$_("landing_page.connect_room.or")}
|
||||
</div>
|
||||
{/if}
|
||||
{#if rejoinRoomCode}
|
||||
<div class="mb-6">
|
||||
<Button
|
||||
class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800"
|
||||
size="lg"
|
||||
disabled={loading && loading != "create"}
|
||||
on:click={() => requestJoinRoom(rejoinRoomCode)}
|
||||
>
|
||||
{#if loading == "create"}
|
||||
<Spinner
|
||||
class="text-primary-350 dark:text-primary-200 me-3"
|
||||
size="4"
|
||||
/>
|
||||
{/if}
|
||||
{$_("landing_page.connect_room.join_last_room")}
|
||||
</Button>
|
||||
{#if create_error}
|
||||
<Helper class="mt-2">
|
||||
<span class="text-red-900 dark:text-red-300 text-sm">
|
||||
{$_(`error_messages.${create_error}`, {
|
||||
default: $_("error_messages.error_message", {
|
||||
values: { error_message: create_error },
|
||||
}),
|
||||
})}
|
||||
</span>
|
||||
</Helper>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full text-center mb-4">
|
||||
{$_("landing_page.connect_room.or")}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mb-4">
|
||||
<ButtonGroup
|
||||
class="w-full overflow-hidden rounded-xl border-1 border-primary-800"
|
||||
size="sm"
|
||||
>
|
||||
<InputAddon
|
||||
class="w-10 text-center bg-primary-400 dark:bg-primary-800"
|
||||
>
|
||||
{#if loading == "join"}
|
||||
<div class="w-full flex justify-center">
|
||||
<Spinner
|
||||
class="text-primary-350 dark:text-primary-200"
|
||||
size="4.2"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="w-full"> # </span>
|
||||
{/if}
|
||||
</InputAddon>
|
||||
<input
|
||||
class="bg-primary-200 px-4 focus:bg-primary-100 text-black dark:text-white mr-md dark:bg-primary-700 dark:focus:bg-primary-600 w-full border-0 focus:outline-none h-12"
|
||||
placeholder={$_("landing_page.connect_room.enter_room_code")}
|
||||
class:cursor-not-allowed={loading}
|
||||
class:opacity-50={loading}
|
||||
disabled={!!loading}
|
||||
bind:this={inputRef}
|
||||
bind:value={joinRoomId}
|
||||
on:input={formatInput}
|
||||
on:keydown={handleKeyDown}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
{#if join_error}
|
||||
<Helper class="mt-2">
|
||||
<span class="text-red-900 dark:text-red-300 text-sm">
|
||||
{$_(`error_messages.${join_error}`, {
|
||||
default: $_("error_messages.error_message", {
|
||||
values: { error_message: join_error },
|
||||
}),
|
||||
})}
|
||||
</span>
|
||||
</Helper>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full text-center mb-4">
|
||||
{$_("landing_page.connect_room.or")}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<Button
|
||||
class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800"
|
||||
size="lg"
|
||||
disabled={loading && loading != "create"}
|
||||
on:click={requestCreateRoom}
|
||||
>
|
||||
{#if loading == "create"}
|
||||
<Spinner
|
||||
class="text-primary-350 dark:text-primary-200 me-3"
|
||||
size="4"
|
||||
/>
|
||||
{/if}
|
||||
{$_("landing_page.connect_room.create_a_room")}
|
||||
</Button>
|
||||
{#if create_error}
|
||||
<Helper class="mt-2">
|
||||
<span class="text-red-900 dark:text-red-300 text-sm">
|
||||
{$_(`error_messages.${create_error}`, {
|
||||
default: $_("error_messages.error_message", {
|
||||
values: { error_message: create_error },
|
||||
}),
|
||||
})}
|
||||
</span>
|
||||
</Helper>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
29
frontend/src/components/Footer.svelte
Normal file
29
frontend/src/components/Footer.svelte
Normal file
@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { Banner, Button, Tooltip } from "flowbite-svelte";
|
||||
import { ArrowUpFromLine, Dot } from "lucide-svelte";
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
let bannerStatus = true;
|
||||
const params = { delay: 250, duration: 500, easing: quintOut };
|
||||
|
||||
function showFooter() {
|
||||
bannerStatus = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Banner id="bottom-banner" position="absolute" bannerType="bottom" transition={slide} {params} bind:bannerStatus>
|
||||
<p class="flex items-center text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
<a href="/imprint">{$_("footer.imprint")}</a>
|
||||
<Dot />
|
||||
<a href="https://github.com/HexCardGames/HexDeck" target="_blank">{$_("footer.github")}</a>
|
||||
</p>
|
||||
</Banner>
|
||||
|
||||
<div class="absolute right-0 bottom-0">
|
||||
<Button on:click={showFooter} class="!p-2 focus:ring-0 mt-2" color="none">
|
||||
<ArrowUpFromLine />
|
||||
</Button>
|
||||
<Tooltip type='auto'>{$_("landing_page.show_footer")}</Tooltip>
|
||||
</div>
|
340
frontend/src/components/Game/Lobby.svelte
Normal file
340
frontend/src/components/Game/Lobby.svelte
Normal file
@ -0,0 +1,340 @@
|
||||
<script lang="ts">
|
||||
import RenamePlayer from "./../RenamePlayer.svelte";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableBodyCell,
|
||||
TableBodyRow,
|
||||
TableHead,
|
||||
TableHeadCell,
|
||||
TableSearch,
|
||||
Badge,
|
||||
Button,
|
||||
Modal,
|
||||
Popover,
|
||||
Tooltip,
|
||||
} from "flowbite-svelte";
|
||||
import {
|
||||
CircleArrowOutUpLeft,
|
||||
Copy,
|
||||
AlertCircle,
|
||||
UserX,
|
||||
Play,
|
||||
TextCursorInput,
|
||||
} from "lucide-svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { GameState, sessionStore } from "../../stores/sessionStore";
|
||||
|
||||
let copied = false;
|
||||
let showLeaveModal = false;
|
||||
|
||||
$: players = $sessionStore.players;
|
||||
|
||||
let searchQuery = "";
|
||||
let rename_player = "";
|
||||
let showRenameModal = false;
|
||||
let kick_player = "";
|
||||
let showKickModal = false;
|
||||
|
||||
function filteredPlayers() {
|
||||
return players.filter((player) =>
|
||||
player.Username.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function insert(str: string, index: number, value: string) {
|
||||
return str.slice(0, index) + value + str.slice(index);
|
||||
}
|
||||
|
||||
function copyGameCodeToClipboard() {
|
||||
navigator.clipboard
|
||||
.writeText(insert($sessionStore.joinCode || "000000", 3, "-"))
|
||||
.then(() => {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function copyGameLinkToClipboard() {
|
||||
navigator.clipboard
|
||||
.writeText(
|
||||
`${window.location.origin}/join/${$sessionStore.joinCode}`,
|
||||
)
|
||||
.then(() => {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function leaveRoom() {
|
||||
sessionStore.leaveRoom();
|
||||
showLeaveModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Modal: Confirm Leave Room -->
|
||||
<Modal
|
||||
bind:open={showLeaveModal}
|
||||
size="md"
|
||||
backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50"
|
||||
autoclose
|
||||
outsideclose
|
||||
>
|
||||
<div class="text-center">
|
||||
<AlertCircle
|
||||
class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200"
|
||||
/>
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
|
||||
{$_("lobby.confirm_leave_message")}
|
||||
</h3>
|
||||
<Button
|
||||
on:click={() => (showLeaveModal = false)}
|
||||
color="alternative"
|
||||
class="hover:text-dark hover:bg-gray-100"
|
||||
>{$_("lobby.cancel")}</Button
|
||||
>
|
||||
<Button on:click={leaveRoom} color="red" class="me-2"
|
||||
>{$_("lobby.confirm_leave")}</Button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal: Rename Player -->
|
||||
<Modal
|
||||
bind:open={showRenameModal}
|
||||
size="md"
|
||||
backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50"
|
||||
autoclose
|
||||
outsideclose
|
||||
>
|
||||
<div class="text-center">
|
||||
<TextCursorInput
|
||||
class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200"
|
||||
/>
|
||||
<RenamePlayer playerId={rename_player} />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal: Confirm Kick Player -->
|
||||
<Modal
|
||||
bind:open={showKickModal}
|
||||
size="md"
|
||||
backdropClass="fixed inset-0 z-40 bg-gray-900 bg-black/50 dark:bg-black/80 backdrop-opacity-50"
|
||||
autoclose
|
||||
outsideclose
|
||||
>
|
||||
<div class="text-center">
|
||||
<UserX
|
||||
class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200"
|
||||
/>
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
|
||||
{$_("lobby.confirm_kick_player_message", {
|
||||
values: { player_name: sessionStore.getUser(kick_player)?.Username || 'Name not found' },
|
||||
})}
|
||||
</h3>
|
||||
<Button
|
||||
on:click={() => (showLeaveModal = false)}
|
||||
color="alternative"
|
||||
class="hover:text-dark hover:bg-gray-100"
|
||||
>{$_("lobby.cancel")}</Button
|
||||
>
|
||||
<Button
|
||||
on:click={() => {
|
||||
sessionStore.kickPlayer(kick_player);
|
||||
}}
|
||||
color="red"
|
||||
class="me-2">{$_("lobby.confirm_kick_player")}</Button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Leave Room Button -->
|
||||
<Button
|
||||
color="none"
|
||||
class="sm:absolute m-2 border-2 border-gray-500 dark:border-gray-300 hover:bg-gray-500 dark:hover:bg-gray-300 hover:text-white dark:hover:text-black rounded-full text-gray-500 dark:text-gray-300"
|
||||
on:click={() => {
|
||||
showLeaveModal = true;
|
||||
}}
|
||||
>
|
||||
<CircleArrowOutUpLeft class="mr-2" />
|
||||
<span>{$_("lobby.leave_game")}</span>
|
||||
</Button>
|
||||
|
||||
<!-- Game Status -->
|
||||
<div class="text-center p-6 w-full">
|
||||
<span
|
||||
>{$_("game_status.game_status", {
|
||||
values: {
|
||||
game_status: $_(
|
||||
`game_status.${GameState[$sessionStore.gameState].toLowerCase()}`,
|
||||
),
|
||||
},
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-flow-col grid-flow-row justify-center mt-6 mb-2 gap-4">
|
||||
<!-- Rename (This) Player -->
|
||||
{#if sessionStore.isConnected()}
|
||||
<RenamePlayer />
|
||||
{/if}
|
||||
|
||||
<!-- Copy Join Code Button -->
|
||||
<Button
|
||||
id="b1"
|
||||
type="button"
|
||||
class="w-xs mx-auto text-dark bg-primary-200 dark:bg-primary-900 hover:bg-primary-200 dark:hover:bg-primary-900 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer"
|
||||
on:click={() => {
|
||||
copyGameCodeToClipboard();
|
||||
}}
|
||||
>
|
||||
<div class="grid justify-items-start">
|
||||
<span class="text-sm">{$_("lobby.room_join_code")}</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
class="text-xl font-semibold tracking-widest select-none transition-opacity duration-300 opacity-0"
|
||||
class:opacity-100={!copied}
|
||||
>{insert($sessionStore.joinCode || "000000", 3, "-")}
|
||||
</span>
|
||||
<span
|
||||
class="absolute left-0 text-xl font-semibold tracking-widest select-none transition-opacity duration-300 opacity-0"
|
||||
class:opacity-100={copied}
|
||||
>
|
||||
{$_("lobby.copied")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Copy />
|
||||
</Button>
|
||||
<Popover
|
||||
class="text-sm max-w-screen font-light z-100"
|
||||
triggeredBy="#b1"
|
||||
placement="bottom"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Button
|
||||
on:click={() => {
|
||||
copyGameCodeToClipboard();
|
||||
}}
|
||||
>
|
||||
{$_("lobby.copy_code")}
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => {
|
||||
copyGameLinkToClipboard();
|
||||
}}
|
||||
>
|
||||
{$_("lobby.copy_join_link")}
|
||||
</Button>
|
||||
{#if sessionStore.getPlayerPermissions().isHost}
|
||||
<Button on:click={() => {}}>
|
||||
{$_("lobby.regenerate_join_code")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<!-- Start game button -->
|
||||
{#if sessionStore.getPlayerPermissions().isHost}
|
||||
<Button
|
||||
class="w-xs mx-auto text-dark bg-green-200 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-900 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer"
|
||||
on:click={() => {
|
||||
copyGameCodeToClipboard();
|
||||
}}
|
||||
>
|
||||
<div class="grid justify-items-start">
|
||||
<div class="relative">
|
||||
<span
|
||||
class="text-xl font-semibold tracking-widest select-none transition-opacity duration-300"
|
||||
>{$_("lobby.start_game")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Play />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if players.length > 5}
|
||||
<!-- Search Bar -->
|
||||
<TableSearch
|
||||
bind:inputValue={searchQuery}
|
||||
placeholder={$_("lobby.search_player")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Players Table -->
|
||||
<Table striped hoverable>
|
||||
<TableHead>
|
||||
<TableHeadCell class="cursor-pointer flex items-center">
|
||||
{$_("lobby.player_name")}
|
||||
</TableHeadCell>
|
||||
<TableHeadCell>{$_("lobby.status")}</TableHeadCell>
|
||||
</TableHead>
|
||||
|
||||
<TableBody tableBodyClass="divide-y">
|
||||
{#each filteredPlayers() as player}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell>
|
||||
{player.Username}
|
||||
{#if sessionStore.isCurrentPlayer(player.PlayerId)}
|
||||
<Badge color="purple" class="ml-1"
|
||||
>{$_("lobby.you")}</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if sessionStore.getPlayerPermissions(player.PlayerId).isHost}
|
||||
<Badge color="blue" class="ml-1"
|
||||
>{$_("lobby.host")}</Badge
|
||||
>
|
||||
{/if}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
{#if player.IsConnected}
|
||||
<Badge color="green"
|
||||
>{$_(`player_status.connected`)}</Badge
|
||||
>
|
||||
{:else}
|
||||
<Badge color="yellow"
|
||||
>{$_(`player_status.disconnected`)}</Badge
|
||||
>
|
||||
{/if}
|
||||
</TableBodyCell>
|
||||
<!-- Can kick and rename player -->
|
||||
{#if sessionStore.getPlayerPermissions().isHost}
|
||||
<TableBodyCell>
|
||||
<!-- kick player -->
|
||||
<Button
|
||||
outline={true}
|
||||
color="alternative"
|
||||
class="p-2! text-red-800 hover:bg-red-500"
|
||||
size="lg"
|
||||
on:click={() => {
|
||||
showKickModal = true;
|
||||
kick_player = player.PlayerId;
|
||||
}}
|
||||
>
|
||||
<UserX class="w-7 h-7" />
|
||||
</Button>
|
||||
<Tooltip type="auto">{$_("lobby.kick_player")}</Tooltip>
|
||||
<!-- rename player -->
|
||||
<Button
|
||||
outline={true}
|
||||
color="alternative"
|
||||
class="p-2! text-red-800 hover:bg-red-500"
|
||||
size="lg"
|
||||
on:click={() => {
|
||||
showRenameModal = true;
|
||||
rename_player = player.PlayerId;
|
||||
}}
|
||||
>
|
||||
<TextCursorInput class="w-7 h-7" />
|
||||
</Button>
|
||||
<Tooltip type="auto"
|
||||
>{$_("lobby.rename_player")}</Tooltip
|
||||
>
|
||||
</TableBodyCell>
|
||||
{/if}
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
85
frontend/src/components/RenamePlayer.svelte
Normal file
85
frontend/src/components/RenamePlayer.svelte
Normal file
@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { sessionStore } from "../stores/sessionStore";
|
||||
import { ButtonGroup, InputAddon, Spinner } from "flowbite-svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let playerId: string = sessionStore.getUserId() || "";
|
||||
|
||||
let playerName: string = sessionStore.getUser(playerId)?.Username || "ERROR USERNAME NOT FOUND";
|
||||
let isLoading: boolean = false;
|
||||
let debounceTimer: NodeJS.Timeout | null = null;
|
||||
let loadingTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
let inputRef: HTMLInputElement | null = null;
|
||||
|
||||
function onInput(event: Event) {
|
||||
const newName = (event.target as HTMLInputElement).value;
|
||||
playerName = newName;
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (loadingTimer) {
|
||||
clearTimeout(loadingTimer);
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
// Start loading spinner after 0.8s
|
||||
loadingTimer = setTimeout(() => {
|
||||
isLoading = true;
|
||||
}, 800);
|
||||
|
||||
// Start a debounce timer (3s)
|
||||
debounceTimer = setTimeout(() => {
|
||||
sessionStore.renamePlayer(playerId, newName);
|
||||
isLoading = false;
|
||||
unfocusInput()
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
console.log(focusInput);
|
||||
inputRef?.focus();
|
||||
}
|
||||
|
||||
function unfocusInput() {
|
||||
console.log(focusInput);
|
||||
inputRef?.blur();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (loadingTimer) clearTimeout(loadingTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group w-xs mx-auto text-dark bg-gray-100 dark:bg-gray-900 focus-within:bg-gray-50 dark:focus-within:bg-gray-800 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={focusInput}
|
||||
on:keydown={(event) => { if (event.key === 'Enter' || event.key === ' ') focusInput(); }}>
|
||||
<div class="grid justify-items-start w-full">
|
||||
{#if playerId == sessionStore.getUserId()}
|
||||
<span class="text-sm">{$_("lobby.rename_yourself")}</span>
|
||||
{:else}
|
||||
<span class="text-sm">{$_("lobby.rename_player")}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Rename Player -->
|
||||
<div class="w-full">
|
||||
<input
|
||||
class="text-black w-full dark:text-white mr-md w-full border-0 focus:outline-none h-8 bg-opacity-50"
|
||||
bind:value={playerName}
|
||||
on:input={onInput}
|
||||
bind:this={inputRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<div class="">
|
||||
<Spinner
|
||||
class="text-primary-350 w-8 h-8 dark:text-primary-200"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
44
frontend/src/components/StatsContainer.svelte
Normal file
44
frontend/src/components/StatsContainer.svelte
Normal file
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { ChartNoAxesCombined } from "lucide-svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
let stats = {
|
||||
online_player_count: null,
|
||||
current_game_rooms: null,
|
||||
games_played: null,
|
||||
}
|
||||
|
||||
async function getStats() {
|
||||
try {
|
||||
const res = await fetch("/api/stats");
|
||||
const resJson = await res.json();
|
||||
stats.online_player_count = resJson?.OnlinePlayerCount
|
||||
stats.current_game_rooms = resJson?.RunningGames
|
||||
stats.games_played = resJson?.TotalGamesPlayed
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let getStateInterval: any = undefined;
|
||||
|
||||
onMount(() => {
|
||||
getStats();
|
||||
// Request stats update every 10s
|
||||
getStateInterval = setInterval(getStats, 10 * 60 * 1000);
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(getStateInterval);
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="p-4 bg-primary-50 dark:bg-primary-950 rounded-xl grid content-start justify-items-center w-3xs text-center space-y-2 border-1 border-primary-200 dark:border-primary-800">
|
||||
<ChartNoAxesCombined size="48px" />
|
||||
<h4 class="text-xl font-semibold">{$_("landing_page.stats_container.title")}</h4>
|
||||
<!-- content div -->
|
||||
<div class="grid justify-items-start text-start">
|
||||
<span>{$_("landing_page.stats_container.online_player_count", { values: { count: stats.online_player_count ?? $_("landing_page.stats_container.no_data") } })}</span>
|
||||
<span>{$_("landing_page.stats_container.current_game_rooms", { values: { count: stats.current_game_rooms ?? $_("landing_page.stats_container.no_data") } })}</span>
|
||||
<span>{$_("landing_page.stats_container.games_played", { values: { count: stats.games_played ?? $_("landing_page.stats_container.no_data") } })}</span>
|
||||
</div>
|
||||
</div>
|
16
frontend/src/components/meta-title.svelte
Normal file
16
frontend/src/components/meta-title.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { _, waitLocale } from "svelte-i18n";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let title = "Loading...";
|
||||
|
||||
onMount(async () => {
|
||||
// Ensure translations are loaded
|
||||
await waitLocale();
|
||||
title = $_("page_name");
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
</svelte:head>
|
Reference in New Issue
Block a user