From 1d8b828d1bbe0e0daa64f6fc2689799c7169afa0 Mon Sep 17 00:00:00 2001
From: Kit La Touche <kit@transneptune.net>
Date: Thu, 19 Sep 2024 23:26:39 -0400
Subject: Add basic browser client

Using Svelte.

No tests, no linting, yet.

This is just starting to get familiar with things.

You'll still have to run the dev server and the dev client builder each
in their own terminals.

Enjoy!
---
 hi-ui/src/lib/LogIn.svelte | 40 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 40 insertions(+)
 create mode 100644 hi-ui/src/lib/LogIn.svelte

(limited to 'hi-ui/src/lib/LogIn.svelte')

diff --git a/hi-ui/src/lib/LogIn.svelte b/hi-ui/src/lib/LogIn.svelte
new file mode 100644
index 0000000..df734ee
--- /dev/null
+++ b/hi-ui/src/lib/LogIn.svelte
@@ -0,0 +1,40 @@
+<script>
+    import { logIn } from '../apiServer';
+    import { currentUser } from '../store';
+
+    let disabled = false;
+    let username = '';
+    let password = '';
+
+    async function handleLogin(event) {
+        event.preventDefault();
+        disabled = true;
+        const response = await logIn(username, password);
+        if (200 <= response.status && response.status < 300) {
+            currentUser.update(() => ({ username }));
+            username = '';
+            password = '';
+        }
+        disabled = false;
+    }
+</script>
+
+<form class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" on:submit={handleLogin}>
+    <div class="mb-4">
+        <label class="block text-gray-700 text-sm font-bold mb-2" for="username">
+            username
+        </label>
+        <input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" name="username" type="text" placeholder="username" bind:value={username} disabled={disabled}>
+    </div>
+    <div class="mb-6">
+        <label class="block text-gray-700 text-sm font-bold mb-2" for="password">
+            password
+        </label>
+        <input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" id="password" name="password" type="password" placeholder="password" bind:value={password} disabled={disabled}>
+    </div>
+    <div class="flex items-center justify-between">
+        <button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
+            sign in or up
+        </button>
+    </div>
+</form>
-- 
cgit v1.2.3


From 30c13478d61065a512f5bc8824fecbf2ee6afc81 Mon Sep 17 00:00:00 2001
From: Kit La Touche <kit@transneptune.net>
Date: Thu, 3 Oct 2024 23:30:10 -0400
Subject: Handle basics of interface scrolling

---
 hi-ui/src/apiServer.js                 | 61 ++++++++++++++++++++++++++++++++--
 hi-ui/src/lib/ActiveChannel.svelte     | 31 +++++++++--------
 hi-ui/src/lib/CreateChannelForm.svelte |  3 +-
 hi-ui/src/lib/LogIn.svelte             |  3 +-
 hi-ui/src/lib/LogOut.svelte            |  3 +-
 hi-ui/src/lib/Message.svelte           | 38 +++++++++++++++++++++
 hi-ui/src/lib/MessageInput.svelte      | 18 +++++-----
 hi-ui/src/routes/+page.svelte          |  5 ++-
 8 files changed, 130 insertions(+), 32 deletions(-)
 create mode 100644 hi-ui/src/lib/Message.svelte

(limited to 'hi-ui/src/lib/LogIn.svelte')

diff --git a/hi-ui/src/apiServer.js b/hi-ui/src/apiServer.js
index 7365a36..5e521de 100644
--- a/hi-ui/src/apiServer.js
+++ b/hi-ui/src/apiServer.js
@@ -1,5 +1,5 @@
 import axios from 'axios';
-import { events } from './store';
+import { activeChannel, channelsList, events } from './store';
 
 export const apiServer = axios.create({
   baseURL: '/api/',
@@ -33,13 +33,70 @@ export async function postToChannel(channelId, message) {
   return apiServer.post(`/channels/${channelId}`, { message });
 }
 
+export async function deleteMessage(messageId) {
+  // TODO
+}
+
 export function subscribeToEvents() {
   const evtSource = new EventSource("/api/events");
+  events.update(() => []);
   // TODO: this should process all incoming events and store them.
   // TODO: eventually we'll need to handle expiring old info, so as not to use
   //       infinite browser memory.
+  /*
+   * Known message types as of now:
+   * - created: a channel is created.
+   *   - action: ignore.
+   * - message: a message is created.
+   *   - action: display message in channel.
+   * - message_deleted: a message is deleted.
+   *   - action: replace message with <...>.
+   * - deleted: a channel is deleted.
+   *   - action: remove channel from sidebar.
+  */
   evtSource.onmessage = (evt) => {
     const data = JSON.parse(evt.data);
-    events.update((value) => [...value, data]);
+
+    switch (data.type) {
+      case 'created':
+        break;
+      case 'message':
+        events.update((value) => {
+          const eventList = [...value, data];
+          eventList.sort((a, b) => a.at - b.at);
+          return eventList;
+        });
+        break;
+      case 'message_deleted':
+        events.update((value) => {
+          const eventList = value.map((el) => {
+            if (el.message?.id === data.message) {
+              el.message.body = '&laquo;&hellip;&raquo;';
+              return el
+            } else {
+              return el;
+            }
+          });
+          return eventList;
+        });
+        break;
+      case 'deleted':
+        activeChannel.update((value) => {
+          if (value?.id === data.channel) {
+            return null;
+          }
+          return value;
+        });
+        channelsList.update((value) => {
+          const channelIndex = value.map((e) => e.id).indexOf(data.channel);
+          if (channelIndex !== -1) {
+            value.splice(channelIndex, 1);
+          }
+          return value;
+        });
+        break;
+      default:
+        break;
+    }
   }
 }
diff --git a/hi-ui/src/lib/ActiveChannel.svelte b/hi-ui/src/lib/ActiveChannel.svelte
index 680a785..84f9119 100644
--- a/hi-ui/src/lib/ActiveChannel.svelte
+++ b/hi-ui/src/lib/ActiveChannel.svelte
@@ -1,28 +1,33 @@
 <script>
     import { activeChannel, events } from '../store';
+    import Message from './Message.svelte';
 
-    let channel;
-    let allMessages = [];
-    $: messages = allMessages.filter(
-        (ev) => ev.type === 'message' && channel !== null && ev.channel.id === channel.id
+    let container;
+    $: messages = $events.filter(
+        (ev) => (
+            ev.type === 'message'
+            && $activeChannel !== null
+            && ev.channel.id === $activeChannel.id
+        )
     );
 
-    activeChannel.subscribe((value) => {
-        channel = value;
-    });
+    // TODO: eventually, store scroll height/last unread in channel? scroll there?
 
-    events.subscribe((value) => {
-        allMessages = value;
-    });
+    let scroll = (message) => {
+        message.scrollIntoView();
+    }
 </script>
 
-<div>
+<div class="container" bind:this={container}>
     {#each messages as message}
-        <div>
-            <pre><tt>{message.at} @{message.sender.name}: {message.message.body}</tt></pre>
+        <div use:scroll>
+            <Message {...message} />
         </div>
     {/each}
 </div>
 
 <style>
+    .container {
+        overflow: scroll;
+    }
 </style>
diff --git a/hi-ui/src/lib/CreateChannelForm.svelte b/hi-ui/src/lib/CreateChannelForm.svelte
index 584fa61..70dc13d 100644
--- a/hi-ui/src/lib/CreateChannelForm.svelte
+++ b/hi-ui/src/lib/CreateChannelForm.svelte
@@ -7,7 +7,6 @@
     let disabled = false;
 
     async function handleSubmit(event) {
-        event.preventDefault();
         disabled = true;
         const response = await createChannel(name);
         if (200 <= response.status && response.status < 300) {
@@ -18,7 +17,7 @@
     }
 </script>
 
-<form on:submit={handleSubmit}>
+<form on:submit|preventDefault={handleSubmit}>
     <input type="text" placeholder="channel name" bind:value={name} disabled={disabled} />
     <button type="submit">create</button>
 </form>
diff --git a/hi-ui/src/lib/LogIn.svelte b/hi-ui/src/lib/LogIn.svelte
index df734ee..1ec6772 100644
--- a/hi-ui/src/lib/LogIn.svelte
+++ b/hi-ui/src/lib/LogIn.svelte
@@ -7,7 +7,6 @@
     let password = '';
 
     async function handleLogin(event) {
-        event.preventDefault();
         disabled = true;
         const response = await logIn(username, password);
         if (200 <= response.status && response.status < 300) {
@@ -19,7 +18,7 @@
     }
 </script>
 
-<form class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" on:submit={handleLogin}>
+<form class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" on:submit|preventDefault={handleLogin}>
     <div class="mb-4">
         <label class="block text-gray-700 text-sm font-bold mb-2" for="username">
             username
diff --git a/hi-ui/src/lib/LogOut.svelte b/hi-ui/src/lib/LogOut.svelte
index 64d26c9..738be24 100644
--- a/hi-ui/src/lib/LogOut.svelte
+++ b/hi-ui/src/lib/LogOut.svelte
@@ -9,7 +9,6 @@
     });
 
     async function handleLogout(event) {
-        event.preventDefault();
         const response = await logOut();
         if (200 <= response.status && response.status < 300) {
             currentUser.update(() => null);
@@ -17,7 +16,7 @@
     }
 </script>
 
-<form on:submit={handleLogout}>
+<form on:submit|preventDefault={handleLogout}>
     @{user.username}
     <button
         class="border-slate-500 border-solid border-2 font-bold p-1 rounded"
diff --git a/hi-ui/src/lib/Message.svelte b/hi-ui/src/lib/Message.svelte
new file mode 100644
index 0000000..d3ecbd8
--- /dev/null
+++ b/hi-ui/src/lib/Message.svelte
@@ -0,0 +1,38 @@
+<script>
+    import { currentUser } from '../store';
+    import { deleteMessage } from '../apiServer';
+
+    export let at;
+    export let sender;
+    export let message;
+
+    let timestamp = new Date(at).toTimeString();
+
+    function handleDeleteMessage() {
+        deleteMessage(message.id);
+    }
+</script>
+
+<div class="hover:bg-zinc-300 flex flex-row">
+    <div class="sender basis-20 text-right mr-1">
+        @{sender.name}:
+    </div>
+    <div class="body grow">
+        {message.body}
+    </div>
+    <div class="timestamp basis-6">
+        <!-- TODO: this is too long and looks awful. -->
+        <!-- {timestamp} -->
+    </div>
+    {#if sender.id === $currentUser?.id}
+        <div class="controls basis-1 hidden relative -top-3 rounded-md border-2 border-slate-600 px-1 bg-slate-300">
+            <button on:click|preventDefault={handleDeleteMessage}>&#x1F5D1;</button>
+        </div>
+    {/if}
+</div>
+
+<style>
+    div:hover .controls {
+        display: block;
+    }
+</style>
diff --git a/hi-ui/src/lib/MessageInput.svelte b/hi-ui/src/lib/MessageInput.svelte
index 96e9577..938e467 100644
--- a/hi-ui/src/lib/MessageInput.svelte
+++ b/hi-ui/src/lib/MessageInput.svelte
@@ -1,27 +1,25 @@
 <script>
+    import { tick } from 'svelte';
     import { postToChannel } from '../apiServer';
     import { activeChannel } from '../store';
 
+    let self;
     let input;
-    let disabled = false;
-    let activeChannelId;
-
-    activeChannel.subscribe((value) => {
-        activeChannelId = value ? value.id : null;
-    });
+    $: disabled = $activeChannel == null;
 
     async function handleSubmit(event) {
-        event.preventDefault();
         disabled = true;
         // TODO try/catch:
-        await postToChannel(activeChannelId, input);
+        await postToChannel($activeChannel?.id, input);
         input = '';
         disabled = false;
+        await tick();
+        self.focus();
     }
 </script>
 
-<form on:submit={handleSubmit}>
-    <input type="text" class="border rounded px-3" bind:value={input} disabled={disabled}>
+<form on:submit|preventDefault={handleSubmit}>
+    <input type="text" class="border rounded px-3" bind:this={self} bind:value={input} disabled={disabled}>
     <button type="submit">➤</button>
 </form>
 
diff --git a/hi-ui/src/routes/+page.svelte b/hi-ui/src/routes/+page.svelte
index 646665e..66b4f8d 100644
--- a/hi-ui/src/routes/+page.svelte
+++ b/hi-ui/src/routes/+page.svelte
@@ -25,6 +25,7 @@
                 case 200:
                     currentUser.update(() => ({
                         username: response.data.login.name,
+                        id: response.data.login.id,
                     }));
                     subscribeToEvents();
                     break;
@@ -66,7 +67,7 @@
 
 <style>
     #interface {
-        height: 90%;
+        height: 89vh;
         margin: 1rem;
         display: grid;
         grid-template-columns: 18rem auto;
@@ -74,6 +75,8 @@
         grid-gap: 0.25rem;
     }
     #interface div {
+        max-height: 100%;
+        overflow: scroll;
         border: 1px solid grey;
     }
 </style>
-- 
cgit v1.2.3

