summaryrefslogtreecommitdiff
path: root/hi-ui/src
diff options
context:
space:
mode:
Diffstat (limited to 'hi-ui/src')
-rw-r--r--hi-ui/src/apiServer.js102
-rw-r--r--hi-ui/src/app.css3
-rw-r--r--hi-ui/src/app.html12
-rw-r--r--hi-ui/src/lib/ActiveChannel.svelte33
-rw-r--r--hi-ui/src/lib/Channel.svelte23
-rw-r--r--hi-ui/src/lib/ChannelList.svelte34
-rw-r--r--hi-ui/src/lib/CreateChannelForm.svelte26
-rw-r--r--hi-ui/src/lib/LogIn.svelte39
-rw-r--r--hi-ui/src/lib/LogOut.svelte28
-rw-r--r--hi-ui/src/lib/Message.svelte29
-rw-r--r--hi-ui/src/lib/MessageInput.svelte31
-rw-r--r--hi-ui/src/lib/index.js1
-rw-r--r--hi-ui/src/routes/+layout.svelte20
-rw-r--r--hi-ui/src/routes/+page.svelte82
-rw-r--r--hi-ui/src/store.js6
15 files changed, 469 insertions, 0 deletions
diff --git a/hi-ui/src/apiServer.js b/hi-ui/src/apiServer.js
new file mode 100644
index 0000000..5e521de
--- /dev/null
+++ b/hi-ui/src/apiServer.js
@@ -0,0 +1,102 @@
+import axios from 'axios';
+import { activeChannel, channelsList, events } from './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 listChannels() {
+ return apiServer.get('/channels');
+}
+
+export async function createChannel(name) {
+ return apiServer.post('/channels', { name });
+}
+
+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);
+
+ 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/app.css b/hi-ui/src/app.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/hi-ui/src/app.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/hi-ui/src/app.html b/hi-ui/src/app.html
new file mode 100644
index 0000000..77a5ff5
--- /dev/null
+++ b/hi-ui/src/app.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+ <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">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/hi-ui/src/lib/ActiveChannel.svelte b/hi-ui/src/lib/ActiveChannel.svelte
new file mode 100644
index 0000000..84f9119
--- /dev/null
+++ b/hi-ui/src/lib/ActiveChannel.svelte
@@ -0,0 +1,33 @@
+<script>
+ import { activeChannel, events } from '../store';
+ import Message from './Message.svelte';
+
+ let container;
+ $: messages = $events.filter(
+ (ev) => (
+ ev.type === 'message'
+ && $activeChannel !== null
+ && ev.channel.id === $activeChannel.id
+ )
+ );
+
+ // TODO: eventually, store scroll height/last unread in channel? scroll there?
+
+ let scroll = (message) => {
+ message.scrollIntoView();
+ }
+</script>
+
+<div class="container" bind:this={container}>
+ {#each messages as message}
+ <div use:scroll>
+ <Message {...message} />
+ </div>
+ {/each}
+</div>
+
+<style>
+ .container {
+ overflow: scroll;
+ }
+</style>
diff --git a/hi-ui/src/lib/Channel.svelte b/hi-ui/src/lib/Channel.svelte
new file mode 100644
index 0000000..7826c46
--- /dev/null
+++ b/hi-ui/src/lib/Channel.svelte
@@ -0,0 +1,23 @@
+<script>
+ import { activeChannel } from '../store';
+
+ export let id;
+ export let name;
+ let active = false;
+
+ activeChannel.subscribe((value) => {
+ active = value ? value.id == id : false;
+ });
+
+ function activate() {
+ activeChannel.update(() => ({ id, name }));
+ }
+</script>
+
+<li
+ class="cursor-pointer hover:bg-slate-300"
+ class:bg-slate-400={active}
+ on:click={activate}
+>
+ #{name}
+</li>
diff --git a/hi-ui/src/lib/ChannelList.svelte b/hi-ui/src/lib/ChannelList.svelte
new file mode 100644
index 0000000..9f88e24
--- /dev/null
+++ b/hi-ui/src/lib/ChannelList.svelte
@@ -0,0 +1,34 @@
+<script>
+ import { onMount } from 'svelte';
+
+ import { listChannels } from '../apiServer';
+ import { channelsList } from '../store';
+ import Channel from './Channel.svelte';
+
+ let channels;
+ let loading = true;
+
+ channelsList.subscribe((value) => {
+ channels = value;
+ });
+
+ onMount(async () => {
+ let channels = await listChannels();
+ channelsList.update(() => channels.data);
+ loading = false;
+ });
+</script>
+
+<ul class="select-none">
+ {#if loading}
+ <li><em>loading channels&hellip;</em></li>
+ {:else}
+ {#each channels as channel}
+ <Channel {...channel} />
+ {/each}
+ {/if}
+</ul>
+
+<style>
+</style>
+
diff --git a/hi-ui/src/lib/CreateChannelForm.svelte b/hi-ui/src/lib/CreateChannelForm.svelte
new file mode 100644
index 0000000..70dc13d
--- /dev/null
+++ b/hi-ui/src/lib/CreateChannelForm.svelte
@@ -0,0 +1,26 @@
+<script>
+ import { createChannel } from '../apiServer';
+
+ import { channelsList } from '../store';
+
+ let name = '';
+ let disabled = false;
+
+ async function handleSubmit(event) {
+ disabled = true;
+ const response = await createChannel(name);
+ if (200 <= response.status && response.status < 300) {
+ channelsList.update((value) => [...value, response.data]);
+ name = '';
+ }
+ disabled = false;
+ }
+</script>
+
+<form on:submit|preventDefault={handleSubmit}>
+ <input type="text" placeholder="channel name" bind:value={name} disabled={disabled} />
+ <button type="submit">create</button>
+</form>
+
+<style>
+</style>
diff --git a/hi-ui/src/lib/LogIn.svelte b/hi-ui/src/lib/LogIn.svelte
new file mode 100644
index 0000000..1ec6772
--- /dev/null
+++ b/hi-ui/src/lib/LogIn.svelte
@@ -0,0 +1,39 @@
+<script>
+ import { logIn } from '../apiServer';
+ import { currentUser } from '../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>
+
+<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
+ </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>
diff --git a/hi-ui/src/lib/LogOut.svelte b/hi-ui/src/lib/LogOut.svelte
new file mode 100644
index 0000000..738be24
--- /dev/null
+++ b/hi-ui/src/lib/LogOut.svelte
@@ -0,0 +1,28 @@
+<script>
+ import { logOut} from '../apiServer';
+ import { currentUser } from '../store';
+
+ let user;
+
+ currentUser.subscribe((value) => {
+ user = value;
+ });
+
+ async function handleLogout(event) {
+ const response = await logOut();
+ if (200 <= response.status && response.status < 300) {
+ currentUser.update(() => null);
+ }
+ }
+</script>
+
+<form on:submit|preventDefault={handleLogout}>
+ @{user.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/hi-ui/src/lib/Message.svelte b/hi-ui/src/lib/Message.svelte
new file mode 100644
index 0000000..88e47cf
--- /dev/null
+++ b/hi-ui/src/lib/Message.svelte
@@ -0,0 +1,29 @@
+<script>
+ import { currentUser } from '../store';
+ import { deleteMessage } from '../apiServer';
+
+ export let at;
+ export let sender;
+ export let message;
+
+ let timestamp = new Date(at).toTimeString();
+</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>
+</div>
+
+<style>
+ div:hover .controls {
+ display: block;
+ }
+</style>
diff --git a/hi-ui/src/lib/MessageInput.svelte b/hi-ui/src/lib/MessageInput.svelte
new file mode 100644
index 0000000..9a8475c
--- /dev/null
+++ b/hi-ui/src/lib/MessageInput.svelte
@@ -0,0 +1,31 @@
+<script>
+ import { Input, ButtonGroup, Button } from 'flowbite-svelte';
+ import { CaretRightSolid } from 'flowbite-svelte-icons';
+
+ import { tick } from 'svelte';
+ import { postToChannel } from '../apiServer';
+ import { activeChannel } from '../store';
+
+ let self;
+ let input;
+ $: disabled = $activeChannel == null;
+
+ async function handleSubmit(event) {
+ disabled = true;
+ // TODO try/catch:
+ await postToChannel($activeChannel?.id, input);
+ input = '';
+ disabled = false;
+ await tick();
+ self.focus();
+ }
+</script>
+
+<form on:submit|preventDefault={handleSubmit} class="w-full">
+ <ButtonGroup>
+ <Input disabled={disabled} bind:this={self} bind:value={input} />
+ <Button color="primary" type="submit">
+ <CaretRightSolid class="w-5 h-5" />
+ </Button>
+ </ButtonGroup>
+</form>
diff --git a/hi-ui/src/lib/index.js b/hi-ui/src/lib/index.js
new file mode 100644
index 0000000..856f2b6
--- /dev/null
+++ b/hi-ui/src/lib/index.js
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/hi-ui/src/routes/+layout.svelte b/hi-ui/src/routes/+layout.svelte
new file mode 100644
index 0000000..67a5fa5
--- /dev/null
+++ b/hi-ui/src/routes/+layout.svelte
@@ -0,0 +1,20 @@
+<script>
+ import "../app.css";
+
+ export const ssr = false;
+</script>
+
+<div id="app">
+ <h1>hi</h1>
+
+ <slot />
+</div>
+
+<style>
+ #app {
+ margin: 0;
+ padding: 1rem;
+ height: 100vh;
+ width: 100%;
+ }
+</style>
diff --git a/hi-ui/src/routes/+page.svelte b/hi-ui/src/routes/+page.svelte
new file mode 100644
index 0000000..66b4f8d
--- /dev/null
+++ b/hi-ui/src/routes/+page.svelte
@@ -0,0 +1,82 @@
+<script>
+ import { onMount } from 'svelte';
+
+ import { boot, subscribeToEvents } from '../apiServer';
+ import { currentUser } from '../store';
+
+ import ActiveChannel from '../lib/ActiveChannel.svelte';
+ import ChannelList from '../lib/ChannelList.svelte';
+ import CreateChannelForm from '../lib/CreateChannelForm.svelte';
+ import LogIn from '../lib/LogIn.svelte';
+ import LogOut from '../lib/LogOut.svelte';
+ import MessageInput from '../lib/MessageInput.svelte';
+
+ let user;
+ let loading = true;
+
+ currentUser.subscribe((value) => {
+ user = value;
+ });
+
+ onMount(async () => {
+ try {
+ let response = await boot();
+ switch (response.status) {
+ case 200:
+ currentUser.update(() => ({
+ username: response.data.login.name,
+ id: response.data.login.id,
+ }));
+ subscribeToEvents();
+ 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}
+ <LogOut />
+ <div id="interface">
+ <div>
+ <ChannelList />
+ </div>
+ <div>
+ <ActiveChannel />
+ </div>
+ <div>
+ <CreateChannelForm />
+ </div>
+ <div>
+ <MessageInput />
+ </div>
+ </div>
+{:else}
+ <LogIn />
+{/if}
+
+<style>
+ #interface {
+ height: 89vh;
+ 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;
+ border: 1px solid grey;
+ }
+</style>
diff --git a/hi-ui/src/store.js b/hi-ui/src/store.js
new file mode 100644
index 0000000..a9d9421
--- /dev/null
+++ b/hi-ui/src/store.js
@@ -0,0 +1,6 @@
+import { writable } from 'svelte/store';
+
+export const currentUser = writable(null);
+export const activeChannel = writable(null);
+export const channelsList = writable([]);
+export const events = writable([]);