From 63f928bbe68561af3fcc60b6a5b99e568087309d Mon Sep 17 00:00:00 2001 From: vegard Date: Mon, 16 Mar 2026 01:43:24 +0100 Subject: [PATCH] =?UTF-8?q?Chat:=20svar-tr=C3=A5der,=20kanban-konvertering?= =?UTF-8?q?=20og=20kalender-konvertering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legger til tre nye funksjoner fra chatmeldinger: - Svar på meldinger med reply-kontekst (↩ forfatter: tekst) - Konverter melding til kanban-kort via dialog - Konverter melding til kalenderhendelse via dialog Utvider messages API med reply_count, parent-info og LEFT JOIN til kanban/kalender view-tabeller for badges. Nye list-endepunkter for /api/kanban og /api/calendar. Co-Authored-By: Claude Opus 4.6 --- web/src/lib/blocks/ChatBlock.svelte | 110 +++++++- web/src/lib/chat/pg.svelte.ts | 11 +- web/src/lib/chat/spacetime.svelte.ts | 104 ++++--- web/src/lib/chat/types.ts | 5 +- web/src/lib/components/ConvertDialog.svelte | 255 ++++++++++++++++++ web/src/lib/components/MessageBox.svelte | 222 +++++++++++++-- web/src/lib/types/message.ts | 10 +- web/src/routes/api/calendar/+server.ts | 17 ++ .../api/channels/[id]/messages/+server.ts | 58 +++- web/src/routes/api/kanban/+server.ts | 38 +++ .../messages/[messageId]/convert/+server.ts | 67 +++++ 11 files changed, 834 insertions(+), 63 deletions(-) create mode 100644 web/src/lib/components/ConvertDialog.svelte create mode 100644 web/src/routes/api/calendar/+server.ts create mode 100644 web/src/routes/api/kanban/+server.ts create mode 100644 web/src/routes/api/messages/[messageId]/convert/+server.ts diff --git a/web/src/lib/blocks/ChatBlock.svelte b/web/src/lib/blocks/ChatBlock.svelte index bca8b04..7af9b0b 100644 --- a/web/src/lib/blocks/ChatBlock.svelte +++ b/web/src/lib/blocks/ChatBlock.svelte @@ -7,6 +7,7 @@ import type { ChatConnection } from '$lib/chat/types'; import MessageBox from '$lib/components/MessageBox.svelte'; import Editor from '$lib/components/Editor.svelte'; + import ConvertDialog from '$lib/components/ConvertDialog.svelte'; let { props = {} }: { props?: Record } = $props(); @@ -15,9 +16,34 @@ let chat = $state(null); let sending = $state(false); let messagesEl: HTMLDivElement | undefined; + let replyingTo = $state(null); + let convertTarget = $state<{ messageId: string; type: 'kanban' | 'calendar' } | null>(null); + + let currentUserId = $derived($page.data.user?.id ?? undefined); const chatCallbacks = { + get currentUserId() { return currentUserId; }, onMentionClick: (entityId: string) => goto(`/entities/${entityId}`), + onEdit: async (messageId: string, newBody: string) => { + // Oppdater lokalt umiddelbart (optimistisk) + chat?.updateLocal?.(messageId, newBody); + try { + const res = await fetch(`/api/messages/${messageId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: newBody }) + }); + if (res.ok) await chat?.refresh(); + } catch { /* nettverksfeil — allerede oppdatert lokalt */ } + }, + onDelete: async (messageId: string) => { + // Fjern fra lokal state umiddelbart + chat?.removeLocal?.(messageId); + // Slett fra PG (ignorerer 404 — meldingen kan være usynket) + try { await fetch(`/api/messages/${messageId}`, { method: 'DELETE' }); } catch {} + // Slett fra SpacetimeDB slik at den ikke dukker opp igjen ved reconnect + chat?.deleteFromSpacetime?.(messageId); + }, onReaction: async (messageId: string, reaction: string) => { try { const msg = chat?.messages.find(m => m.id === messageId); @@ -39,6 +65,16 @@ }); if (res.ok) await chat?.refresh(); } catch { /* stille feil */ } + }, + onReply: (messageId: string) => { + const msg = chat?.messages.find(m => m.id === messageId); + if (msg) replyingTo = msg; + }, + onConvertToKanban: (messageId: string) => { + convertTarget = { messageId, type: 'kanban' }; + }, + onConvertToCalendar: (messageId: string) => { + convertTarget = { messageId, type: 'calendar' }; } }; @@ -46,13 +82,30 @@ if (!chat || sending) return; sending = true; try { - await chat.send(html, mentions.length > 0 ? mentions : undefined); + await chat.send(html, mentions.length > 0 ? mentions : undefined, replyingTo?.id); + replyingTo = null; scrollToBottom(); } finally { sending = false; } } + async function handleConvert(detail: { columnId?: string; calendarId?: string; startsAt?: string; endsAt?: string; allDay?: boolean }) { + if (!convertTarget) return; + const body = convertTarget.type === 'kanban' + ? { type: 'kanban', columnId: detail.columnId } + : { type: 'calendar', calendarId: detail.calendarId, startsAt: detail.startsAt, endsAt: detail.endsAt, allDay: detail.allDay }; + try { + const res = await fetch(`/api/messages/${convertTarget.messageId}/convert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (res.ok) await chat?.refresh(); + } catch { /* stille feil */ } + convertTarget = null; + } + function scrollToBottom() { requestAnimationFrame(() => { messagesEl?.scrollTo(0, messagesEl.scrollHeight); @@ -97,10 +150,11 @@ onMount(() => { if (channelId) { const user = $page.data.user; + const workspaceId = $page.data.workspace?.id; chat = createChat(channelId, { id: user?.id ?? 'anonymous', name: user?.name ?? 'Ukjent' - }); + }, workspaceId); } return () => chat?.destroy(); }); @@ -131,15 +185,31 @@
{chat.error}
{/if} + {#if replyingTo} +
+ ↩ Svar til {replyingTo.author_name ?? 'Ukjent'} + +
+ {/if} +
+ + {#if convertTarget} + convertTarget = null} + /> + {/if} {/if} diff --git a/web/src/lib/components/MessageBox.svelte b/web/src/lib/components/MessageBox.svelte index a615219..be778cb 100644 --- a/web/src/lib/components/MessageBox.svelte +++ b/web/src/lib/components/MessageBox.svelte @@ -1,5 +1,6 @@ {#if mode === 'expanded'} -
+
+ {#if message.reply_to && message.parent_author_name} + + +
{ e.stopPropagation(); const el = document.getElementById(`msg-${message.reply_to}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }}> + ↩ {message.parent_author_name}: {truncate(stripHtml(message.parent_body ?? ''), 50)} +
+ {/if} {#if showAuthor || showTimestamp}
{#if showAuthor} @@ -79,19 +143,58 @@ {#if showTimestamp} {formatTime(message.created_at)} {/if} - {#if callbacks.onTogglePin} - - {:else if message.pinned} - 📌 - {/if} + + {#if isOwnMessage && !editing} + + {#if callbacks.onEdit} + + {/if} + {#if callbacks.onDelete} + + {/if} + + {/if} + + {#if callbacks.onReply} + + {/if} + {#if callbacks.onConvertToKanban} + + {/if} + {#if callbacks.onConvertToCalendar} + + {/if} + + {#if callbacks.onTogglePin} + + {:else if message.pinned} + 📌 + {/if} +
{/if} -
{@html message.body}
+ {#if editing} + +
{ if (e.key === 'Escape') cancelEdit(); }}> + submitEdit(html)} + /> +
+ Enter = lagre · Esc = avbryt + +
+
+ {:else} +
{@html message.body}
+ {/if} {#if hasReactions || callbacks.onReaction}
{#each message.reactions ?? [] as r} @@ -101,9 +204,9 @@ onclick={(e) => handleReaction(e, r.reaction)} >{r.reaction} {r.count} {/each} - {#if callbacks.onReaction} + {#if callbacks.onReaction && availableReactions.length > 0} - {#each QUICK_REACTIONS as emoji} + {#each availableReactions as emoji}