6 Commits

10 changed files with 693 additions and 9 deletions

1
.npmrc
View File

@@ -1 +1,2 @@
engine-strict=true engine-strict=true
script-shell=C:\Program Files\Git\git-bash.exe

View File

@@ -18,6 +18,8 @@ services:
JEOPARDY_URL: http://localhost:11000 JEOPARDY_URL: http://localhost:11000
ports: ports:
- "11001:12345" - "11001:12345"
volumes:
- jeopardyserver_data_volume:/data
mongo: mongo:
image: mongo:8.0.14 image: mongo:8.0.14
restart: always restart: always
@@ -41,3 +43,4 @@ services:
volumes: volumes:
mongodb_data_volume: mongodb_data_volume:
jeopardyserver_data_volume:

4
docker-dev.sh Normal file
View File

@@ -0,0 +1,4 @@
docker compose down &
docker build -t jeopardy .
docker compose up -d

View File

@@ -12,7 +12,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"docker-build": "docker build -t jeopardy ." "docker-build": "docker build -t jeopardy .",
"docker-dev": "./docker-dev.sh"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",

View File

@@ -9,7 +9,10 @@
cancelFn?: () => void; cancelFn?: () => void;
okFn: () => Promise<boolean>; okFn: () => Promise<boolean>;
oncloseFn?: () => void; oncloseFn?: () => void;
okButtonText?: string;
[key: string]: unknown; [key: string]: unknown;
width?: string;
height?: string;
} }
let { let {
@@ -19,7 +22,10 @@
cancelFn, cancelFn,
okFn, okFn,
oncloseFn, oncloseFn,
actionButtons actionButtons,
okButtonText = "Ok",
width,
height
}: Props = $props(); }: Props = $props();
let dialog: HTMLDialogElement | undefined = $state(); // HTMLDialogElement let dialog: HTMLDialogElement | undefined = $state(); // HTMLDialogElement
@@ -40,10 +46,15 @@
if (e.target === dialog) dialog.close(); if (e.target === dialog) dialog.close();
}} }}
class="rounded-md" class="rounded-md"
style:width
style:height
> >
<div class="flex flex-col gap-4 p-4"> <div class="flex h-full flex-col gap-4 p-4">
{@render header?.()} {@render header?.()}
<div class="grow overflow-y-auto">
{@render children?.()} {@render children?.()}
</div>
<!-- <div class="grow"></div> -->
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y_autofocus -->
<div class="flex justify-end gap-4"> <div class="flex justify-end gap-4">
{@render actionButtons?.()} {@render actionButtons?.()}
@@ -62,7 +73,7 @@
dialog?.close(); dialog?.close();
} }
}} }}
class="btn min-w-[64px]">Ok</button class="btn min-w-[64px]">{okButtonText}</button
> >
</div> </div>
</div> </div>

View File

@@ -0,0 +1,452 @@
<script lang="ts">
import { env } from "$env/dynamic/public";
import axios from "axios";
import Modal from "./Modal.svelte";
import { url } from "./util";
import { isDir, isRessource, type Directory, type Ressource } from "./Types";
import { onMount } from "svelte";
interface Props {
show: boolean;
ok?: (res: Ressource) => void;
}
let { show = $bindable(false), ok = (res) => {} }: Props = $props();
let file: File | null = null;
let path: string = $state("/");
let fetchingRessources = $state(false);
let ressources: (Ressource | Directory)[] = $state([]);
let selectedRessource: Ressource | undefined = $state();
let error = $state("");
let showNewDir = $state(false);
let newDirName = $state("");
let showDeleteDir = $state(false);
let dirToDelete: Directory | undefined = $state();
let showDeleteRessource = $state(false);
let resToDelete: Ressource | undefined = $state();
let showRenameFile = $state(false);
let fileToRename: Ressource | undefined = $state();
let newFileName = $state("");
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement | null;
if (target?.files?.[0]) {
file = target.files[0];
}
}
function uploadData(event: SubmitEvent) {
event.preventDefault();
if (file === null) return;
const formData = new FormData();
formData.append("path", path);
formData.append("file", file);
axios
.post(
`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/upload`,
formData,
{
withCredentials: true,
headers: {
"Content-Type": "multipart/form-data"
},
onUploadProgress: (event) => {
console.log(event);
}
}
)
.then((response) => {
if (response.status === 200) {
fetchDirectory();
} else {
alert("Failed with status: " + response.status);
}
})
.catch((err) => {
console.error(err);
alert(err);
});
}
async function deleteRessource(res: Ressource | Directory): Promise<boolean> {
if (isRessource(res)) {
return axios
.delete(url("/cdn/" + res.user + "/" + res.filename), {
withCredentials: true
})
.then((response) => {
if (response.status === 200) {
fetchDirectory();
return true;
} else {
alert("Something went wrong: " + response.status);
return false;
}
})
.catch((err) => {
console.error(err);
return false;
});
} else if (isDir(res)) {
showDeleteDir = true;
dirToDelete = res;
return true;
}
return false;
}
function deleteRessourceCancel() {
resToDelete = undefined;
}
async function deleteDir() {
if (dirToDelete === undefined) return false;
return axios
.delete(url("/directory"), {
headers: {
"Content-Type": "application/json"
},
data: { path: path + (path === "/" ? "" : "/") + dirToDelete.name },
withCredentials: true
})
.then((response) => {
if (response.status === 200) {
fetchDirectory();
dirToDelete = undefined;
return true;
} else {
alert("Failed to delete directory: " + response.status);
}
return false;
})
.catch((err) => {
console.error(err);
return false;
});
}
function deleteDirCancel() {
dirToDelete = undefined;
}
async function fetchDirectory() {
fetchingRessources = true;
return axios
.post(
url("/directory"),
{ path },
{
withCredentials: true
}
)
.then((response) => {
if (response.status === 200) {
ressources = response.data;
ressources.sort((a, b) => {
if (isDir(a) && !isDir(b)) return -1;
if (!isDir(a) && isDir(b)) return 1;
return a.name.localeCompare(b.name);
});
if (path !== "/") {
ressources.unshift({
isDir: true,
name: ".."
});
}
}
})
.catch((e) => {
console.log(e);
})
.finally(() => {
fetchingRessources = false;
});
}
async function addDirectoryOk(): Promise<boolean> {
error = "";
if (newDirName.length <= 0) {
error = "Gib einen Namen für den Ordner ein";
return false;
}
return axios
.put(url("/directory"), { name: newDirName, path }, { withCredentials: true })
.then((response) => {
if (response.status === 200) {
fetchDirectory();
return true;
} else {
return false;
}
})
.catch(() => {
error = "Etwas ist schief gelaufen";
return false;
});
}
function addDirectoryCancel() {
error = "";
newDirName = "";
}
function ressourceClicked(res: Ressource | Directory) {
if (isRessource(res)) {
selectedRessource = res;
} else if (isDir(res)) {
if (res.name === "..") {
let breadcumbs = path.split("/");
breadcumbs.pop();
path = breadcumbs.join("/");
if (path.length <= 0) path = "/";
} else {
if (!path.endsWith("/")) {
path += "/";
}
path += res.name;
}
fetchDirectory();
}
}
function renameRessource(res: Ressource) {
fileToRename = res;
newFileName = res.name;
showRenameFile = true;
}
async function renameFile() {
if (fileToRename === undefined) return false;
return axios
.put(
url("/cdn/" + fileToRename.user + "/" + fileToRename.filename),
{
name: newFileName
},
{ withCredentials: true }
)
.then((response) => {
if (response.status === 200) {
fetchDirectory();
fileToRename = undefined;
newFileName = "";
return true;
} else {
alert("Failed to rename File: " + response.status);
}
return false;
})
.catch((err) => {
console.error(err);
return false;
});
}
function renameFileCancel() {
fileToRename = undefined;
newFileName = "";
}
function closeRessourceManager() {
selectedRessource = undefined;
}
$effect(() => {
if (show) {
fetchDirectory();
}
});
</script>
<Modal
bind:showModal={show}
okFn={async () => {
if (selectedRessource) ok({ ...selectedRessource });
return true;
}}
oncloseFn={closeRessourceManager}
okButtonText={selectedRessource !== undefined ? selectedRessource.name : "Ok"}
width="90%"
height="90%"
>
{#snippet header()}
<h2 class="text-3xl">Ressourcenmanager - {path}</h2>
{/snippet}
<div class="h-full">
{#if fetchingRessources}
Loading...
{:else if ressources.length > 0}
<div class="flex flex-col gap-2">
{#each ressources as ressource}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex grow cursor-pointer items-center justify-between rounded-sm border-1 border-solid p-1 hover:bg-gray-200"
onclick={() => ressourceClicked(ressource)}
class:bg-green-200={isRessource(ressource) &&
ressource._id === selectedRessource?._id}
>
<div>
{#if isDir(ressource)}
<i class="fa-solid fa-folder"></i>
{ressource.name}
{:else}
{#if ressource.mimetype.includes("image")}
<i class="fa-solid fa-image"></i>
{:else if ressource.mimetype.includes("audio")}
<i class="fa-solid fa-music"></i>
{:else}
<i class="fa-solid fa-file"></i>
{/if}
{ressource.name}
{/if}
</div>
{#if !(isDir(ressource) && ressource.name === "..")}
<div class="flex gap-4">
{#if isRessource(ressource)}
<!-- svelte-ignore a11y_consider_explicit_label -->
<button
type="button"
class="btn border-black"
onclick={(event) => {
event.stopPropagation();
renameRessource(ressource);
}}><i class="fa-solid fa-pencil"></i></button
>
{/if}
<!-- svelte-ignore a11y_consider_explicit_label -->
<button
type="button"
class="btn border-red-600 text-red-600"
onclick={(event) => {
event.stopPropagation();
if (isRessource(ressource)) {
showDeleteRessource = true;
resToDelete = ressource;
} else if (isDir(ressource)) {
dirToDelete = ressource;
showDeleteDir = true;
}
}}><i class="fa-solid fa-trash"></i></button
>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="flex h-full w-full grow-2 flex-col items-center justify-center">
<div class="text-[128px] text-gray-300"><i class="fa-solid fa-database"></i></div>
<div class="text-[24px] text-gray-500 select-none">
Noch keine Ressourcen vorhanden
</div>
</div>
{/if}
</div>
{#snippet actionButtons()}
<form
action={`${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}/upload`}
enctype="multipart/form-data"
method="post"
onsubmit={uploadData}
>
<input class="btn" type="file" name="file" onchange={handleFileChange} />
<input type="submit" value="Hochladen" class="btn" />
</form>
<button class="btn" type="button" onclick={() => (showNewDir = true)}>Neuer Ordner</button>
{/snippet}
</Modal>
<Modal bind:showModal={showNewDir} okFn={addDirectoryOk} cancelFn={addDirectoryCancel}>
{#snippet header()}
<h2 class="text-3xl">Neuer Ordner</h2>
{/snippet}
<div>
<label for="directory" class="">Name</label>
<div>
<input
type="text"
name="directory"
id="directory"
class="borders mt-2 mb-2 w-full"
bind:value={newDirName}
/>
</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</div>
</Modal>
<Modal
bind:showModal={showDeleteRessource}
okFn={async () => {
if (resToDelete === undefined) return false;
return deleteRessource(resToDelete);
}}
cancelFn={deleteRessourceCancel}
>
{#snippet header()}
<h2 class="text-3xl">Ressource löschen</h2>
{/snippet}
<div>
Soll die Ressource {resToDelete?.name} wirklich gelöscht werden?
</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</Modal>
<Modal bind:showModal={showDeleteDir} okFn={deleteDir} cancelFn={deleteDirCancel}>
{#snippet header()}
<h2 class="text-3xl">Ordner löschen</h2>
{/snippet}
<div>
Soll der Ordner {dirToDelete?.name} wirklich gelöscht werden? Alle darin enthaltenen Daten gehen
verloren.
</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</Modal>
<Modal bind:showModal={showRenameFile} okFn={renameFile} cancelFn={renameFileCancel}>
{#snippet header()}
<h2 class="text-3xl">Datei umbenennen</h2>
{/snippet}
<div>
<label for="directory" class="">Name</label>
<div>
<input
type="text"
name="directory"
id="directory"
class="borders mt-2 mb-2 w-full"
bind:value={newFileName}
/>
</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</div>
</Modal>
<style>
.borders {
border: 1px solid black;
border-radius: 5px;
display: flex;
padding: 2px;
}
</style>

View File

@@ -1 +1,35 @@
export type VisitedQuestions = number[][]; export type VisitedQuestions = number[][];
export type Directory = {
name: string;
isDir: true;
};
export type Ressource = {
_id: string;
fullpath: string;
path: string;
user: string;
mimetype: string;
name: string;
filename: string;
};
export function isDir(dir: Directory | Ressource): dir is Directory {
return (dir as Directory).isDir === true;
}
export function isRessource(ressource: Ressource | Directory): ressource is Ressource {
return (ressource as Directory).isDir === undefined;
}
export type GameId = string;
export type Game = {
name: string;
owner: string;
_id: GameId;
walls: WallId[];
};
export type WallId = string;

5
src/lib/util.ts Normal file
View File

@@ -0,0 +1,5 @@
import { env } from "$env/dynamic/public";
export function url(path: string) {
return `${env.PUBLIC_JEOPARDY_SERVER_PROTOCOL}://${env.PUBLIC_JEOPARDY_SERVER}${path.startsWith("/") ? "" : "/"}${path}`;
}

View File

@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import RessourceManager from "$lib/RessourceManager.svelte";
import UserSvelte from "$lib/User.svelte"; import UserSvelte from "$lib/User.svelte";
import websocket, { SocketConnectionType } from "$lib/websocket.svelte"; import websocket, { SocketConnectionType } from "$lib/websocket.svelte";
import axios from "axios"; import axios from "axios";
let showRessourceManager = $state(false);
$effect(() => { $effect(() => {
if (websocket.connectionType === SocketConnectionType.HOST) { if (websocket.connectionType === SocketConnectionType.HOST) {
console.log(`Type: ${websocket.connectionType}. Redirecting to /connected/games`); console.log(`Type: ${websocket.connectionType}. Redirecting to /connected/games`);
@@ -53,11 +56,12 @@
{#if UserSvelte.role === "admin"} {#if UserSvelte.role === "admin"}
<button type="button" class="btn" onclick={() => goto("/admin")}>Administration</button> <button type="button" class="btn" onclick={() => goto("/admin")}>Administration</button>
{/if} {/if}
<button type="button" class="btn" onclick={() => goto("/settings")}>Einstellungen</button> <button type="button" class="btn" onclick={() => (showRessourceManager = true)}
<button type="button" class="btn" onclick={logout}>Logout</button> >Ressourcen</button
<button type="button" class="btn" onclick={logoutFromAllDevices}
>Logout von allen Geräten</button
> >
<button type="button" class="btn" onclick={() => goto("/editor")}>Editor</button>
<button type="button" class="btn" onclick={() => goto("/settings")}>Einstellungen</button>
<button type="button" class="btn" onclick={logoutFromAllDevices}>Logout</button>
<div class="btn profile ps-2 pe-2"> <div class="btn profile ps-2 pe-2">
<i class="fa-regular fa-user"></i> <i class="fa-regular fa-user"></i>
{UserSvelte.username} {UserSvelte.username}
@@ -74,6 +78,13 @@
</div> </div>
</div> </div>
<RessourceManager
bind:show={showRessourceManager}
ok={(res) => {
console.log(res);
}}
></RessourceManager>
<style> <style>
.profile { .profile {
border-color: gray; border-color: gray;

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Modal from "$lib/Modal.svelte";
import type { Game } from "$lib/Types";
import { url } from "$lib/util";
import axios from "axios";
import { onMount } from "svelte";
let games: Game[] = $state([]);
let error = $state("");
let showNewGame = $state(false);
let newGameName = $state("");
let showDeleteGame = $state(false);
let gameToDelete: Game | undefined = $state();
async function addNewGame() {
if (!newGameName) return false;
return axios
.post(url("game"), { name: newGameName }, { withCredentials: true })
.then((response) => {
if (response.status === 200) {
fetchGames();
newGameName = "";
return true;
} else return false;
});
}
function addNewGameCancel() {
newGameName = "";
}
async function deleteGame() {
if (gameToDelete === undefined) return false;
return axios
.delete(url("/game/" + gameToDelete._id), { withCredentials: true })
.then((response) => {
if (response.status === 200) {
fetchGames();
gameToDelete = undefined;
return true;
} else {
console.error("Failed to delete Game: " + response.status);
return false;
}
})
.catch((err) => {
console.error(err);
return false;
});
}
function deleteGameCancel() {
gameToDelete = undefined;
}
function fetchGames() {
axios
.get(url("games"), { withCredentials: true })
.then((response) => {
if (response.status === 200) {
games = response.data;
games.sort((a, b) => a.name.localeCompare(b.name));
} else {
console.error("Could not fetch games: " + response.status);
}
})
.catch((err) => {
console.error(err);
});
}
onMount(() => {
fetchGames();
});
</script>
<div class="flex h-full flex-col">
<div class="flex items-center gap-4">
<h1 class="m-4 mb-8 text-7xl font-bold">Editor</h1>
<button class="btn" type="button" onclick={() => (showNewGame = true)}
><i class="fa-solid fa-plus"></i> Neues Spiel</button
>
</div>
{#if games.length > 0}
<div class="flex flex-col space-y-4 overflow-y-auto">
{#each games as game}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="ms-4 me-4 flex items-center justify-between rounded-xl border-2 p-2 hover:cursor-pointer hover:bg-emerald-200"
onclick={() => {
goto(`/editor/${game._id}`);
}}
>
<div>
{game.name}
</div>
<!-- svelte-ignore a11y_consider_explicit_label -->
<button
type="button"
class="btn border-red-600 text-red-600"
onclick={(event) => {
event.stopPropagation();
gameToDelete = game;
showDeleteGame = true;
}}><i class="fa-solid fa-trash"></i></button
>
</div>
{/each}
</div>
{:else}
<div class="flex h-full w-full grow-2 flex-col items-center justify-center">
<div class="text-[128px] text-gray-300"><i class="fa-solid fa-database"></i></div>
<div class="text-[24px] text-gray-500 select-none">Noch keine Spiele vorhanden</div>
</div>
{/if}
</div>
<Modal bind:showModal={showNewGame} okFn={addNewGame} cancelFn={addNewGameCancel}>
{#snippet header()}
<h2 class="text-3xl">Neues Spiel</h2>
{/snippet}
<div>
<label for="directory" class="">Name</label>
<div>
<input
type="text"
name="directory"
id="directory"
class="borders mt-2 mb-2 w-full"
bind:value={newGameName}
/>
</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</div>
</Modal>
<Modal bind:showModal={showDeleteGame} okFn={deleteGame} cancelFn={deleteGameCancel}>
{#snippet header()}
<h2 class="text-3xl">Spiel löschen</h2>
{/snippet}
<div>Soll das Spiel {gameToDelete?.name} wirklich gelöscht werden?</div>
{#if error.length > 0}
<div class="text-red-700">{error}</div>
{/if}
</Modal>
<style>
.borders {
border: 1px solid black;
border-radius: 5px;
display: flex;
padding: 2px;
}
</style>