MessageBox: universell meldingsboks-komponent for chat, kanban og kalender

Felles MessageData-type (matcher messages-tabellen) med 3 renderingsmodi:
- expanded (chat): forfatter, tid, HTML-body, mention-klikk, badges
- compact (kanban): tittel, trunkert preview, fargestripe
- calendar (pill): tidspunkt, tittel, bakgrunnsfarge

Alle blokker (ChatBlock, KanbanBlock, CalendarBlock) migrert til MessageBox.
PG-adaptere mapper API-respons til MessageData med view-spesifikke felter.
SpacetimeDB-adapter oppdatert for kompatibilitet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-15 22:15:36 +01:00
parent 50e26e3c48
commit e3e3bbc24f
12 changed files with 567 additions and 255 deletions

View file

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { createCalendar } from '$lib/calendar/create.svelte'; import { createCalendar } from '$lib/calendar/create.svelte';
import type { CalendarConnection, CalendarEvent } from '$lib/calendar/types'; import type { CalendarConnection } from '$lib/calendar/types';
import type { MessageData } from '$lib/types/message';
import MessageBox from '$lib/components/MessageBox.svelte';
let { props = {} }: { props?: Record<string, unknown> } = $props(); let { props = {} }: { props?: Record<string, unknown> } = $props();
@ -29,7 +31,7 @@
let addColor = $state<string | null>(null); let addColor = $state<string | null>(null);
// Redigering // Redigering
let editingEvent = $state<CalendarEvent | null>(null); let editingEvent = $state<MessageData | null>(null);
let editTitle = $state(''); let editTitle = $state('');
let editDescription = $state(''); let editDescription = $state('');
let editStartsAt = $state(''); let editStartsAt = $state('');
@ -84,19 +86,16 @@
return dateStr === fmt(new Date()); return dateStr === fmt(new Date());
} }
function eventsForDate(dateStr: string): CalendarEvent[] { function eventsForDate(dateStr: string): MessageData[] {
return events.filter((e) => { return events.filter((e) => {
const d = new Date(e.starts_at); const startsAt = e.calendar_view?.starts_at;
if (!startsAt) return false;
const d = new Date(startsAt);
const eDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const eDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return eDate === dateStr; return eDate === dateStr;
}); });
} }
function formatTime(isoStr: string): string {
const d = new Date(isoStr);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function prevMonth() { function prevMonth() {
cal?.setViewDate(new Date(year, month - 1, 1)); cal?.setViewDate(new Date(year, month - 1, 1));
} }
@ -142,20 +141,20 @@
showAdd = false; showAdd = false;
} }
function openEdit(event: CalendarEvent) { function openEdit(event: MessageData) {
editingEvent = event; editingEvent = event;
editTitle = event.title; editTitle = event.title ?? '';
editDescription = event.description ?? ''; editDescription = event.body ?? '';
editAllDay = event.all_day; editAllDay = event.calendar_view?.all_day ?? false;
editStartsAt = event.starts_at.slice(0, 16); editStartsAt = event.calendar_view?.starts_at?.slice(0, 16) ?? '';
editEndsAt = event.ends_at?.slice(0, 16) ?? ''; editEndsAt = event.calendar_view?.ends_at?.slice(0, 16) ?? '';
editColor = event.color; editColor = event.calendar_view?.color ?? null;
} }
async function saveEdit() { async function saveEdit() {
if (!cal || !editingEvent) return; if (!cal || !editingEvent) return;
await cal.updateEvent(editingEvent.id, { await cal.updateEvent(editingEvent.id, {
title: editTitle.trim() || editingEvent.title, title: editTitle.trim() || editingEvent.title || '',
description: editDescription.trim() || null, description: editDescription.trim() || null,
starts_at: editStartsAt ? new Date(editStartsAt).toISOString() : undefined, starts_at: editStartsAt ? new Date(editStartsAt).toISOString() : undefined,
ends_at: editEndsAt ? new Date(editEndsAt).toISOString() : null, ends_at: editEndsAt ? new Date(editEndsAt).toISOString() : null,
@ -223,16 +222,15 @@
> >
<span class="day-number">{day.date.getDate()}</span> <span class="day-number">{day.date.getDate()}</span>
{#each eventsForDate(day.dateStr) as event} {#each eventsForDate(day.dateStr) as event}
<button <!-- svelte-ignore a11y_click_events_have_key_events -->
type="button" <!-- svelte-ignore a11y_no_static_element_interactions -->
class="event-pill" <div onclick={(e) => { e.stopPropagation(); openEdit(event); }}>
class:all-day={event.all_day} <MessageBox
style:background={event.color || cal?.calendar?.color || '#3b82f6'} message={event}
onclick={(e) => { e.stopPropagation(); openEdit(event); }} mode="calendar"
> callbacks={{ onClick: () => openEdit(event) }}
{#if !event.all_day}<span class="event-time">{formatTime(event.starts_at)}</span>{/if} />
{event.title} </div>
</button>
{/each} {/each}
</button> </button>
{/each} {/each}
@ -443,36 +441,6 @@
font-weight: 700; font-weight: 700;
} }
.event-pill {
font-size: 0.6rem;
color: white;
padding: 0.1rem 0.25rem;
border-radius: 3px;
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
border: none;
text-align: left;
font-family: inherit;
line-height: 1.3;
}
.event-pill.all-day {
border-radius: 3px;
}
.event-time {
font-weight: 600;
margin-right: 0.2rem;
opacity: 0.85;
}
.event-pill:hover {
filter: brightness(1.2);
}
.color-picker { .color-picker {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;

View file

@ -3,7 +3,9 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { createChat } from '$lib/chat/create.svelte'; import { createChat } from '$lib/chat/create.svelte';
import type { Message, ChatConnection } from '$lib/chat/types'; import type { MessageData } from '$lib/types/message';
import type { ChatConnection } from '$lib/chat/types';
import MessageBox from '$lib/components/MessageBox.svelte';
import Editor from '$lib/components/Editor.svelte'; import Editor from '$lib/components/Editor.svelte';
let { props = {} }: { props?: Record<string, unknown> } = $props(); let { props = {} }: { props?: Record<string, unknown> } = $props();
@ -14,6 +16,10 @@
let sending = $state(false); let sending = $state(false);
let messagesEl: HTMLDivElement | undefined; let messagesEl: HTMLDivElement | undefined;
const chatCallbacks = {
onMentionClick: (entityId: string) => goto(`/entities/${entityId}`)
};
async function handleSubmit(html: string, json: Record<string, unknown>, mentions: Array<{ id: string; name: string; type: string; aliases: string[] }>) { async function handleSubmit(html: string, json: Record<string, unknown>, mentions: Array<{ id: string; name: string; type: string; aliases: string[] }>) {
if (!chat || sending) return; if (!chat || sending) return;
sending = true; sending = true;
@ -25,24 +31,12 @@
} }
} }
function handleMessageClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.classList.contains('mention') && target.dataset.id) {
e.preventDefault();
goto(`/entities/${target.dataset.id}`);
}
}
function scrollToBottom() { function scrollToBottom() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
messagesEl?.scrollTo(0, messagesEl.scrollHeight); messagesEl?.scrollTo(0, messagesEl.scrollHeight);
}); });
} }
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
}
function formatDate(iso: string): string { function formatDate(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
const today = new Date(); const today = new Date();
@ -65,7 +59,7 @@
}); });
let grouped = $derived.by(() => { let grouped = $derived.by(() => {
const groups: { date: string; messages: Message[] }[] = []; const groups: { date: string; messages: MessageData[] }[] = [];
let currentDate = ''; let currentDate = '';
for (const msg of messages) { for (const msg of messages) {
const date = formatDate(msg.created_at); const date = formatDate(msg.created_at);
@ -102,15 +96,7 @@
<span>{group.date}</span> <span>{group.date}</span>
</div> </div>
{#each group.messages as msg (msg.id)} {#each group.messages as msg (msg.id)}
<div class="message"> <MessageBox message={msg} mode="expanded" callbacks={chatCallbacks} />
<div class="message-header">
<span class="author">{msg.author_name ?? 'Ukjent'}</span>
<span class="time">{formatTime(msg.created_at)}</span>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="message-body" onclick={handleMessageClick}>{@html msg.body}</div>
</div>
{/each} {/each}
{/each} {/each}
@ -166,78 +152,6 @@
border-radius: 10px; border-radius: 10px;
} }
.message {
padding: 0.3rem 0.5rem;
border-radius: 4px;
}
.message:hover {
background: rgba(255, 255, 255, 0.03);
}
.message-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.author {
font-size: 0.8rem;
font-weight: 600;
color: #7dd3fc;
}
.time {
font-size: 0.65rem;
color: #8b92a5;
}
.message-body {
font-size: 0.85rem;
color: #e1e4e8;
line-height: 1.4;
word-break: break-word;
}
/* Render HTML from Tiptap */
.message-body :global(p) {
margin: 0;
}
.message-body :global(p + p) {
margin-top: 0.3em;
}
.message-body :global(strong) {
font-weight: 700;
color: #f1f3f5;
}
.message-body :global(code) {
background: #1e2235;
padding: 0.1em 0.25em;
border-radius: 3px;
font-size: 0.8rem;
}
.message-body :global(.mention) {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
padding: 0.05em 0.25em;
border-radius: 3px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
}
.message-body :global(.mention:hover) {
background: rgba(139, 92, 246, 0.25);
}
.message-body :global(.mention::before) {
content: '#';
}
.empty { .empty {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { createKanban } from '$lib/kanban/create.svelte'; import { createKanban } from '$lib/kanban/create.svelte';
import type { KanbanConnection, KanbanColumn, KanbanCard } from '$lib/kanban/types'; import type { KanbanConnection, KanbanColumn } from '$lib/kanban/types';
import type { MessageData } from '$lib/types/message';
import MessageBox from '$lib/components/MessageBox.svelte';
let { props = {} }: { props?: Record<string, unknown> } = $props(); let { props = {} }: { props?: Record<string, unknown> } = $props();
@ -15,7 +17,7 @@
let dragOverColumnId = $state<string | null>(null); let dragOverColumnId = $state<string | null>(null);
// Redigering // Redigering
let editingCard = $state<KanbanCard | null>(null); let editingCard = $state<MessageData | null>(null);
let editTitle = $state(''); let editTitle = $state('');
let editDescription = $state(''); let editDescription = $state('');
@ -23,7 +25,7 @@
let columns = $derived(board?.columns ?? []); let columns = $derived(board?.columns ?? []);
let firstColumnId = $derived(columns[0]?.id ?? null); let firstColumnId = $derived(columns[0]?.id ?? null);
function handleDragStart(e: DragEvent, card: KanbanCard) { function handleDragStart(e: DragEvent, card: MessageData) {
dragCardId = card.id; dragCardId = card.id;
if (e.dataTransfer) { if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@ -47,9 +49,9 @@
if (!kanban || !dragCardId) return; if (!kanban || !dragCardId) return;
const lastCard = column.cards[column.cards.length - 1]; const lastCard = column.cards[column.cards.length - 1];
const position = lastCard ? lastCard.position + 1 : 1; const position = lastCard ? (lastCard as MessageData & { position?: number }).position ?? column.cards.length : 1;
kanban.moveCard(dragCardId, column.id, position); kanban.moveCard(dragCardId, column.id, typeof position === 'number' ? position + 1 : 1);
dragCardId = null; dragCardId = null;
} }
@ -85,10 +87,10 @@
} }
} }
function openEdit(card: KanbanCard) { function openEdit(card: MessageData) {
editingCard = card; editingCard = card;
editTitle = card.title; editTitle = card.title ?? '';
editDescription = card.description ?? ''; editDescription = card.body ?? '';
} }
function closeEdit() { function closeEdit() {
@ -97,11 +99,11 @@
function saveEdit() { function saveEdit() {
if (!kanban || !editingCard) return; if (!kanban || !editingCard) return;
const titleChanged = editTitle.trim() !== editingCard.title; const titleChanged = editTitle.trim() !== (editingCard.title ?? '');
const descChanged = (editDescription.trim() || null) !== (editingCard.description ?? null); const descChanged = (editDescription.trim() || null) !== (editingCard.body || null);
if (titleChanged || descChanged) { if (titleChanged || descChanged) {
kanban.updateCard(editingCard.id, { kanban.updateCard(editingCard.id, {
title: editTitle.trim() || editingCard.title, title: editTitle.trim() || editingCard.title || '',
description: editDescription.trim() || null description: editDescription.trim() || null
}); });
} }
@ -114,10 +116,6 @@
closeEdit(); closeEdit();
} }
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeEdit();
}
$effect(() => { $effect(() => {
if (editingCard) { if (editingCard) {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -163,19 +161,21 @@
<div class="cards"> <div class="cards">
{#each column.cards as card (card.id)} {#each column.cards as card (card.id)}
<button <div
type="button" class="card-drag-wrapper"
class="card"
class:dragging={dragCardId === card.id} class:dragging={dragCardId === card.id}
draggable="true" draggable="true"
ondragstart={(e) => handleDragStart(e, card)} ondragstart={(e) => handleDragStart(e, card)}
onclick={() => openEdit(card)} role="listitem"
> >
<div class="card-title">{card.title}</div> <button
{#if card.description} type="button"
<div class="card-desc">{card.description}</div> class="card"
{/if} onclick={() => openEdit(card)}
</button> >
<MessageBox message={card} mode="compact" />
</button>
</div>
{/each} {/each}
</div> </div>
</div> </div>
@ -218,7 +218,7 @@
{#if editingCard} {#if editingCard}
<div class="modal-backdrop" onclick={closeEdit} role="presentation"> <div class="modal-backdrop" onclick={closeEdit} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={handleEditKeydown} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<input <input
class="edit-title" class="edit-title"
type="text" type="text"
@ -308,13 +308,22 @@
gap: 0.35rem; gap: 0.35rem;
} }
.card-drag-wrapper {
cursor: grab;
transition: opacity 0.15s;
}
.card-drag-wrapper.dragging {
opacity: 0.4;
}
.card { .card {
background: #0f1117; background: #0f1117;
border: 1px solid #2d3148; border: 1px solid #2d3148;
border-radius: 6px; border-radius: 6px;
padding: 0.5rem; padding: 0.5rem;
cursor: grab; cursor: pointer;
transition: opacity 0.15s, border-color 0.15s; transition: border-color 0.15s;
text-align: left; text-align: left;
width: 100%; width: 100%;
font-family: inherit; font-family: inherit;
@ -324,23 +333,6 @@
border-color: #3b82f6; border-color: #3b82f6;
} }
.card.dragging {
opacity: 0.4;
}
.card-title {
font-size: 0.8rem;
color: #e1e4e8;
line-height: 1.3;
}
.card-desc {
font-size: 0.7rem;
color: #8b92a5;
margin-top: 0.25rem;
line-height: 1.3;
}
.add-card-row { .add-card-row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;

View file

@ -1,8 +1,54 @@
import type { Calendar, CalendarEvent, CalendarConnection } from './types'; import type { MessageData } from '$lib/types/message';
import type { Calendar, CalendarConnection } from './types';
interface RawEvent {
id: string;
calendar_id: string;
title: string;
description: string | null;
starts_at: string;
ends_at: string | null;
all_day: boolean;
color: string | null;
linked_node: string | null;
created_by: string | null;
created_at: string;
}
interface RawCalendar {
id: string;
name: string;
color: string | null;
events: RawEvent[];
}
function eventToMessageData(event: RawEvent): MessageData {
return {
id: event.id,
channel_id: null,
reply_to: null,
author_id: event.created_by,
author_name: null,
message_type: 'calendar',
title: event.title,
body: event.description ?? '',
pinned: false,
visibility: 'workspace',
created_at: event.created_at,
updated_at: event.created_at,
kanban_view: null,
calendar_view: {
starts_at: event.starts_at,
ends_at: event.ends_at,
all_day: event.all_day,
color: event.color
}
};
}
export function createPgCalendar(calendarId: string): CalendarConnection { export function createPgCalendar(calendarId: string): CalendarConnection {
let _calendar = $state<Calendar | null>(null); let _calendar = $state<Calendar | null>(null);
let _events = $state<CalendarEvent[]>([]); let _events = $state<MessageData[]>([]);
let _error = $state(''); let _error = $state('');
let _loading = $state(true); let _loading = $state(true);
let _viewDate = $state(new Date()); let _viewDate = $state(new Date());
@ -11,7 +57,6 @@ export function createPgCalendar(calendarId: string): CalendarConnection {
function getMonthRange(date: Date): { from: string; to: string } { function getMonthRange(date: Date): { from: string; to: string } {
const year = date.getFullYear(); const year = date.getFullYear();
const month = date.getMonth(); const month = date.getMonth();
// Hent fra 6 dager før månedens start (for kalendervisning) til 6 dager etter slutt
const from = new Date(year, month, -6); const from = new Date(year, month, -6);
const to = new Date(year, month + 1, 7); const to = new Date(year, month + 1, 7);
return { return {
@ -30,9 +75,10 @@ export function createPgCalendar(calendarId: string): CalendarConnection {
_error = `Feil: ${res.status}`; _error = `Feil: ${res.status}`;
return; return;
} }
const data = await res.json(); const data: RawCalendar = await res.json();
_calendar = { id: data.id, name: data.name, color: data.color, events: data.events }; const mapped = data.events.map(eventToMessageData);
_events = data.events; _calendar = { id: data.id, name: data.name, color: data.color, events: mapped };
_events = mapped;
_error = ''; _error = '';
} catch (e) { } catch (e) {
_error = e instanceof Error ? e.message : 'Ukjent feil'; _error = e instanceof Error ? e.message : 'Ukjent feil';

View file

@ -1,27 +1,20 @@
export interface CalendarEvent { import type { MessageData } from '$lib/types/message';
id: string;
calendar_id: string; export type { MessageData };
title: string;
description: string | null; /** Bakoverkompatibelt alias */
starts_at: string; export type CalendarEvent = MessageData;
ends_at: string | null;
all_day: boolean;
color: string | null;
linked_node: string | null;
created_by: string | null;
created_at: string;
}
export interface Calendar { export interface Calendar {
id: string; id: string;
name: string; name: string;
color: string | null; color: string | null;
events: CalendarEvent[]; events: MessageData[];
} }
export interface CalendarConnection { export interface CalendarConnection {
readonly calendar: Calendar | null; readonly calendar: Calendar | null;
readonly events: CalendarEvent[]; readonly events: MessageData[];
readonly error: string; readonly error: string;
readonly loading: boolean; readonly loading: boolean;
readonly viewDate: Date; readonly viewDate: Date;

View file

@ -1,4 +1,5 @@
import type { Message, ChatConnection, MentionRef } from './types'; import type { MessageData } from '$lib/types/message';
import type { ChatConnection, MentionRef } from './types';
/** /**
* Chat-adapter som poller PostgreSQL via REST API. * Chat-adapter som poller PostgreSQL via REST API.
@ -6,18 +7,38 @@ import type { Message, ChatConnection, MentionRef } from './types';
* og som referanseimplementasjon for testing. * og som referanseimplementasjon for testing.
*/ */
export function createPgChat(channelId: string): ChatConnection { export function createPgChat(channelId: string): ChatConnection {
let messages = $state<Message[]>([]); let messages = $state<MessageData[]>([]);
let error = $state(''); let error = $state('');
let connected = $state(false); let connected = $state(false);
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
let destroyed = false; let destroyed = false;
function toMessageData(raw: Record<string, unknown>): MessageData {
return {
id: raw.id as string,
channel_id: (raw.channel_id as string) ?? null,
reply_to: (raw.reply_to as string) ?? null,
author_id: (raw.author_id as string) ?? null,
author_name: (raw.author_name as string) ?? null,
message_type: (raw.message_type as string) ?? 'chat',
title: (raw.title as string) ?? null,
body: (raw.body as string) ?? '',
pinned: (raw.pinned as boolean) ?? false,
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),
kanban_view: null,
calendar_view: null
};
}
async function refresh() { async function refresh() {
if (destroyed) return; if (destroyed) return;
try { try {
const res = await fetch(`/api/channels/${channelId}/messages`); const res = await fetch(`/api/channels/${channelId}/messages`);
if (!res.ok) throw new Error('Feil ved lasting'); if (!res.ok) throw new Error('Feil ved lasting');
messages = await res.json(); const raw: Record<string, unknown>[] = await res.json();
messages = raw.map(toMessageData);
error = ''; error = '';
connected = true; connected = true;
} catch { } catch {

View file

@ -1,4 +1,5 @@
import type { Message, ChatConnection, ChatUser, MentionRef } from './types'; import type { MessageData } from '$lib/types/message';
import type { ChatConnection, ChatUser, MentionRef } from './types';
import { DbConnection, type EventContext } from './module_bindings'; import { DbConnection, type EventContext } from './module_bindings';
/** /**
@ -15,18 +16,38 @@ export function createSpacetimeChat(
moduleName: string, moduleName: string,
user: ChatUser user: ChatUser
): ChatConnection { ): ChatConnection {
let messages = $state<Message[]>([]); let messages = $state<MessageData[]>([]);
let error = $state(''); let error = $state('');
let connected = $state(false); let connected = $state(false);
let conn: InstanceType<typeof DbConnection> | null = null; let conn: InstanceType<typeof DbConnection> | null = null;
let destroyed = false; let destroyed = false;
function toMessageData(raw: Record<string, unknown>): MessageData {
return {
id: raw.id as string,
channel_id: (raw.channel_id as string) ?? null,
reply_to: (raw.reply_to as string) ?? null,
author_id: (raw.author_id as string) ?? null,
author_name: (raw.author_name as string) ?? null,
message_type: (raw.message_type as string) ?? 'chat',
title: (raw.title as string) ?? null,
body: (raw.body as string) ?? '',
pinned: (raw.pinned as boolean) ?? false,
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),
kanban_view: null,
calendar_view: null
};
}
// Hent historikk fra PG // Hent historikk fra PG
async function loadFromPg() { async function loadFromPg() {
try { try {
const res = await fetch(`/api/channels/${channelId}/messages`); const res = await fetch(`/api/channels/${channelId}/messages`);
if (!res.ok) throw new Error('Feil ved lasting'); if (!res.ok) throw new Error('Feil ved lasting');
messages = await res.json(); const raw: Record<string, unknown>[] = await res.json();
messages = raw.map(toMessageData);
} catch { } catch {
error = 'Kunne ikke laste meldinger'; error = 'Kunne ikke laste meldinger';
} }
@ -81,7 +102,7 @@ export function createSpacetimeChat(
} }
} }
function spacetimeRowToMessage(row: any): Message { function spacetimeRowToMessage(row: any): MessageData {
let createdAt: string; let createdAt: string;
try { try {
const micros = row.createdAt?.microsSinceEpoch; const micros = row.createdAt?.microsSinceEpoch;
@ -92,12 +113,19 @@ export function createSpacetimeChat(
} }
return { return {
id: row.id, id: row.id,
body: row.body, channel_id: channelId,
message_type: row.messageType, reply_to: row.replyTo || null,
created_at: createdAt,
author_name: row.authorName || null,
author_id: row.authorId || null, author_id: row.authorId || null,
reply_to: row.replyTo || null author_name: row.authorName || null,
message_type: row.messageType ?? 'chat',
title: null,
body: row.body,
pinned: false,
visibility: 'workspace',
created_at: createdAt,
updated_at: createdAt,
kanban_view: null,
calendar_view: null
}; };
} }

View file

@ -1,18 +1,15 @@
import type { MessageData } from '$lib/types/message';
export type { MessageData };
/** Bakoverkompatibelt alias */
export type Message = MessageData;
export interface ChatUser { export interface ChatUser {
id: string; id: string;
name: string; name: string;
} }
export interface Message {
id: string;
body: string;
message_type: string;
created_at: string;
author_name: string | null;
author_id: string | null;
reply_to: string | null;
}
export interface MentionRef { export interface MentionRef {
id: string; id: string;
name: string; name: string;
@ -26,7 +23,7 @@ export interface MentionRef {
* Alle felter er reaktive (Svelte 5 $state). * Alle felter er reaktive (Svelte 5 $state).
*/ */
export interface ChatConnection { export interface ChatConnection {
readonly messages: Message[]; readonly messages: MessageData[];
readonly error: string; readonly error: string;
readonly connected: boolean; readonly connected: boolean;
send(body: string, mentions?: MentionRef[]): Promise<void>; send(body: string, mentions?: MentionRef[]): Promise<void>;

View file

@ -0,0 +1,256 @@
<script lang="ts">
import type { MessageData, MessageBoxMode, MessageBoxCallbacks } from '$lib/types/message';
let {
message,
mode = 'expanded',
showAuthor = true,
showTimestamp = true,
callbacks = {}
}: {
message: MessageData;
mode?: MessageBoxMode;
showAuthor?: boolean;
showTimestamp?: boolean;
callbacks?: MessageBoxCallbacks;
} = $props();
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
}
function stripHtml(html: string): string {
if (typeof document !== 'undefined') {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent ?? '';
}
return html.replace(/<[^>]*>/g, '');
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max) + '…';
}
function handleClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.classList.contains('mention') && target.dataset.id) {
e.preventDefault();
e.stopPropagation();
callbacks.onMentionClick?.(target.dataset.id);
return;
}
callbacks.onClick?.(message.id);
}
let hasBadges = $derived(
(mode === 'expanded' && (message.kanban_view || message.calendar_view))
);
let bodyPreview = $derived(truncate(stripHtml(message.body), 80));
</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}>
{#if showAuthor || showTimestamp}
<div class="messagebox__header">
{#if showAuthor}
<span class="messagebox__author">{message.author_name ?? 'Ukjent'}</span>
{/if}
{#if showTimestamp}
<span class="messagebox__time">{formatTime(message.created_at)}</span>
{/if}
{#if message.pinned}
<span class="messagebox__pinned" title="Festet">&#128204;</span>
{/if}
</div>
{/if}
<div class="messagebox__body">{@html message.body}</div>
{#if hasBadges}
<div class="messagebox__badges">
{#if message.kanban_view}
<span class="messagebox__badge messagebox__badge--kanban">Kort</span>
{/if}
{#if message.calendar_view}
<span class="messagebox__badge messagebox__badge--calendar">Hendelse</span>
{/if}
</div>
{/if}
</div>
{:else if mode === 'compact'}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="messagebox messagebox--compact"
onclick={handleClick}
style:border-left-color={message.kanban_view?.color ?? 'transparent'}
>
<div class="messagebox__title">{message.title ?? bodyPreview}</div>
{#if message.body && message.title}
<div class="messagebox__preview">{bodyPreview}</div>
{/if}
</div>
{:else if mode === 'calendar'}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="messagebox messagebox--calendar"
onclick={handleClick}
style:background={message.calendar_view?.color ?? '#3b82f6'}
>
{#if message.calendar_view && !message.calendar_view.all_day}
<span class="messagebox__event-time">{formatTime(message.calendar_view.starts_at)}</span>
{/if}
{message.title ?? stripHtml(message.body)}
</div>
{/if}
<style>
/* === Expanded mode (chat) === */
.messagebox--expanded {
padding: 0.3rem 0.5rem;
border-radius: 4px;
}
.messagebox--expanded:hover {
background: rgba(255, 255, 255, 0.03);
}
.messagebox__header {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.messagebox__author {
font-size: 0.8rem;
font-weight: 600;
color: #7dd3fc;
}
.messagebox__time {
font-size: 0.65rem;
color: #8b92a5;
}
.messagebox__pinned {
font-size: 0.65rem;
margin-left: auto;
}
.messagebox__body {
font-size: 0.85rem;
color: #e1e4e8;
line-height: 1.4;
word-break: break-word;
}
/* Tiptap HTML rendering */
.messagebox__body :global(p) {
margin: 0;
}
.messagebox__body :global(p + p) {
margin-top: 0.3em;
}
.messagebox__body :global(strong) {
font-weight: 700;
color: #f1f3f5;
}
.messagebox__body :global(code) {
background: #1e2235;
padding: 0.1em 0.25em;
border-radius: 3px;
font-size: 0.8rem;
}
.messagebox__body :global(.mention) {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
padding: 0.05em 0.25em;
border-radius: 3px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
}
.messagebox__body :global(.mention:hover) {
background: rgba(139, 92, 246, 0.25);
}
.messagebox__body :global(.mention::before) {
content: '#';
}
.messagebox__badges {
display: flex;
gap: 0.35rem;
margin-top: 0.25rem;
}
.messagebox__badge {
font-size: 0.6rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
color: #e1e4e8;
}
.messagebox__badge--kanban {
background: rgba(59, 130, 246, 0.2);
}
.messagebox__badge--calendar {
background: rgba(16, 185, 129, 0.2);
}
/* === Compact mode (kanban) === */
.messagebox--compact {
border-left: 3px solid transparent;
padding-left: 0.4rem;
}
.messagebox__title {
font-size: 0.8rem;
color: #e1e4e8;
line-height: 1.3;
}
.messagebox__preview {
font-size: 0.7rem;
color: #8b92a5;
margin-top: 0.25rem;
line-height: 1.3;
}
/* === Calendar mode (pill) === */
.messagebox--calendar {
font-size: 0.6rem;
color: white;
padding: 0.1rem 0.25rem;
border-radius: 3px;
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
border: none;
text-align: left;
font-family: inherit;
line-height: 1.3;
}
.messagebox--calendar:hover {
filter: brightness(1.2);
}
.messagebox__event-time {
font-weight: 600;
margin-right: 0.2rem;
opacity: 0.85;
}
</style>

View file

@ -1,4 +1,66 @@
import type { KanbanBoard, KanbanConnection } from './types'; import type { MessageData } from '$lib/types/message';
import type { KanbanBoard, KanbanColumn, KanbanConnection } from './types';
interface RawCard {
id: string;
column_id: string;
title: string;
description: string | null;
assignee_id: string | null;
position: number;
created_by: string | null;
created_at: string;
}
interface RawColumn {
id: string;
name: string;
color: string | null;
position: number;
cards: RawCard[];
}
interface RawBoard {
id: string;
name: string;
columns: RawColumn[];
}
function cardToMessageData(card: RawCard, column: RawColumn): MessageData {
return {
id: card.id,
channel_id: null,
reply_to: null,
author_id: card.created_by,
author_name: null,
message_type: 'kanban',
title: card.title,
body: card.description ?? '',
pinned: false,
visibility: 'workspace',
created_at: card.created_at,
updated_at: card.created_at,
kanban_view: {
column_id: card.column_id,
color: column.color
},
calendar_view: null
};
}
function mapBoard(raw: RawBoard): KanbanBoard {
return {
id: raw.id,
name: raw.name,
columns: raw.columns.map((col): KanbanColumn => ({
id: col.id,
name: col.name,
color: col.color,
position: col.position,
cards: col.cards.map(card => cardToMessageData(card, col))
}))
};
}
/** /**
* Kanban PG-adapter. * Kanban PG-adapter.
@ -16,7 +78,8 @@ export function createPgKanban(boardId: string): KanbanConnection {
try { try {
const res = await fetch(`/api/kanban/${boardId}`); const res = await fetch(`/api/kanban/${boardId}`);
if (!res.ok) throw new Error('Feil ved lasting'); if (!res.ok) throw new Error('Feil ved lasting');
board = await res.json(); const raw: RawBoard = await res.json();
board = mapBoard(raw);
error = ''; error = '';
} catch { } catch {
error = 'Kunne ikke laste kanban-brett'; error = 'Kunne ikke laste kanban-brett';
@ -64,9 +127,11 @@ export function createPgKanban(boardId: string): KanbanConnection {
if (card) { if (card) {
const targetCol = updatedColumns.find(c => c.id === toColumnId); const targetCol = updatedColumns.find(c => c.id === toColumnId);
if (targetCol) { if (targetCol) {
const movedCard = { ...card, column_id: toColumnId, position }; const movedCard = {
...card,
kanban_view: { column_id: toColumnId, color: targetCol.color }
};
targetCol.cards.push(movedCard); targetCol.cards.push(movedCard);
targetCol.cards.sort((a, b) => a.position - b.position);
} }
} }
board = { ...board, columns: updatedColumns }; board = { ...board, columns: updatedColumns };

View file

@ -1,20 +1,16 @@
export interface KanbanCard { import type { MessageData } from '$lib/types/message';
id: string;
column_id: string; export type { MessageData };
title: string;
description: string | null; /** Bakoverkompatibelt alias */
assignee_id: string | null; export type KanbanCard = MessageData;
position: number;
created_by: string | null;
created_at: string;
}
export interface KanbanColumn { export interface KanbanColumn {
id: string; id: string;
name: string; name: string;
color: string | null; color: string | null;
position: number; position: number;
cards: KanbanCard[]; cards: MessageData[];
} }
export interface KanbanBoard { export interface KanbanBoard {

View file

@ -0,0 +1,36 @@
export interface MessageData {
id: string;
channel_id: string | null;
reply_to: string | null;
author_id: string | null;
author_name: string | null;
message_type: string;
title: string | null;
body: string;
pinned: boolean;
visibility: 'workspace' | 'private';
created_at: string;
updated_at: string;
reply_count?: number;
reactions?: ReactionSummary[];
kanban_view?: { column_id: string; board_name?: string; color: string | null } | null;
calendar_view?: {
starts_at: string;
ends_at: string | null;
all_day: boolean;
color: string | null;
} | null;
}
export interface ReactionSummary {
reaction: string;
count: number;
user_reacted: boolean;
}
export type MessageBoxMode = 'compact' | 'calendar' | 'expanded';
export interface MessageBoxCallbacks {
onClick?: (id: string) => void;
onMentionClick?: (entityId: string) => void;
}