diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2024-10-10 21:05:48 -0400 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2024-10-10 21:05:48 -0400 |
| commit | 4401dce2b5545ce8117818812d8e3c8919f5f7fd (patch) | |
| tree | df04478e6094a2a8cdd14ecd31b77caacff78de6 /ui/src | |
| parent | 999996961e6e8ebcde125ff0022df875d62817b3 (diff) | |
Remove redundancy in `hi-ui` directory name.
Diffstat (limited to 'ui/src')
| -rw-r--r-- | ui/src/app.css | 3 | ||||
| -rw-r--r-- | ui/src/app.html | 12 | ||||
| -rw-r--r-- | ui/src/lib/apiServer.js | 101 | ||||
| -rw-r--r-- | ui/src/lib/components/ActiveChannel.svelte | 27 | ||||
| -rw-r--r-- | ui/src/lib/components/Channel.svelte | 21 | ||||
| -rw-r--r-- | ui/src/lib/components/ChannelList.svelte | 18 | ||||
| -rw-r--r-- | ui/src/lib/components/CreateChannelForm.svelte | 23 | ||||
| -rw-r--r-- | ui/src/lib/components/LogIn.svelte | 35 | ||||
| -rw-r--r-- | ui/src/lib/components/LogOut.svelte | 22 | ||||
| -rw-r--r-- | ui/src/lib/components/Message.svelte | 33 | ||||
| -rw-r--r-- | ui/src/lib/components/MessageInput.svelte | 30 | ||||
| -rw-r--r-- | ui/src/lib/index.js | 1 | ||||
| -rw-r--r-- | ui/src/lib/store.js | 10 | ||||
| -rw-r--r-- | ui/src/lib/store/channels.js | 71 | ||||
| -rw-r--r-- | ui/src/lib/store/logins.js | 22 | ||||
| -rw-r--r-- | ui/src/lib/store/messages.js | 44 | ||||
| -rw-r--r-- | ui/src/routes/(app)/+layout.svelte | 89 | ||||
| -rw-r--r-- | ui/src/routes/(app)/+page.svelte | 0 | ||||
| -rw-r--r-- | ui/src/routes/(app)/ch/[channel]/+page.svelte | 17 | ||||
| -rw-r--r-- | ui/src/routes/+layout.js | 1 | ||||
| -rw-r--r-- | ui/src/routes/+layout.svelte | 30 |
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">➕</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">»</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…</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> |
