about summary refs log tree commit diff
path: root/client/src
diff options
context:
space:
mode:
authorMelonai <einebeere@gmail.com>2021-01-20 23:18:09 +0100
committerMelonai <einebeere@gmail.com>2021-01-20 23:18:09 +0100
commit826c7c47785ee01d2b9267919132ada696425344 (patch)
tree901cc90be9a953a6c3f968b6c1abe33cc13774b4 /client/src
parent2953dec527cedaabaa5f0eb48637c5ddd4a4103b (diff)
downloadshorest-826c7c47785ee01d2b9267919132ada696425344.tar.zst
shorest-826c7c47785ee01d2b9267919132ada696425344.zip
Remade the client in SvelteKit
Diffstat (limited to 'client/src')
-rw-r--r--client/src/App.css179
-rw-r--r--client/src/App.js25
-rw-r--r--client/src/Components/Button.js15
-rw-r--r--client/src/Components/CopyButton.js24
-rw-r--r--client/src/Components/Form.js39
-rw-r--r--client/src/Components/Form.svelte64
-rw-r--r--client/src/Components/Loader.js13
-rw-r--r--client/src/Components/Response.js55
-rw-r--r--client/src/Components/Response.svelte33
-rw-r--r--client/src/Components/ResponseContainer.js12
-rw-r--r--client/src/Components/Responses.svelte24
-rw-r--r--client/src/Components/Title.js12
-rw-r--r--client/src/Components/Title.svelte22
-rw-r--r--client/src/Components/icons/ArrowIcon.svelte10
-rw-r--r--client/src/Components/icons/CrossIcon.svelte10
-rw-r--r--client/src/actions/shorten.ts50
-rw-r--r--client/src/app.html19
-rw-r--r--client/src/data/links.ts17
-rw-r--r--client/src/globals.d.ts39
-rw-r--r--client/src/index.css18
-rw-r--r--client/src/index.js17
-rw-r--r--client/src/routes/index.svelte44
-rw-r--r--client/src/serviceWorker.js141
-rw-r--r--client/src/utils/addProtocol.ts6
-rw-r--r--client/src/utils/checkUrl.ts10
-rw-r--r--client/src/utils/debounce.ts12
26 files changed, 360 insertions, 550 deletions
diff --git a/client/src/App.css b/client/src/App.css
deleted file mode 100644
index cf0e529..0000000
--- a/client/src/App.css
+++ /dev/null
@@ -1,179 +0,0 @@
-.title {
-  padding-left: 30px;
-  margin-bottom: 10px;
-  line-height: 1.42857143;
-  color: #ffffff;
-}
-
-.response-text {
-  margin-bottom: 0;
-  color: #C5C5D3;
-}
-
-.right-item {
-  color: #ffffff;
-  padding-right: 30px;
-  text-align: right;
-}
-
-.copy-text {
-  color: #c5c5d3;
-  -webkit-touch-callout: none !important;
-  -webkit-user-select: none !important;
-  -moz-user-select: none !important;
-  -ms-user-select: none !important;
-  user-select: none !important;
-}
-
-.input-group {
-  position: relative;
-  display: table;
-  border-collapse: separate;
-}
-
-.response-container {
-  display: flex;
-  align-items: center;
-  margin: 15px 30px 0 30px;
-  height: 14vh;
-  justify-content: space-between;
-  border-radius: 0 5vh 5vh 5vh;
-  background-color: #ffffff;
-}
-
-.input-field {
-  position: relative;
-  z-index: 2;
-  margin-bottom: 0;
-  text-indent: 0;
-  color: #3A3A5C;
-  background-color: #fff;
-  background-image: none;
-  box-sizing: border-box;
-  border: none;
-  outline: none;
-  height: 100%;
-  width: 100%;
-}
-
-.input-field-text {
-  margin-left: 30px;
-  box-sizing: border-box;
-  color: #C5C5D3;
-}
-
-.input-container {
-  z-index: 2;
-  width: 100%;
-  float: left;
-  display: flex;
-  align-items: center;
-  position: relative;
-  padding: 0;
-  border-radius: 5vh 0 0 5vh;
-  margin: 0;
-  box-sizing: border-box;
-  height: 10vh;
-  background-color: white;
-}
-
-.button-container {
-  display: table-cell;
-  position: relative;
-  font-size: 0;
-  white-space: nowrap;
-  width: 1%;
-  height: 0;
-  vertical-align: middle;
-}
-
-.button {
-  z-index: 2;
-  display: inline-block;
-  margin-bottom: 0;
-  font-weight: 400;
-  text-align: center;
-  white-space: nowrap;
-  vertical-align: middle;
-  -ms-touch-action: manipulation;
-  touch-action: manipulation;
-  cursor: pointer;
-  background-image: none;
-  background-color: #fff;
-  color: #3A3A5C;
-  padding: 4px 5vw;
-  height: 10vh;
-  -webkit-user-select: none;
-  -moz-user-select: none;
-  -ms-user-select: none;
-  user-select: none;
-  border-radius: 0 5vh 5vh 0;
-  -webkit-appearance: none;
-  -moz-appearance: none;
-  appearance: none;
-  border: none;
-}
-
-strong {
-  font-weight: normal;
-  color: #3A3A5C;
-}
-
-a {
-  text-decoration: none;
-}
-
-.disabled {
-  background-color: #ff1a61;
-  color: #ffffff;
-}
-
-@-webkit-keyframes scale {
-  0% {
-    -webkit-transform: scale(1);
-    transform: scale(1);
-    opacity: 1; }
-  45% {
-    -webkit-transform: scale(0.1);
-    transform: scale(0.1);
-    opacity: 0.2; }
-  80% {
-    -webkit-transform: scale(1);
-    transform: scale(1);
-    opacity: 1; } }
-
-@keyframes scale {
-  0% {
-    -webkit-transform: scale(1);
-    transform: scale(1);
-    opacity: 1; }
-  45% {
-    -webkit-transform: scale(0.1);
-    transform: scale(0.1);
-    opacity: 0.2; }
-  80% {
-    -webkit-transform: scale(1);
-    transform: scale(1);
-    opacity: 1; } }
-
-.ball-pulse > div:nth-child(1) {
-  -webkit-animation: scale 0.75s -0.24s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08);
-  animation: scale 0.75s -0.48s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08); }
-
-.ball-pulse > div:nth-child(2) {
-  -webkit-animation: scale 0.75s -0.12s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08);
-  animation: scale 0.75s -0.36s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08); }
-
-.ball-pulse > div:nth-child(3) {
-  -webkit-animation: scale 0.75s 0s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08);
-  animation: scale 0.75s -0.24s infinite cubic-bezier(0.2, 0.68, 0.18, 1.08); }
-
-.ball-pulse > div {
-  background-color: #3A3A5C;
-  width: 6px;
-  height: 6px;
-  margin: 5px;
-  border-radius: 100%;
-  -webkit-animation-fill-mode: both;
-  animation-fill-mode: both;
-  display: inline-block; }
\ No newline at end of file
diff --git a/client/src/App.js b/client/src/App.js
deleted file mode 100644
index 135a037..0000000
--- a/client/src/App.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import React, {useState} from 'react';
-import './App.css';
-import Title from './Components/Title';
-import Form from './Components/Form';
-import ResponseContainer from './Components/ResponseContainer';
-import shortid from 'shortid';
-
-function App() {
-    const [requests, setRequests] = useState([]);
-
-    const addRequest = (newRequest) => {
-        const newRequests = [{url: newRequest, key: shortid.generate()}, ...requests];
-        setRequests(newRequests.slice(0, 2));
-    }
-
-    return (
-        <div>
-            <Title/>
-            <Form addRequest={addRequest}/>
-            <ResponseContainer requests={requests}/>
-        </div>
-    );
-}
-
-export default App;
diff --git a/client/src/Components/Button.js b/client/src/Components/Button.js
deleted file mode 100644
index b6ed91b..0000000
--- a/client/src/Components/Button.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import React from 'react';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import {faArrowRight, faTimes} from '@fortawesome/free-solid-svg-icons';
-
-function Button(props) {
-    return (
-        <div className="button-container">
-            <button type="submit" className={"button" + (props.valid ? "" : " disabled")} id="btn" onClick={props.submit}>
-                <FontAwesomeIcon icon={props.valid ? faArrowRight : faTimes}/>
-            </button>
-        </div>
-    )
-}
-
-export default Button;
\ No newline at end of file
diff --git a/client/src/Components/CopyButton.js b/client/src/Components/CopyButton.js
deleted file mode 100644
index 65c4cb9..0000000
--- a/client/src/Components/CopyButton.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import React, {useState} from 'react';
-import copy from 'clipboard-copy';
-
-function CopyButton(props) {
-    const [copied, setCopied] = useState(false);
-
-    const handleClick = async () => {
-        await copy("https://sho.rest/" + props.hash);
-        setCopied(true);
-    };
-
-    let content;
-    if (copied) {
-        content = <span>Link Copied!</span>;
-    } else {
-        content = <strong>Copy Link</strong>;
-    }
-
-    return (
-        <span className="copy-text right-item" onClick={handleClick}>{content}</span>
-    )
-}
-
-export default CopyButton;
\ No newline at end of file
diff --git a/client/src/Components/Form.js b/client/src/Components/Form.js
deleted file mode 100644
index 979d9c9..0000000
--- a/client/src/Components/Form.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import React, {useState} from 'react';
-import Button from './Button';
-import isURL from "validator/lib/isURL";
-
-function Form(props) {
-    const [state, setState] = useState({value: '', valid: false});
-
-    const handleSubmit = () => {
-        if (state.valid) {
-            props.addRequest(state.value);
-        }
-    };
-
-    const handleChange = e => {
-        const userInput = e.target.value;
-        const valid = isURL('https://' + userInput);
-        setState({value: userInput, valid: valid});
-    };
-
-    const handlePaste = e => {
-        e.preventDefault();
-        const pattern = /^https?:\/\//;
-        setState({value: e.clipboardData.getData('Text').replace(pattern, ''), valid: false});
-    };
-
-    return (
-        <form id="form" onSubmit={(e) => e.preventDefault()}>
-            <div className="input-group">
-                <div className="input-container">
-                    <span className="input-field-text">https://</span>
-                    <input className="input-field" required value={state.value} onChange={handleChange} onPaste={handlePaste}/>
-                </div>
-                <Button valid={state.valid} submit={handleSubmit}/>
-            </div>
-        </form>
-    )
-}
-
-export default Form;
\ No newline at end of file
diff --git a/client/src/Components/Form.svelte b/client/src/Components/Form.svelte
new file mode 100644
index 0000000..327d25d
--- /dev/null
+++ b/client/src/Components/Form.svelte
@@ -0,0 +1,64 @@
+<script lang="ts">
+    import shorten from "$actions/shorten";
+    import { links } from "$data/links";
+    import checkUrl from "$utils/checkUrl";
+    import debounce from "$utils/debounce";
+    import ArrowIcon from "./icons/ArrowIcon.svelte";
+    import CrossIcon from "./icons/CrossIcon.svelte";
+
+    let value = "";
+    let valid = false;
+
+    function submit() {
+        const url = checkUrl(value);
+        if (url !== null) {
+            links.add(shorten(url));
+        }
+    }
+
+    const check = debounce(() => valid = !!checkUrl(value), 100);
+
+    // @ts-ignore: Value is a dependency
+    $: value, check();
+</script>
+
+<style>
+    form {
+        position: relative;
+        border: 1px solid #aaaabb;
+        box-shadow: 0 4px 6px #aaaabb30;
+        box-sizing: border-box;
+        border-radius: 5px;
+    }
+
+    .field {
+        box-sizing: border-box;
+        width: 100%;
+        border: none;
+        padding: 15px 50px 15px 20px;
+        background: transparent;
+        font-size: 1rem;
+    }
+
+    .button {
+        position: absolute;
+        right: 10px;
+        margin: auto;
+        top: 0;
+        bottom: 0;
+        background: transparent;
+        border: none;
+        cursor: pointer;
+    }
+</style>
+
+<form on:submit|preventDefault={submit}>
+    <input class="field" bind:value type="text"/>
+    <button class="button" type="submit">
+        {#if valid}
+            <ArrowIcon/>
+        {:else}
+            <CrossIcon/>
+        {/if}
+    </button>
+</form>
diff --git a/client/src/Components/Loader.js b/client/src/Components/Loader.js
deleted file mode 100644
index 3f4c47f..0000000
--- a/client/src/Components/Loader.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react';
-
-function Loader() {
-    return (
-        <div className="ball-pulse">
-            <div/>
-            <div/>
-            <div/>
-        </div>
-    )
-}
-
-export default Loader;
\ No newline at end of file
diff --git a/client/src/Components/Response.js b/client/src/Components/Response.js
deleted file mode 100644
index f69000a..0000000
--- a/client/src/Components/Response.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import React, {useEffect, useState} from 'react';
-import axios from "axios";
-import Loader from "./Loader";
-import CopyButton from "./CopyButton";
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faBomb } from '@fortawesome/free-solid-svg-icons';
-
-function Response(props){
-    const CancelToken = axios.CancelToken;
-    const [requestState, setRequestState] = useState({loading: true, cancel: CancelToken.source()});
-
-    useEffect(() => {
-        axios.post('/', {url: "https://" + props.url}, {cancelToken: requestState.cancel.token})
-            .then((r) => {
-                setRequestState({loading: false, hash: r.data.hash, cancel: requestState.cancel});
-            }).catch((e) => {
-                if (!axios.isCancel(e)) {
-                    setRequestState({loading: false, error: true, cancel: requestState.cancel});
-                }
-            });
-
-        return () => {
-            requestState.cancel.cancel();
-        };
-    }, [props.url, requestState.cancel])
-
-    let text;
-    let rightItem;
-    if (!requestState.loading) {
-        if (!requestState.error) {
-            rightItem = <CopyButton hash={requestState.hash}/>;
-            if (props.url.length < 20) {
-                text =
-                    <span>The short link for <strong>{props.url}</strong> is<br/><strong>sho.rest/{requestState.hash}</strong></span>;
-            } else {
-                text =
-                    <span>The short link for your URL is<br/><strong>sho.rest/{requestState.hash}</strong></span>;
-            }
-        } else {
-            rightItem = <FontAwesomeIcon className="right-item" icon={faBomb}/>;
-            text = <span>There was an error.</span>
-        }
-    } else {
-        text = <Loader/>
-    }
-
-    return (
-        <div className={"response-container" + (requestState.error ? " disabled" : "")}>
-            <div className={"title response-text" + (requestState.error ? " disabled" : "")}>{text}</div>
-            {rightItem}
-        </div>
-    )
-}
-
-export default Response;
\ No newline at end of file
diff --git a/client/src/Components/Response.svelte b/client/src/Components/Response.svelte
new file mode 100644
index 0000000..215af1b
--- /dev/null
+++ b/client/src/Components/Response.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+    import type { ShortenRequest } from "$actions/shorten";
+
+    export let info: ShortenRequest;
+</script>
+
+<style>
+    div {
+        display: flex;
+        justify-content: space-between;
+    }
+
+    .url {
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+    }
+
+    .output {
+        margin-left: 50px;
+    }
+</style>
+
+<div>
+    <span class="url">{info.url}</span>
+    {#await info.response}
+        <span class="output">Loading...</span>
+    {:then { hash }} 
+        <a class="output" href="https://sho.rest/{hash}">sho.rest/{hash}</a>
+    {:catch { error }}
+        <span class="output">{error}</span>
+    {/await}
+</div>
\ No newline at end of file
diff --git a/client/src/Components/ResponseContainer.js b/client/src/Components/ResponseContainer.js
deleted file mode 100644
index 8fad4bd..0000000
--- a/client/src/Components/ResponseContainer.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-import Response from "./Response";
-
-function ResponseContainer(props){
-    const responseContent = props.requests.map((r) => <Response key={r.key} url={r.url}/>);
-
-    return (
-        responseContent
-    )
-}
-
-export default ResponseContainer;
\ No newline at end of file
diff --git a/client/src/Components/Responses.svelte b/client/src/Components/Responses.svelte
new file mode 100644
index 0000000..7124f1e
--- /dev/null
+++ b/client/src/Components/Responses.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+    import Response from "./Response.svelte"
+    import { slide } from 'svelte/transition';
+    import { links } from "$data/links";
+</script>
+
+<style>
+    ul {
+        list-style: none;
+        padding: 0 10px;
+    }
+
+    li {
+        margin-bottom: 10px;
+    }
+</style>
+
+<ul>
+    {#each $links as info (info.nonce)}
+        <li transition:slide >
+            <Response {info}/>
+        </li>
+    {/each}
+</ul>
\ No newline at end of file
diff --git a/client/src/Components/Title.js b/client/src/Components/Title.js
deleted file mode 100644
index 8ea96a9..0000000
--- a/client/src/Components/Title.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-
-function Title() {
-    return (
-        <div className="title">
-            <span><b>sho.rest</b><br/></span>
-            <span>Made with ❤ by <b>Mel</b></span>
-        </div>
-    )
-}
-
-export default Title;
\ No newline at end of file
diff --git a/client/src/Components/Title.svelte b/client/src/Components/Title.svelte
new file mode 100644
index 0000000..4266eb1
--- /dev/null
+++ b/client/src/Components/Title.svelte
@@ -0,0 +1,22 @@
+<style>
+    p {
+        color: #212121;
+        margin: 0 0 5px 0;
+    }
+
+    .text {
+        margin: 10px 0 15px 0;
+    }
+
+    img {
+        width: 25px;
+    }
+</style>
+
+<div>
+    <img src="/shorest.svg" alt=""/>
+    <div class="text">
+        <p><b>sho.rest</b></p>
+        <p>Made with ❤ by <b>Mel</b></p>
+    </div>
+</div>
diff --git a/client/src/Components/icons/ArrowIcon.svelte b/client/src/Components/icons/ArrowIcon.svelte
new file mode 100644
index 0000000..52c79ae
--- /dev/null
+++ b/client/src/Components/icons/ArrowIcon.svelte
@@ -0,0 +1,10 @@
+<style>
+    svg {
+        width: 20px;
+        color: #212121;
+    }
+</style>
+
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
+</svg>
\ No newline at end of file
diff --git a/client/src/Components/icons/CrossIcon.svelte b/client/src/Components/icons/CrossIcon.svelte
new file mode 100644
index 0000000..55525d8
--- /dev/null
+++ b/client/src/Components/icons/CrossIcon.svelte
@@ -0,0 +1,10 @@
+<style>
+    svg {
+        width: 20px;
+        color: #212121;
+    }
+</style>
+
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+</svg>
\ No newline at end of file
diff --git a/client/src/actions/shorten.ts b/client/src/actions/shorten.ts
new file mode 100644
index 0000000..58dd4f0
--- /dev/null
+++ b/client/src/actions/shorten.ts
@@ -0,0 +1,50 @@
+interface ShortenResponse {
+    hash: string;
+}
+
+export interface ShortenRequest {
+    url: string;
+    nonce: string;
+    response: Promise<ShortenResponse>;
+}
+
+async function makeRequest(url: string): Promise<ShortenResponse> {
+    let body;
+
+    try {
+        const response = await fetch("http://localhost:4000/", {
+            headers: {
+                Accept: "application/json",
+                "Content-Type": "application/json",
+            },
+            method: "post",
+            body: JSON.stringify({ url }),
+        });
+
+        body = await response.json();
+    } catch (err) {
+        throw {
+            error: "Error!",
+        };
+    }
+
+    if (body.hash) {
+        return {
+            hash: body.hash,
+        };
+    } else {
+        throw {
+            message: body.error || "Error!",
+        };
+    }
+}
+
+export default function shorten(url: string): ShortenRequest {
+    const nonce = Math.random().toString(36).substr(2, 5);
+
+    return {
+        url,
+        nonce,
+        response: makeRequest(url),
+    };
+}
diff --git a/client/src/app.html b/client/src/app.html
new file mode 100644
index 0000000..fe758f5
--- /dev/null
+++ b/client/src/app.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8" />
+        <title>sho.rest</title>
+        <meta name="viewport" content="width=device-width, initial-scale=1" />
+        <link rel="icon" href="/favicon.ico" />
+        <link rel="stylesheet" href="/global.css" />
+        <link rel="preconnect" href="https://fonts.gstatic.com" />
+        <link
+            href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;700&display=swap"
+            rel="stylesheet"
+        />
+        %svelte.head%
+    </head>
+    <body>
+        %svelte.body%
+    </body>
+</html>
diff --git a/client/src/data/links.ts b/client/src/data/links.ts
new file mode 100644
index 0000000..3ccf100
--- /dev/null
+++ b/client/src/data/links.ts
@@ -0,0 +1,17 @@
+import type { ShortenRequest } from "$actions/shorten";
+import { Writable, writable } from "svelte/store";
+
+function createLinks() {
+    const { subscribe, update }: Writable<ShortenRequest[]> = writable([]);
+
+    function add(request: ShortenRequest) {
+        update((l) => [request, ...l.slice(0, 2)]);
+    }
+
+    return {
+        subscribe,
+        add,
+    };
+}
+
+export const links = createLinks();
diff --git a/client/src/globals.d.ts b/client/src/globals.d.ts
new file mode 100644
index 0000000..06d88b7
--- /dev/null
+++ b/client/src/globals.d.ts
@@ -0,0 +1,39 @@
+/// <reference types="@sveltejs/kit" />
+
+//#region Ensure Svelte file endings have a type for TypeScript
+declare module '*.svelte' {
+	export { SvelteComponent as default } from 'svelte';
+}
+//#endregion
+
+//#region Ensure image file endings have a type for TypeScript
+declare module "*.gif" {
+	const value: string;
+	export = value;
+}
+
+declare module "*.jpg" {
+	const value: string;
+	export = value;
+}
+
+declare module "*.jpeg" {
+	const value: string;
+	export = value;
+}
+
+declare module "*.png" {
+	const value: string;
+	export = value;
+}
+
+declare module "*.svg" {
+	const value: string;
+	export = value;
+}
+
+declare module "*.webp" {
+	const value: string;
+	export = value;
+}
+//#endregion
diff --git a/client/src/index.css b/client/src/index.css
deleted file mode 100644
index b7be091..0000000
--- a/client/src/index.css
+++ /dev/null
@@ -1,18 +0,0 @@
-html * {
-  font-family: 'Ubuntu', sans-serif;
-  font-size: 3vmin;
-}
-
-body {
-  background-color: #3A3A5C;
-  margin: 0;
-}
-
-#root {
-  justify-content: center;
-  position: absolute;
-  width: 100%;
-  box-sizing: border-box;
-  top: 40%;
-  padding: 0 50px 0 50px;
-}
\ No newline at end of file
diff --git a/client/src/index.js b/client/src/index.js
deleted file mode 100644
index f5185c1..0000000
--- a/client/src/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import './index.css';
-import App from './App';
-import * as serviceWorker from './serviceWorker';
-
-ReactDOM.render(
-  <React.StrictMode>
-    <App />
-  </React.StrictMode>,
-  document.getElementById('root')
-);
-
-// If you want your app to work offline and load faster, you can change
-// unregister() to register() below. Note this comes with some pitfalls.
-// Learn more about service workers: https://bit.ly/CRA-PWA
-serviceWorker.unregister();
diff --git a/client/src/routes/index.svelte b/client/src/routes/index.svelte
new file mode 100644
index 0000000..5e51f13
--- /dev/null
+++ b/client/src/routes/index.svelte
@@ -0,0 +1,44 @@
+<script lang="ts">
+	import Title from '$components/Title.svelte';
+	import Form from '$components/Form.svelte';
+	import Responses from '$components/Responses.svelte';
+</script>
+
+<style>
+	main {
+		width: 100%;
+		height: 100%;
+		display: flex;
+		flex-direction: column;
+		justify-content: center;
+		align-items: center;
+		overflow: hidden;
+	}
+
+	.output {
+		position: absolute;
+		top: 175px;
+		width: 100%;
+	}
+
+	.content {
+		position: relative;
+		width: 80%;
+	}
+
+	@media (min-width: 800px) {
+		.content {
+			width: 40%;
+		}
+	}
+</style>
+
+<main>
+	<div class="content">
+		<Title/>
+		<Form/>
+		<div class="output">
+			<Responses/>
+		</div>
+	</div>
+</main>
\ No newline at end of file
diff --git a/client/src/serviceWorker.js b/client/src/serviceWorker.js
deleted file mode 100644
index b04b771..0000000
--- a/client/src/serviceWorker.js
+++ /dev/null
@@ -1,141 +0,0 @@
-// This optional code is used to register a service worker.
-// register() is not called by default.
-
-// This lets the app load faster on subsequent visits in production, and gives
-// it offline capabilities. However, it also means that developers (and users)
-// will only see deployed updates on subsequent visits to a page, after all the
-// existing tabs open on the page have been closed, since previously cached
-// resources are updated in the background.
-
-// To learn more about the benefits of this model and instructions on how to
-// opt-in, read https://bit.ly/CRA-PWA
-
-const isLocalhost = Boolean(
-  window.location.hostname === 'localhost' ||
-    // [::1] is the IPv6 localhost address.
-    window.location.hostname === '[::1]' ||
-    // 127.0.0.0/8 are considered localhost for IPv4.
-    window.location.hostname.match(
-      /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
-    )
-);
-
-export function register(config) {
-  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
-    // The URL constructor is available in all browsers that support SW.
-    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
-    if (publicUrl.origin !== window.location.origin) {
-      // Our service worker won't work if PUBLIC_URL is on a different origin
-      // from what our page is served on. This might happen if a CDN is used to
-      // serve assets; see https://github.com/facebook/create-react-app/issues/2374
-      return;
-    }
-
-    window.addEventListener('load', () => {
-      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
-
-      if (isLocalhost) {
-        // This is running on localhost. Let's check if a service worker still exists or not.
-        checkValidServiceWorker(swUrl, config);
-
-        // Add some additional logging to localhost, pointing developers to the
-        // service worker/PWA documentation.
-        navigator.serviceWorker.ready.then(() => {
-          console.log(
-            'This web app is being served cache-first by a service ' +
-              'worker. To learn more, visit https://bit.ly/CRA-PWA'
-          );
-        });
-      } else {
-        // Is not localhost. Just register service worker
-        registerValidSW(swUrl, config);
-      }
-    });
-  }
-}
-
-function registerValidSW(swUrl, config) {
-  navigator.serviceWorker
-    .register(swUrl)
-    .then(registration => {
-      registration.onupdatefound = () => {
-        const installingWorker = registration.installing;
-        if (installingWorker == null) {
-          return;
-        }
-        installingWorker.onstatechange = () => {
-          if (installingWorker.state === 'installed') {
-            if (navigator.serviceWorker.controller) {
-              // At this point, the updated precached content has been fetched,
-              // but the previous service worker will still serve the older
-              // content until all client tabs are closed.
-              console.log(
-                'New content is available and will be used when all ' +
-                  'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
-              );
-
-              // Execute callback
-              if (config && config.onUpdate) {
-                config.onUpdate(registration);
-              }
-            } else {
-              // At this point, everything has been precached.
-              // It's the perfect time to display a
-              // "Content is cached for offline use." message.
-              console.log('Content is cached for offline use.');
-
-              // Execute callback
-              if (config && config.onSuccess) {
-                config.onSuccess(registration);
-              }
-            }
-          }
-        };
-      };
-    })
-    .catch(error => {
-      console.error('Error during service worker registration:', error);
-    });
-}
-
-function checkValidServiceWorker(swUrl, config) {
-  // Check if the service worker can be found. If it can't reload the page.
-  fetch(swUrl, {
-    headers: { 'Service-Worker': 'script' },
-  })
-    .then(response => {
-      // Ensure service worker exists, and that we really are getting a JS file.
-      const contentType = response.headers.get('content-type');
-      if (
-        response.status === 404 ||
-        (contentType != null && contentType.indexOf('javascript') === -1)
-      ) {
-        // No service worker found. Probably a different app. Reload the page.
-        navigator.serviceWorker.ready.then(registration => {
-          registration.unregister().then(() => {
-            window.location.reload();
-          });
-        });
-      } else {
-        // Service worker found. Proceed as normal.
-        registerValidSW(swUrl, config);
-      }
-    })
-    .catch(() => {
-      console.log(
-        'No internet connection found. App is running in offline mode.'
-      );
-    });
-}
-
-export function unregister() {
-  if ('serviceWorker' in navigator) {
-    navigator.serviceWorker.ready
-      .then(registration => {
-        registration.unregister();
-      })
-      .catch(error => {
-        console.error(error.message);
-      });
-  }
-}
diff --git a/client/src/utils/addProtocol.ts b/client/src/utils/addProtocol.ts
new file mode 100644
index 0000000..75c6214
--- /dev/null
+++ b/client/src/utils/addProtocol.ts
@@ -0,0 +1,6 @@
+export default function (url: string) {
+    if (!/^https?:\/\//.test(url)) {
+        url = "https://" + url;
+    }
+    return url;
+}
diff --git a/client/src/utils/checkUrl.ts b/client/src/utils/checkUrl.ts
new file mode 100644
index 0000000..8ed747f
--- /dev/null
+++ b/client/src/utils/checkUrl.ts
@@ -0,0 +1,10 @@
+import addProtocol from "./addProtocol";
+
+export default function (url: string): string | null {
+    try {
+        const normalizedUrl = new URL(addProtocol(url));
+        return normalizedUrl.toString();
+    } catch (e) {
+        return null;
+    }
+}
\ No newline at end of file
diff --git a/client/src/utils/debounce.ts b/client/src/utils/debounce.ts
new file mode 100644
index 0000000..86ef3db
--- /dev/null
+++ b/client/src/utils/debounce.ts
@@ -0,0 +1,12 @@
+type Procedure = (...args: any[]) => any;
+
+export default function <F extends Procedure>(f: F, duration: number) {
+    let timeout: ReturnType<typeof setTimeout> | null = null;
+    return function (...args: Parameters<F>) {
+        if (timeout !== null) {
+            clearTimeout(timeout);
+            timeout = null;
+        }
+        timeout = setTimeout(() => f(...args), duration);
+    };
+}