aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorgammelalf <gammelalf@hopfen.space>2023-01-23 03:12:21 +0100
committerFlorian Fischer <florian.fischer@muhq.space>2023-02-24 12:47:40 +0100
commit7b9d908d2c1612843990dd39decbdbf40e51dbd7 (patch)
tree540f2c6aaf25f9251e4666dc56f0afbf85b2944c /web
parent95f95783383a58af5250bcb66155a04971385b75 (diff)
downloadWRMS-7b9d908d2c1612843990dd39decbdbf40e51dbd7.tar.gz
WRMS-7b9d908d2c1612843990dd39decbdbf40e51dbd7.zip
Reimplemented current frontend in svelte
Diffstat (limited to 'web')
-rw-r--r--web/.eslintrc.cjs20
-rw-r--r--web/.gitignore6
-rw-r--r--web/.prettierignore3
-rw-r--r--web/README.md38
-rw-r--r--web/client.html294
-rw-r--r--web/package.json59
-rw-r--r--web/src/app.d.ts12
-rw-r--r--web/src/app.html13
-rw-r--r--web/src/lib/api.ts40
-rw-r--r--web/src/routes/+page.svelte159
-rw-r--r--web/src/routes/+page.ts1
-rw-r--r--web/static/client.css67
-rw-r--r--web/svelte.config.js23
-rw-r--r--web/tsconfig.json17
-rw-r--r--web/vite.config.ts8
15 files changed, 425 insertions, 335 deletions
diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs
new file mode 100644
index 0000000..3ccf435
--- /dev/null
+++ b/web/.eslintrc.cjs
@@ -0,0 +1,20 @@
+module.exports = {
+ root: true,
+ parser: '@typescript-eslint/parser',
+ extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
+ plugins: ['svelte3', '@typescript-eslint'],
+ ignorePatterns: ['*.cjs'],
+ overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
+ settings: {
+ 'svelte3/typescript': () => require('typescript')
+ },
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 2020
+ },
+ env: {
+ browser: true,
+ es2017: true,
+ node: true
+ }
+};
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..c97ad87
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+build/
+.svelte-kit/
+.idea/
+
+package-lock.json
diff --git a/web/.prettierignore b/web/.prettierignore
new file mode 100644
index 0000000..29e170b
--- /dev/null
+++ b/web/.prettierignore
@@ -0,0 +1,3 @@
+build/
+node_modules/
+.svelte-kit/
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..5c91169
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,38 @@
+# create-svelte
+
+Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
+
+## Creating a project
+
+If you're seeing this, you've probably already done this step. Congrats!
+
+```bash
+# create a new project in the current directory
+npm create svelte@latest
+
+# create a new project in my-app
+npm create svelte@latest my-app
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```bash
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+To create a production version of your app:
+
+```bash
+npm run build
+```
+
+You can preview the production build with `npm run preview`.
+
+> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
diff --git a/web/client.html b/web/client.html
deleted file mode 100644
index 2fbc4fd..0000000
--- a/web/client.html
+++ /dev/null
@@ -1,294 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>WRMS</title>
-
- <script>
- /*
- @licstart The following is the entire license notice for the
- JavaScript code in this page.
-
- Copyright (C) 2022 Florian Fischer
-
- The JavaScript code in this page is free software: you can
- redistribute it and/or modify it under the terms of the GNU
- General Public License (GNU GPL) as published by the Free Software
- Foundation, either version 3 of the License, or (at your option)
- any later version. The code is distributed WITHOUT ANY WARRANTY;
- without even the implied warranty of MERCHANTABILITY or FITNESS
- FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
-
- As additional permission under GNU GPL version 3 section 7, you
- may distribute non-source (e.g., minimized or compacted) forms of
- that code without the copy of the GNU GPL normally required by
- section 4, provided you include this license notice and a URL
- through which recipients can access the Corresponding Source.
-
-
- @licend The above is the entire license notice
- for the JavaScript code in this page.
- */
- </script>
-
- <script>
- let PLAY_PAUSE;
-
- const HttpClient = function() {
- function newRequest(callback) {
- const request = new XMLHttpRequest();
- request.onreadystatechange = function() {
- if (request.readyState === 4 && request.status === 200)
- callback(request.responseText);
- }
- return request;
- }
-
- this.get = function(url, callback) {
- const request = newRequest(callback);
- request.open("GET", url, true);
- request.send(null);
- }
-
- this.post = function(url, data, callback) {
- const request = newRequest(callback);
- request.open("POST", url, true);
- request.send(data);
- }
- }
-
- let songs = [];
- let playing = {};
- let votes = new Map();
-
- function formatSong(song) {
- if (song.artist)
- return song.artist + ' - ' + song.title
- return song.title
- }
-
- function renderPlaylist() {
- const playlist = document.getElementById("playlist");
- playlist.innerHTML = "";
-
- songs.sort(function(a, b) {return b.weight - a.weight});
-
- for (const song of songs) {
- const listItem = document.createElement("li");
-
- const btnHTML = "<svg width='36' height='36'><path d='M2 10h32L18 26 2 10z' fill='currentColor'></path></svg>";
- const upvoteBtn = document.createElement("span");
- const downvoteBtn = document.createElement("span");
-
- upvoteBtn.innerHTML = downvoteBtn.innerHTML = btnHTML;
- upvoteBtn.className = downvoteBtn.className = "vote";
- upvoteBtn.style.transform = 'rotate(180deg)';
-
- if (votes.has(song.uri)) {
- if (votes.get(song.uri) === "up")
- upvoteBtn.classList.toggle("on");
- else
- downvoteBtn.classList.toggle("on");
- }
-
- function onVote(event) {
- const btn = event.currentTarget;
- btn.classList.toggle("on");
- const isUpvoteBtn = btn === upvoteBtn;
- const isVote = btn.classList.contains("on");
-
- let url;
- if (isVote) {
- if (isUpvoteBtn) {
- downvoteBtn.classList.remove("on")
- votes.set(song.uri, "up")
- } else {
- upvoteBtn.classList.remove("on")
- votes.set(song.uri, "down")
- }
-
- url = "/" + (isUpvoteBtn ? "up" : "down") + "?song=" + encodeURIComponent(song.uri);
- } else {
- votes.delete(song.uri);
- url = "/unvote?song=" + encodeURIComponent(song.uri);
- }
-
- console.log("Get " + url);
- new HttpClient().get(url, console.log);
- }
- upvoteBtn.addEventListener("click", onVote);
- downvoteBtn.addEventListener("click", onVote);
-
- listItem.appendChild(upvoteBtn);
- listItem.appendChild(downvoteBtn);
-
- listItem.appendChild(document.createTextNode(song.weight + ' ' + formatSong(song)));
- const sourceLabel = document.createElement("SMALL");
- sourceLabel.innerHTML = " (" + song.source + ")";
- listItem.appendChild(sourceLabel);
- playlist.appendChild(listItem);
- }
- }
-
- function handleAdd(added) {
- songs = songs.concat(added);
- renderPlaylist();
- }
-
- function handleVotes(direction, votedSongs) {
- for (const song of votedSongs) {
- votes.set(song.uri, direction)
- }
- renderPlaylist();
- }
-
- function handleUpdate(updated) {
- for (const updated_song of updated) {
- songs[songs.findIndex(function(s) {return s.uri === updated_song.uri})] = updated_song;
- }
- renderPlaylist();
- }
-
- function handlePause() {
- PLAY_PAUSE.classList.remove("pause");
- PLAY_PAUSE.classList.add("play");
- }
-
- function handleStop() {
- PLAY_PAUSE.classList.remove("pause");
- PLAY_PAUSE.classList.add("play");
- playing = document.getElementById("playing").innerHTML = "";
- }
-
- function handlePlay(currentSong) {
- PLAY_PAUSE.classList.remove("play");
- PLAY_PAUSE.classList.add("pause");
-
- let idx = -1;
- songs.forEach(function(song, index) {
- if (song.uri === currentSong.uri)
- idx = index;
- });
- if (idx !== -1) {
- songs.splice(idx, 1);
- renderPlaylist();
- }
-
- const songLabel = document.createTextNode(formatSong(currentSong));
-
- playing = document.getElementById("playing");
- playing.innerHTML = "";
- playing.appendChild(songLabel);
- }
-
- const socket = new WebSocket("ws://localhost:8080/ws");
- console.log("Attempting Connection...");
-
- socket.onopen = () => {
- console.log("Successfully Connected");
- };
-
- socket.onclose = event => {
- console.log("Socket Closed Connection: ", event);
- };
-
- socket.onmessage = message => {
- console.log("Socket Received Message: ", message);
- const cmd = JSON.parse(message.data);
- switch (cmd.cmd) {
- case "add":
- handleAdd(cmd.songs)
- break;
- case "update":
- handleUpdate(cmd.songs)
- break;
- case "pause":
- handlePause()
- break;
- case "play":
- handlePlay(cmd.songs[0])
- break;
- case "upvoted":
- handleVotes("up", cmd.songs)
- break;
- case "downvoted":
- handleVotes("down", cmd.songs)
- break;
- }
- };
-
- socket.onerror = error => {
- console.log("Socket Error: ", error);
- };
-
- function submitSearch() {
- const form = new FormData(document.getElementById("searchForm"));
- const pattern = new URLSearchParams(form).toString();
- new HttpClient().get("/search?" + pattern, showSearchResults);
- return false;
- }
-
- function addSong(song) {
- new HttpClient().post("/add", JSON.stringify(song), console.log);
- }
-
- function showSearchResults(responseText) {
- const response = JSON.parse(responseText)
- if (!response)
- return
-
- const searchResults = document.getElementById("searchResults");
-
- // Clear search Results
- searchResults.innerHTML = "";
-
- for (const song of response.songs) {
- const listItem = document.createElement("div");
- listItem.className = "flex-row";
-
- listItem.innerHTML = "<button class='icon-button'><img src='static/add.svg' alt='Add'></button>" + formatSong(song) + " <small>(" + song.source + ")</small>";
- listItem.querySelector("button").addEventListener("click", () => addSong(song));
-
- searchResults.appendChild(listItem);
- }
- }
-
- window.onload = function() {
- PLAY_PAUSE = document.getElementById("play-pause");
- PLAY_PAUSE.addEventListener("click", function() {
- new HttpClient().get("/playpause", console.log);
- });
- }
- </script>
-
- <link rel="stylesheet" href="static/client.css">
- </head>
-
- <body>
- <div class="flex-column">
- <h2>Currently Playing</h2>
- <p id='playing'>Nothing</p>
- <div id='controls'>
- <button id="play-pause" class="icon-button play">
- <img src="static/play.svg" alt="Play">
- <img src="static/pause.svg" alt="Pause">
- </button>
- </div>
-
- <h2>Add your song</h2>
- <div id="add">
- <form id="searchForm" onsubmit="return submitSearch()">
- <label><input id="searchInput" name="pattern" type="text" placeholder="Title/Artist/Album/..."></label>
- <button id="searchButton">Search</button>
- </form>
- </div>
-
- <div id="searchResults" class="flex-column">
- </div>
-
- <h2>Playlist</h2>
- <ul id='playlist'></ul>
- </div>
- </body>
-</html>
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..e6e0eef
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "wrms",
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "lint": "prettier --plugin-search-dir . --check . && eslint .",
+ "format": "prettier --plugin-search-dir . --write ."
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^1.0.0",
+ "@sveltejs/adapter-static": "^1.0.5",
+ "@sveltejs/kit": "^1.0.0",
+ "@sveltejs/package": "^1.0.0",
+ "@tsconfig/svelte": "^3.0.0",
+ "@typescript-eslint/eslint-plugin": "^5.45.0",
+ "@typescript-eslint/parser": "^5.45.0",
+ "eslint": "^8.28.0",
+ "eslint-config-prettier": "^8.5.0",
+ "eslint-plugin-svelte3": "^4.0.0",
+ "prettier": "^2.8.0",
+ "prettier-plugin-svelte": "^2.8.1",
+ "svelte": "^3.54.0",
+ "svelte-check": "^3.0.2",
+ "svelte-preprocess": "^5.0.1",
+ "tslib": "^2.4.1",
+ "typescript": "^4.9.4",
+ "vite": "^4.0.0"
+ },
+ "prettier": {
+ "useTabs": true,
+ "singleQuote": true,
+ "trailingComma": "none",
+ "printWidth": 100,
+ "plugins": [
+ "prettier-plugin-svelte"
+ ],
+ "pluginSearchDirs": [
+ "."
+ ],
+ "overrides": [
+ {
+ "files": "*.svelte",
+ "options": {
+ "parser": "svelte"
+ }
+ }
+ ]
+ },
+ "eslintIgnore": [
+ ".svelte-kit",
+ "build",
+ "node_modules"
+ ],
+ "type": "module"
+}
diff --git a/web/src/app.d.ts b/web/src/app.d.ts
new file mode 100644
index 0000000..f59b884
--- /dev/null
+++ b/web/src/app.d.ts
@@ -0,0 +1,12 @@
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/web/src/app.html b/web/src/app.html
new file mode 100644
index 0000000..1d67ac6
--- /dev/null
+++ b/web/src/app.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <title>WRMS</title>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <link rel="stylesheet" href="%sveltekit.assets%/client.css" />
+ %sveltekit.head%
+ </head>
+ <body>
+ <div>%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 0000000..032cbd4
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,40 @@
+export type Song = {
+ title: string;
+ artist: string;
+ source: string;
+ uri: string;
+ weight: number;
+};
+
+export type WsMessage = {
+ cmd: 'add' | 'update' | 'pause' | 'play' | 'upvoted' | 'downvoted';
+ songs: Array<Song>;
+};
+export type SearchResponse = {
+ cmd: 'search';
+ songs: Array<Song>;
+};
+
+const HEADERS = new Headers([
+ ['Accept', 'application/json'],
+ ['Content-Type', 'application/json']
+]);
+const API = {
+ baseUrl: '',
+ get(url: string, body?: Record<string, string>) {
+ if (body !== undefined) {
+ url += '?' + new URLSearchParams(body);
+ }
+ return fetch(this.baseUrl + url, {
+ method: 'GET'
+ });
+ },
+ post(url: string, body?: object) {
+ return fetch(this.baseUrl + url, {
+ method: 'POST',
+ body: body && JSON.stringify(body),
+ headers: HEADERS
+ });
+ }
+};
+export default API;
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte
new file mode 100644
index 0000000..1cb4c71
--- /dev/null
+++ b/web/src/routes/+page.svelte
@@ -0,0 +1,159 @@
+<script lang="ts">
+ import { onDestroy, onMount } from 'svelte';
+ import addSVG from '../../static/play.svg';
+ import playSVG from '../../static/play.svg';
+ import pauseSVG from '../../static/pause.svg';
+ import type { WsMessage, Song, SearchResponse } from '../lib/api';
+ import API from '../lib/api';
+ API.baseUrl = '/';
+
+ let status: 'paused' | 'playing' = 'paused';
+ let currentSong: Song | null = null;
+ let playlist: Array<Song> = [];
+ let votes: Map<string, 'up' | 'down'> = new Map();
+
+ let search = '';
+ let searchResults: Array<Song> = [];
+
+ let socket: WebSocket;
+ onMount(function () {
+ socket = new WebSocket('ws://localhost:8080/ws');
+ console.info('Attempting Connection...');
+
+ socket.onopen = () => {
+ console.info('Successfully Connected');
+ };
+
+ socket.onclose = (event) => {
+ console.info('Socket Closed Connection: ', event);
+ };
+
+ socket.onerror = (error) => {
+ console.error('Socket Error: ', error);
+ };
+
+ socket.onmessage = ({ data }) => {
+ const { cmd, songs }: WsMessage = JSON.parse(data);
+ console.debug('Ws Message', cmd, songs);
+ switch (cmd) {
+ case 'add':
+ playlist = [...playlist, ...songs];
+ break;
+ case 'update':
+ for (const song of songs) {
+ playlist[
+ playlist.findIndex(({ uri }) => {
+ return uri === song.uri;
+ })
+ ] = song;
+ }
+ break;
+ case 'pause':
+ status = 'paused';
+ break;
+ case 'play':
+ status = 'playing';
+ currentSong = songs[0];
+ playlist = playlist.filter(({ uri }) => uri !== currentSong?.uri);
+ break;
+ case 'upvoted':
+ for (const { uri } of songs) {
+ votes.set(uri, 'up');
+ }
+ votes = votes;
+ break;
+ case 'downvoted':
+ for (const { uri } of songs) {
+ votes.set(uri, 'down');
+ }
+ votes = votes;
+ break;
+ }
+ };
+ });
+ onDestroy(function () {
+ socket?.close();
+ });
+
+ $: playlist = playlist.sort(function (a, b) {
+ return b.weight - a.weight;
+ });
+
+ async function submitSearch() {
+ const response = await API.get('search', { pattern: search });
+ const { songs }: SearchResponse = await response.json();
+ searchResults = songs;
+ }
+
+ function vote(direction: 'up' | 'down', uri: string) {
+ return async function () {
+ let action;
+ if (votes.get(uri) === direction) {
+ votes.delete(uri);
+ action = 'unvote';
+ } else {
+ votes.set(uri, direction);
+ action = direction;
+ }
+ const response = await API.get(action, { song: uri });
+ console.debug(response);
+ };
+ }
+</script>
+
+<div class="flex-column">
+ <h2>Currently Playing</h2>
+ <p>{currentSong?.title || 'Nothing'}</p>
+ <div id="controls">
+ <button class="icon-button play" on:click={() => API.get('playpause').then(console.debug)}>
+ {#if status === 'playing'}
+ <img src={pauseSVG} alt="Pause" />
+ {:else}
+ <img src={playSVG} alt="Play" />
+ {/if}
+ </button>
+ </div>
+ <div class="flex-row">
+ <div class="flex-column">
+ <h1>Add your song</h1>
+ <form on:submit|preventDefault={submitSearch}>
+ <label><input bind:value={search} placeholder="Title/Artist/Album/..." /></label>
+ <button formaction="submit">Search</button>
+ {#each searchResults as { title, artist, source, uri, weight } (uri)}
+ <div class="flex-row search-results">
+ <button
+ class="icon-button"
+ on:click={() =>
+ API.post('add', { title, artist, source, uri, weight }).then(console.debug)}
+ >
+ <img src={addSVG} alt="Add" />
+ </button>
+ {artist + ' - ' + title}
+ <small>{source}</small>
+ </div>
+ {/each}
+ </form>
+ </div>
+ <div class="flex-column">
+ <h1>Playlist</h1>
+ {#each playlist as { title, artist, source, uri, weight } (uri)}
+ <div>
+ <button class="vote" on:click={vote('up', uri)}>Up</button>
+ <button class="vote" on:click={vote('down', uri)}>Down</button>
+ {`${weight} ${artist} - ${title}`}
+ <small>{`(${source})`}</small>
+ </div>
+ {/each}
+ </div>
+ </div>
+</div>
+
+<style>
+ .search-results > button {
+ --icon-size: 2em;
+ }
+ .search-results > small {
+ color: var(--secondary);
+ margin-left: 0.5em;
+ }
+</style>
diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts
new file mode 100644
index 0000000..189f71e
--- /dev/null
+++ b/web/src/routes/+page.ts
@@ -0,0 +1 @@
+export const prerender = true;
diff --git a/web/static/client.css b/web/static/client.css
index c01b307..ba4c864 100644
--- a/web/static/client.css
+++ b/web/static/client.css
@@ -1,60 +1,45 @@
:root {
- --background: #222222;
- --primary: white;
- --secondary: gray;
- --link: yellow;
+ --background: #222222;
+ --primary: white;
+ --secondary: gray;
+ --link: yellow;
}
html {
- width: 100%;
- overflow: hidden;
+ width: 100%;
+ overflow: hidden;
}
body {
- position: fixed;
- overflow-y: scroll;
- -webkit-overflow-scrolling: touch;
- width: 100%;
- height: 100%;
- margin: 0;
- background-color: var(--background);
- color: var(--primary);
+ position: fixed;
+ overflow-y: scroll;
+ -webkit-overflow-scrolling: touch;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ background-color: var(--background);
+ color: var(--primary);
}
.flex-column {
- display: flex;
- flex-direction: column;
- align-items: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
}
.flex-row {
- display: flex;
- flex-direction: row;
- align-items: center;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
}
.icon-button {
- --icon-size: 4em;
- border: none;
- background-color: transparent;
+ --icon-size: 4em;
+ border: none;
+ background-color: transparent;
}
.icon-button > img {
- width: var(--icon-size);
- height: var(--icon-size);
- filter: invert(100%);
-}
-
-#play-pause.pause > img:nth-child(1) {
- display: none;
-}
-#play-pause.play > img:nth-child(2) {
- display: none;
-}
-
-#searchResults > div > button {
- --icon-size: 2em;
-}
-#searchResults > div > small {
- color: var(--secondary);
- margin-left: 0.5em;
+ width: var(--icon-size);
+ height: var(--icon-size);
+ filter: invert(100%);
}
diff --git a/web/svelte.config.js b/web/svelte.config.js
new file mode 100644
index 0000000..8c8f77b
--- /dev/null
+++ b/web/svelte.config.js
@@ -0,0 +1,23 @@
+import adapter from '@sveltejs/adapter-static';
+import { vitePreprocess } from '@sveltejs/kit/vite';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ // Consult https://kit.svelte.dev/docs/integrations#preprocessors
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+
+ kit: {
+ adapter: adapter({
+ // default options are shown. On some platforms
+ // these options are set automatically — see below
+ pages: 'build',
+ assets: 'build',
+ fallback: null,
+ precompress: false,
+ strict: true
+ })
+ }
+};
+
+export default config;
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..6ae0c8c
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true
+ }
+ // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
+ //
+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+ // from the referenced tsconfig.json - TypeScript does not merge them in
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..1695034
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,8 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import type { UserConfig } from 'vite';
+
+const config: UserConfig = {
+ plugins: [sveltekit()]
+};
+
+export default config;