From a04b54ce996bd460c51e40a624a5f623244263e5 Mon Sep 17 00:00:00 2001
From: Niki Wix Skaarup <niki@skaarup.dev>
Date: Wed, 2 Apr 2025 01:56:28 +0200
Subject: [PATCH] table done and finished shitty auth

---
 src/components/input-email.svelte    |  33 ++++++++
 src/components/input-password.svelte |  32 ++++++++
 src/components/login.svelte          |  48 +++++++++--
 src/components/main.svelte           |   2 +-
 src/components/progress-table.svelte | 100 +++++++++++++++++++++++
 src/index.css                        |   6 ++
 src/index.svelte                     |   6 +-
 src/index.ts                         |   1 -
 src/server.ts                        | 102 +++++++++++++++++++++++
 src/server/pt-api.ts                 | 118 +++++++++++++++++++++++++++
 src/shared.svelte.ts                 |  28 +++++++
 src/util.ts                          |  47 +++++++++++
 12 files changed, 513 insertions(+), 10 deletions(-)
 create mode 100644 src/components/input-email.svelte
 create mode 100644 src/components/input-password.svelte
 create mode 100644 src/components/progress-table.svelte
 create mode 100644 src/server/pt-api.ts
 create mode 100644 src/shared.svelte.ts
 create mode 100644 src/util.ts

diff --git a/src/components/input-email.svelte b/src/components/input-email.svelte
new file mode 100644
index 0000000..a412661
--- /dev/null
+++ b/src/components/input-email.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+  import type { ClassValue } from 'svelte/elements';
+
+  const {
+    class: className = undefined,
+    id,
+    name,
+    label = 'Email',
+    required = false,
+  }: {
+    class?: ClassValue;
+    id: string;
+    name: string;
+    required?: boolean;
+    label?: string;
+  } = $props();
+</script>
+
+<div class="relative {className ?? ''}">
+  <input
+    {id}
+    type="text"
+    {name}
+    class="peer w-full min-w-0 rounded bg-gray-200 px-2 py-1.5 text-black"
+    inputmode="email"
+    placeholder=""
+    {required}
+  />
+  <label
+    class="pointer-events-none absolute -top-6 left-0 text-sm transition-[translate,color,font-size] peer-placeholder-shown:translate-x-2 peer-placeholder-shown:translate-y-7.5 peer-placeholder-shown:text-base peer-placeholder-shown:text-black/50 peer-focus:translate-x-0 peer-focus:translate-y-0 peer-focus:text-sm peer-focus:text-white"
+    for={id}>{label}</label
+  >
+</div>
diff --git a/src/components/input-password.svelte b/src/components/input-password.svelte
new file mode 100644
index 0000000..98352d3
--- /dev/null
+++ b/src/components/input-password.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+  import type { ClassValue } from 'svelte/elements';
+
+  const {
+    class: className = undefined,
+    id,
+    name,
+    label = 'Password',
+    required = false,
+  }: {
+    class?: ClassValue;
+    id: string;
+    name: string;
+    required?: boolean;
+    label?: string;
+  } = $props();
+</script>
+
+<div class="relative {className ?? ''}">
+  <input
+    {id}
+    type="password"
+    {name}
+    class="peer w-full min-w-0 rounded bg-gray-200 px-2 py-1.5 text-black"
+    placeholder=""
+    {required}
+  />
+  <label
+    class="pointer-events-none absolute -top-6 left-0 text-sm transition-[translate,color,font-size] peer-placeholder-shown:translate-x-2 peer-placeholder-shown:translate-y-7.5 peer-placeholder-shown:text-base peer-placeholder-shown:text-black/50 peer-focus:translate-x-0 peer-focus:translate-y-0 peer-focus:text-sm peer-focus:text-white"
+    for={id}>{label}</label
+  >
+</div>
diff --git a/src/components/login.svelte b/src/components/login.svelte
index b1e9bed..5c171a7 100644
--- a/src/components/login.svelte
+++ b/src/components/login.svelte
@@ -1,10 +1,46 @@
 <script>
-  function login() {
-    globalThis.user = { name: 'John Doe' };
-    console.log('User logged in:', globalThis.user);
+  import { userstate } from '../shared.svelte';
+  import InputEmail from './input-email.svelte';
+  import InputPassword from './input-password.svelte';
+
+  async function handleSubmit(event) {
+    event.preventDefault();
+    const form = event.target;
+
+    const formData = new FormData(form);
+    const data = Object.fromEntries(formData.entries());
+
+    try {
+      const response = await fetch('/auth/login', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(data),
+        credentials: 'same-origin',
+      });
+
+      if (response.ok) {
+        // window.location.reload();
+
+        userstate.checkIsLoggedIn();
+      } else {
+        form.reset();
+      }
+    } catch (error) {
+      console.error('Error:', error);
+    }
   }
 </script>
 
-<button onclick={login}> Login </button>
-
-<button type="button" onclick={(e) => console.log(e)}> login2 </button>
+<div class="col-[content] flex grow items-center justify-center">
+  <form onsubmit={handleSubmit} class="flex w-full max-w-xs flex-col gap-6 px-2">
+    <InputEmail label="Email" id="login-email" name="email" required></InputEmail>
+    <InputPassword class="mt-2" label="Password" id="login-password" name="password" required
+    ></InputPassword>
+    <div>
+      <button
+        class="flex cursor-pointer items-center justify-center rounded bg-pink-700 px-2.5 py-1.5 text-white transition-[background-color] hover:bg-pink-600 active:outline-1 active:outline-white"
+        type="submit">Login</button
+      >
+    </div>
+  </form>
+</div>
diff --git a/src/components/main.svelte b/src/components/main.svelte
index 237d15f..bc64388 100644
--- a/src/components/main.svelte
+++ b/src/components/main.svelte
@@ -4,6 +4,6 @@
   const { children }: { children?: Snippet } = $props();
 </script>
 
-<main id="main-content">
+<main id="main-content" class="content grid min-h-(--main-min-height) pt-4 pb-20">
   {@render children?.()}
 </main>
diff --git a/src/components/progress-table.svelte b/src/components/progress-table.svelte
new file mode 100644
index 0000000..618f2d2
--- /dev/null
+++ b/src/components/progress-table.svelte
@@ -0,0 +1,100 @@
+<script lang="ts">
+  import { fetchEntries, formatter } from '../util';
+
+  let entriesPromise = $state(fetchEntries());
+
+  let interval: Parameters<typeof clearInterval>[0] = undefined;
+  $effect(() => {
+    clearInterval(interval);
+
+    // Pooling interval to fetch entries every 31 seconds
+    interval = setInterval(async () => {
+      const promise = fetchEntries();
+      // wait for the promise to resolve before assigning to prevent flash
+      await promise;
+      entriesPromise = promise;
+    }, 1000 * 31);
+
+    return () => clearInterval(interval);
+  });
+</script>
+
+<div class="col-[content]">
+  {#await entriesPromise}
+    <p class="text-center">Loading entries...</p>
+  {:then entries}
+    {#if entries.length === 0}
+      <p class="text-center">No entries.</p>
+    {:else}
+      {@render table(entries)}
+    {/if}
+  {:catch error}
+    <p class="mx-auto max-w-md text-center">
+      Something went wrong fetching entries: {error.message}
+    </p>
+  {/await}
+</div>
+
+{#snippet table(entries: Awaited<ReturnType<typeof fetchEntries>>)}
+  <div
+    class="@container grid w-full grid-cols-[auto,1fr,auto,auto,auto] gap-1 font-light @lg:gap-3"
+  >
+    <div
+      class="sticky top-(--header-height) col-span-5 grid grid-cols-[subgrid] gap-1 bg-gray-950 py-2 text-gray-400 capitalize"
+    >
+      <div>id</div>
+      <div class="px-1">name</div>
+      <div class="hidden px-1 @3xl:block">done</div>
+      <div class="hidden px-1 @5xl:block">created</div>
+      <div class="hidden px-1 @xl:block">updated</div>
+    </div>
+    {#each entries as entry (entry.id)}
+      <div class="col-span-5 grid grid-cols-[subgrid] gap-1">
+        <div class="py-2 text-xs text-gray-400/50">{entry.id}</div>
+        <a
+          href={entry.href}
+          class="block px-2 py-1 text-gray-200 capitalize transition-colors selection:bg-pink-600 selection:text-gray-200 hover:text-pink-600 hover:underline"
+          target="_blank"
+          referrerpolicy="no-referrer">{entry.name}</a
+        >
+        {@render done(entry.finished)}
+        <div class="hidden max-w-24 px-1 py-1 text-xs text-gray-200 @5xl:block">
+          {formatter.format(entry.created_at)}
+        </div>
+        <div class="hidden max-w-24 px-1 py-1 text-xs text-gray-200 @xl:block">
+          {formatter.format(entry.updated_at)}
+        </div>
+      </div>
+    {/each}
+  </div>
+{/snippet}
+
+{#snippet done(finished: boolean)}
+  {#if finished}
+    <svg
+      class="hidden text-green-500 @3xl:block"
+      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"><path d="M20 6 9 17l-5-5" /></svg
+    >
+  {:else}
+    <svg
+      class="hidden text-red-500 @3xl:block"
+      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"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg
+    >
+  {/if}
+{/snippet}
diff --git a/src/index.css b/src/index.css
index 5c13b75..1c0ea34 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,4 +1,5 @@
 @import 'tailwindcss' source('.');
+@source './components/main.svelte';
 
 @theme {
   --breakout-size: calc((var(--breakpoint-xl) - var(--breakpoint-lg)) / 2);
@@ -7,6 +8,11 @@
   --content-val: min(100% - calc(var(--spacing) * 8), var(--breakpoint-lg));
 }
 
+:root {
+  --header-height: calc(var(--spacing) * 16);
+  --main-min-height: calc(100dvh - var(--header-height));
+}
+
 @layer base {
   * {
     min-width: 0;
diff --git a/src/index.svelte b/src/index.svelte
index 84e0bf2..75842cd 100644
--- a/src/index.svelte
+++ b/src/index.svelte
@@ -3,13 +3,15 @@
   import Header from './components/header.svelte';
   import Main from './components/main.svelte';
   import Login from './components/login.svelte';
+  import ProgressTable from './components/progress-table.svelte';
+  import { userstate } from './shared.svelte';
 </script>
 
 <Header></Header>
 <Main>
-  {#if !globalThis.user}
+  {#if !userstate.isLoggedIn}
     <Login></Login>
   {:else}
-    {globalThis.user.name}
+    <ProgressTable></ProgressTable>
   {/if}
 </Main>
diff --git a/src/index.ts b/src/index.ts
index 6cc2355..3a852b5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,7 +4,6 @@ import './index.css';
 
 declare global {
   var didMount: boolean | undefined;
-  var user: { name: string } | undefined;
 }
 
 let app: Record<string, any> | undefined;
diff --git a/src/server.ts b/src/server.ts
index 5e78869..7b235d5 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -6,6 +6,18 @@ import sitemapTxt from './static/sitemap.txt';
 import icon from './static/favicon.png' with { type: 'file' };
 const favicon = await Bun.file(icon).bytes();
 const development = env.NODE_ENV !== 'production';
+import { randomUUIDv7 } from 'bun';
+import ptApi from './server/pt-api';
+
+declare global {
+  var loginTokens: Set<string>;
+}
+
+if (!globalThis.loginTokens) {
+  globalThis.loginTokens = new Set<string>();
+}
+
+const authCookie = 'pt-auth';
 
 Bun.serve({
   routes: {
@@ -20,9 +32,99 @@ Bun.serve({
       headers: { 'Content-Type': 'image/png' },
     }),
     '/health': new Response('OK'),
+    '/api/entries': {
+      async GET(req) {
+        if (!isLoggedIn(req)) return unauthorizedResp();
+
+        const data = await ptApi.query();
+        return new Response(JSON.stringify(data), {
+          headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' },
+        });
+      },
+    },
+    '/auth/logout': {
+      async POST() {
+        return new Response('Logout successful', { headers: logoutHeaders() });
+      },
+    },
+    '/auth/login': {
+      async POST(req) {
+        const data = await req.json();
+        const email = data.email;
+        const password = data.password;
+
+        if (
+          typeof email !== 'string' ||
+          typeof password !== 'string' ||
+          email.length < 3 ||
+          password.length === 0
+        ) {
+          return new Response('Missing email or password', { status: 400 });
+        }
+
+        await new Promise((resolve) => setTimeout(resolve, 100));
+
+        if (email === env.EMAIL && password === env.PASSWORD) {
+          let token = randomUUIDv7('base64url');
+          while (globalThis.loginTokens.has(token)) {
+            // generate a new token if it already exists
+            // this is unlikely to happen, but just in case
+            token = randomUUIDv7();
+          }
+
+          const cookie = new Bun.Cookie({
+            name: authCookie,
+            value: token,
+            path: '/',
+            maxAge: 60 * 60 * 24 * 31,
+            secure: true,
+            sameSite: 'strict',
+          });
+
+          const headers = new Headers({ 'Set-Cookie': cookie.toString() });
+
+          globalThis.loginTokens.add(token);
+
+          return new Response('Login successful', { headers });
+        }
+
+        await new Promise((resolve) => setTimeout(resolve, 900));
+
+        return new Response('Incorrect email or password', {
+          status: 400,
+        });
+      },
+    },
   },
   development,
   // async fetch(req, server) {
   //   return new Response("Not found", { status: 404 });
   // },
 });
+
+function isLoggedIn(req: Request) {
+  const cookie = new Bun.CookieMap(req.headers.get('cookie') || '');
+  const token = cookie.get(authCookie);
+
+  if (!token) {
+    return false;
+  }
+
+  return globalThis.loginTokens.has(token);
+}
+
+function logoutHeaders() {
+  return new Headers({
+    'Set-Cookie': new Bun.Cookie({
+      name: authCookie,
+      path: '/',
+      maxAge: -1,
+      secure: true,
+      sameSite: 'strict',
+    }).toString(),
+  });
+}
+
+function unauthorizedResp() {
+  return new Response('Unauthorized', { status: 401, headers: logoutHeaders() });
+}
diff --git a/src/server/pt-api.ts b/src/server/pt-api.ts
new file mode 100644
index 0000000..7e34c4e
--- /dev/null
+++ b/src/server/pt-api.ts
@@ -0,0 +1,118 @@
+import { env } from 'bun';
+
+type SelectBookmark = {
+  id: number;
+  name: string;
+  href: string;
+  finished: boolean;
+  created_at: number;
+  updated_at: number;
+};
+
+const baseUrl = env.API_SERVICE_URL;
+const bookmarksUrl = `${baseUrl}/bookmarks`;
+
+const noBodyHeaders = new Headers({
+  Authorization: `Bearer ${env.BEARER_TOKEN}`,
+});
+
+const bodyHeaders = new Headers({
+  'Content-Type': 'application/json',
+  Authorization: `Bearer ${env.BEARER_TOKEN}`,
+});
+
+async function query(): Promise<Array<SelectBookmark>> {
+  try {
+    const response = await fetch(bookmarksUrl, {
+      method: 'GET',
+      signal: AbortSignal.timeout(50000),
+      headers: noBodyHeaders,
+    });
+
+    if (!response.ok) {
+      throw new Error('Failed to fetch bookmarks');
+    }
+
+    const data = await response.json();
+    if (!Array.isArray(data)) {
+      throw new Error('Invalid bookmarks data');
+    }
+
+    return data;
+  } catch (err) {
+    console.error(err);
+    throw new Error('Failed to fetch bookmarks');
+  }
+}
+
+async function remove(id: number) {
+  try {
+    const response = await fetch(`${bookmarksUrl}/${id}`, {
+      method: 'DELETE',
+      signal: AbortSignal.timeout(5000),
+      headers: noBodyHeaders,
+    });
+
+    if (!response.ok) {
+      throw new Error('Failed to remove bookmark');
+    }
+  } catch (err) {
+    console.error(err);
+    throw new Error('Failed to remove bookmark');
+  }
+}
+
+async function update(id: number, body: { name: string; href: string }) {
+  try {
+    const response = await fetch(`${bookmarksUrl}/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(body),
+      signal: AbortSignal.timeout(5000),
+      headers: bodyHeaders,
+    });
+
+    if (!response.ok) {
+      throw new Error('Failed to update bookmark');
+    }
+  } catch (err) {
+    console.error(err);
+    throw new Error('Failed to update bookmark');
+  }
+}
+
+async function check(id: number, finished: boolean) {
+  try {
+    const response = await fetch(`${bookmarksUrl}/${id}/check/${finished}`, {
+      method: 'PUT',
+      signal: AbortSignal.timeout(5000),
+      headers: noBodyHeaders,
+    });
+    if (!response.ok) {
+      throw new Error('Failed to check bookmark');
+    }
+  } catch (err) {
+    console.error(err);
+    throw new Error('Failed to check bookmark');
+  }
+}
+
+async function create(body: { name: string; href: string }) {
+  try {
+    const response = await fetch(`${bookmarksUrl}`, {
+      method: 'POST',
+      body: JSON.stringify(body),
+      signal: AbortSignal.timeout(5000),
+      headers: bodyHeaders,
+    });
+    if (!response.ok) {
+      throw new Error('Failed to create bookmark');
+    }
+  } catch (err) {
+    if (err) {
+      console.error(err);
+      throw new Error('Failed to create bookmark');
+    }
+  }
+}
+
+export default { query, remove, update, check, create };
diff --git a/src/shared.svelte.ts b/src/shared.svelte.ts
new file mode 100644
index 0000000..9721734
--- /dev/null
+++ b/src/shared.svelte.ts
@@ -0,0 +1,28 @@
+function getCookieMap() {
+  const cookies = document.cookie.split('; ');
+  const cookieMap = new Map<string, string>();
+  for (let i = 0; i < cookies.length; i++) {
+    const cookie = cookies[i];
+    if (!cookie) continue;
+    let [key, value] = cookie.split('=');
+    if (!key || !value) continue;
+    cookieMap.set(key, value);
+  }
+
+  return cookieMap;
+}
+
+class UserState {
+  isLoggedIn = $state(false);
+
+  constructor() {
+    this.checkIsLoggedIn();
+  }
+
+  checkIsLoggedIn() {
+    const cookieVal = getCookieMap().get('pt-auth');
+    this.isLoggedIn = cookieVal !== undefined;
+  }
+}
+
+export const userstate = new UserState();
diff --git a/src/util.ts b/src/util.ts
new file mode 100644
index 0000000..5b32ca1
--- /dev/null
+++ b/src/util.ts
@@ -0,0 +1,47 @@
+import { userstate } from './shared.svelte';
+
+type SelectBookmark = {
+  id: number;
+  name: string;
+  href: string;
+  finished: boolean;
+  created_at: number;
+  updated_at: number;
+};
+
+export async function fetchEntries(): Promise<SelectBookmark[]> {
+  try {
+    const response = await fetch('/api/entries', {
+      method: 'GET',
+      credentials: 'include',
+      mode: 'same-origin',
+    });
+
+    if (!response.ok) {
+      if (response.status === 401) {
+        userstate.checkIsLoggedIn();
+      }
+      throw new Error('Network response was not ok');
+    }
+
+    const data = await response.json();
+
+    if (!Array.isArray(data)) {
+      throw new Error('Invalid data format');
+    }
+
+    return data;
+  } catch (error) {
+    console.error('Error fetching entries:', error);
+  }
+
+  return [];
+}
+
+export const formatter = new Intl.DateTimeFormat(undefined, {
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+  hour: '2-digit',
+  minute: '2-digit',
+});