Chat: svar-tråder, kanban-konvertering og kalender-konvertering
Legger til tre nye funksjoner fra chatmeldinger: - Svar på meldinger med reply-kontekst (↩ forfatter: tekst) - Konverter melding til kanban-kort via dialog - Konverter melding til kalenderhendelse via dialog Utvider messages API med reply_count, parent-info og LEFT JOIN til kanban/kalender view-tabeller for badges. Nye list-endepunkter for /api/kanban og /api/calendar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d5a4de55de
commit
63f928bbe6
11 changed files with 834 additions and 63 deletions
|
|
@ -7,6 +7,7 @@
|
|||
import type { ChatConnection } from '$lib/chat/types';
|
||||
import MessageBox from '$lib/components/MessageBox.svelte';
|
||||
import Editor from '$lib/components/Editor.svelte';
|
||||
import ConvertDialog from '$lib/components/ConvertDialog.svelte';
|
||||
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
|
||||
|
|
@ -15,9 +16,34 @@
|
|||
let chat = $state<ChatConnection | null>(null);
|
||||
let sending = $state(false);
|
||||
let messagesEl: HTMLDivElement | undefined;
|
||||
let replyingTo = $state<MessageData | null>(null);
|
||||
let convertTarget = $state<{ messageId: string; type: 'kanban' | 'calendar' } | null>(null);
|
||||
|
||||
let currentUserId = $derived($page.data.user?.id ?? undefined);
|
||||
|
||||
const chatCallbacks = {
|
||||
get currentUserId() { return currentUserId; },
|
||||
onMentionClick: (entityId: string) => goto(`/entities/${entityId}`),
|
||||
onEdit: async (messageId: string, newBody: string) => {
|
||||
// Oppdater lokalt umiddelbart (optimistisk)
|
||||
chat?.updateLocal?.(messageId, newBody);
|
||||
try {
|
||||
const res = await fetch(`/api/messages/${messageId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: newBody })
|
||||
});
|
||||
if (res.ok) await chat?.refresh();
|
||||
} catch { /* nettverksfeil — allerede oppdatert lokalt */ }
|
||||
},
|
||||
onDelete: async (messageId: string) => {
|
||||
// Fjern fra lokal state umiddelbart
|
||||
chat?.removeLocal?.(messageId);
|
||||
// Slett fra PG (ignorerer 404 — meldingen kan være usynket)
|
||||
try { await fetch(`/api/messages/${messageId}`, { method: 'DELETE' }); } catch {}
|
||||
// Slett fra SpacetimeDB slik at den ikke dukker opp igjen ved reconnect
|
||||
chat?.deleteFromSpacetime?.(messageId);
|
||||
},
|
||||
onReaction: async (messageId: string, reaction: string) => {
|
||||
try {
|
||||
const msg = chat?.messages.find(m => m.id === messageId);
|
||||
|
|
@ -39,6 +65,16 @@
|
|||
});
|
||||
if (res.ok) await chat?.refresh();
|
||||
} catch { /* stille feil */ }
|
||||
},
|
||||
onReply: (messageId: string) => {
|
||||
const msg = chat?.messages.find(m => m.id === messageId);
|
||||
if (msg) replyingTo = msg;
|
||||
},
|
||||
onConvertToKanban: (messageId: string) => {
|
||||
convertTarget = { messageId, type: 'kanban' };
|
||||
},
|
||||
onConvertToCalendar: (messageId: string) => {
|
||||
convertTarget = { messageId, type: 'calendar' };
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -46,13 +82,30 @@
|
|||
if (!chat || sending) return;
|
||||
sending = true;
|
||||
try {
|
||||
await chat.send(html, mentions.length > 0 ? mentions : undefined);
|
||||
await chat.send(html, mentions.length > 0 ? mentions : undefined, replyingTo?.id);
|
||||
replyingTo = null;
|
||||
scrollToBottom();
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConvert(detail: { columnId?: string; calendarId?: string; startsAt?: string; endsAt?: string; allDay?: boolean }) {
|
||||
if (!convertTarget) return;
|
||||
const body = convertTarget.type === 'kanban'
|
||||
? { type: 'kanban', columnId: detail.columnId }
|
||||
: { type: 'calendar', calendarId: detail.calendarId, startsAt: detail.startsAt, endsAt: detail.endsAt, allDay: detail.allDay };
|
||||
try {
|
||||
const res = await fetch(`/api/messages/${convertTarget.messageId}/convert`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) await chat?.refresh();
|
||||
} catch { /* stille feil */ }
|
||||
convertTarget = null;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEl?.scrollTo(0, messagesEl.scrollHeight);
|
||||
|
|
@ -97,10 +150,11 @@
|
|||
onMount(() => {
|
||||
if (channelId) {
|
||||
const user = $page.data.user;
|
||||
const workspaceId = $page.data.workspace?.id;
|
||||
chat = createChat(channelId, {
|
||||
id: user?.id ?? 'anonymous',
|
||||
name: user?.name ?? 'Ukjent'
|
||||
});
|
||||
}, workspaceId);
|
||||
}
|
||||
return () => chat?.destroy();
|
||||
});
|
||||
|
|
@ -131,15 +185,31 @@
|
|||
<div class="error">{chat.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if replyingTo}
|
||||
<div class="reply-preview">
|
||||
<span class="reply-preview__text">↩ Svar til {replyingTo.author_name ?? 'Ukjent'}</span>
|
||||
<button class="reply-preview__close" onclick={() => replyingTo = null}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="input-row">
|
||||
<Editor
|
||||
mode="compact"
|
||||
placeholder="Skriv en melding..."
|
||||
placeholder={replyingTo ? 'Skriv et svar...' : 'Skriv en melding...'}
|
||||
onSubmit={handleSubmit}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if convertTarget}
|
||||
<ConvertDialog
|
||||
type={convertTarget.type}
|
||||
workspaceId={$page.data.workspace?.id}
|
||||
onConfirm={handleConvert}
|
||||
onCancel={() => convertTarget = null}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
|
@ -189,6 +259,40 @@
|
|||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.reply-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-left: 2px solid #3b82f6;
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 0.75rem;
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.reply-preview__text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reply-preview__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b92a5;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.2rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.reply-preview__close:hover {
|
||||
color: #e1e4e8;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #2d3148;
|
||||
|
|
|
|||
|
|
@ -27,9 +27,12 @@ export function createPgChat(channelId: string): ChatConnection {
|
|||
visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace',
|
||||
created_at: raw.created_at as string,
|
||||
updated_at: (raw.updated_at as string) ?? (raw.created_at as string),
|
||||
reply_count: (raw.reply_count as number) ?? 0,
|
||||
parent_body: (raw.parent_body as string) ?? null,
|
||||
parent_author_name: (raw.parent_author_name as string) ?? null,
|
||||
reactions: (raw.reactions as MessageData['reactions']) ?? [],
|
||||
kanban_view: null,
|
||||
calendar_view: null
|
||||
kanban_view: (raw.kanban_view as MessageData['kanban_view']) ?? null,
|
||||
calendar_view: (raw.calendar_view as MessageData['calendar_view']) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -48,13 +51,13 @@ export function createPgChat(channelId: string): ChatConnection {
|
|||
}
|
||||
}
|
||||
|
||||
async function send(body: string, mentions?: MentionRef[]) {
|
||||
async function send(body: string, mentions?: MentionRef[], replyTo?: string) {
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body, mentions })
|
||||
body: JSON.stringify({ body, mentions, replyTo: replyTo ?? null })
|
||||
});
|
||||
if (!res.ok) throw new Error('Feil ved sending');
|
||||
await refresh();
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ export function createSpacetimeChat(
|
|||
channelId: string,
|
||||
spacetimeUrl: string,
|
||||
moduleName: string,
|
||||
user: ChatUser
|
||||
user: ChatUser,
|
||||
workspaceId: string
|
||||
): ChatConnection {
|
||||
let messages = $state<MessageData[]>([]);
|
||||
let error = $state('');
|
||||
let connected = $state(false);
|
||||
let conn: InstanceType<typeof DbConnection> | null = null;
|
||||
let destroyed = false;
|
||||
const deletedIds = new Set<string>();
|
||||
|
||||
function toMessageData(raw: Record<string, unknown>): MessageData {
|
||||
return {
|
||||
|
|
@ -36,9 +38,12 @@ export function createSpacetimeChat(
|
|||
visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace',
|
||||
created_at: raw.created_at as string,
|
||||
updated_at: (raw.updated_at as string) ?? (raw.created_at as string),
|
||||
reply_count: (raw.reply_count as number) ?? 0,
|
||||
parent_body: (raw.parent_body as string) ?? null,
|
||||
parent_author_name: (raw.parent_author_name as string) ?? null,
|
||||
reactions: (raw.reactions as MessageData['reactions']) ?? [],
|
||||
kanban_view: null,
|
||||
calendar_view: null
|
||||
kanban_view: (raw.kanban_view as MessageData['kanban_view']) ?? null,
|
||||
calendar_view: (raw.calendar_view as MessageData['calendar_view']) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -50,8 +55,8 @@ export function createSpacetimeChat(
|
|||
const raw: Record<string, unknown>[] = await res.json();
|
||||
const pgMessages = raw.map(toMessageData);
|
||||
const pgIds = new Set(pgMessages.map(m => m.id));
|
||||
// Behold SpacetimeDB-meldinger som ikke finnes i PG ennå (antatt nyere)
|
||||
const spacetimeOnly = messages.filter(m => !pgIds.has(m.id));
|
||||
// Behold SpacetimeDB-meldinger som ikke finnes i PG ennå, unntatt slettede
|
||||
const spacetimeOnly = messages.filter(m => !pgIds.has(m.id) && !deletedIds.has(m.id));
|
||||
messages = [...pgMessages, ...spacetimeOnly];
|
||||
} catch {
|
||||
error = 'Kunne ikke laste meldinger';
|
||||
|
|
@ -96,12 +101,20 @@ export function createSpacetimeChat(
|
|||
conn.db.chat_message.onInsert((ctx: EventContext, row) => {
|
||||
if (destroyed) return;
|
||||
if (row.channelId !== channelId) return;
|
||||
// Dedupliser mot PG-data
|
||||
if (deletedIds.has(row.id)) return;
|
||||
// Dedupliser mot eksisterende
|
||||
if (messages.some(m => m.id === row.id)) return;
|
||||
|
||||
const msg = spacetimeRowToMessage(row);
|
||||
messages = [...messages, msg];
|
||||
});
|
||||
|
||||
// Fjern meldinger som slettes i sanntid
|
||||
conn.db.chat_message.onDelete((ctx: EventContext, row) => {
|
||||
if (destroyed) return;
|
||||
if (row.channelId !== channelId) return;
|
||||
messages = messages.filter(m => m.id !== row.id);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[spacetime] setup feilet, bruker kun PG:', e);
|
||||
}
|
||||
|
|
@ -134,35 +147,38 @@ export function createSpacetimeChat(
|
|||
};
|
||||
}
|
||||
|
||||
async function send(body: string, mentions?: MentionRef[]) {
|
||||
// Alltid send via PG API — dette oppretter noden og MENTIONS-edges atomisk.
|
||||
// SpacetimeDB brukes kun for real-time push til andre klienter.
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body, mentions })
|
||||
});
|
||||
if (!res.ok) throw new Error('Feil ved sending');
|
||||
async function sendViaPgApi(body: string, mentions?: MentionRef[], replyTo?: string) {
|
||||
const res = await fetch(`/api/channels/${channelId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body, mentions, replyTo: replyTo ?? null })
|
||||
});
|
||||
if (!res.ok) throw new Error('Feil ved sending');
|
||||
await loadFromPg();
|
||||
}
|
||||
|
||||
// Push til SpacetimeDB for sanntidsvisning hos andre klienter
|
||||
if (conn && connected) {
|
||||
try {
|
||||
const msg = await res.clone().json();
|
||||
conn.reducers.sendMessage({
|
||||
id: msg.id,
|
||||
channelId,
|
||||
workspaceId: '',
|
||||
authorName: user.name,
|
||||
body,
|
||||
replyTo: ''
|
||||
});
|
||||
} catch {
|
||||
// Ikke kritisk — PG er allerede oppdatert
|
||||
}
|
||||
async function send(body: string, mentions?: MentionRef[], replyTo?: string) {
|
||||
// Fallback til PG API hvis SpacetimeDB er nede
|
||||
if (!conn || !connected) {
|
||||
try {
|
||||
await sendViaPgApi(body, mentions, replyTo);
|
||||
} catch {
|
||||
error = 'Kunne ikke sende melding';
|
||||
}
|
||||
|
||||
await loadFromPg();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
conn.reducers.sendMessage({
|
||||
id,
|
||||
channelId,
|
||||
workspaceId,
|
||||
authorId: user.id,
|
||||
authorName: user.name,
|
||||
body,
|
||||
replyTo: replyTo ?? ''
|
||||
});
|
||||
// Ingen reload — onInsert-callback viser meldingen instant
|
||||
} catch {
|
||||
error = 'Kunne ikke sende melding';
|
||||
}
|
||||
|
|
@ -188,12 +204,34 @@ export function createSpacetimeChat(
|
|||
loadFromPg();
|
||||
connectRealtime();
|
||||
|
||||
function updateLocal(messageId: string, newBody: string) {
|
||||
messages = messages.map(m =>
|
||||
m.id === messageId ? { ...m, body: newBody } : m
|
||||
);
|
||||
}
|
||||
|
||||
function removeLocal(messageId: string) {
|
||||
deletedIds.add(messageId);
|
||||
messages = messages.filter(m => m.id !== messageId);
|
||||
}
|
||||
|
||||
function deleteFromSpacetime(messageId: string) {
|
||||
if (conn && connected) {
|
||||
try {
|
||||
conn.reducers.deleteMessage({ id: messageId });
|
||||
} catch { /* SpacetimeDB nede — ignoreres */ }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get messages() { return messages; },
|
||||
get error() { return error; },
|
||||
get connected() { return connected; },
|
||||
send,
|
||||
refresh: loadFromPg,
|
||||
updateLocal,
|
||||
removeLocal,
|
||||
deleteFromSpacetime,
|
||||
destroy
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ export interface ChatConnection {
|
|||
readonly messages: MessageData[];
|
||||
readonly error: string;
|
||||
readonly connected: boolean;
|
||||
send(body: string, mentions?: MentionRef[]): Promise<void>;
|
||||
send(body: string, mentions?: MentionRef[], replyTo?: string): Promise<void>;
|
||||
refresh(): Promise<void>;
|
||||
removeLocal?(messageId: string): void;
|
||||
updateLocal?(messageId: string, newBody: string): void;
|
||||
deleteFromSpacetime?(messageId: string): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
|
|
|||
255
web/src/lib/components/ConvertDialog.svelte
Normal file
255
web/src/lib/components/ConvertDialog.svelte
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
type,
|
||||
workspaceId,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: {
|
||||
type: 'kanban' | 'calendar';
|
||||
workspaceId?: string;
|
||||
onConfirm: (detail: { columnId?: string; calendarId?: string; startsAt?: string; endsAt?: string; allDay?: boolean }) => void;
|
||||
onCancel: () => void;
|
||||
} = $props();
|
||||
|
||||
// Kanban state
|
||||
let boards = $state<{ id: string; name: string; columns: { id: string; name: string }[] }[]>([]);
|
||||
let selectedColumnId = $state('');
|
||||
|
||||
// Calendar state
|
||||
let calendars = $state<{ id: string; name: string; color: string }[]>([]);
|
||||
let selectedCalendarId = $state('');
|
||||
let startsAt = $state('');
|
||||
let endsAt = $state('');
|
||||
let allDay = $state(false);
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (type === 'kanban') {
|
||||
try {
|
||||
const res = await fetch('/api/kanban');
|
||||
if (res.ok) {
|
||||
boards = await res.json();
|
||||
if (boards.length > 0 && boards[0].columns.length > 0) {
|
||||
selectedColumnId = boards[0].columns[0].id;
|
||||
}
|
||||
}
|
||||
} catch { /* stille feil */ }
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch('/api/calendar');
|
||||
if (res.ok) {
|
||||
calendars = await res.json();
|
||||
if (calendars.length > 0) {
|
||||
selectedCalendarId = calendars[0].id;
|
||||
}
|
||||
}
|
||||
} catch { /* stille feil */ }
|
||||
// Default til nå
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
startsAt = toLocalDatetime(now);
|
||||
const end = new Date(now);
|
||||
end.setHours(end.getHours() + 1);
|
||||
endsAt = toLocalDatetime(end);
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function toLocalDatetime(d: Date): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (type === 'kanban') {
|
||||
if (!selectedColumnId) return;
|
||||
onConfirm({ columnId: selectedColumnId });
|
||||
} else {
|
||||
if (!selectedCalendarId || !startsAt) return;
|
||||
onConfirm({
|
||||
calendarId: selectedCalendarId,
|
||||
startsAt: new Date(startsAt).toISOString(),
|
||||
endsAt: endsAt ? new Date(endsAt).toISOString() : undefined,
|
||||
allDay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="convert-overlay" onclick={onCancel} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="convert-dialog" onclick={(e) => e.stopPropagation()}>
|
||||
<h3>{type === 'kanban' ? 'Konverter til kanban-kort' : 'Konverter til kalenderhendelse'}</h3>
|
||||
|
||||
{#if loading}
|
||||
<p class="convert-dialog__loading">Laster...</p>
|
||||
{:else if type === 'kanban'}
|
||||
{#if boards.length === 0}
|
||||
<p class="convert-dialog__empty">Ingen kanban-tavler funnet i dette workspace.</p>
|
||||
{:else}
|
||||
<label class="convert-dialog__label">
|
||||
Velg kolonne:
|
||||
<select bind:value={selectedColumnId} class="convert-dialog__select">
|
||||
{#each boards as board}
|
||||
<optgroup label={board.name}>
|
||||
{#each board.columns as col}
|
||||
<option value={col.id}>{col.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if calendars.length === 0}
|
||||
<p class="convert-dialog__empty">Ingen kalendere funnet i dette workspace.</p>
|
||||
{:else}
|
||||
<label class="convert-dialog__label">
|
||||
Kalender:
|
||||
<select bind:value={selectedCalendarId} class="convert-dialog__select">
|
||||
{#each calendars as cal}
|
||||
<option value={cal.id}>{cal.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="convert-dialog__label convert-dialog__checkbox">
|
||||
<input type="checkbox" bind:checked={allDay} />
|
||||
Heldagshendelse
|
||||
</label>
|
||||
{#if !allDay}
|
||||
<label class="convert-dialog__label">
|
||||
Starter:
|
||||
<input type="datetime-local" bind:value={startsAt} class="convert-dialog__input" />
|
||||
</label>
|
||||
<label class="convert-dialog__label">
|
||||
Slutter:
|
||||
<input type="datetime-local" bind:value={endsAt} class="convert-dialog__input" />
|
||||
</label>
|
||||
{:else}
|
||||
<label class="convert-dialog__label">
|
||||
Dato:
|
||||
<input type="date" bind:value={startsAt} class="convert-dialog__input" />
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="convert-dialog__actions">
|
||||
<button class="convert-dialog__btn convert-dialog__btn--cancel" onclick={onCancel}>Avbryt</button>
|
||||
<button class="convert-dialog__btn convert-dialog__btn--confirm" onclick={handleSubmit}
|
||||
disabled={loading || (type === 'kanban' ? !selectedColumnId : !selectedCalendarId || !startsAt)}
|
||||
>Konverter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.convert-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.convert-dialog {
|
||||
background: #161822;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 8px;
|
||||
padding: 1.2rem;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.convert-dialog h3 {
|
||||
margin: 0 0 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
color: #e1e4e8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.convert-dialog__loading,
|
||||
.convert-dialog__empty {
|
||||
font-size: 0.8rem;
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.convert-dialog__label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #8b92a5;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.convert-dialog__checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.convert-dialog__select,
|
||||
.convert-dialog__input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.2rem;
|
||||
padding: 0.4rem;
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 4px;
|
||||
color: #e1e4e8;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.convert-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.convert-dialog__btn {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.convert-dialog__btn--cancel {
|
||||
background: none;
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.convert-dialog__btn--cancel:hover {
|
||||
color: #e1e4e8;
|
||||
}
|
||||
|
||||
.convert-dialog__btn--confirm {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.convert-dialog__btn--confirm:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.convert-dialog__btn--confirm:disabled {
|
||||
background: #1e2235;
|
||||
color: #8b92a5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { MessageData, MessageBoxMode, MessageBoxCallbacks } from '$lib/types/message';
|
||||
import Editor from '$lib/components/Editor.svelte';
|
||||
|
||||
let {
|
||||
message,
|
||||
|
|
@ -44,7 +45,17 @@
|
|||
callbacks.onClick?.(message.id);
|
||||
}
|
||||
|
||||
const QUICK_REACTIONS = ['👍', '❤️', '🔥', '👀', '💯'];
|
||||
const ALL_REACTIONS = ['👍', '❤️', '🔥', '👀', '💯'];
|
||||
|
||||
let userReaction = $derived(
|
||||
message.reactions?.find(r => r.user_reacted)?.reaction ?? null
|
||||
);
|
||||
|
||||
let availableReactions = $derived(
|
||||
userReaction ? [] : ALL_REACTIONS.filter(
|
||||
emoji => !message.reactions?.some(r => r.reaction === emoji && r.user_reacted)
|
||||
)
|
||||
);
|
||||
|
||||
let hasBadges = $derived(
|
||||
(mode === 'expanded' && (message.kanban_view || message.calendar_view))
|
||||
|
|
@ -56,6 +67,13 @@
|
|||
|
||||
let bodyPreview = $derived(truncate(stripHtml(message.body), 80));
|
||||
|
||||
let editing = $state(false);
|
||||
let editBody = $state('');
|
||||
|
||||
let isOwnMessage = $derived(
|
||||
callbacks.currentUserId != null && message.author_id === callbacks.currentUserId
|
||||
);
|
||||
|
||||
function handleReaction(e: MouseEvent, reaction: string) {
|
||||
e.stopPropagation();
|
||||
callbacks.onReaction?.(message.id, reaction);
|
||||
|
|
@ -65,12 +83,58 @@
|
|||
e.stopPropagation();
|
||||
callbacks.onTogglePin?.(message.id, !message.pinned);
|
||||
}
|
||||
|
||||
function startEdit(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
editBody = message.body;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing = false;
|
||||
editBody = '';
|
||||
}
|
||||
|
||||
function submitEdit(html: string) {
|
||||
const body = html || editBody;
|
||||
if (!body.trim()) return;
|
||||
callbacks.onEdit?.(message.id, body);
|
||||
editing = false;
|
||||
editBody = '';
|
||||
}
|
||||
|
||||
function handleDelete(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
callbacks.onDelete?.(message.id);
|
||||
}
|
||||
|
||||
function handleReply(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
callbacks.onReply?.(message.id);
|
||||
}
|
||||
|
||||
function handleConvertToKanban(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
callbacks.onConvertToKanban?.(message.id);
|
||||
}
|
||||
|
||||
function handleConvertToCalendar(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
callbacks.onConvertToCalendar?.(message.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if mode === 'expanded'}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="messagebox messagebox--expanded" onclick={handleClick}>
|
||||
<div class="messagebox messagebox--expanded" id="msg-{message.id}" onclick={handleClick}>
|
||||
{#if message.reply_to && message.parent_author_name}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="messagebox__reply-context" onclick={(e) => { e.stopPropagation(); const el = document.getElementById(`msg-${message.reply_to}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }}>
|
||||
↩ {message.parent_author_name}: {truncate(stripHtml(message.parent_body ?? ''), 50)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showAuthor || showTimestamp}
|
||||
<div class="messagebox__header">
|
||||
{#if showAuthor}
|
||||
|
|
@ -79,19 +143,58 @@
|
|||
{#if showTimestamp}
|
||||
<span class="messagebox__time">{formatTime(message.created_at)}</span>
|
||||
{/if}
|
||||
{#if callbacks.onTogglePin}
|
||||
<button
|
||||
class="messagebox__pin-toggle"
|
||||
class:messagebox__pin-toggle--active={message.pinned}
|
||||
title={message.pinned ? 'Løsne' : 'Fest'}
|
||||
onclick={handleTogglePin}
|
||||
>📌</button>
|
||||
{:else if message.pinned}
|
||||
<span class="messagebox__pinned" title="Festet">📌</span>
|
||||
{/if}
|
||||
<span class="messagebox__header-right">
|
||||
{#if isOwnMessage && !editing}
|
||||
<span class="messagebox__actions">
|
||||
{#if callbacks.onEdit}
|
||||
<button class="messagebox__action" title="Rediger" onclick={startEdit}>✏️</button>
|
||||
{/if}
|
||||
{#if callbacks.onDelete}
|
||||
<button class="messagebox__action" title="Slett" onclick={handleDelete}>🗑️</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="messagebox__actions">
|
||||
{#if callbacks.onReply}
|
||||
<button class="messagebox__action" title="Svar" onclick={handleReply}>💬</button>
|
||||
{/if}
|
||||
{#if callbacks.onConvertToKanban}
|
||||
<button class="messagebox__action" title="Kanban-kort" onclick={handleConvertToKanban}>📋</button>
|
||||
{/if}
|
||||
{#if callbacks.onConvertToCalendar}
|
||||
<button class="messagebox__action" title="Kalenderhendelse" onclick={handleConvertToCalendar}>📅</button>
|
||||
{/if}
|
||||
</span>
|
||||
{#if callbacks.onTogglePin}
|
||||
<button
|
||||
class="messagebox__pin-toggle"
|
||||
class:messagebox__pin-toggle--active={message.pinned}
|
||||
title={message.pinned ? 'Løsne' : 'Fest'}
|
||||
onclick={handleTogglePin}
|
||||
>📌</button>
|
||||
{:else if message.pinned}
|
||||
<span class="messagebox__pinned" title="Festet">📌</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="messagebox__body">{@html message.body}</div>
|
||||
{#if editing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="messagebox__edit" onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }}>
|
||||
<Editor
|
||||
mode="compact"
|
||||
content={editBody}
|
||||
autofocus
|
||||
onSubmit={(html) => submitEdit(html)}
|
||||
/>
|
||||
<div class="messagebox__edit-actions">
|
||||
<span class="messagebox__edit-hint">Enter = lagre · Esc = avbryt</span>
|
||||
<button class="messagebox__edit-btn messagebox__edit-btn--cancel" onclick={cancelEdit}>Avbryt</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="messagebox__body">{@html message.body}</div>
|
||||
{/if}
|
||||
{#if hasReactions || callbacks.onReaction}
|
||||
<div class="messagebox__reactions">
|
||||
{#each message.reactions ?? [] as r}
|
||||
|
|
@ -101,9 +204,9 @@
|
|||
onclick={(e) => handleReaction(e, r.reaction)}
|
||||
>{r.reaction} {r.count}</button>
|
||||
{/each}
|
||||
{#if callbacks.onReaction}
|
||||
{#if callbacks.onReaction && availableReactions.length > 0}
|
||||
<span class="messagebox__reaction-add">
|
||||
{#each QUICK_REACTIONS as emoji}
|
||||
{#each availableReactions as emoji}
|
||||
<button
|
||||
class="messagebox__reaction-quick"
|
||||
onclick={(e) => handleReaction(e, emoji)}
|
||||
|
|
@ -180,14 +283,19 @@
|
|||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.messagebox__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.messagebox__pinned {
|
||||
font-size: 0.65rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.messagebox__pin-toggle {
|
||||
font-size: 0.65rem;
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
|
@ -208,6 +316,69 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
/* === Edit/Delete actions === */
|
||||
.messagebox__actions {
|
||||
display: none;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.messagebox--expanded:hover .messagebox__actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.messagebox__action {
|
||||
font-size: 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.05rem 0.15rem;
|
||||
border-radius: 4px;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.messagebox__action:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* === Inline edit === */
|
||||
.messagebox__edit {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.messagebox__edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.messagebox__edit-hint {
|
||||
font-size: 0.6rem;
|
||||
color: #8b92a5;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.messagebox__edit-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.messagebox__edit-btn--cancel {
|
||||
background: none;
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.messagebox__edit-btn--cancel:hover {
|
||||
color: #e1e4e8;
|
||||
}
|
||||
|
||||
/* === Reactions === */
|
||||
.messagebox__reactions {
|
||||
display: flex;
|
||||
|
|
@ -262,6 +433,23 @@
|
|||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* === Reply context === */
|
||||
.messagebox__reply-context {
|
||||
font-size: 0.7rem;
|
||||
color: #8b92a5;
|
||||
padding: 0.1rem 0.4rem;
|
||||
margin-bottom: 0.1rem;
|
||||
border-left: 2px solid #3b82f6;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.messagebox__reply-context:hover {
|
||||
color: #c0c6d4;
|
||||
}
|
||||
|
||||
.messagebox__body {
|
||||
font-size: 0.85rem;
|
||||
color: #e1e4e8;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ export interface MessageData {
|
|||
created_at: string;
|
||||
updated_at: string;
|
||||
reply_count?: number;
|
||||
parent_body?: string | null;
|
||||
parent_author_name?: string | null;
|
||||
reactions?: ReactionSummary[];
|
||||
kanban_view?: { column_id: string; board_name?: string; color: string | null } | null;
|
||||
kanban_view?: { column_id: string; column_name?: string; board_name?: string; color: string | null } | null;
|
||||
calendar_view?: {
|
||||
starts_at: string;
|
||||
ends_at: string | null;
|
||||
|
|
@ -35,4 +37,10 @@ export interface MessageBoxCallbacks {
|
|||
onMentionClick?: (entityId: string) => void;
|
||||
onReaction?: (messageId: string, reaction: string) => void;
|
||||
onTogglePin?: (messageId: string, pinned: boolean) => void;
|
||||
onEdit?: (messageId: string, newBody: string) => void;
|
||||
onDelete?: (messageId: string) => void;
|
||||
onReply?: (messageId: string) => void;
|
||||
onConvertToKanban?: (messageId: string) => void;
|
||||
onConvertToCalendar?: (messageId: string) => void;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
|
|
|||
17
web/src/routes/api/calendar/+server.ts
Normal file
17
web/src/routes/api/calendar/+server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { sql } from '$lib/server/db';
|
||||
|
||||
/** GET /api/calendar — List alle kalendere for workspace */
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.workspace || !locals.user) error(401);
|
||||
|
||||
const calendars = await sql`
|
||||
SELECT c.id, c.name, c.color FROM calendars c
|
||||
JOIN nodes n ON n.id = c.id
|
||||
WHERE n.workspace_id = ${locals.workspace.id}
|
||||
ORDER BY c.name
|
||||
`;
|
||||
|
||||
return json(calendars);
|
||||
};
|
||||
|
|
@ -23,9 +23,21 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
|||
? await sql`
|
||||
SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
|
||||
m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to,
|
||||
u.display_name as author_name, u.authentik_id as author_id
|
||||
u.display_name as author_name, u.authentik_id as author_id,
|
||||
(SELECT count(*)::int FROM messages r WHERE r.reply_to = m.id) as reply_count,
|
||||
pm.body as parent_body, pu.display_name as parent_author_name,
|
||||
kcv.column_id, kc.name as column_name, kcv.color as kanban_color,
|
||||
kb.name as board_name,
|
||||
cev.starts_at as cal_starts_at, cev.ends_at as cal_ends_at,
|
||||
cev.all_day as cal_all_day, cev.color as cal_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON u.authentik_id = m.author_id
|
||||
LEFT JOIN messages pm ON pm.id = m.reply_to
|
||||
LEFT JOIN users pu ON pu.authentik_id = pm.author_id
|
||||
LEFT JOIN kanban_card_view kcv ON kcv.message_id = m.id
|
||||
LEFT JOIN kanban_columns kc ON kc.id = kcv.column_id
|
||||
LEFT JOIN kanban_boards kb ON kb.id = kc.board_id
|
||||
LEFT JOIN calendar_event_view cev ON cev.message_id = m.id
|
||||
WHERE m.channel_id = ${channelId} AND m.created_at > ${after}
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT ${limit}
|
||||
|
|
@ -33,9 +45,21 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
|||
: await sql`
|
||||
SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
|
||||
m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to,
|
||||
u.display_name as author_name, u.authentik_id as author_id
|
||||
u.display_name as author_name, u.authentik_id as author_id,
|
||||
(SELECT count(*)::int FROM messages r WHERE r.reply_to = m.id) as reply_count,
|
||||
pm.body as parent_body, pu.display_name as parent_author_name,
|
||||
kcv.column_id, kc.name as column_name, kcv.color as kanban_color,
|
||||
kb.name as board_name,
|
||||
cev.starts_at as cal_starts_at, cev.ends_at as cal_ends_at,
|
||||
cev.all_day as cal_all_day, cev.color as cal_color
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON u.authentik_id = m.author_id
|
||||
LEFT JOIN messages pm ON pm.id = m.reply_to
|
||||
LEFT JOIN users pu ON pu.authentik_id = pm.author_id
|
||||
LEFT JOIN kanban_card_view kcv ON kcv.message_id = m.id
|
||||
LEFT JOIN kanban_columns kc ON kc.id = kcv.column_id
|
||||
LEFT JOIN kanban_boards kb ON kb.id = kc.board_id
|
||||
LEFT JOIN calendar_event_view cev ON cev.message_id = m.id
|
||||
WHERE m.channel_id = ${channelId}
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ${limit}
|
||||
|
|
@ -66,8 +90,34 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
|||
}
|
||||
|
||||
const messages = rows.map((m) => ({
|
||||
...m,
|
||||
reactions: reactionsMap[m.id as string] ?? []
|
||||
id: m.id,
|
||||
channel_id: m.channel_id,
|
||||
body: m.body,
|
||||
message_type: m.message_type,
|
||||
title: m.title,
|
||||
pinned: m.pinned,
|
||||
visibility: m.visibility,
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
reply_to: m.reply_to,
|
||||
author_name: m.author_name,
|
||||
author_id: m.author_id,
|
||||
reply_count: m.reply_count ?? 0,
|
||||
parent_body: m.parent_body ?? null,
|
||||
parent_author_name: m.parent_author_name ?? null,
|
||||
reactions: reactionsMap[m.id as string] ?? [],
|
||||
kanban_view: m.column_id ? {
|
||||
column_id: m.column_id,
|
||||
column_name: m.column_name,
|
||||
board_name: m.board_name,
|
||||
color: m.kanban_color ?? null
|
||||
} : null,
|
||||
calendar_view: m.cal_starts_at ? {
|
||||
starts_at: m.cal_starts_at,
|
||||
ends_at: m.cal_ends_at ?? null,
|
||||
all_day: m.cal_all_day ?? false,
|
||||
color: m.cal_color ?? null
|
||||
} : null
|
||||
}));
|
||||
|
||||
return json(messages);
|
||||
|
|
|
|||
38
web/src/routes/api/kanban/+server.ts
Normal file
38
web/src/routes/api/kanban/+server.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { sql } from '$lib/server/db';
|
||||
|
||||
/** GET /api/kanban — List alle kanban-brett med kolonner for workspace */
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.workspace || !locals.user) error(401);
|
||||
|
||||
const boards = await sql`
|
||||
SELECT b.id, b.name FROM kanban_boards b
|
||||
JOIN nodes n ON n.id = b.id
|
||||
WHERE n.workspace_id = ${locals.workspace.id}
|
||||
ORDER BY b.name
|
||||
`;
|
||||
|
||||
const boardIds = boards.map(b => b.id as string);
|
||||
if (boardIds.length === 0) return json([] as unknown[]);
|
||||
|
||||
const columns = await sql`
|
||||
SELECT id, board_id, name, position
|
||||
FROM kanban_columns
|
||||
WHERE board_id = ANY(${boardIds})
|
||||
ORDER BY position ASC
|
||||
`;
|
||||
|
||||
const colsByBoard: Record<string, (typeof columns[number])[]> = {};
|
||||
for (const col of columns) {
|
||||
const bid = col.board_id as string;
|
||||
if (!colsByBoard[bid]) colsByBoard[bid] = [];
|
||||
colsByBoard[bid].push(col);
|
||||
}
|
||||
|
||||
return json(boards.map(b => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
columns: colsByBoard[b.id as string] ?? []
|
||||
})));
|
||||
};
|
||||
67
web/src/routes/api/messages/[messageId]/convert/+server.ts
Normal file
67
web/src/routes/api/messages/[messageId]/convert/+server.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { sql } from '$lib/server/db';
|
||||
|
||||
/** POST /api/messages/:messageId/convert — Konverter melding til kanban-kort eller kalenderhendelse */
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.workspace || !locals.user) error(401);
|
||||
const workspace = locals.workspace;
|
||||
const messageId = params.messageId;
|
||||
|
||||
const body = await request.json();
|
||||
const { type } = body;
|
||||
|
||||
// Verifiser at meldingen tilhører workspace
|
||||
const [msg] = await sql`
|
||||
SELECT m.id FROM messages m
|
||||
JOIN nodes n ON n.id = m.id
|
||||
WHERE m.id = ${messageId} AND n.workspace_id = ${workspace.id}
|
||||
`;
|
||||
if (!msg) error(404, 'Melding ikke funnet');
|
||||
|
||||
if (type === 'kanban') {
|
||||
const { columnId } = body;
|
||||
if (!columnId) error(400, 'columnId er påkrevd');
|
||||
|
||||
// Verifiser at kolonnen tilhører workspace
|
||||
const [col] = await sql`
|
||||
SELECT kc.id FROM kanban_columns kc
|
||||
JOIN kanban_boards kb ON kb.id = kc.board_id
|
||||
JOIN nodes n ON n.id = kb.id
|
||||
WHERE kc.id = ${columnId} AND n.workspace_id = ${workspace.id}
|
||||
`;
|
||||
if (!col) error(404, 'Kolonne ikke funnet');
|
||||
|
||||
await sql`
|
||||
INSERT INTO kanban_card_view (message_id, column_id, position)
|
||||
VALUES (${messageId}, ${columnId},
|
||||
(SELECT COALESCE(MAX(position), 0) + 1 FROM kanban_card_view WHERE column_id = ${columnId}))
|
||||
ON CONFLICT (message_id) DO NOTHING
|
||||
`;
|
||||
|
||||
return json({ ok: true, type: 'kanban' });
|
||||
}
|
||||
|
||||
if (type === 'calendar') {
|
||||
const { calendarId, startsAt, endsAt, allDay } = body;
|
||||
if (!calendarId || !startsAt) error(400, 'calendarId og startsAt er påkrevd');
|
||||
|
||||
// Verifiser at kalenderen tilhører workspace
|
||||
const [cal] = await sql`
|
||||
SELECT c.id FROM calendars c
|
||||
JOIN nodes n ON n.id = c.id
|
||||
WHERE c.id = ${calendarId} AND n.workspace_id = ${workspace.id}
|
||||
`;
|
||||
if (!cal) error(404, 'Kalender ikke funnet');
|
||||
|
||||
await sql`
|
||||
INSERT INTO calendar_event_view (message_id, calendar_id, starts_at, ends_at, all_day)
|
||||
VALUES (${messageId}, ${calendarId}, ${startsAt}, ${endsAt ?? null}, ${allDay ?? false})
|
||||
ON CONFLICT (message_id) DO NOTHING
|
||||
`;
|
||||
|
||||
return json({ ok: true, type: 'calendar' });
|
||||
}
|
||||
|
||||
error(400, 'Ugyldig type — bruk "kanban" eller "calendar"');
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue