diff options
| author | Kit La Touche <kit@transneptune.net> | 2024-11-15 10:14:41 -0500 |
|---|---|---|
| committer | Kit La Touche <kit@transneptune.net> | 2024-11-15 10:14:41 -0500 |
| commit | 1635a4db77898e9394adaa104b4c53b94c59e2da (patch) | |
| tree | 041158bc15a1b83caaa245fbe60faf46e84a3070 /ui | |
| parent | fefe76b35b6329cbcc92755a65e47c7f62f64690 (diff) | |
| parent | 2fb328089f01776e5bb553a1d50a061396588c8c (diff) | |
Merge branch 'main' into prop/shorter-expiry
Diffstat (limited to 'ui')
22 files changed, 266 insertions, 113 deletions
diff --git a/ui/app.html b/ui/app.html index 10525fe..5e7d92b 100644 --- a/ui/app.html +++ b/ui/app.html @@ -4,6 +4,7 @@ <meta charset="utf-8" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="manifest" href="%sveltekit.assets%/manifest.json" /> %sveltekit.head% </head> <body data-sveltekit-preload-data="hover" data-theme="skeleton"> diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index 6ada0f7..fee1a81 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -34,6 +34,10 @@ export async function postToChannel(channelId, body) { return apiServer.post(`/channels/${channelId}`, { body }); } +export async function deleteMessage(messageId) { + return apiServer.delete(`/messages/${messageId}`, {}); +} + export async function createInvite() { return apiServer.post(`/invite`, {}); } diff --git a/ui/lib/assets/logo.png b/ui/lib/assets/logo.png Binary files differindex 5df6b4e..4b35d9b 100644 --- a/ui/lib/assets/logo.png +++ b/ui/lib/assets/logo.png diff --git a/ui/lib/components/ActiveChannel.svelte b/ui/lib/components/ActiveChannel.svelte index f939dbd..ba62d6c 100644 --- a/ui/lib/components/ActiveChannel.svelte +++ b/ui/lib/components/ActiveChannel.svelte @@ -4,16 +4,8 @@ let { messageRuns } = $props(); </script> -<div class="container"> - {#each messageRuns as { sender, messages }} - <div> - <MessageRun {sender} {messages} /> - </div> - {/each} -</div> - -<style> - .container { - overflow: auto; - } -</style> +{#each messageRuns as { sender, messages }} + <div> + <MessageRun {sender} {messages} /> + </div> +{/each} diff --git a/ui/lib/components/ChangePassword.svelte b/ui/lib/components/ChangePassword.svelte new file mode 100644 index 0000000..1e48bee --- /dev/null +++ b/ui/lib/components/ChangePassword.svelte @@ -0,0 +1,60 @@ +<script> + import { changePassword } from '$lib/apiServer.js'; + + let currentPassword = $state(''), + newPassword = $state(''), + confirmPassword = $state(''), + pending = $state(false), + form; + let valid = $derived(newPassword === confirmPassword && newPassword !== currentPassword); + let disabled = $derived(pending || !valid); + + async function onsubmit(event) { + event.preventDefault(); + pending = true; + let response = await changePassword(currentPassword, newPassword); + switch (response.status) { + case 200: + form.reset(); + break; + } + pending = false; + } +</script> + +<form {onsubmit} bind:this={form}> + <label + >current password + <input + class="input" + name="currentPassword" + type="password" + placeholder="password" + bind:value={currentPassword} + /> + </label> + + <label + >new password + <input + class="input" + name="newPassword" + type="password" + placeholder="password" + bind:value={newPassword} + /> + </label> + + <label + >confirm new password + <input + class="input" + name="confirmPassword" + type="password" + placeholder="password" + bind:value={confirmPassword} + /> + </label> + + <button class="btn bg-orange-500 mt-4" type="submit" {disabled}>change password</button> +</form> diff --git a/ui/lib/components/Invite.svelte b/ui/lib/components/Invite.svelte index 35e00b4..937c911 100644 --- a/ui/lib/components/Invite.svelte +++ b/ui/lib/components/Invite.svelte @@ -5,8 +5,5 @@ let inviteUrl = $derived(new URL(`/invite/${id}`, document.location)); </script> -<button - class="border-slate-500 border-solid border-2 font-bold p-1 rounded" - use:clipboard={inviteUrl}>Copy</button -> +<button class="btn bg-secondary-500" use:clipboard={inviteUrl}>copy</button> <span data-clipboard="inviteUrl">{inviteUrl}</span> diff --git a/ui/lib/components/Invites.svelte b/ui/lib/components/Invites.svelte index cc14f3b..493bf1c 100644 --- a/ui/lib/components/Invites.svelte +++ b/ui/lib/components/Invites.svelte @@ -4,7 +4,7 @@ let invites = $state([]); - async function onSubmit(event) { + async function onsubmit(event) { event.preventDefault(); let response = await createInvite(); if (response.status == 200) { @@ -13,11 +13,12 @@ } </script> -<ul> +<form {onsubmit}> + <button class="btn bg-primary-500" type="submit">create invitation</button> +</form> + +<ul class="mt-4"> {#each invites as invite} - <li><Invite id={invite.id} /></li> + <li class="my-1"><Invite id={invite.id} /></li> {/each} </ul> -<form onsubmit={onSubmit}> - <button class="btn variant-filled" type="submit"> Create Invitation </button> -</form> diff --git a/ui/lib/components/LogOut.svelte b/ui/lib/components/LogOut.svelte new file mode 100644 index 0000000..25dd5e9 --- /dev/null +++ b/ui/lib/components/LogOut.svelte @@ -0,0 +1,18 @@ +<script> + import { goto } from '$app/navigation'; + import { logOut } from '$lib/apiServer.js'; + import { currentUser } from '$lib/store'; + + async function onsubmit(event) { + event.preventDefault(); + const response = await logOut(); + if (200 <= response.status && response.status < 300) { + currentUser.update(() => null); + goto('/login'); + } + } +</script> + +<form {onsubmit}> + <button class="btn bg-orange-400" type="submit">log out</button> +</form> diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte index 68c5c91..0c8eeec 100644 --- a/ui/lib/components/Message.svelte +++ b/ui/lib/components/Message.svelte @@ -2,16 +2,38 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; + import { deleteMessage } from '$lib/apiServer'; + function scroll(message) { message.scrollIntoView(); } - let { at, body } = $props(); + let { id, at, body, editable = false } = $props(); let renderedBody = $derived(DOMPurify.sanitize(marked.parse(body, { breaks: true }))); + let deleteArmed = $state(false); + + function onDelete(event) { + event.preventDefault(); + if (deleteArmed) { + deleteArmed = false; + deleteMessage(id); + } else { + deleteArmed = true; + } + } + + function onmouseleave() { + deleteArmed = false; + } </script> -<div class="message relative"> - <span class="timestamp chip variant-soft absolute top-0 right-0">{at}</span> +<div class="message relative" class:bg-warning-800={deleteArmed} {onmouseleave}> + <div class="handle chip bg-surface-700 absolute -top-6 right-0"> + {at} + {#if editable} + <button onclick={onDelete}>🗑️</button> + {/if} + </div> <section use:scroll class="py-1 message-body"> <!-- eslint-disable-next-line svelte/no-at-html-tags --> {@html renderedBody} @@ -19,10 +41,10 @@ </div> <style> - .message .timestamp { + .message .handle { display: none; } - .message:hover .timestamp { + .message:hover .handle { display: flex; } .message-body { diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte index c071bea..26521e1 100644 --- a/ui/lib/components/MessageInput.svelte +++ b/ui/lib/components/MessageInput.svelte @@ -31,7 +31,7 @@ bind:value {disabled} type="search" - class="flex-auto h-6 input rounded-r-none" + class="flex-auto h-6 py-0 input rounded-r-none text-nowrap" ></textarea> <button color="primary variant-filled-secondary" diff --git a/ui/lib/components/MessageRun.svelte b/ui/lib/components/MessageRun.svelte index b71e972..83a82a9 100644 --- a/ui/lib/components/MessageRun.svelte +++ b/ui/lib/components/MessageRun.svelte @@ -9,14 +9,14 @@ </script> <div - class="card m-4 px-4 py-1 relative" + class="card my-4 px-4 py-1 relative" class:own-message={ownMessage} class:other-message={!ownMessage} > <span class="chip variant-soft sticky top-o left-0"> @{name}: </span> - {#each messages as { at, body }} - <Message {at} {body} /> + {#each messages as { id, at, body }} + <Message {id} {at} {body} editable={ownMessage} /> {/each} </div> diff --git a/ui/lib/store/messages.svelte.js b/ui/lib/store/messages.svelte.js index 2442675..c0db71b 100644 --- a/ui/lib/store/messages.svelte.js +++ b/ui/lib/store/messages.svelte.js @@ -11,7 +11,20 @@ export class Messages { let parsedAt = new Date(at); const message = { id, at: parsedAt, body }; - let runs = (this.channels[channel] ||= []); + // You might be thinking, can't this be + // + // let runs = (this.channels[channel] ||= []); + // + // Let me tell you, I thought that too. Javascript's semantics allow it. It + // didn't work - the first message in each channel was getting lost as the + // update to `this.channels` wasn't actually happening. I suspect this is + // due to the implementation of Svelte's `$state` rune, but I don't know it + // for sure. + // + // In any case, splitting the read and write up like this has the same + // semantics, and _works_. (This time, for sure!) + let runs = this.channels[channel] || []; + let currentRun = runs.slice(-1)[0]; if (currentRun === undefined) { currentRun = { sender, messages: [message] }; @@ -29,6 +42,8 @@ export class Messages { } } + this.channels[channel] = runs; + return this; } diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 0a8c58d..86bc330 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -56,7 +56,7 @@ </script> <svelte:head> - <title>understory</title> + <title>pilcrow</title> </svelte:head> {#if loading} @@ -71,7 +71,7 @@ <CreateChannelForm /> </div> </nav> - <main> + <main class="pl-4"> {@render children?.()} </main> </div> diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte index 8d24a61..aded292 100644 --- a/ui/routes/(app)/me/+page.svelte +++ b/ui/routes/(app)/me/+page.svelte @@ -1,79 +1,17 @@ <script> - import { goto } from '$app/navigation'; - import { changePassword, logOut } from '$lib/apiServer.js'; - import { currentUser } from '$lib/store'; - + import LogOut from '$lib/components/LogOut.svelte'; import Invites from '$lib/components/Invites.svelte'; - - let currentPassword = $state(''), - newPassword = $state(''), - confirmPassword = $state(''), - passwordForm; - let pending = $state(false); - let valid = $derived(newPassword === confirmPassword && newPassword !== currentPassword); - let disabled = $derived(pending || !valid); - - async function onLogOut(event) { - event.preventDefault(); - const response = await logOut(); - if (200 <= response.status && response.status < 300) { - currentUser.update(() => null); - goto('/login'); - } - } - - async function onPasswordChange(event) { - event.preventDefault(); - pending = true; - let response = await changePassword(currentPassword, newPassword); - switch (response.status) { - case 200: - passwordForm.reset(); - break; - } - pending = false; - } + import ChangePassword from '$lib/components/ChangePassword.svelte'; </script> -<form onsubmit={onLogOut}> - <button class="btn variant-filled" type="submit">log out</button> -</form> - -<form onsubmit={onPasswordChange} bind:this={passwordForm}> - <label - >current password - <input - class="input" - name="currentPassword" - type="password" - placeholder="password" - bind:value={currentPassword} - /> - </label> - - <label - >new password - <input - class="input" - name="newPassword" - type="password" - placeholder="password" - bind:value={newPassword} - /> - </label> - - <label - >confirm new password - <input - class="input" - name="confirmPassword" - type="password" - placeholder="password" - bind:value={confirmPassword} - /> - </label> +<div class="mb-4"> + <ChangePassword /> +</div> - <button class="btn variant-filled" type="submit" {disabled}> change password </button> -</form> +<div class="mb-4"> + <Invites /> +</div> -<Invites /> +<div> + <LogOut /> +</div> diff --git a/ui/routes/(login)/invite/[invite]/+page.svelte b/ui/routes/(login)/invite/[invite]/+page.svelte index 18bf437..4433bd6 100644 --- a/ui/routes/(login)/invite/[invite]/+page.svelte +++ b/ui/routes/(login)/invite/[invite]/+page.svelte @@ -11,10 +11,10 @@ let pending = false; let disabled = $derived(pending); - async function onSubmit(event) { + async function onSubmit(event, inviteId) { event.preventDefault(); pending = true; - const response = await acceptInvite(data.invite.id, username, password); + const response = await acceptInvite(inviteId, username, password); if (200 <= response.status && response.status < 300) { username = ''; password = ''; @@ -32,5 +32,5 @@ <div class="card m-4 p-4"> <p>Hi there! {invite.issuer} invites you to the conversation.</p> </div> - <LogIn {disabled} bind:username bind:password onsubmit={onSubmit} /> + <LogIn {disabled} bind:username bind:password onsubmit={(event) => onSubmit(event, invite.id)} /> {/await} diff --git a/ui/routes/+layout.svelte b/ui/routes/+layout.svelte index ef3e823..8940659 100644 --- a/ui/routes/+layout.svelte +++ b/ui/routes/+layout.svelte @@ -30,10 +30,10 @@ <img class="w-8 h-8" alt="logo" src={logo} /> </button> </svelte:fragment> - <a href="/">understory</a> + <a href="/">pilcrow</a> <svelte:fragment slot="trail"> {#if $currentUser} - <div class="rounded-full bg-secondary-400 px-2 py-1"> + <div class="rounded-full bg-secondary-400 px-3 py-1"> <a href="/me">@{$currentUser.username}</a> </div> {/if} diff --git a/ui/service-worker.js b/ui/service-worker.js new file mode 100644 index 0000000..9855a73 --- /dev/null +++ b/ui/service-worker.js @@ -0,0 +1,54 @@ +/// <reference types="@sveltejs/kit" /> +/// <reference no-default-lib="true"/> +/// <reference lib="esnext" /> +/// <reference lib="webworker" /> + +// Because of this line, this service worker won't run in dev mode in Firefox. +// Only Safari, Edge, Chrome can run it at the moment, because only they +// support modules in service workers. +// +// That's okay! If you run `tools/run` with PILCROW_DEV unset, you will get the +// bundled version, and can work on it. Or just use Safari. +import { build, files, version } from '$service-worker'; + +// Create a unique cache name for this deployment +const CACHE = `cache-${version}`; + +const ASSETS = [ + ...build, // the app itself + ...files // everything in `static` +]; + +self.addEventListener('install', (event) => { + // Create a new cache and add all files to it + async function addFilesToCache() { + const cache = await caches.open(CACHE); + await cache.addAll(ASSETS); + } + + event.waitUntil(addFilesToCache()); +}); + +self.addEventListener('activate', (event) => { + // Remove previous cached data from disk + async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) await caches.delete(key); + } + } + + event.waitUntil(deleteOldCaches()); +}); + +// The simplest possible use of the caches above: +async function cacheFirst(request) { + const responseFromCache = await caches.match(request); + if (responseFromCache) { + return responseFromCache; + } + return fetch(request); +} + +self.addEventListener('fetch', (event) => { + event.respondWith(cacheFirst(event.request)); +}); diff --git a/ui/static/apple-touch-icon-precomposed.png b/ui/static/apple-touch-icon-precomposed.png Binary files differnew file mode 100644 index 0000000..4b35d9b --- /dev/null +++ b/ui/static/apple-touch-icon-precomposed.png diff --git a/ui/static/apple-touch-icon.png b/ui/static/apple-touch-icon.png Binary files differnew file mode 100644 index 0000000..4b35d9b --- /dev/null +++ b/ui/static/apple-touch-icon.png diff --git a/ui/static/favicon.ico b/ui/static/favicon.ico Binary files differnew file mode 100644 index 0000000..4b35d9b --- /dev/null +++ b/ui/static/favicon.ico diff --git a/ui/static/favicon.png b/ui/static/favicon.png Binary files differindex 5df6b4e..4b35d9b 100644 --- a/ui/static/favicon.png +++ b/ui/static/favicon.png diff --git a/ui/static/manifest.json b/ui/static/manifest.json new file mode 100644 index 0000000..5d735d0 --- /dev/null +++ b/ui/static/manifest.json @@ -0,0 +1,51 @@ +{ + "name": "Pilcrow", + "short_name": "Pilcrow", + "start_url": "/", + "display": "standalone", + "background_color": "#fdfdfd", + "theme_color": "#2c3656", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon.png", + "type": "image/png", + "sizes": "72x72" + }, + { + "src": "/favicon.png", + "type": "image/png", + "sizes": "96x96" + }, + { + "src": "/favicon.png", + "type": "image/png", + "sizes": "128x128" + }, + { + "src": "/favicon.png", + "type": "image/png", + "sizes": "144x144" + }, + { + "src": "/favicon.png", + "type": "image/png", + "sizes": "152x152" + }, + { + "src": "/favicon.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "/favicon.png", + "type": "image/png", + "sizes": "384x384" + }, + { + "src": "/favicon.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} |
