diff options
Diffstat (limited to 'ui/src/lib')
| -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 |
14 files changed, 458 insertions, 0 deletions
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; + } +} |
