summaryrefslogtreecommitdiff
path: root/ui/src
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-10-10 21:05:48 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-10-10 21:05:48 -0400
commit4401dce2b5545ce8117818812d8e3c8919f5f7fd (patch)
treedf04478e6094a2a8cdd14ecd31b77caacff78de6 /ui/src
parent999996961e6e8ebcde125ff0022df875d62817b3 (diff)
Remove redundancy in `hi-ui` directory name.
Diffstat (limited to 'ui/src')
-rw-r--r--ui/src/app.css3
-rw-r--r--ui/src/app.html12
-rw-r--r--ui/src/lib/apiServer.js101
-rw-r--r--ui/src/lib/components/ActiveChannel.svelte27
-rw-r--r--ui/src/lib/components/Channel.svelte21
-rw-r--r--ui/src/lib/components/ChannelList.svelte18
-rw-r--r--ui/src/lib/components/CreateChannelForm.svelte23
-rw-r--r--ui/src/lib/components/LogIn.svelte35
-rw-r--r--ui/src/lib/components/LogOut.svelte22
-rw-r--r--ui/src/lib/components/Message.svelte33
-rw-r--r--ui/src/lib/components/MessageInput.svelte30
-rw-r--r--ui/src/lib/index.js1
-rw-r--r--ui/src/lib/store.js10
-rw-r--r--ui/src/lib/store/channels.js71
-rw-r--r--ui/src/lib/store/logins.js22
-rw-r--r--ui/src/lib/store/messages.js44
-rw-r--r--ui/src/routes/(app)/+layout.svelte89
-rw-r--r--ui/src/routes/(app)/+page.svelte0
-rw-r--r--ui/src/routes/(app)/ch/[channel]/+page.svelte17
-rw-r--r--ui/src/routes/+layout.js1
-rw-r--r--ui/src/routes/+layout.svelte30
21 files changed, 610 insertions, 0 deletions
diff --git a/ui/src/app.css b/ui/src/app.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/ui/src/app.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/ui/src/app.html b/ui/src/app.html
new file mode 100644
index 0000000..63eb917
--- /dev/null
+++ b/ui/src/app.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en" class="dark">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" href="%sveltekit.assets%/favicon.png" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ %sveltekit.head%
+ </head>
+ <body data-sveltekit-preload-data="hover" data-theme="skeleton">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/ui/src/lib/apiServer.js b/ui/src/lib/apiServer.js
new file mode 100644
index 0000000..f6d6148
--- /dev/null
+++ b/ui/src/lib/apiServer.js
@@ -0,0 +1,101 @@
+import axios from 'axios';
+import { activeChannel, channelsList, logins, messages } from '$lib/store';
+
+export const apiServer = axios.create({
+ baseURL: '/api/',
+});
+
+export async function boot() {
+ return apiServer.get('/boot');
+}
+
+export async function logIn(username, password) {
+ const data = {
+ name: username,
+ password,
+ };
+ return apiServer.post('/auth/login', data);
+}
+
+export async function logOut() {
+ return apiServer.post('/auth/logout', {});
+}
+
+export async function createChannel(name) {
+ return apiServer.post('/channels', { name });
+}
+
+export async function postToChannel(channelId, body) {
+ return apiServer.post(`/channels/${channelId}`, { body });
+}
+
+export async function deleteMessage(messageId) {
+ // TODO
+}
+
+export function subscribeToEvents(resume_point) {
+ const eventsUrl = new URL('/api/events', window.location);
+ eventsUrl.searchParams.append('resume_point', resume_point);
+ const evtSource = new EventSource(eventsUrl.toString());
+ // 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);
+
+ switch (data.type) {
+ case 'login':
+ onLoginEvent(data);
+ break;
+ case 'channel':
+ onChannelEvent(data);
+ break;
+ case 'message':
+ onMessageEvent(data);
+ break;
+ }
+ }
+}
+
+function onLoginEvent(data) {
+ switch (data.event) {
+ case 'created':
+ logins.update((value) => value.addLogin(data.id, data.name))
+ break;
+ }
+}
+
+function onChannelEvent(data) {
+ switch (data.event) {
+ case 'created':
+ channelsList.update((value) => value.addChannel(data.id, data.name))
+ break;
+ case 'deleted':
+ activeChannel.update((value) => value.deleteChannel(data.id));
+ channelsList.update((value) => value.deleteChannel(data.id));
+ messages.update((value) => value.deleteChannel(data.id));
+ break;
+ }
+}
+
+function onMessageEvent(data) {
+ switch (data.event) {
+ case 'sent':
+ messages.update((value) => value.addMessage(data.channel, data.id, data.at, data.sender, data.body));
+ break;
+ case 'deleted':
+ messages.update((value) => value.deleteMessage(data.id));
+ break;
+ }
+}
diff --git a/ui/src/lib/components/ActiveChannel.svelte b/ui/src/lib/components/ActiveChannel.svelte
new file mode 100644
index 0000000..978e952
--- /dev/null
+++ b/ui/src/lib/components/ActiveChannel.svelte
@@ -0,0 +1,27 @@
+<script>
+ import { activeChannel, messages } from '$lib/store';
+ import Message from './Message.svelte';
+
+ let container;
+ $: messageList = $activeChannel.isSet() ? $messages.inChannel($activeChannel.get()) : [];
+
+ // TODO: eventually, store scroll height/last unread in channel? scroll there?
+
+ let scroll = (message) => {
+ message.scrollIntoView();
+ }
+</script>
+
+<div class="container" bind:this={container}>
+ {#each messageList as message}
+ <div use:scroll>
+ <Message {...message} />
+ </div>
+ {/each}
+</div>
+
+<style>
+ .container {
+ overflow: scroll;
+ }
+</style>
diff --git a/ui/src/lib/components/Channel.svelte b/ui/src/lib/components/Channel.svelte
new file mode 100644
index 0000000..97fea1f
--- /dev/null
+++ b/ui/src/lib/components/Channel.svelte
@@ -0,0 +1,21 @@
+<script>
+ import { activeChannel } from '$lib/store';
+
+ export let id;
+ export let name;
+ let active = false;
+
+ activeChannel.subscribe((value) => {
+ active = value.is(id);
+ });
+</script>
+
+<li
+ class="rounded-full"
+ class:bg-slate-400={active}
+>
+<a href="/ch/{id}">
+ <span class="badge bg-primary-500">#</span>
+ <span class="flex-auto">{name}</span>
+</a>
+</li>
diff --git a/ui/src/lib/components/ChannelList.svelte b/ui/src/lib/components/ChannelList.svelte
new file mode 100644
index 0000000..e0e5f06
--- /dev/null
+++ b/ui/src/lib/components/ChannelList.svelte
@@ -0,0 +1,18 @@
+<script>
+ import { channelsList } from '$lib/store';
+ import Channel from './Channel.svelte';
+
+ let channels;
+
+ channelsList.subscribe((value) => {
+ channels = value.channels;
+ });
+</script>
+
+<nav class="list-nav">
+ <ul>
+ {#each channels as channel}
+ <Channel {...channel} />
+ {/each}
+ </ul>
+</nav>
diff --git a/ui/src/lib/components/CreateChannelForm.svelte b/ui/src/lib/components/CreateChannelForm.svelte
new file mode 100644
index 0000000..ddcf486
--- /dev/null
+++ b/ui/src/lib/components/CreateChannelForm.svelte
@@ -0,0 +1,23 @@
+<script>
+ import { createChannel } from '$lib/apiServer';
+
+ let name = '';
+ let disabled = false;
+
+ async function handleSubmit(event) {
+ disabled = true;
+ const response = await createChannel(name);
+ if (200 <= response.status && response.status < 300) {
+ name = '';
+ }
+ disabled = false;
+ }
+</script>
+
+<form on:submit|preventDefault={handleSubmit} class="form form-row flex-nowrap">
+ <input type="text" placeholder="create channel" bind:value={name} disabled={disabled} class="input flex-auto h-6 w-9/12" />
+ <button type="submit" class="flex-none w-6 h-6">&#x2795;</button>
+</form>
+
+<style>
+</style>
diff --git a/ui/src/lib/components/LogIn.svelte b/ui/src/lib/components/LogIn.svelte
new file mode 100644
index 0000000..2836e6d
--- /dev/null
+++ b/ui/src/lib/components/LogIn.svelte
@@ -0,0 +1,35 @@
+<script>
+ import { logIn } from '$lib/apiServer';
+ import { currentUser } from '$lib/store';
+
+ let disabled = false;
+ let username = '';
+ let password = '';
+
+ async function handleLogin(event) {
+ disabled = true;
+ const response = await logIn(username, password);
+ if (200 <= response.status && response.status < 300) {
+ currentUser.update(() => ({ username }));
+ username = '';
+ password = '';
+ }
+ disabled = false;
+ }
+</script>
+
+<div class="card m-4 p-4">
+ <form on:submit|preventDefault={handleLogin}>
+ <label class="label" for="username">
+ username
+ <input class="input" name="username" type="text" placeholder="username" bind:value={username} disabled={disabled}>
+ </label>
+ <label class="label" for="password">
+ password
+ <input class="input" name="password" type="password" placeholder="password" bind:value={password} disabled={disabled}>
+ </label>
+ <button class="btn variant-filled" type="submit">
+ sign in or up
+ </button>
+ </form>
+</div>
diff --git a/ui/src/lib/components/LogOut.svelte b/ui/src/lib/components/LogOut.svelte
new file mode 100644
index 0000000..01bef1b
--- /dev/null
+++ b/ui/src/lib/components/LogOut.svelte
@@ -0,0 +1,22 @@
+<script>
+ import { logOut} from '$lib/apiServer';
+ import { currentUser } from '$lib/store';
+
+ async function handleLogout(event) {
+ const response = await logOut();
+ if (200 <= response.status && response.status < 300) {
+ currentUser.update(() => null);
+ }
+ }
+</script>
+
+<form on:submit|preventDefault={handleLogout}>
+ @{$currentUser.username}
+ <button
+ class="border-slate-500 border-solid border-2 font-bold p-1 rounded"
+ type="submit"
+ >log out</button>
+</form>
+
+<style>
+</style>
diff --git a/ui/src/lib/components/Message.svelte b/ui/src/lib/components/Message.svelte
new file mode 100644
index 0000000..d040433
--- /dev/null
+++ b/ui/src/lib/components/Message.svelte
@@ -0,0 +1,33 @@
+<script>
+ import SvelteMarkdown from 'svelte-markdown';
+ import { currentUser, logins } from '$lib/store';
+ import { deleteMessage } from '$lib/apiServer';
+
+ export let at; // XXX: Omitted for now.
+ export let sender;
+ export let body;
+
+ let timestamp = new Date(at).toTimeString();
+ let name;
+ $: name = $logins.get(sender);
+</script>
+
+<div class="card card-hover m-4 relative">
+ <span class="chip variant-soft sticky top-o left-0">
+ <!-- TODO: should this show up for only the first of a run? -->
+ @{name}:
+ </span>
+ <span class="timestamp chip variant-soft absolute top-0 right-0">{at}</span>
+ <section class="p-4">
+ <SvelteMarkdown source={body} />
+ </section>
+</div>
+
+<style>
+ .card .timestamp {
+ display: none;
+ }
+ .card:hover .timestamp {
+ display: flex;
+ }
+</style>
diff --git a/ui/src/lib/components/MessageInput.svelte b/ui/src/lib/components/MessageInput.svelte
new file mode 100644
index 0000000..b33574b
--- /dev/null
+++ b/ui/src/lib/components/MessageInput.svelte
@@ -0,0 +1,30 @@
+<script>
+ import { tick } from 'svelte';
+ import { postToChannel } from '$lib/apiServer';
+ import { activeChannel } from '$lib/store';
+
+ let input;
+ let value;
+ let disabled;
+ activeChannel.subscribe((value) => {
+ disabled = !value.isSet();
+ if (input && !disabled) {
+ input.focus();
+ }
+ });
+
+ async function handleSubmit(event) {
+ disabled = true;
+ // TODO try/catch:
+ await postToChannel($activeChannel.get(), value);
+ value = '';
+ disabled = false;
+ await tick();
+ input.focus();
+ }
+</script>
+
+<form on:submit|preventDefault={handleSubmit} class="flex flex-row flex-nowrap">
+ <input bind:this={input} bind:value={value} disabled={disabled} type="search" class="flex-auto h-6 input rounded-r-none" />
+ <button color="primary variant-filled-secondary" type="submit" class="flex-none w-6 h-6 btn-icon variant-filled rounded-l-none">&raquo;</button>
+</form>
diff --git a/ui/src/lib/index.js b/ui/src/lib/index.js
new file mode 100644
index 0000000..856f2b6
--- /dev/null
+++ b/ui/src/lib/index.js
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/ui/src/lib/store.js b/ui/src/lib/store.js
new file mode 100644
index 0000000..b964b4b
--- /dev/null
+++ b/ui/src/lib/store.js
@@ -0,0 +1,10 @@
+import { writable } from 'svelte/store';
+import { ActiveChannel, Channels } from '$lib/store/channels';
+import { Messages } from '$lib/store/messages';
+import { Logins } from '$lib/store/logins';
+
+export const currentUser = writable(null);
+export const activeChannel = writable(new ActiveChannel());
+export const logins = writable(new Logins());
+export const channelsList = writable(new Channels());
+export const messages = writable(new Messages());
diff --git a/ui/src/lib/store/channels.js b/ui/src/lib/store/channels.js
new file mode 100644
index 0000000..bb6c86c
--- /dev/null
+++ b/ui/src/lib/store/channels.js
@@ -0,0 +1,71 @@
+export class Channels {
+ constructor() {
+ this.channels = [];
+ }
+
+ setChannels(channels) {
+ this.channels = [...channels];
+ this.sort();
+ return this;
+ }
+
+ addChannel(id, name) {
+ this.channels = [...this.channels, { id, name }];
+ this.sort();
+ return this;
+ }
+
+ deleteChannel(id) {
+ const channelIndex = this.channels.map((e) => e.id).indexOf(id);
+ if (channelIndex !== -1) {
+ this.channels.splice(channelIndex, 1);
+ }
+ return this;
+ }
+
+ sort() {
+ this.channels.sort((a, b) => {
+ if (a.name < b.name) {
+ return -1;
+ } else if (a.name > b.name) {
+ return 1;
+ }
+ return 0;
+ });
+ }
+}
+
+export class ActiveChannel {
+ constructor() {
+ this.channel = null;
+ }
+
+ isSet() {
+ return this.channel !== null;
+ }
+
+ get() {
+ return this.channel;
+ }
+
+ is(id) {
+ return this.channel === id;
+ }
+
+ set(id) {
+ this.channel = id;
+ return this;
+ }
+
+ deleteChannel(id) {
+ if (this.is(id)) {
+ return this.clear();
+ }
+ return this;
+ }
+
+ clear() {
+ this.channel = null;
+ return this;
+ }
+}
diff --git a/ui/src/lib/store/logins.js b/ui/src/lib/store/logins.js
new file mode 100644
index 0000000..5b45206
--- /dev/null
+++ b/ui/src/lib/store/logins.js
@@ -0,0 +1,22 @@
+export class Logins {
+ constructor() {
+ this.logins = {};
+ }
+
+ addLogin(id, name) {
+ this.logins[id] = name;
+ return this;
+ }
+
+ setLogins(logins) {
+ this.logins = {};
+ for (let { id, name } of logins) {
+ this.addLogin(id, name);
+ }
+ return this;
+ }
+
+ get(id) {
+ return this.logins[id];
+ }
+}
diff --git a/ui/src/lib/store/messages.js b/ui/src/lib/store/messages.js
new file mode 100644
index 0000000..931b8fb
--- /dev/null
+++ b/ui/src/lib/store/messages.js
@@ -0,0 +1,44 @@
+export class Messages {
+ constructor() {
+ this.channels = {};
+ }
+
+ inChannel(channel) {
+ return this.channels[channel] = (this.channels[channel] || []);
+ }
+
+ addMessage(channel, id, at, sender, body) {
+ this.updateChannel(channel, (messages) => [...messages, { id, at, sender, body }]);
+ return this;
+ }
+
+ setMessages(messages) {
+ this.channels = {};
+ for (let { channel, id, at, sender, body } of messages) {
+ this.inChannel(channel).push({ id, at, sender, body, });
+ }
+ for (let channel in this.channels) {
+ this.channels[channel].sort((a, b) => a.at - b.at);
+ }
+ return this;
+ }
+
+
+ deleteMessage(message) {
+ for (let channel in this.channels) {
+ this.updateChannel(channel, (messages) => messages.filter((msg) => msg.id != message));
+ }
+ return this;
+ }
+
+ deleteChannel(id) {
+ delete this.channels[id];
+ return this;
+ }
+
+ updateChannel(channel, callback) {
+ let messages = callback(this.inChannel(channel));
+ messages.sort((a, b) => a.at - b.at);
+ this.channels[channel] = messages;
+ }
+}
diff --git a/ui/src/routes/(app)/+layout.svelte b/ui/src/routes/(app)/+layout.svelte
new file mode 100644
index 0000000..f8744c1
--- /dev/null
+++ b/ui/src/routes/(app)/+layout.svelte
@@ -0,0 +1,89 @@
+<script>
+ import { onMount } from 'svelte';
+
+ import { boot, subscribeToEvents } from '$lib/apiServer';
+ import { currentUser, logins, channelsList, messages } from '$lib/store';
+
+ import ChannelList from '$lib/components/ChannelList.svelte';
+ import CreateChannelForm from '$lib/components/CreateChannelForm.svelte';
+ import LogIn from '$lib/components/LogIn.svelte';
+ import MessageInput from '$lib/components/MessageInput.svelte';
+
+ let user;
+ let loading = true;
+
+ currentUser.subscribe((value) => {
+ user = value;
+ });
+
+ function onBooted(boot) {
+ currentUser.update(() => ({
+ id: boot.login.id,
+ username: boot.login.name,
+ }));
+ logins.update((value) => value.setLogins(boot.logins));
+ channelsList.update((value) => value.setChannels(boot.channels));
+ messages.update((value) => value.setMessages(boot.messages));
+ }
+
+ onMount(async () => {
+ try {
+ let response = await boot();
+ switch (response.status) {
+ case 200:
+ onBooted(response.data);
+ subscribeToEvents(response.data.resume_point);
+ break;
+ case 401:
+ currentUser.update(() => null);
+ break;
+ default:
+ // TODO: display error.
+ break;
+ }
+ } catch (_) {
+ // I don't want exceptions on non-200 series responses, dammit.
+ }
+ loading = false;
+ });
+</script>
+
+{#if loading}
+ <h2>Loading&hellip;</h2>
+{:else if user != null}
+ <div id="interface">
+ <div class="channel-list">
+ <ChannelList />
+ </div>
+ <div class="active-channel">
+ <slot />
+ </div>
+ <div class="create-channel">
+ <CreateChannelForm />
+ </div>
+ <div class="create-message">
+ <MessageInput />
+ </div>
+ </div>
+{:else}
+ <LogIn />
+{/if}
+
+<style>
+ #interface {
+ height: 88vh;
+ margin: 1rem;
+ display: grid;
+ grid-template-columns: 18rem auto;
+ grid-template-rows: auto 2rem;
+ grid-gap: 0.25rem;
+ }
+ #interface div {
+ max-height: 100%;
+ overflow: scroll;
+ }
+ #interface .active-channel {
+ border: 1px solid grey;
+ border-radius: 1.25rem;
+ }
+</style>
diff --git a/ui/src/routes/(app)/+page.svelte b/ui/src/routes/(app)/+page.svelte
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ui/src/routes/(app)/+page.svelte
diff --git a/ui/src/routes/(app)/ch/[channel]/+page.svelte b/ui/src/routes/(app)/ch/[channel]/+page.svelte
new file mode 100644
index 0000000..ef439d0
--- /dev/null
+++ b/ui/src/routes/(app)/ch/[channel]/+page.svelte
@@ -0,0 +1,17 @@
+<script>
+ import { afterNavigate } from '$app/navigation';
+ import { page } from '$app/stores';
+
+ import { activeChannel } from '$lib/store';
+ import ActiveChannel from '$lib/components/ActiveChannel.svelte';
+
+ afterNavigate(async () => {
+ let { channel } = $page.params;
+ activeChannel.update((value) => {
+ value.set(channel)
+ return value;
+ });
+ });
+</script>
+
+<ActiveChannel />
diff --git a/ui/src/routes/+layout.js b/ui/src/routes/+layout.js
new file mode 100644
index 0000000..a3d1578
--- /dev/null
+++ b/ui/src/routes/+layout.js
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte
new file mode 100644
index 0000000..7b99d62
--- /dev/null
+++ b/ui/src/routes/+layout.svelte
@@ -0,0 +1,30 @@
+<script>
+ import { AppBar } from '@skeletonlabs/skeleton';
+ import "../app.css";
+
+ import { currentUser } from '$lib/store';
+ import LogOut from '$lib/components/LogOut.svelte';
+</script>
+
+<div id="app">
+ <AppBar>
+ <svelte:fragment slot="lead">🌳</svelte:fragment>
+ <a href="/">understory</a>
+ <svelte:fragment slot="trail">
+ {#if $currentUser}
+ <LogOut />
+ {/if}
+ </svelte:fragment>
+ </AppBar>
+
+ <slot />
+</div>
+
+<style>
+ #app {
+ margin: 0;
+ padding: 1rem;
+ height: 100vh;
+ width: 100%;
+ }
+</style>