Chat: svar-tråder, kanban-konvertering og kalender-konvertering

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 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 01:43:24 +01:00
parent d5a4de55de
commit 63f928bbe6
11 changed files with 834 additions and 63 deletions

View file

@ -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<string, unknown> } = $props();
@ -15,9 +16,34 @@
let chat = $state<ChatConnection | null>(null);
let sending = $state(false);
let messagesEl: HTMLDivElement | undefined;
let replyingTo = $state<MessageData | null>(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 @@
<div class="error">{chat.error}</div>
{/if}
{#if replyingTo}
<div class="reply-preview">
<span class="reply-preview__text">↩ Svar til {replyingTo.author_name ?? 'Ukjent'}</span>
<button class="reply-preview__close" onclick={() => replyingTo = null}>✕</button>
</div>
{/if}
<div class="input-row">
<Editor
mode="compact"
placeholder="Skriv en melding..."
placeholder={replyingTo ? 'Skriv et svar...' : 'Skriv en melding...'}
onSubmit={handleSubmit}
autofocus
/>
</div>
</div>
{#if convertTarget}
<ConvertDialog
type={convertTarget.type}
workspaceId={$page.data.workspace?.id}
onConfirm={handleConvert}
onCancel={() => convertTarget = null}
/>
{/if}
{/if}
<style>
@ -189,6 +259,40 @@
padding: 0.25rem 0.5rem;
}
.reply-preview {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
background: rgba(59, 130, 246, 0.08);
border-left: 2px solid #3b82f6;
border-radius: 0 4px 4px 0;
font-size: 0.75rem;
color: #8b92a5;
}
.reply-preview__text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reply-preview__close {
background: none;
border: none;
color: #8b92a5;
cursor: pointer;
font-size: 0.7rem;
padding: 0.1rem 0.2rem;
border-radius: 3px;
}
.reply-preview__close:hover {
color: #e1e4e8;
background: rgba(255, 255, 255, 0.08);
}
.input-row {
padding-top: 0.5rem;
border-top: 1px solid #2d3148;

View file

@ -27,9 +27,12 @@ export function createPgChat(channelId: string): ChatConnection {
visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace',
created_at: raw.created_at as string,
updated_at: (raw.updated_at as string) ?? (raw.created_at as string),
reply_count: (raw.reply_count as number) ?? 0,
parent_body: (raw.parent_body as string) ?? null,
parent_author_name: (raw.parent_author_name as string) ?? null,
reactions: (raw.reactions as MessageData['reactions']) ?? [],
kanban_view: null,
calendar_view: null
kanban_view: (raw.kanban_view as MessageData['kanban_view']) ?? null,
calendar_view: (raw.calendar_view as MessageData['calendar_view']) ?? null
};
}
@ -48,13 +51,13 @@ export function createPgChat(channelId: string): ChatConnection {
}
}
async function send(body: string, mentions?: MentionRef[]) {
async function send(body: string, mentions?: MentionRef[], replyTo?: string) {
error = '';
try {
const res = await fetch(`/api/channels/${channelId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body, mentions })
body: JSON.stringify({ body, mentions, replyTo: replyTo ?? null })
});
if (!res.ok) throw new Error('Feil ved sending');
await refresh();

View file

@ -14,13 +14,15 @@ export function createSpacetimeChat(
channelId: string,
spacetimeUrl: string,
moduleName: string,
user: ChatUser
user: ChatUser,
workspaceId: string
): ChatConnection {
let messages = $state<MessageData[]>([]);
let error = $state('');
let connected = $state(false);
let conn: InstanceType<typeof DbConnection> | null = null;
let destroyed = false;
const deletedIds = new Set<string>();
function toMessageData(raw: Record<string, unknown>): MessageData {
return {
@ -36,9 +38,12 @@ export function createSpacetimeChat(
visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace',
created_at: raw.created_at as string,
updated_at: (raw.updated_at as string) ?? (raw.created_at as string),
reply_count: (raw.reply_count as number) ?? 0,
parent_body: (raw.parent_body as string) ?? null,
parent_author_name: (raw.parent_author_name as string) ?? null,
reactions: (raw.reactions as MessageData['reactions']) ?? [],
kanban_view: null,
calendar_view: null
kanban_view: (raw.kanban_view as MessageData['kanban_view']) ?? null,
calendar_view: (raw.calendar_view as MessageData['calendar_view']) ?? null
};
}
@ -50,8 +55,8 @@ export function createSpacetimeChat(
const raw: Record<string, unknown>[] = await res.json();
const pgMessages = raw.map(toMessageData);
const pgIds = new Set(pgMessages.map(m => m.id));
// Behold SpacetimeDB-meldinger som ikke finnes i PG ennå (antatt nyere)
const spacetimeOnly = messages.filter(m => !pgIds.has(m.id));
// Behold SpacetimeDB-meldinger som ikke finnes i PG ennå, unntatt slettede
const spacetimeOnly = messages.filter(m => !pgIds.has(m.id) && !deletedIds.has(m.id));
messages = [...pgMessages, ...spacetimeOnly];
} catch {
error = 'Kunne ikke laste meldinger';
@ -96,12 +101,20 @@ export function createSpacetimeChat(
conn.db.chat_message.onInsert((ctx: EventContext, row) => {
if (destroyed) return;
if (row.channelId !== channelId) return;
// Dedupliser mot PG-data
if (deletedIds.has(row.id)) return;
// Dedupliser mot eksisterende
if (messages.some(m => m.id === row.id)) return;
const msg = spacetimeRowToMessage(row);
messages = [...messages, msg];
});
// Fjern meldinger som slettes i sanntid
conn.db.chat_message.onDelete((ctx: EventContext, row) => {
if (destroyed) return;
if (row.channelId !== channelId) return;
messages = messages.filter(m => m.id !== row.id);
});
} catch (e) {
console.warn('[spacetime] setup feilet, bruker kun PG:', e);
}
@ -134,35 +147,38 @@ export function createSpacetimeChat(
};
}
async function send(body: string, mentions?: MentionRef[]) {
// Alltid send via PG API — dette oppretter noden og MENTIONS-edges atomisk.
// SpacetimeDB brukes kun for real-time push til andre klienter.
try {
const res = await fetch(`/api/channels/${channelId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body, mentions })
});
if (!res.ok) throw new Error('Feil ved sending');
async function sendViaPgApi(body: string, mentions?: MentionRef[], replyTo?: string) {
const res = await fetch(`/api/channels/${channelId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body, mentions, replyTo: replyTo ?? null })
});
if (!res.ok) throw new Error('Feil ved sending');
await loadFromPg();
}
// Push til SpacetimeDB for sanntidsvisning hos andre klienter
if (conn && connected) {
try {
const msg = await res.clone().json();
conn.reducers.sendMessage({
id: msg.id,
channelId,
workspaceId: '',
authorName: user.name,
body,
replyTo: ''
});
} catch {
// Ikke kritisk — PG er allerede oppdatert
}
async function send(body: string, mentions?: MentionRef[], replyTo?: string) {
// Fallback til PG API hvis SpacetimeDB er nede
if (!conn || !connected) {
try {
await sendViaPgApi(body, mentions, replyTo);
} catch {
error = 'Kunne ikke sende melding';
}
await loadFromPg();
return;
}
try {
const id = crypto.randomUUID();
conn.reducers.sendMessage({
id,
channelId,
workspaceId,
authorId: user.id,
authorName: user.name,
body,
replyTo: replyTo ?? ''
});
// Ingen reload — onInsert-callback viser meldingen instant
} catch {
error = 'Kunne ikke sende melding';
}
@ -188,12 +204,34 @@ export function createSpacetimeChat(
loadFromPg();
connectRealtime();
function updateLocal(messageId: string, newBody: string) {
messages = messages.map(m =>
m.id === messageId ? { ...m, body: newBody } : m
);
}
function removeLocal(messageId: string) {
deletedIds.add(messageId);
messages = messages.filter(m => m.id !== messageId);
}
function deleteFromSpacetime(messageId: string) {
if (conn && connected) {
try {
conn.reducers.deleteMessage({ id: messageId });
} catch { /* SpacetimeDB nede — ignoreres */ }
}
}
return {
get messages() { return messages; },
get error() { return error; },
get connected() { return connected; },
send,
refresh: loadFromPg,
updateLocal,
removeLocal,
deleteFromSpacetime,
destroy
};
}

View file

@ -26,7 +26,10 @@ export interface ChatConnection {
readonly messages: MessageData[];
readonly error: string;
readonly connected: boolean;
send(body: string, mentions?: MentionRef[]): Promise<void>;
send(body: string, mentions?: MentionRef[], replyTo?: string): Promise<void>;
refresh(): Promise<void>;
removeLocal?(messageId: string): void;
updateLocal?(messageId: string, newBody: string): void;
deleteFromSpacetime?(messageId: string): void;
destroy(): void;
}

View file

@ -0,0 +1,255 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
type,
workspaceId,
onConfirm,
onCancel
}: {
type: 'kanban' | 'calendar';
workspaceId?: string;
onConfirm: (detail: { columnId?: string; calendarId?: string; startsAt?: string; endsAt?: string; allDay?: boolean }) => void;
onCancel: () => void;
} = $props();
// Kanban state
let boards = $state<{ id: string; name: string; columns: { id: string; name: string }[] }[]>([]);
let selectedColumnId = $state('');
// Calendar state
let calendars = $state<{ id: string; name: string; color: string }[]>([]);
let selectedCalendarId = $state('');
let startsAt = $state('');
let endsAt = $state('');
let allDay = $state(false);
let loading = $state(true);
onMount(async () => {
if (type === 'kanban') {
try {
const res = await fetch('/api/kanban');
if (res.ok) {
boards = await res.json();
if (boards.length > 0 && boards[0].columns.length > 0) {
selectedColumnId = boards[0].columns[0].id;
}
}
} catch { /* stille feil */ }
} else {
try {
const res = await fetch('/api/calendar');
if (res.ok) {
calendars = await res.json();
if (calendars.length > 0) {
selectedCalendarId = calendars[0].id;
}
}
} catch { /* stille feil */ }
// Default til nå
const now = new Date();
now.setMinutes(0, 0, 0);
startsAt = toLocalDatetime(now);
const end = new Date(now);
end.setHours(end.getHours() + 1);
endsAt = toLocalDatetime(end);
}
loading = false;
});
function toLocalDatetime(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function handleSubmit() {
if (type === 'kanban') {
if (!selectedColumnId) return;
onConfirm({ columnId: selectedColumnId });
} else {
if (!selectedCalendarId || !startsAt) return;
onConfirm({
calendarId: selectedCalendarId,
startsAt: new Date(startsAt).toISOString(),
endsAt: endsAt ? new Date(endsAt).toISOString() : undefined,
allDay
});
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onCancel();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="convert-overlay" onclick={onCancel} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="convert-dialog" onclick={(e) => e.stopPropagation()}>
<h3>{type === 'kanban' ? 'Konverter til kanban-kort' : 'Konverter til kalenderhendelse'}</h3>
{#if loading}
<p class="convert-dialog__loading">Laster...</p>
{:else if type === 'kanban'}
{#if boards.length === 0}
<p class="convert-dialog__empty">Ingen kanban-tavler funnet i dette workspace.</p>
{:else}
<label class="convert-dialog__label">
Velg kolonne:
<select bind:value={selectedColumnId} class="convert-dialog__select">
{#each boards as board}
<optgroup label={board.name}>
{#each board.columns as col}
<option value={col.id}>{col.name}</option>
{/each}
</optgroup>
{/each}
</select>
</label>
{/if}
{:else}
{#if calendars.length === 0}
<p class="convert-dialog__empty">Ingen kalendere funnet i dette workspace.</p>
{:else}
<label class="convert-dialog__label">
Kalender:
<select bind:value={selectedCalendarId} class="convert-dialog__select">
{#each calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
</label>
<label class="convert-dialog__label convert-dialog__checkbox">
<input type="checkbox" bind:checked={allDay} />
Heldagshendelse
</label>
{#if !allDay}
<label class="convert-dialog__label">
Starter:
<input type="datetime-local" bind:value={startsAt} class="convert-dialog__input" />
</label>
<label class="convert-dialog__label">
Slutter:
<input type="datetime-local" bind:value={endsAt} class="convert-dialog__input" />
</label>
{:else}
<label class="convert-dialog__label">
Dato:
<input type="date" bind:value={startsAt} class="convert-dialog__input" />
</label>
{/if}
{/if}
{/if}
<div class="convert-dialog__actions">
<button class="convert-dialog__btn convert-dialog__btn--cancel" onclick={onCancel}>Avbryt</button>
<button class="convert-dialog__btn convert-dialog__btn--confirm" onclick={handleSubmit}
disabled={loading || (type === 'kanban' ? !selectedColumnId : !selectedCalendarId || !startsAt)}
>Konverter</button>
</div>
</div>
</div>
<style>
.convert-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.convert-dialog {
background: #161822;
border: 1px solid #2d3148;
border-radius: 8px;
padding: 1.2rem;
min-width: 300px;
max-width: 400px;
}
.convert-dialog h3 {
margin: 0 0 0.8rem;
font-size: 0.9rem;
color: #e1e4e8;
font-weight: 600;
}
.convert-dialog__loading,
.convert-dialog__empty {
font-size: 0.8rem;
color: #8b92a5;
}
.convert-dialog__label {
display: block;
font-size: 0.75rem;
color: #8b92a5;
margin-bottom: 0.6rem;
}
.convert-dialog__checkbox {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
}
.convert-dialog__select,
.convert-dialog__input {
display: block;
width: 100%;
margin-top: 0.2rem;
padding: 0.4rem;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
font-family: inherit;
font-size: 0.8rem;
}
.convert-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.convert-dialog__btn {
padding: 0.35rem 0.8rem;
border-radius: 4px;
border: none;
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
}
.convert-dialog__btn--cancel {
background: none;
color: #8b92a5;
}
.convert-dialog__btn--cancel:hover {
color: #e1e4e8;
}
.convert-dialog__btn--confirm {
background: #3b82f6;
color: white;
}
.convert-dialog__btn--confirm:hover:not(:disabled) {
background: #2563eb;
}
.convert-dialog__btn--confirm:disabled {
background: #1e2235;
color: #8b92a5;
cursor: not-allowed;
}
</style>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { MessageData, MessageBoxMode, MessageBoxCallbacks } from '$lib/types/message';
import Editor from '$lib/components/Editor.svelte';
let {
message,
@ -44,7 +45,17 @@
callbacks.onClick?.(message.id);
}
const QUICK_REACTIONS = ['👍', '❤️', '🔥', '👀', '💯'];
const ALL_REACTIONS = ['👍', '❤️', '🔥', '👀', '💯'];
let userReaction = $derived(
message.reactions?.find(r => r.user_reacted)?.reaction ?? null
);
let availableReactions = $derived(
userReaction ? [] : ALL_REACTIONS.filter(
emoji => !message.reactions?.some(r => r.reaction === emoji && r.user_reacted)
)
);
let hasBadges = $derived(
(mode === 'expanded' && (message.kanban_view || message.calendar_view))
@ -56,6 +67,13 @@
let bodyPreview = $derived(truncate(stripHtml(message.body), 80));
let editing = $state(false);
let editBody = $state('');
let isOwnMessage = $derived(
callbacks.currentUserId != null && message.author_id === callbacks.currentUserId
);
function handleReaction(e: MouseEvent, reaction: string) {
e.stopPropagation();
callbacks.onReaction?.(message.id, reaction);
@ -65,12 +83,58 @@
e.stopPropagation();
callbacks.onTogglePin?.(message.id, !message.pinned);
}
function startEdit(e: MouseEvent) {
e.stopPropagation();
editBody = message.body;
editing = true;
}
function cancelEdit() {
editing = false;
editBody = '';
}
function submitEdit(html: string) {
const body = html || editBody;
if (!body.trim()) return;
callbacks.onEdit?.(message.id, body);
editing = false;
editBody = '';
}
function handleDelete(e: MouseEvent) {
e.stopPropagation();
callbacks.onDelete?.(message.id);
}
function handleReply(e: MouseEvent) {
e.stopPropagation();
callbacks.onReply?.(message.id);
}
function handleConvertToKanban(e: MouseEvent) {
e.stopPropagation();
callbacks.onConvertToKanban?.(message.id);
}
function handleConvertToCalendar(e: MouseEvent) {
e.stopPropagation();
callbacks.onConvertToCalendar?.(message.id);
}
</script>
{#if mode === 'expanded'}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="messagebox messagebox--expanded" onclick={handleClick}>
<div class="messagebox messagebox--expanded" id="msg-{message.id}" onclick={handleClick}>
{#if message.reply_to && message.parent_author_name}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="messagebox__reply-context" onclick={(e) => { 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)}
</div>
{/if}
{#if showAuthor || showTimestamp}
<div class="messagebox__header">
{#if showAuthor}
@ -79,19 +143,58 @@
{#if showTimestamp}
<span class="messagebox__time">{formatTime(message.created_at)}</span>
{/if}
{#if callbacks.onTogglePin}
<button
class="messagebox__pin-toggle"
class:messagebox__pin-toggle--active={message.pinned}
title={message.pinned ? 'Løsne' : 'Fest'}
onclick={handleTogglePin}
>📌</button>
{:else if message.pinned}
<span class="messagebox__pinned" title="Festet">📌</span>
{/if}
<span class="messagebox__header-right">
{#if isOwnMessage && !editing}
<span class="messagebox__actions">
{#if callbacks.onEdit}
<button class="messagebox__action" title="Rediger" onclick={startEdit}>✏️</button>
{/if}
{#if callbacks.onDelete}
<button class="messagebox__action" title="Slett" onclick={handleDelete}>🗑️</button>
{/if}
</span>
{/if}
<span class="messagebox__actions">
{#if callbacks.onReply}
<button class="messagebox__action" title="Svar" onclick={handleReply}>💬</button>
{/if}
{#if callbacks.onConvertToKanban}
<button class="messagebox__action" title="Kanban-kort" onclick={handleConvertToKanban}>📋</button>
{/if}
{#if callbacks.onConvertToCalendar}
<button class="messagebox__action" title="Kalenderhendelse" onclick={handleConvertToCalendar}>📅</button>
{/if}
</span>
{#if callbacks.onTogglePin}
<button
class="messagebox__pin-toggle"
class:messagebox__pin-toggle--active={message.pinned}
title={message.pinned ? 'Løsne' : 'Fest'}
onclick={handleTogglePin}
>📌</button>
{:else if message.pinned}
<span class="messagebox__pinned" title="Festet">📌</span>
{/if}
</span>
</div>
{/if}
<div class="messagebox__body">{@html message.body}</div>
{#if editing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="messagebox__edit" onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }}>
<Editor
mode="compact"
content={editBody}
autofocus
onSubmit={(html) => submitEdit(html)}
/>
<div class="messagebox__edit-actions">
<span class="messagebox__edit-hint">Enter = lagre · Esc = avbryt</span>
<button class="messagebox__edit-btn messagebox__edit-btn--cancel" onclick={cancelEdit}>Avbryt</button>
</div>
</div>
{:else}
<div class="messagebox__body">{@html message.body}</div>
{/if}
{#if hasReactions || callbacks.onReaction}
<div class="messagebox__reactions">
{#each message.reactions ?? [] as r}
@ -101,9 +204,9 @@
onclick={(e) => handleReaction(e, r.reaction)}
>{r.reaction} {r.count}</button>
{/each}
{#if callbacks.onReaction}
{#if callbacks.onReaction && availableReactions.length > 0}
<span class="messagebox__reaction-add">
{#each QUICK_REACTIONS as emoji}
{#each availableReactions as emoji}
<button
class="messagebox__reaction-quick"
onclick={(e) => handleReaction(e, emoji)}
@ -180,14 +283,19 @@
color: #8b92a5;
}
.messagebox__header-right {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.messagebox__pinned {
font-size: 0.65rem;
margin-left: auto;
}
.messagebox__pin-toggle {
font-size: 0.65rem;
margin-left: auto;
background: none;
border: none;
cursor: pointer;
@ -208,6 +316,69 @@
opacity: 1;
}
/* === Edit/Delete actions === */
.messagebox__actions {
display: none;
gap: 0.15rem;
}
.messagebox--expanded:hover .messagebox__actions {
display: flex;
}
.messagebox__action {
font-size: 0.6rem;
background: none;
border: none;
cursor: pointer;
padding: 0.05rem 0.15rem;
border-radius: 4px;
opacity: 0.4;
transition: opacity 0.15s;
}
.messagebox__action:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.08);
}
/* === Inline edit === */
.messagebox__edit {
margin-top: 0.2rem;
}
.messagebox__edit-actions {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.25rem;
justify-content: flex-end;
}
.messagebox__edit-hint {
font-size: 0.6rem;
color: #8b92a5;
margin-right: auto;
}
.messagebox__edit-btn {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
border: none;
cursor: pointer;
font-family: inherit;
}
.messagebox__edit-btn--cancel {
background: none;
color: #8b92a5;
}
.messagebox__edit-btn--cancel:hover {
color: #e1e4e8;
}
/* === Reactions === */
.messagebox__reactions {
display: flex;
@ -262,6 +433,23 @@
background: rgba(255, 255, 255, 0.08);
}
/* === Reply context === */
.messagebox__reply-context {
font-size: 0.7rem;
color: #8b92a5;
padding: 0.1rem 0.4rem;
margin-bottom: 0.1rem;
border-left: 2px solid #3b82f6;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.messagebox__reply-context:hover {
color: #c0c6d4;
}
.messagebox__body {
font-size: 0.85rem;
color: #e1e4e8;

View file

@ -12,8 +12,10 @@ export interface MessageData {
created_at: string;
updated_at: string;
reply_count?: number;
parent_body?: string | null;
parent_author_name?: string | null;
reactions?: ReactionSummary[];
kanban_view?: { column_id: string; board_name?: string; color: string | null } | null;
kanban_view?: { column_id: string; column_name?: string; board_name?: string; color: string | null } | null;
calendar_view?: {
starts_at: string;
ends_at: string | null;
@ -35,4 +37,10 @@ export interface MessageBoxCallbacks {
onMentionClick?: (entityId: string) => void;
onReaction?: (messageId: string, reaction: string) => void;
onTogglePin?: (messageId: string, pinned: boolean) => void;
onEdit?: (messageId: string, newBody: string) => void;
onDelete?: (messageId: string) => void;
onReply?: (messageId: string) => void;
onConvertToKanban?: (messageId: string) => void;
onConvertToCalendar?: (messageId: string) => void;
currentUserId?: string;
}

View file

@ -0,0 +1,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** GET /api/calendar — List alle kalendere for workspace */
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.workspace || !locals.user) error(401);
const calendars = await sql`
SELECT c.id, c.name, c.color FROM calendars c
JOIN nodes n ON n.id = c.id
WHERE n.workspace_id = ${locals.workspace.id}
ORDER BY c.name
`;
return json(calendars);
};

View file

@ -23,9 +23,21 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
? await sql`
SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to,
u.display_name as author_name, u.authentik_id as author_id
u.display_name as author_name, u.authentik_id as author_id,
(SELECT count(*)::int FROM messages r WHERE r.reply_to = m.id) as reply_count,
pm.body as parent_body, pu.display_name as parent_author_name,
kcv.column_id, kc.name as column_name, kcv.color as kanban_color,
kb.name as board_name,
cev.starts_at as cal_starts_at, cev.ends_at as cal_ends_at,
cev.all_day as cal_all_day, cev.color as cal_color
FROM messages m
LEFT JOIN users u ON u.authentik_id = m.author_id
LEFT JOIN messages pm ON pm.id = m.reply_to
LEFT JOIN users pu ON pu.authentik_id = pm.author_id
LEFT JOIN kanban_card_view kcv ON kcv.message_id = m.id
LEFT JOIN kanban_columns kc ON kc.id = kcv.column_id
LEFT JOIN kanban_boards kb ON kb.id = kc.board_id
LEFT JOIN calendar_event_view cev ON cev.message_id = m.id
WHERE m.channel_id = ${channelId} AND m.created_at > ${after}
ORDER BY m.created_at ASC
LIMIT ${limit}
@ -33,9 +45,21 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
: await sql`
SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to,
u.display_name as author_name, u.authentik_id as author_id
u.display_name as author_name, u.authentik_id as author_id,
(SELECT count(*)::int FROM messages r WHERE r.reply_to = m.id) as reply_count,
pm.body as parent_body, pu.display_name as parent_author_name,
kcv.column_id, kc.name as column_name, kcv.color as kanban_color,
kb.name as board_name,
cev.starts_at as cal_starts_at, cev.ends_at as cal_ends_at,
cev.all_day as cal_all_day, cev.color as cal_color
FROM messages m
LEFT JOIN users u ON u.authentik_id = m.author_id
LEFT JOIN messages pm ON pm.id = m.reply_to
LEFT JOIN users pu ON pu.authentik_id = pm.author_id
LEFT JOIN kanban_card_view kcv ON kcv.message_id = m.id
LEFT JOIN kanban_columns kc ON kc.id = kcv.column_id
LEFT JOIN kanban_boards kb ON kb.id = kc.board_id
LEFT JOIN calendar_event_view cev ON cev.message_id = m.id
WHERE m.channel_id = ${channelId}
ORDER BY m.created_at DESC
LIMIT ${limit}
@ -66,8 +90,34 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
}
const messages = rows.map((m) => ({
...m,
reactions: reactionsMap[m.id as string] ?? []
id: m.id,
channel_id: m.channel_id,
body: m.body,
message_type: m.message_type,
title: m.title,
pinned: m.pinned,
visibility: m.visibility,
created_at: m.created_at,
updated_at: m.updated_at,
reply_to: m.reply_to,
author_name: m.author_name,
author_id: m.author_id,
reply_count: m.reply_count ?? 0,
parent_body: m.parent_body ?? null,
parent_author_name: m.parent_author_name ?? null,
reactions: reactionsMap[m.id as string] ?? [],
kanban_view: m.column_id ? {
column_id: m.column_id,
column_name: m.column_name,
board_name: m.board_name,
color: m.kanban_color ?? null
} : null,
calendar_view: m.cal_starts_at ? {
starts_at: m.cal_starts_at,
ends_at: m.cal_ends_at ?? null,
all_day: m.cal_all_day ?? false,
color: m.cal_color ?? null
} : null
}));
return json(messages);

View file

@ -0,0 +1,38 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** GET /api/kanban — List alle kanban-brett med kolonner for workspace */
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.workspace || !locals.user) error(401);
const boards = await sql`
SELECT b.id, b.name FROM kanban_boards b
JOIN nodes n ON n.id = b.id
WHERE n.workspace_id = ${locals.workspace.id}
ORDER BY b.name
`;
const boardIds = boards.map(b => b.id as string);
if (boardIds.length === 0) return json([] as unknown[]);
const columns = await sql`
SELECT id, board_id, name, position
FROM kanban_columns
WHERE board_id = ANY(${boardIds})
ORDER BY position ASC
`;
const colsByBoard: Record<string, (typeof columns[number])[]> = {};
for (const col of columns) {
const bid = col.board_id as string;
if (!colsByBoard[bid]) colsByBoard[bid] = [];
colsByBoard[bid].push(col);
}
return json(boards.map(b => ({
id: b.id,
name: b.name,
columns: colsByBoard[b.id as string] ?? []
})));
};

View file

@ -0,0 +1,67 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** POST /api/messages/:messageId/convert — Konverter melding til kanban-kort eller kalenderhendelse */
export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const workspace = locals.workspace;
const messageId = params.messageId;
const body = await request.json();
const { type } = body;
// Verifiser at meldingen tilhører workspace
const [msg] = await sql`
SELECT m.id FROM messages m
JOIN nodes n ON n.id = m.id
WHERE m.id = ${messageId} AND n.workspace_id = ${workspace.id}
`;
if (!msg) error(404, 'Melding ikke funnet');
if (type === 'kanban') {
const { columnId } = body;
if (!columnId) error(400, 'columnId er påkrevd');
// Verifiser at kolonnen tilhører workspace
const [col] = await sql`
SELECT kc.id FROM kanban_columns kc
JOIN kanban_boards kb ON kb.id = kc.board_id
JOIN nodes n ON n.id = kb.id
WHERE kc.id = ${columnId} AND n.workspace_id = ${workspace.id}
`;
if (!col) error(404, 'Kolonne ikke funnet');
await sql`
INSERT INTO kanban_card_view (message_id, column_id, position)
VALUES (${messageId}, ${columnId},
(SELECT COALESCE(MAX(position), 0) + 1 FROM kanban_card_view WHERE column_id = ${columnId}))
ON CONFLICT (message_id) DO NOTHING
`;
return json({ ok: true, type: 'kanban' });
}
if (type === 'calendar') {
const { calendarId, startsAt, endsAt, allDay } = body;
if (!calendarId || !startsAt) error(400, 'calendarId og startsAt er påkrevd');
// Verifiser at kalenderen tilhører workspace
const [cal] = await sql`
SELECT c.id FROM calendars c
JOIN nodes n ON n.id = c.id
WHERE c.id = ${calendarId} AND n.workspace_id = ${workspace.id}
`;
if (!cal) error(404, 'Kalender ikke funnet');
await sql`
INSERT INTO calendar_event_view (message_id, calendar_id, starts_at, ends_at, all_day)
VALUES (${messageId}, ${calendarId}, ${startsAt}, ${endsAt ?? null}, ${allDay ?? false})
ON CONFLICT (message_id) DO NOTHING
`;
return json({ ok: true, type: 'calendar' });
}
error(400, 'Ugyldig type — bruk "kanban" eller "calendar"');
};