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:
parent
43c24ec16b
commit
3215917b7a
8 changed files with 48 additions and 6 deletions
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ interface ActivityRow {
|
|||
ciphertext: Uint8Array | null;
|
||||
nonce: Uint8Array | null;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
scheduled_at: number | null;
|
||||
loc_label: string | null;
|
||||
loc_lat: number | null;
|
||||
|
|
@ -128,6 +129,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
id: row.id,
|
||||
visibility: 'semi',
|
||||
title: row.title ?? '',
|
||||
description: row.description,
|
||||
tags,
|
||||
loc_label: row.loc_label,
|
||||
loc_lat: row.loc_lat,
|
||||
|
|
@ -149,6 +151,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
owner_display: attrib.display,
|
||||
owner_username: attrib.username,
|
||||
title: row.title ?? '',
|
||||
description: row.description,
|
||||
tags,
|
||||
loc_label: row.loc_label,
|
||||
loc_lat: row.loc_lat,
|
||||
|
|
@ -237,11 +240,13 @@ activitiesRoutes.post('/', requireAuth, async (c) => {
|
|||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO activities
|
||||
(id, owner_id, visibility, title, scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, owner_id, visibility, title, description, scheduled_at,
|
||||
loc_label, loc_lat, loc_lng, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, userId, body.visibility,
|
||||
body.title!.trim(),
|
||||
body.description?.trim() || null,
|
||||
body.scheduled_at ?? null,
|
||||
body.loc_label ?? null,
|
||||
body.loc_lat ?? null,
|
||||
|
|
@ -282,6 +287,7 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
|
|||
ciphertext = ?,
|
||||
nonce = ?,
|
||||
title = NULL,
|
||||
description = NULL,
|
||||
scheduled_at = NULL,
|
||||
loc_label = NULL,
|
||||
loc_lat = NULL,
|
||||
|
|
@ -296,6 +302,7 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
|
|||
UPDATE activities SET
|
||||
visibility = ?,
|
||||
title = ?,
|
||||
description = ?,
|
||||
scheduled_at = ?,
|
||||
loc_label = ?,
|
||||
loc_lat = ?,
|
||||
|
|
@ -307,6 +314,7 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
|
|||
`).run(
|
||||
body.visibility,
|
||||
body.title!.trim(),
|
||||
body.description?.trim() || null,
|
||||
body.scheduled_at ?? null,
|
||||
body.loc_label ?? null,
|
||||
body.loc_lat ?? null,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||
ciphertext BLOB,
|
||||
nonce BLOB,
|
||||
title TEXT,
|
||||
-- Optional free-text body. Plain text for now (markdown is a possible
|
||||
-- future polish). For private rows this column stays NULL — the
|
||||
-- description lives inside the encrypted payload alongside the title.
|
||||
description TEXT,
|
||||
scheduled_at INTEGER,
|
||||
loc_label TEXT,
|
||||
loc_lat REAL,
|
||||
|
|
@ -184,6 +188,7 @@ export function getDb(): Database {
|
|||
// Feedback triage columns (added after the feedback feature shipped).
|
||||
ensureColumn(db, 'feedback', 'done_at', 'INTEGER');
|
||||
ensureColumn(db, 'feedback', 'done_by', 'TEXT');
|
||||
ensureColumn(db, 'activities', 'description', 'TEXT');
|
||||
ensureColumn(db, 'users', 'username', 'TEXT');
|
||||
ensureColumn(db, 'users', 'public_list_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
||||
// UNIQUE index on username via separate CREATE INDEX so the ALTER TABLE
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface ActivityRow {
|
|||
id: string;
|
||||
owner_id: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
scheduled_at: number | null;
|
||||
loc_label: string | null;
|
||||
loc_lat: number | null;
|
||||
|
|
@ -41,8 +42,8 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT id, owner_id, title, scheduled_at, loc_label, loc_lat, loc_lng,
|
||||
created_at, updated_at
|
||||
SELECT id, owner_id, title, description, scheduled_at, loc_label,
|
||||
loc_lat, loc_lng, created_at, updated_at
|
||||
FROM activities
|
||||
WHERE owner_id = ? AND visibility = 'public'
|
||||
ORDER BY created_at DESC
|
||||
|
|
@ -64,6 +65,7 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
// Surfacing it on each row keeps ActivityRow's rendering uniform.
|
||||
owner_username: username,
|
||||
title: r.title ?? '',
|
||||
description: r.description,
|
||||
tags: tagsFor(r.id),
|
||||
loc_label: r.loc_label,
|
||||
loc_lat: r.loc_lat,
|
||||
|
|
|
|||
|
|
@ -182,6 +182,8 @@ export function unwrapDek(sealed: Sealed, kek: Bytes): Bytes {
|
|||
export interface PrivatePayload {
|
||||
title: string;
|
||||
tags: string[];
|
||||
/** Optional free-text body. Plain text (markdown rendering is a future polish). */
|
||||
description?: string;
|
||||
loc_label?: string;
|
||||
loc_lat?: number;
|
||||
loc_lng?: number;
|
||||
|
|
|
|||
|
|
@ -184,6 +184,8 @@ export interface ActivityPublic {
|
|||
// client renders the owner attribution as a link to /<owner_username>/list.
|
||||
owner_username: string | null;
|
||||
title: string;
|
||||
/** Optional free-text body. Plain text. Empty string and null treated the same client-side. */
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
loc_label: string | null;
|
||||
loc_lat: number | null;
|
||||
|
|
@ -205,6 +207,7 @@ export interface ActivitySemi {
|
|||
// anyone else. Stripped server-side for any other viewer; see SECURITY.md.
|
||||
owner_id?: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
loc_label: string | null;
|
||||
loc_lat: number | null;
|
||||
|
|
@ -237,6 +240,7 @@ export interface CreateActivityRequest {
|
|||
visibility: Visibility;
|
||||
// For semi/public:
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
tags?: string[];
|
||||
loc_label?: string | null;
|
||||
loc_lat?: number | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue