🎉first frontend commit (WIP)

This commit is contained in:
pixii
2025-03-04 09:43:45 +01:00
parent e91fc6ce93
commit 3217b2c4c3
37 changed files with 5501 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# IDEs and editors
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln

26
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.routify/*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

47
frontend/README.md Normal file
View File

@ -0,0 +1,47 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/hexagon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

33
frontend/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "hexdeck-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"flowbite": "^3.1.2",
"flowbite-svelte": "^0.47.4",
"flowbite-svelte-icons": "^2.0.2",
"sass-embedded": "^1.83.4",
"svelte": "^5.15.0",
"svelte-check": "^4.1.1",
"typescript": "~5.6.2",
"vite": "^6.0.5"
},
"dependencies": {
"@roxi/routify": "3.0.0-next.254",
"@tailwindcss/vite": "^4.0.6",
"lucide-svelte": "^0.474.0",
"routify": "^2.0.1",
"socket.io-client": "^4.8.1",
"svelte-exmarkdown": "^4.0.2",
"svelte-i18n": "^4.0.1",
"tailwindcss": "^4.0.6"
}
}

3319
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hexagon"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>

After

Width:  |  Height:  |  Size: 350 B

14
frontend/src/App.svelte Normal file
View File

@ -0,0 +1,14 @@
<script context="module">
import { Router, createRouter } from "@roxi/routify";
import routes from "../.routify/routes.default.js";
import MetaTitle from "./components/meta-title.svelte";
export const router = createRouter({ routes });
router.ready().then(() => {
console.log("Routify router is ready!");
});
</script>
<MetaTitle />
<Router {router} />

87
frontend/src/app.scss Normal file
View File

@ -0,0 +1,87 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-primary-50: color-mix(in srgb, var(--primary) 10%, white);
--color-primary-100: color-mix(in srgb, var(--primary) 20%, white);
--color-primary-200: color-mix(in srgb, var(--primary) 30%, white);
--color-primary-300: color-mix(in srgb, var(--primary) 40%, white);
--color-primary-400: color-mix(in srgb, var(--primary) 50%, white);
--color-primary-500: color-mix(in srgb, var(--primary) 60%, white);
--color-primary-600: color-mix(in srgb, var(--primary) 70%, white);
--color-primary-700: color-mix(in srgb, var(--primary) 80%, white);
--color-primary-800: color-mix(in srgb, var(--primary) 90%, white);
--color-primary-900: var(--primary);
--color-primary-950: color-mix(in srgb, var(--primary) 90%, black);
--color-secondary-50: color-mix(in srgb, var(--secondary) 10%, white);
--color-secondary-100: color-mix(in srgb, var(--secondary) 20%, white);
--color-secondary-200: color-mix(in srgb, var(--secondary) 30%, white);
--color-secondary-300: color-mix(in srgb, var(--secondary) 40%, white);
--color-secondary-400: color-mix(in srgb, var(--secondary) 50%, white);
--color-secondary-500: color-mix(in srgb, var(--secondary) 60%, white);
--color-secondary-600: color-mix(in srgb, var(--secondary) 70%, white);
--color-secondary-700: color-mix(in srgb, var(--secondary) 80%, white);
--color-secondary-800: color-mix(in srgb, var(--secondary) 90%, white);
--color-secondary-900: var(--secondary);
--color-secondary-950: color-mix(in srgb, var(--secondary) 90%, black);
--color-tertiary-50: color-mix(in srgb, var(--tertiary) 10%, white);
--color-tertiary-100: color-mix(in srgb, var(--tertiary) 20%, white);
--color-tertiary-200: color-mix(in srgb, var(--tertiary) 30%, white);
--color-tertiary-300: color-mix(in srgb, var(--tertiary) 40%, white);
--color-tertiary-400: color-mix(in srgb, var(--tertiary) 50%, white);
--color-tertiary-500: color-mix(in srgb, var(--tertiary) 60%, white);
--color-tertiary-600: color-mix(in srgb, var(--tertiary) 70%, white);
--color-tertiary-700: color-mix(in srgb, var(--tertiary) 80%, white);
--color-tertiary-800: color-mix(in srgb, var(--tertiary) 90%, white);
--color-tertiary-900: var(--tertiary);
--color-tertiary-950: color-mix(in srgb, var(--tertiary) 90%, black);
}
@layer base {
button,
[role='button'] {
cursor: pointer;
}
}
body {
margin: 0;
color: var(--default-element-color);
background-color: var(--default-background-color);
transition: color 0.4s ease, background-color 0.2s ease;
}
:root {
font-family: "Lexend Deca", serif;
font-optical-sizing: auto;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--primary: #d5b6ff;
--secondary: #ffb6f5;
--tertiary: #08aeea;
}
body.dark-theme {
--primary: #53346a;
--secondary: #710cff;
--tertiary: #0b465c;
--default-element-color: rgba(255, 255, 255, 0.87);
--default-background-color: oklch(0.279 0.041 260.031);
}
body.light-theme {
--primary: #d5b6ff;
--secondary: #ffb6f5;
--tertiary: #08aeea;
--default-element-color: #213547;
--default-background-color: #ffffff;
}

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

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

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

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

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

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

103
frontend/src/i18n/de.json Normal file
View File

@ -0,0 +1,103 @@
{
"page_name": "HexDeck",
"header": {
"theme_btn": {
"tooltip": "Thema wechseln: {current_theme}",
"dark": "Dunkel",
"light": "Hell",
"system": "System"
}
},
"footer": {
"imprint": "Impressum",
"github": "GitHub"
},
"404": {
"404_page_not_found": "404 - Seite nicht gefunden",
"page_not_found": "Die Seite {page} konnte nicht gefunden werden."
},
"imprint": {
"title": "Impressum",
"something_went_wrong": "Etwas ist schief gelaufen",
"timeout_while_loading": "Zeitüberschreitung beim Laden",
"retry": "Erneut versuchen",
"go_back": "Zurück"
},
"landing_page": {
"sub_title": "Multiplayer, kostenlos, für alle",
"connect_room": {
"rejoin_last_room": "Letztes Spiel erneut beitreten",
"join_last_room": "Letztem Raum beitreten",
"enter_room_code": "Raumcode eingeben",
"join_room": "Beitreten",
"enter_room_code_to_join": "Bitte geben Sie einen Raumcode ein, um beizutreten",
"or": "oder",
"create_a_room": "Einen Raum erstellen"
},
"open_source_container": {
"title": "Open Source",
"content": "Der Quellcode dieses Spiels ist auf GitHub verfügbar",
"github": "GitHub"
},
"stats_container": {
"title": "Statistiken",
"online_player_count": "Aktuelle Spieler: {count}",
"current_game_rooms": "Aktuelle Spiele: {count}",
"games_played": "Gespielte Spiele: {count}",
"no_data": "Keine Daten"
},
"show_footer": "Footer anzeigen"
},
"lobby": {
"search_player": "Spieler suchen...",
"kick_player": "Spieler entfernen",
"confirm_kick_player_message": "Möchten Sie den Spieler {player_name} wirklich entfernen?",
"confirm_kick_player": "Entfernen",
"rename_yourself": "Sich selbst umbenennen",
"rename_player": "Spieler umbenennen",
"regenerate_join_code": "Beitrittscode neu generieren",
"copy_join_code": "Beitrittscode kopieren",
"room_join_code": "Raum Beitrittscode",
"copy_code": "Code kopieren",
"copy_join_link": "Link kopieren",
"leave_game": "Spiel verlassen",
"confirm_leave_message": "Möchten Sie das Spiel wirklich verlassen?",
"confirm_leave": "Ja, verlassen",
"cancel": "Abbrechen",
"start_game": "Spiel starten",
"copied": "Kopiert",
"player_name": "Spielername",
"status": "Status",
"host": "Host",
"you": "Du"
},
"player_status": {
"connected": "Verbunden",
"disconnected": "Getrennt"
},
"game_status": {
"game_status": "Spielstatus: {game_status}",
"lobby": "Lobby",
"running": "Läuft",
"ended": "Beendet"
},
"error_messages": {
"no_room_found": "Kein Raum mit diesem Code gefunden",
"request_timeout": "Internet fehlgeschlagen! (Zeitüberschreitung)",
"invalid_player": "Ungültiger Spieler",
"invalid_session": "Ungültige Sitzung",
"game_not_running": "Das Spiel läuft nicht",
"player_not_active": "Der Spieler ist nicht aktiv",
"insufficient_permission": "Unzureichende Berechtigung",
"username_taken": "Der Benutzername ist bereits vergeben",
"game_already_started": "Das Spiel hat bereits begonnen",
"missing_parameter": "Fehlender Parameter",
"invalid_card_index": "Ungültige Karte ausgewählt (Index außerhalb der Grenzen)",
"card_not_playable": "Die Karte ist nicht spielbar",
"card_not_updatable": "Die Karte ist nicht aktualisierbar",
"error_message": "Fehlermeldung: {error_message}"
},
"game_screen": {
"loading": "Laden"
}
}

103
frontend/src/i18n/en.json Normal file
View File

@ -0,0 +1,103 @@
{
"page_name": "HexDeck",
"header": {
"theme_btn": {
"tooltip": "Switch theme: {current_theme}",
"dark": "Dark",
"light": "Light",
"system": "System"
}
},
"footer": {
"imprint": "Imprint",
"github": "GitHub"
},
"404": {
"404_page_not_found": "404 - Page not found",
"page_not_found": "The page {page} could not be found."
},
"imprint": {
"title": "Imprint",
"something_went_wrong": "Something went wrong",
"timeout_while_loading": "Timeout while loading",
"retry": "Retry",
"go_back": "Back"
},
"landing_page": {
"sub_title": "Multiplayer, free, for everyone",
"connect_room": {
"rejoin_last_room": "Rejoin last game",
"join_last_room": "Join last room",
"enter_room_code": "Enter a room code",
"join_room": "Join",
"enter_room_code_to_join": "Please enter a room code to join",
"or": "or",
"create_a_room": "Create a room"
},
"open_source_container": {
"title": "Open Source",
"content": "The Source Code of this game is available on GitHub",
"github": "GitHub"
},
"stats_container": {
"title": "Stats",
"online_player_count": "Current player: {count}",
"current_game_rooms": "Current games: {count}",
"games_played": "Games played: {count}",
"no_data": "No data"
},
"show_footer": "Show footer"
},
"lobby": {
"search_player": "Search player...",
"kick_player": "Kick player",
"confirm_kick_player_message": "Do you really want to kick the player {player_name}?",
"confirm_kick_player": "Kick",
"rename_yourself": "Rename yourself",
"rename_player": "Rename player",
"regenerate_join_code": "Regenerate join code",
"copy_join_code": "Copy join code",
"room_join_code": "Room Join Code",
"copy_code": "Copy Code",
"copy_join_link": "Copy Link",
"leave_game": "Leave game",
"confirm_leave_message": "Do you really want to leave the game?",
"confirm_leave": "Yes, leave",
"cancel": "Cancel",
"start_game": "Start game",
"copied": "Copied",
"player_name": "Player Name",
"status": "Status",
"host": "Host",
"you": "You"
},
"player_status": {
"connected": "Connected",
"disconnected": "Disconnected"
},
"game_status": {
"game_status": "Game status: {game_status}",
"lobby": "Lobby",
"running": "Running",
"ended": "Ended"
},
"error_messages": {
"no_room_found": "No room was found with this code",
"request_timeout": "Internet failed! (Timeout)",
"invalid_player": "Invalid player",
"invalid_session": "Invalid session",
"game_not_running": "The game is not running",
"player_not_active": "The player ist not active",
"insufficient_permission": "Insufficient permission",
"username_taken": "The username is already taken",
"game_already_started": "The game has already started",
"missing_parameter": "Missing parameter",
"invalid_card_index": "Invalid card selected (Index not in bounds)",
"card_not_playable": "The card is not playable",
"card_not_updatable": "The card is not updatable",
"error_message": "Error message: {error_message}"
},
"game_screen": {
"loading": "Loading"
}
}

18
frontend/src/i18n/i18n.ts Normal file
View File

@ -0,0 +1,18 @@
import { addMessages, register, init, getLocaleFromNavigator } from 'svelte-i18n';
import en from './en.json';
import de from './de.json';
addMessages('en', en);
addMessages('de', de);
register('en', () => import('./en.json'));
register('de', () => import('./de.json'));
const initialLocale = getLocaleFromNavigator();
console.log('Initial locale:', initialLocale);
init({
fallbackLocale: 'en',
initialLocale: initialLocale,
});

3
frontend/src/index.css Normal file
View File

@ -0,0 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@100..900&display=swap');
@import "tailwindcss";
@import './app.scss';

11
frontend/src/main.ts Normal file
View File

@ -0,0 +1,11 @@
import { mount } from 'svelte'
import './i18n/i18n'
import './index.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app

View File

@ -0,0 +1,47 @@
<script lang="ts">
import Lobby from "../components/Game/Lobby.svelte";
import { _ } from "svelte-i18n";
import { onMount } from "svelte";
import { GameState, sessionStore } from "../stores/sessionStore";
import { Spinner } from "flowbite-svelte";
import { SvelteDate } from "svelte/reactivity";
onMount(() => {
if (!sessionStore.hasSessionData()) {
console.warn("No sessionData found! Go back home.");
window.history.replaceState({}, "", "/");
}
sessionStore.connect()
});
</script>
{#if !$sessionStore.connected}
<div class="flex flex-row w-full mt-32 h-full justify-center items-center">
<div
class="flex flex-col items-center gap-6 p-7 md:flex-row md:gap-8 rounded-2xl"
>
<div>
<Spinner size="12" class="text-primary-100" />
</div>
<div class="grid items-center text-center md:items-start">
<span class="text-2xl font-medium">
{$_('game_screen.loading')}
</span>
<span class="font-medium text-sky-500">{$sessionStore.players?.find((player) => player.PlayerId == $sessionStore.userId)?.Username}</span>
<span
class="flex gap-2 font-medium text-gray-600 dark:text-gray-400"
>
<span>{new SvelteDate().toLocaleString()}</span>
</span>
</div>
</div>
</div>
{:else if $sessionStore.gameState == GameState.Lobby}
<div>
<Lobby />
</div>
{:else if $sessionStore.gameState == GameState.Running}
<div>Running Game</div>
{:else if $sessionStore.gameState == GameState.Ended}
<div>Game Ended</div>
{/if}

View File

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { Card } from "flowbite-svelte";
import { _ } from "svelte-i18n";
import { onMount } from "svelte";
import { Spinner } from "flowbite-svelte";
import { sessionStore } from "../stores/sessionStore";
onMount(async () => {
await sessionStore.leaveRoom()
})
</script>
<div class="container mx-auto p-6">
<Card
class="max-w-lg mx-auto dark:text-gray-200 rounded-xl text-center space-y-2 flex"
>
<Spinner
class="text-primary-350 dark:text-primary-200 w-12"
size="4.2"
/>
<h1 class="text-2xl font-bold">{$_("leave.leaving_game")}</h1>
</Card>
</div>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { Button, Card } from "flowbite-svelte";
import { MoveLeft } from "lucide-svelte";
import { _ } from "svelte-i18n";
import { url } from '@roxi/routify'
function goBack() {
window.history.pushState({}, "", "/");
}
</script>
<div class="container mx-auto p-6">
<Button
color="none"
class="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 mb-12"
on:click={goBack}
>
<MoveLeft class="mr-2" />
<span>{$_("imprint.go_back")}</span>
</Button>
<Card class="max-w-lg mx-auto dark:text-gray-200 rounded-xl text-center space-y-2">
<h1 class="text-2xl font-bold">{$_("404.404_page_not_found")}</h1>
<h1 class="">{$_("404.page_not_found", { values: { page: $url('$leaf') }})}</h1>
</Card>
</div>

View File

@ -0,0 +1,101 @@
<script>
import { theme, toggleTheme } from "../stores/theme";
import { Moon, Sun, SunMoon } from "lucide-svelte";
import { Tooltip, Button } from "flowbite-svelte";
import options from "../stores/pageoptions";
import { _ } from "svelte-i18n";
</script>
<header class="Header">
<div class="Header-bg"></div>
<div class="Header-content">
<div class="left-header-group header-group">
<div class="page-header-icon">
<svelte:component this={options.page_icon} size="2.4rem" />
</div>
<h1 class="text-3xl">{$_("page_name")}</h1>
</div>
<div class="middle-header-group header-group"></div>
<div class="right-header-group header-group">
<Button
on:click={toggleTheme}
class="!p-2 mt-2 rounded-full focus:bg-primary-700 hover:bg-primary-600 focus:ring-0"
color="none"
>
{#if $theme === "dark"}
<Moon size="2rem" />
{:else if $theme === "light"}
<Sun size="2rem" />
{:else if $theme === "system"}
<SunMoon size="2rem" />
{/if}
</Button>
<Tooltip type='auto'>
{$_("header.theme_btn.tooltip", { values: { current_theme: $_(`header.theme_btn.${$theme}`)}})}
</Tooltip>
</div>
</div>
</header>
<div class="page-slot">
<slot />
</div>
<style>
.page-slot {
margin-top: 90px;
}
.Header {
background: linear-gradient(180deg, var(--default-background-color) 30%, transparent 100%);
opacity: 1;
position: fixed;
height: 100px;
top: 0;
left: 0;
width: 100%;
z-index: 30;
}
.Header-bg {
background: linear-gradient(
180deg,
var(--primary) 50%,
transparent 100%
);
z-index: -1;
margin: 0px;
position: absolute;
width: 100%;
height: 100%;
}
.Header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1rem;
}
.left-header-group,
.middle-header-group,
.right-header-group {
display: flex;
align-items: center;
}
.middle-header-group {
flex-grow: 1;
justify-content: center;
}
.right-header-group {
margin-left: auto;
}
.page-header-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
</style>

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { Button, Card, Skeleton } from "flowbite-svelte";
import { MoveLeft, RefreshCcw } from "lucide-svelte";
import { _ } from "svelte-i18n";
import Markdown from "svelte-exmarkdown";
import { onMount } from "svelte";
import { goto } from '@roxi/routify'
// Reactive stores for better state management
let md: string = "";
let loading: boolean = true;
let error: string | null = null;
function goBack() {
window.history.back();
}
async function getImprintMd() {
loading = true;
error = null;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/api/imprint`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) throw new Error("Server error");
const data = await response.json();
if (!data?.Content) throw new Error("Empty response");
md = data.Content;
} catch (err: any) {
if (err.name === "AbortError") {
error = "imprint.timeout_while_loading";
} else {
error = "imprint.something_went_wrong";
}
} finally {
loading = false;
}
}
onMount(getImprintMd);
</script>
<div class="container mx-auto p-6">
<Button
color="none"
class="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 mb-12"
on:click={goBack}
>
<MoveLeft class="mr-2" />
<span>{$_("imprint.go_back")}</span>
</Button>
<Card class="max-w-lg mx-auto dark:text-gray-200 rounded-xl">
<h1 class="text-2xl font-bold mb-6">{$_("imprint.title")}</h1>
{#if loading}
<div class="w-full">
<Skeleton size="lg" />
</div>
{:else if error}
<div class="text-red-400 text-lg font-semibold grid">
{$_(error)}
<Button
color="none"
class="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 mt-4"
on:click={getImprintMd}
>
<RefreshCcw class="mr-2" />
<span>{$_("imprint.retry")}</span>
</Button>
</div>
{:else}
<Markdown {md} />
{/if}
</Card>
</div>

View File

@ -0,0 +1,49 @@
<script>
import Footer from "../components/Footer.svelte";
import ConnectRoom from "../components/ConnectRoom.svelte";
import StatsContainer from "../components/StatsContainer.svelte";
import { GradientButton } from "flowbite-svelte";
import { _ } from "svelte-i18n";
import options from "../stores/pageoptions";
</script>
<div class="flex justify-center mb-8">
<div>
<!-- Top Title container -->
<div class="flex items-center space-x-2 my-6">
<div>
<svelte:component this={options.page_icon} size="5.2rem" />
</div>
<div>
<h2 class="text-4xl">{$_("page_name")}</h2>
<h3 class="text-xl">
{$_("landing_page.sub_title")}
</h3>
</div>
</div>
<!-- Join or create rooms -->
<ConnectRoom />
</div>
</div>
<div class="flex justify-center">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-8">
<!-- Open source container -->
<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">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.157 20.136c.211.51.8.757 1.284.492a9.25 9.25 0 1 0-8.882 0c.484.265 1.073.018 1.284-.492l1.358-3.28c.212-.51-.043-1.086-.478-1.426a3.7 3.7 0 1 1 4.554 0c-.435.34-.69.916-.478 1.426z"/></svg>
<h4 class="text-xl font-semibold">{$_("landing_page.open_source_container.title")}</h4>
<span>{$_("landing_page.open_source_container.content")}</span>
<GradientButton color="purpleToBlue" class="opacity-75 focus:opacity-100 focus:ring-0" href="https://github.com/HexCardGames/HexDeck" target="_blank">
{$_("landing_page.open_source_container.github")}
</GradientButton>
</div>
<!-- stats container -->
<StatsContainer />
</div>
</div>
<Footer />

View File

@ -0,0 +1,7 @@
import { Gamepad2 } from 'lucide-svelte';
const options = {
page_icon: Gamepad2,
}
export default options;

View File

@ -0,0 +1,298 @@
import { writable, get } from 'svelte/store';
import { io, Socket } from 'socket.io-client';
export enum GameState {
Undefined = -1,
Lobby,
Running,
Ended
}
interface PlayerPermissionObj {
isHost: boolean;
}
interface GameOptions { }
interface PlayerObj {
PlayerId: string;
Username: string;
Permissions: number;
IsConnected: boolean;
}
interface SessionData {
roomId: string | null;
joinCode: string | null;
gameOptions: GameOptions;
players: Array<PlayerObj>;
cardDeckId: string | null;
gameState: GameState;
socket: Socket | null;
connected: boolean;
userId: string | null;
messages: string[];
sessionToken: string | null;
}
interface RoomInfoObj {
RoomId: string;
JoinCode: string;
GameState: GameState;
CardDeckId: number;
Winner?: string;
Players: PlayerObj[];
}
interface StatusInfoObj {
IsError: boolean;
StatusCode: string;
Message: string;
}
class SessionManager {
private store = writable<SessionData>({
roomId: null,
joinCode: null,
gameState: -1,
gameOptions: {},
players: [],
cardDeckId: null,
socket: null,
connected: false,
userId: null,
messages: [],
sessionToken: null,
});
private socket: Socket | null = null;
constructor() {
const storedSessionIds = this.getStoredSessionIds();
if (storedSessionIds) {
console.info(`Found stored session: ${JSON.stringify(storedSessionIds)}`);
// this.connect(storedSessionIds.sessionToken, storedSessionIds.userId);
}
}
getState() {
return get(this.store);
}
hasSessionData(): boolean {
const state = this.getState();
if (state.sessionToken && state.userId) return true;
const sessionIds = localStorage.getItem('currentSessionIds');
if (!sessionIds) return false;
const sessionIdsJson = JSON.parse(sessionIds);
return typeof sessionIdsJson.userId === "string" && typeof sessionIdsJson.sessionToken === "string";
}
private checkPermissionBit(permissionNumber: number, bitIndex: number): boolean {
return (permissionNumber & (1 << bitIndex)) > 0;
}
getPlayerPermissions(PlayerId?: string): PlayerPermissionObj {
if (!PlayerId) PlayerId = this.getState().userId ?? undefined;
const playerPermissionNumber: number = this.getState().players?.find((player) => player.PlayerId == PlayerId)?.Permissions ?? 0;
return {
isHost: this.checkPermissionBit(playerPermissionNumber, 0)
};
}
subscribe = this.store.subscribe;
private getStoredSessionIds(): { sessionToken: string, userId: string } | null {
if (typeof window === 'undefined') return null;
const sessionIds = localStorage.getItem('currentSessionIds');
if (!sessionIds) return null;
const sessionIdsJson = JSON.parse(sessionIds);
if (typeof sessionIdsJson.userId !== "string" || typeof sessionIdsJson.sessionToken !== "string") {
return null;
}
return { sessionToken: sessionIdsJson.sessionToken, userId: sessionIdsJson.userId };
}
private saveSessionIds(sessionToken: string, userId: string) {
if (typeof window !== 'undefined') {
localStorage.setItem('currentSessionIds', JSON.stringify({ sessionToken, userId, joinCode: this.getState().joinCode }));
}
}
private saveJoinCode() {
const sessionIds = localStorage.getItem('currentSessionIds');
if (!sessionIds) return;
const sessionIdsJson = JSON.parse(sessionIds);
localStorage.setItem('currentSessionIds', JSON.stringify({ sessionToken: sessionIdsJson.sessionToken, userId: sessionIdsJson.userId, joinCode: this.getState().joinCode }));
}
private clearSessionIds() {
if (typeof window !== 'undefined') {
const sessionIds = localStorage.getItem('currentSessionIds');
if (!sessionIds) return;
const sessionIdsJson = JSON.parse(sessionIds);
const lastSessionData = { joinCode: sessionIdsJson.joinCode };
localStorage.setItem('lastSessionIds', JSON.stringify(lastSessionData));
localStorage.removeItem('currentSessionIds');
}
}
isConnected(): boolean {
return this.socket?.connected ?? false;
}
hasRoomData(): boolean {
return get(this.store).gameState != -1;
}
getUserId(): string | undefined {
return this.getState().userId ?? undefined;
}
getUser(playerId?: string): PlayerObj | undefined {
if (!playerId) playerId = this.getUserId();
return this.getState().players.find((player) => player.PlayerId == playerId);
}
kickPlayer(playerId: string) {
if (!this.getPlayerPermissions().isHost) return;
this.socket?.emit("KickPlayer", JSON.stringify({ PlayerId: playerId }));
}
renamePlayer(playerId: string | undefined, newName: string) {
if (!playerId) playerId = this.getUserId();
if (!this.getPlayerPermissions().isHost && playerId != this.getUserId()) return;
this.socket?.emit("UpdatePlayer", JSON.stringify({ PlayerId: playerId, Username: newName }));
}
isCurrentPlayer(playerId: string): boolean {
return this.getState().userId == playerId;
}
connect(sessionToken?: string, userId?: string) {
if (!sessionToken) sessionToken = this.getState().sessionToken || undefined;
if (!userId) userId = this.getState().userId || undefined;
if (!sessionToken || !userId) {
const storedSessionIds = this.getStoredSessionIds();
if (!sessionToken) sessionToken = storedSessionIds?.sessionToken;
if (!userId) userId = storedSessionIds?.userId;
}
if (this.socket) {
console.warn(`Socket already connected! Rejecting new connection to ${sessionToken}`);
return;
}
this.socket = io({
transports: ['websocket'],
query: { sessionToken },
});
this.setupSocketEventHandlers(sessionToken, userId);
}
private setupSocketEventHandlers(sessionToken: string, userId: string) {
this.socket?.on('connect', () => this.handleConnect(sessionToken, userId));
this.socket?.on('disconnect', this.handleDisconnect.bind(this));
this.socket?.on('Status', this.handleStatus.bind(this));
this.socket?.on('RoomInfo', this.handleRoomInfo.bind(this));
this.socket?.on('error', this.handleError.bind(this));
}
private handleConnect(sessionToken: string, userId: string) {
console.info('Connected to room');
this.saveSessionIds(sessionToken, userId);
window.history.replaceState({}, "", "/Game");
this.store.update((state) => ({
...state,
socket: this.socket,
userId,
connected: true,
sessionToken,
}));
}
private handleDisconnect() {
console.info('Disconnected from server');
this.store.update((state) => ({ ...state, connected: false }));
}
private handleStatus(message: StatusInfoObj) {
console.log("Status: ", message);
if (message.IsError && message.StatusCode !== "connection_from_different_socket") {
this.leaveRoom();
}
if (message.IsError) {
this.socket = null;
window.history.replaceState({}, "", "/");
}
this.store.update((state) => ({
...state,
messages: [...state.messages, message],
}));
}
private handleRoomInfo(message: RoomInfoObj) {
console.log("RoomInfo: ", message);
this.store.update((state) => ({
...state,
roomId: message.RoomId,
joinCode: message.JoinCode,
gameState: message.GameState,
cardDeckId: message.CardDeckId,
players: message.Players,
}));
this.saveJoinCode();
this.store.update((state) => ({
...state,
messages: [...state.messages, message],
}));
}
private handleError(error: string) {
console.error('Socket error:', error);
}
sendMessage(message: string) {
if (this.socket && message.trim()) {
this.socket.emit('event', message);
}
}
leaveRoom() {
console.log("leave room");
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
if (this.getState().sessionToken) {
fetch(`/api/room/leave`, {
method: "POST",
body: JSON.stringify({
SessionToken: this.getState().sessionToken
}),
headers: {
"Content-Type": "application/json",
},
});
}
this.clearSessionIds();
this.store.set({
roomId: null,
joinCode: null,
gameState: 0,
gameOptions: {},
players: [],
cardDeckId: null,
socket: null,
connected: false,
userId: null,
messages: [],
sessionToken: null,
});
window.history.replaceState({}, "", "/");
}
}
export const sessionStore = new SessionManager();

View File

@ -0,0 +1,43 @@
import { writable } from 'svelte/store';
const getSystemTheme = () => window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const storedTheme = localStorage.getItem("theme") as Theme | null;
const initialTheme: Theme = storedTheme === "dark" || storedTheme === "light" ? storedTheme : "system";
type Theme = "dark" | "light" | "system";
export const theme = writable<Theme>(initialTheme);
const applyTheme = (value: Theme) => {
const resolvedTheme = value === "system" ? getSystemTheme() : value;
document.documentElement.classList.toggle("dark", resolvedTheme === "dark");
document.documentElement.setAttribute("data-theme", resolvedTheme);
document.body.classList.toggle("dark-theme", resolvedTheme === "dark");
document.body.classList.toggle("light-theme", resolvedTheme === "light");
localStorage.setItem("theme", value);
};
theme.subscribe(applyTheme);
// Watch for system theme changes when "system" mode is enabled
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", () => {
theme.update(current => {
if (current === "system") applyTheme("system");
return current;
});
});
export const setTheme = (value: Theme) => {
theme.set(value);
};
export const toggleTheme = () => {
theme.update(current => {
if (current === "dark") return "light";
if (current === "light") return "system";
return "dark";
});
};

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

View File

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

View File

@ -0,0 +1,33 @@
import flowbitePlugin from 'flowbite/plugin'
const { tailwindExtractor } = require("tailwindcss/lib/lib/purgeUnusedStyles");
export default {
content: ['./src/**/*.{html,js,svelte,ts}', './node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}'],
darkMode: ["class", '[data-theme="dark"]'],
theme: {
extend: {
fontFamily: {
lexend: ['"Lexend Deca"', 'sans-serif'], // Add Lexend Deca here
},
},
},
purge: {
content: [
'src/app.html',
'src/**/*.svelte',
],
options: {
defaultExtractor: (content) => [
...tailwindExtractor(content),
...[
...content.matchAll(/(?:class:)*([\w\d-/:%.]+)/gm)
].map(([_match, group, ..._rest]) => group),
],
keyframes: true,
},
},
plugins: [flowbitePlugin],
corePlugins: {
preflight: false,
}
};

View File

@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"exclude": [".routify"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

26
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import routify from '@roxi/routify/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
routify({/* config */ }),
tailwindcss(),
svelte()
],
server: {
host: true,
proxy: {
'/api': {
target: 'http://10.10.39.145:3000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://10.10.39.145:3000',
ws: true,
changeOrigin: true,
}
}
}
})