Optional description field on activities

A free-text body alongside title/tags/location/scheduled. Plain text
for now; markdown rendering is a deliberate non-goal (the user noted
it was nice-to-have but not essential).

Schema (additive, idempotent via ensureColumn):
  - activities.description TEXT NULL
  - For private rows the column stays NULL; the description lives
    inside the encrypted payload alongside title.

Wire/types:
  - PrivatePayload.description?: string  (in shared/crypto.ts)
  - ActivityPublic.description / ActivitySemi.description: string | null
  - CreateActivityRequest.description?: string | null

Server:
  - INSERT and UPDATE handlers now write description for semi/public
  - Private→semi/public transition: description column populated
  - Semi/public→private transition: description column wiped (now in
    the encrypted blob)
  - serialize() includes the column on public and semi rows
  - server/users.ts public-list endpoint surfaces it too

Frontend:
  - ActivityForm.svelte: textarea after the title field; round-trips
    through the existing private-encrypt / plaintext-PATCH paths
  - ActivityRow.svelte: renders the description as a `white-space:
    pre-wrap` <p> so line breaks survive without enabling markdown
  - Home.svelte: search now matches against the description text
    (decrypted client-side for private rows, just like the title)
This commit is contained in:
Ole-Morten Duesund 2026-05-25 14:08:55 +02:00
commit 3215917b7a
8 changed files with 48 additions and 6 deletions

View file

@ -45,6 +45,14 @@
: '',
);
// svelte-ignore state_referenced_locally
let description = $state(
existing
? (existing.visibility === 'private'
? initialPriv?.description ?? ''
: existing.description ?? '')
: '',
);
// svelte-ignore state_referenced_locally
let tags: string[] = $state(
existing
? (existing.visibility === 'private' ? initialPriv?.tags ?? [] : [...existing.tags])
@ -103,6 +111,7 @@
const payload: PrivatePayload = {
title: title.trim(),
tags,
description: description.trim() || undefined,
loc_label: locLabel || undefined,
scheduled_at: scheduledEpoch() ?? undefined,
};
@ -116,6 +125,7 @@
return {
visibility,
title: title.trim(),
description: description.trim() || null,
tags,
loc_label: locLabel || null,
scheduled_at: scheduledEpoch(),
@ -195,6 +205,10 @@
<label for="title">Tittel</label>
<input id="title" type="text" bind:value={title} required />
<label for="desc">Beskrivelse (valgfritt)</label>
<textarea id="desc" rows="4" bind:value={description}
placeholder="Detaljer, lenker, hva som helst"></textarea>
<div id="tags-label" style="margin: 0.75rem 0 0.25rem; font-weight: 500;">Etiketter</div>
<div role="group" aria-labelledby="tags-label">
<TagInput visibility={visibility} bind:tags onChange={(t) => (tags = t)} />

View file

@ -181,6 +181,9 @@
</a>
<span class="vis-badge private">Privat</span>
</h3>
{#if decrypted.description}
<p style="white-space: pre-wrap; margin: 0.25rem 0;">{decrypted.description}</p>
{/if}
{#if decrypted.tags.length}
<div>
{#each decrypted.tags as t}<span class="tag private">{t}</span>{/each}
@ -202,6 +205,9 @@
{activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'}
</span>
</h3>
{#if activity.description}
<p style="white-space: pre-wrap; margin: 0.25rem 0;">{activity.description}</p>
{/if}
{#if activity.tags.length}
<div>
{#each activity.tags as t}<span class="tag">{t}</span>{/each}

View file

@ -84,14 +84,15 @@
if (a.visibility === 'private') {
const p = privateCleartext.get(a.id);
if (!p) return false;
return [p.title, p.loc_label ?? '', ...p.tags]
return [p.title, p.description ?? '', p.loc_label ?? '', ...p.tags]
.some((s) => s.toLowerCase().includes(needle));
}
return [
a.title,
a.description ?? '',
a.loc_label ?? '',
...a.tags,
a.visibility === 'public' ? a.owner_display : '',
a.visibility === 'public' ? a.owner_display ?? '' : '',
].some((s) => s.toLowerCase().includes(needle));
}