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:
parent
50e26e3c48
commit
e3e3bbc24f
12 changed files with 567 additions and 255 deletions
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from '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();
|
||||
|
||||
|
|
@ -29,7 +31,7 @@
|
|||
let addColor = $state<string | null>(null);
|
||||
|
||||
// Redigering
|
||||
let editingEvent = $state<CalendarEvent | null>(null);
|
||||
let editingEvent = $state<MessageData | null>(null);
|
||||
let editTitle = $state('');
|
||||
let editDescription = $state('');
|
||||
let editStartsAt = $state('');
|
||||
|
|
@ -84,19 +86,16 @@
|
|||
return dateStr === fmt(new Date());
|
||||
}
|
||||
|
||||
function eventsForDate(dateStr: string): CalendarEvent[] {
|
||||
function eventsForDate(dateStr: string): MessageData[] {
|
||||
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')}`;
|
||||
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() {
|
||||
cal?.setViewDate(new Date(year, month - 1, 1));
|
||||
}
|
||||
|
|
@ -142,20 +141,20 @@
|
|||
showAdd = false;
|
||||
}
|
||||
|
||||
function openEdit(event: CalendarEvent) {
|
||||
function openEdit(event: MessageData) {
|
||||
editingEvent = event;
|
||||
editTitle = event.title;
|
||||
editDescription = event.description ?? '';
|
||||
editAllDay = event.all_day;
|
||||
editStartsAt = event.starts_at.slice(0, 16);
|
||||
editEndsAt = event.ends_at?.slice(0, 16) ?? '';
|
||||
editColor = event.color;
|
||||
editTitle = event.title ?? '';
|
||||
editDescription = event.body ?? '';
|
||||
editAllDay = event.calendar_view?.all_day ?? false;
|
||||
editStartsAt = event.calendar_view?.starts_at?.slice(0, 16) ?? '';
|
||||
editEndsAt = event.calendar_view?.ends_at?.slice(0, 16) ?? '';
|
||||
editColor = event.calendar_view?.color ?? null;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!cal || !editingEvent) return;
|
||||
await cal.updateEvent(editingEvent.id, {
|
||||
title: editTitle.trim() || editingEvent.title,
|
||||
title: editTitle.trim() || editingEvent.title || '',
|
||||
description: editDescription.trim() || null,
|
||||
starts_at: editStartsAt ? new Date(editStartsAt).toISOString() : undefined,
|
||||
ends_at: editEndsAt ? new Date(editEndsAt).toISOString() : null,
|
||||
|
|
@ -223,16 +222,15 @@
|
|||
>
|
||||
<span class="day-number">{day.date.getDate()}</span>
|
||||
{#each eventsForDate(day.dateStr) as event}
|
||||
<button
|
||||
type="button"
|
||||
class="event-pill"
|
||||
class:all-day={event.all_day}
|
||||
style:background={event.color || cal?.calendar?.color || '#3b82f6'}
|
||||
onclick={(e) => { e.stopPropagation(); openEdit(event); }}
|
||||
>
|
||||
{#if !event.all_day}<span class="event-time">{formatTime(event.starts_at)}</span>{/if}
|
||||
{event.title}
|
||||
</button>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div onclick={(e) => { e.stopPropagation(); openEdit(event); }}>
|
||||
<MessageBox
|
||||
message={event}
|
||||
mode="calendar"
|
||||
callbacks={{ onClick: () => openEdit(event) }}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</button>
|
||||
{/each}
|
||||
|
|
@ -443,36 +441,6 @@
|
|||
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 {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
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';
|
||||
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
|
|
@ -14,6 +16,10 @@
|
|||
let sending = $state(false);
|
||||
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[] }>) {
|
||||
if (!chat || sending) return;
|
||||
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() {
|
||||
requestAnimationFrame(() => {
|
||||
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 {
|
||||
const d = new Date(iso);
|
||||
const today = new Date();
|
||||
|
|
@ -65,7 +59,7 @@
|
|||
});
|
||||
|
||||
let grouped = $derived.by(() => {
|
||||
const groups: { date: string; messages: Message[] }[] = [];
|
||||
const groups: { date: string; messages: MessageData[] }[] = [];
|
||||
let currentDate = '';
|
||||
for (const msg of messages) {
|
||||
const date = formatDate(msg.created_at);
|
||||
|
|
@ -102,15 +96,7 @@
|
|||
<span>{group.date}</span>
|
||||
</div>
|
||||
{#each group.messages as msg (msg.id)}
|
||||
<div class="message">
|
||||
<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>
|
||||
<MessageBox message={msg} mode="expanded" callbacks={chatCallbacks} />
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
|
|
@ -166,78 +152,6 @@
|
|||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from '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();
|
||||
|
||||
|
|
@ -15,7 +17,7 @@
|
|||
let dragOverColumnId = $state<string | null>(null);
|
||||
|
||||
// Redigering
|
||||
let editingCard = $state<KanbanCard | null>(null);
|
||||
let editingCard = $state<MessageData | null>(null);
|
||||
let editTitle = $state('');
|
||||
let editDescription = $state('');
|
||||
|
||||
|
|
@ -23,7 +25,7 @@
|
|||
let columns = $derived(board?.columns ?? []);
|
||||
let firstColumnId = $derived(columns[0]?.id ?? null);
|
||||
|
||||
function handleDragStart(e: DragEvent, card: KanbanCard) {
|
||||
function handleDragStart(e: DragEvent, card: MessageData) {
|
||||
dragCardId = card.id;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
|
|
@ -47,9 +49,9 @@
|
|||
if (!kanban || !dragCardId) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -85,10 +87,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
function openEdit(card: KanbanCard) {
|
||||
function openEdit(card: MessageData) {
|
||||
editingCard = card;
|
||||
editTitle = card.title;
|
||||
editDescription = card.description ?? '';
|
||||
editTitle = card.title ?? '';
|
||||
editDescription = card.body ?? '';
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
|
|
@ -97,11 +99,11 @@
|
|||
|
||||
function saveEdit() {
|
||||
if (!kanban || !editingCard) return;
|
||||
const titleChanged = editTitle.trim() !== editingCard.title;
|
||||
const descChanged = (editDescription.trim() || null) !== (editingCard.description ?? null);
|
||||
const titleChanged = editTitle.trim() !== (editingCard.title ?? '');
|
||||
const descChanged = (editDescription.trim() || null) !== (editingCard.body || null);
|
||||
if (titleChanged || descChanged) {
|
||||
kanban.updateCard(editingCard.id, {
|
||||
title: editTitle.trim() || editingCard.title,
|
||||
title: editTitle.trim() || editingCard.title || '',
|
||||
description: editDescription.trim() || null
|
||||
});
|
||||
}
|
||||
|
|
@ -114,10 +116,6 @@
|
|||
closeEdit();
|
||||
}
|
||||
|
||||
function handleEditKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') closeEdit();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (editingCard) {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
@ -163,19 +161,21 @@
|
|||
|
||||
<div class="cards">
|
||||
{#each column.cards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="card"
|
||||
<div
|
||||
class="card-drag-wrapper"
|
||||
class:dragging={dragCardId === card.id}
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleDragStart(e, card)}
|
||||
role="listitem"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="card"
|
||||
onclick={() => openEdit(card)}
|
||||
>
|
||||
<div class="card-title">{card.title}</div>
|
||||
{#if card.description}
|
||||
<div class="card-desc">{card.description}</div>
|
||||
{/if}
|
||||
<MessageBox message={card} mode="compact" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -218,7 +218,7 @@
|
|||
|
||||
{#if editingCard}
|
||||
<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
|
||||
class="edit-title"
|
||||
type="text"
|
||||
|
|
@ -308,13 +308,22 @@
|
|||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.card-drag-wrapper {
|
||||
cursor: grab;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.card-drag-wrapper.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
cursor: grab;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
|
|
@ -324,23 +333,6 @@
|
|||
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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
let _calendar = $state<Calendar | null>(null);
|
||||
let _events = $state<CalendarEvent[]>([]);
|
||||
let _events = $state<MessageData[]>([]);
|
||||
let _error = $state('');
|
||||
let _loading = $state(true);
|
||||
let _viewDate = $state(new Date());
|
||||
|
|
@ -11,7 +57,6 @@ export function createPgCalendar(calendarId: string): CalendarConnection {
|
|||
function getMonthRange(date: Date): { from: string; to: string } {
|
||||
const year = date.getFullYear();
|
||||
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 to = new Date(year, month + 1, 7);
|
||||
return {
|
||||
|
|
@ -30,9 +75,10 @@ export function createPgCalendar(calendarId: string): CalendarConnection {
|
|||
_error = `Feil: ${res.status}`;
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
_calendar = { id: data.id, name: data.name, color: data.color, events: data.events };
|
||||
_events = data.events;
|
||||
const data: RawCalendar = await res.json();
|
||||
const mapped = data.events.map(eventToMessageData);
|
||||
_calendar = { id: data.id, name: data.name, color: data.color, events: mapped };
|
||||
_events = mapped;
|
||||
_error = '';
|
||||
} catch (e) {
|
||||
_error = e instanceof Error ? e.message : 'Ukjent feil';
|
||||
|
|
|
|||
|
|
@ -1,27 +1,20 @@
|
|||
export interface CalendarEvent {
|
||||
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;
|
||||
}
|
||||
import type { MessageData } from '$lib/types/message';
|
||||
|
||||
export type { MessageData };
|
||||
|
||||
/** Bakoverkompatibelt alias */
|
||||
export type CalendarEvent = MessageData;
|
||||
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
events: CalendarEvent[];
|
||||
events: MessageData[];
|
||||
}
|
||||
|
||||
export interface CalendarConnection {
|
||||
readonly calendar: Calendar | null;
|
||||
readonly events: CalendarEvent[];
|
||||
readonly events: MessageData[];
|
||||
readonly error: string;
|
||||
readonly loading: boolean;
|
||||
readonly viewDate: Date;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -6,18 +7,38 @@ import type { Message, ChatConnection, MentionRef } from './types';
|
|||
* og som referanseimplementasjon for testing.
|
||||
*/
|
||||
export function createPgChat(channelId: string): ChatConnection {
|
||||
let messages = $state<Message[]>([]);
|
||||
let messages = $state<MessageData[]>([]);
|
||||
let error = $state('');
|
||||
let connected = $state(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
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() {
|
||||
if (destroyed) return;
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}/messages`);
|
||||
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 = '';
|
||||
connected = true;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
@ -15,18 +16,38 @@ export function createSpacetimeChat(
|
|||
moduleName: string,
|
||||
user: ChatUser
|
||||
): ChatConnection {
|
||||
let messages = $state<Message[]>([]);
|
||||
let messages = $state<MessageData[]>([]);
|
||||
let error = $state('');
|
||||
let connected = $state(false);
|
||||
let conn: InstanceType<typeof DbConnection> | null = null;
|
||||
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
|
||||
async function loadFromPg() {
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}/messages`);
|
||||
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 {
|
||||
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;
|
||||
try {
|
||||
const micros = row.createdAt?.microsSinceEpoch;
|
||||
|
|
@ -92,12 +113,19 @@ export function createSpacetimeChat(
|
|||
}
|
||||
return {
|
||||
id: row.id,
|
||||
body: row.body,
|
||||
message_type: row.messageType,
|
||||
created_at: createdAt,
|
||||
author_name: row.authorName || null,
|
||||
channel_id: channelId,
|
||||
reply_to: row.replyTo || 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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
import type { MessageData } from '$lib/types/message';
|
||||
|
||||
export type { MessageData };
|
||||
|
||||
/** Bakoverkompatibelt alias */
|
||||
export type Message = MessageData;
|
||||
|
||||
export interface ChatUser {
|
||||
id: 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -26,7 +23,7 @@ export interface MentionRef {
|
|||
* Alle felter er reaktive (Svelte 5 $state).
|
||||
*/
|
||||
export interface ChatConnection {
|
||||
readonly messages: Message[];
|
||||
readonly messages: MessageData[];
|
||||
readonly error: string;
|
||||
readonly connected: boolean;
|
||||
send(body: string, mentions?: MentionRef[]): Promise<void>;
|
||||
|
|
|
|||
256
web/src/lib/components/MessageBox.svelte
Normal file
256
web/src/lib/components/MessageBox.svelte
Normal 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">📌</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>
|
||||
|
|
@ -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.
|
||||
|
|
@ -16,7 +78,8 @@ export function createPgKanban(boardId: string): KanbanConnection {
|
|||
try {
|
||||
const res = await fetch(`/api/kanban/${boardId}`);
|
||||
if (!res.ok) throw new Error('Feil ved lasting');
|
||||
board = await res.json();
|
||||
const raw: RawBoard = await res.json();
|
||||
board = mapBoard(raw);
|
||||
error = '';
|
||||
} catch {
|
||||
error = 'Kunne ikke laste kanban-brett';
|
||||
|
|
@ -64,9 +127,11 @@ export function createPgKanban(boardId: string): KanbanConnection {
|
|||
if (card) {
|
||||
const targetCol = updatedColumns.find(c => c.id === toColumnId);
|
||||
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.sort((a, b) => a.position - b.position);
|
||||
}
|
||||
}
|
||||
board = { ...board, columns: updatedColumns };
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
export interface KanbanCard {
|
||||
id: string;
|
||||
column_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
assignee_id: string | null;
|
||||
position: number;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
import type { MessageData } from '$lib/types/message';
|
||||
|
||||
export type { MessageData };
|
||||
|
||||
/** Bakoverkompatibelt alias */
|
||||
export type KanbanCard = MessageData;
|
||||
|
||||
export interface KanbanColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
position: number;
|
||||
cards: KanbanCard[];
|
||||
cards: MessageData[];
|
||||
}
|
||||
|
||||
export interface KanbanBoard {
|
||||
|
|
|
|||
36
web/src/lib/types/message.ts
Normal file
36
web/src/lib/types/message.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue