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">
|
<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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card"
|
||||||
onclick={() => openEdit(card)}
|
onclick={() => openEdit(card)}
|
||||||
>
|
>
|
||||||
<div class="card-title">{card.title}</div>
|
<MessageBox message={card} mode="compact" />
|
||||||
{#if card.description}
|
|
||||||
<div class="card-desc">{card.description}</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
</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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
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.
|
* 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 };
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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