summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorKit La Touche <kit@transneptune.net>2024-11-15 10:14:41 -0500
committerKit La Touche <kit@transneptune.net>2024-11-15 10:14:41 -0500
commit1635a4db77898e9394adaa104b4c53b94c59e2da (patch)
tree041158bc15a1b83caaa245fbe60faf46e84a3070 /ui
parentfefe76b35b6329cbcc92755a65e47c7f62f64690 (diff)
parent2fb328089f01776e5bb553a1d50a061396588c8c (diff)
Merge branch 'main' into prop/shorter-expiry
Diffstat (limited to 'ui')
-rw-r--r--ui/app.html1
-rw-r--r--ui/lib/apiServer.js4
-rw-r--r--ui/lib/assets/logo.pngbin137101 -> 92469 bytes
-rw-r--r--ui/lib/components/ActiveChannel.svelte18
-rw-r--r--ui/lib/components/ChangePassword.svelte60
-rw-r--r--ui/lib/components/Invite.svelte5
-rw-r--r--ui/lib/components/Invites.svelte13
-rw-r--r--ui/lib/components/LogOut.svelte18
-rw-r--r--ui/lib/components/Message.svelte32
-rw-r--r--ui/lib/components/MessageInput.svelte2
-rw-r--r--ui/lib/components/MessageRun.svelte6
-rw-r--r--ui/lib/store/messages.svelte.js17
-rw-r--r--ui/routes/(app)/+layout.svelte4
-rw-r--r--ui/routes/(app)/me/+page.svelte84
-rw-r--r--ui/routes/(login)/invite/[invite]/+page.svelte6
-rw-r--r--ui/routes/+layout.svelte4
-rw-r--r--ui/service-worker.js54
-rw-r--r--ui/static/apple-touch-icon-precomposed.pngbin0 -> 92469 bytes
-rw-r--r--ui/static/apple-touch-icon.pngbin0 -> 92469 bytes
-rw-r--r--ui/static/favicon.icobin0 -> 92469 bytes
-rw-r--r--ui/static/favicon.pngbin137101 -> 92469 bytes
-rw-r--r--ui/static/manifest.json51
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
index 5df6b4e..4b35d9b 100644
--- a/ui/lib/assets/logo.png
+++ b/ui/lib/assets/logo.png
Binary files differ
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}>&#x1F5D1;&#xFE0F;</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
new file mode 100644
index 0000000..4b35d9b
--- /dev/null
+++ b/ui/static/apple-touch-icon-precomposed.png
Binary files differ
diff --git a/ui/static/apple-touch-icon.png b/ui/static/apple-touch-icon.png
new file mode 100644
index 0000000..4b35d9b
--- /dev/null
+++ b/ui/static/apple-touch-icon.png
Binary files differ
diff --git a/ui/static/favicon.ico b/ui/static/favicon.ico
new file mode 100644
index 0000000..4b35d9b
--- /dev/null
+++ b/ui/static/favicon.ico
Binary files differ
diff --git a/ui/static/favicon.png b/ui/static/favicon.png
index 5df6b4e..4b35d9b 100644
--- a/ui/static/favicon.png
+++ b/ui/static/favicon.png
Binary files differ
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"
+ }
+ ]
+}