diff options
Diffstat (limited to 'hi-ui/src')
| -rw-r--r-- | hi-ui/src/apiServer.js | 102 | ||||
| -rw-r--r-- | hi-ui/src/app.css | 3 | ||||
| -rw-r--r-- | hi-ui/src/app.html | 12 | ||||
| -rw-r--r-- | hi-ui/src/lib/ActiveChannel.svelte | 33 | ||||
| -rw-r--r-- | hi-ui/src/lib/Channel.svelte | 23 | ||||
| -rw-r--r-- | hi-ui/src/lib/ChannelList.svelte | 34 | ||||
| -rw-r--r-- | hi-ui/src/lib/CreateChannelForm.svelte | 26 | ||||
| -rw-r--r-- | hi-ui/src/lib/LogIn.svelte | 39 | ||||
| -rw-r--r-- | hi-ui/src/lib/LogOut.svelte | 28 | ||||
| -rw-r--r-- | hi-ui/src/lib/Message.svelte | 29 | ||||
| -rw-r--r-- | hi-ui/src/lib/MessageInput.svelte | 31 | ||||
| -rw-r--r-- | hi-ui/src/lib/index.js | 1 | ||||
| -rw-r--r-- | hi-ui/src/routes/+layout.svelte | 20 | ||||
| -rw-r--r-- | hi-ui/src/routes/+page.svelte | 82 | ||||
| -rw-r--r-- | hi-ui/src/store.js | 6 |
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 = '«…»'; + 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…</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…</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([]); |
