mirror of
https://github.com/HexCardGames/HexDeck.git
synced 2025-09-03 18:48:38 +02:00
🎉first frontend commit (WIP)
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# IDEs and editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
26
frontend/.gitignore
vendored
Normal file
26
frontend/.gitignore
vendored
Normal 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
47
frontend/README.md
Normal 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
12
frontend/index.html
Normal 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
33
frontend/package.json
Normal 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
3319
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/public/hexagon.svg
Normal file
1
frontend/public/hexagon.svg
Normal 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
14
frontend/src/App.svelte
Normal 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
87
frontend/src/app.scss
Normal 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;
|
||||
}
|
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>
|
103
frontend/src/i18n/de.json
Normal file
103
frontend/src/i18n/de.json
Normal 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
103
frontend/src/i18n/en.json
Normal 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
18
frontend/src/i18n/i18n.ts
Normal 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
3
frontend/src/index.css
Normal 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
11
frontend/src/main.ts
Normal 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
|
||||
|
47
frontend/src/routes/Game.svelte
Normal file
47
frontend/src/routes/Game.svelte
Normal 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}
|
0
frontend/src/routes/Join.svelte
Normal file
0
frontend/src/routes/Join.svelte
Normal file
23
frontend/src/routes/Leave.svelte
Normal file
23
frontend/src/routes/Leave.svelte
Normal 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>
|
27
frontend/src/routes/[...404].svelte
Normal file
27
frontend/src/routes/[...404].svelte
Normal 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>
|
101
frontend/src/routes/_module.svelte
Normal file
101
frontend/src/routes/_module.svelte
Normal 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>
|
90
frontend/src/routes/imprint.svelte
Normal file
90
frontend/src/routes/imprint.svelte
Normal 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>
|
49
frontend/src/routes/index.svelte
Normal file
49
frontend/src/routes/index.svelte
Normal 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 />
|
7
frontend/src/stores/pageoptions.ts
Normal file
7
frontend/src/stores/pageoptions.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Gamepad2 } from 'lucide-svelte';
|
||||
|
||||
const options = {
|
||||
page_icon: Gamepad2,
|
||||
}
|
||||
|
||||
export default options;
|
298
frontend/src/stores/sessionStore.ts
Normal file
298
frontend/src/stores/sessionStore.ts
Normal 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();
|
43
frontend/src/stores/theme.ts
Normal file
43
frontend/src/stores/theme.ts
Normal 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
2
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
7
frontend/svelte.config.js
Normal file
7
frontend/svelte.config.js
Normal 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(),
|
||||
}
|
33
frontend/tailwind.config.js
Normal file
33
frontend/tailwind.config.js
Normal 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,
|
||||
}
|
||||
};
|
21
frontend/tsconfig.app.json
Normal file
21
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal 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
26
frontend/vite.config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user