Markdown-rendering og revisjons-toggle for AI-behandlede meldinger
- AI-resultat (markdown) rendres med marked-biblioteket - «Vis original» / «Vis AI-resultat» toggle under AI-behandlede meldinger - Nytt API: GET /api/messages/:id/revisions for å hente originaltekst - Markdown-stiler: overskrifter, lister, blockquotes, kodeblokker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ea3b3d5a38
commit
d5882c8c45
4 changed files with 327 additions and 83 deletions
13
web/package-lock.json
generated
13
web/package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
||||||
"@tiptap/extension-placeholder": "^3.20.1",
|
"@tiptap/extension-placeholder": "^3.20.1",
|
||||||
"@tiptap/pm": "^3.20.1",
|
"@tiptap/pm": "^3.20.1",
|
||||||
"@tiptap/starter-kit": "^3.20.1",
|
"@tiptap/starter-kit": "^3.20.1",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"spacetimedb": "^2.0.4",
|
"spacetimedb": "^2.0.4",
|
||||||
"svelte": "^5.53.12",
|
"svelte": "^5.53.12",
|
||||||
|
|
@ -2045,6 +2046,18 @@
|
||||||
"markdown-it": "bin/markdown-it.mjs"
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "17.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
|
||||||
|
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mdurl": {
|
"node_modules/mdurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@tiptap/extension-placeholder": "^3.20.1",
|
"@tiptap/extension-placeholder": "^3.20.1",
|
||||||
"@tiptap/pm": "^3.20.1",
|
"@tiptap/pm": "^3.20.1",
|
||||||
"@tiptap/starter-kit": "^3.20.1",
|
"@tiptap/starter-kit": "^3.20.1",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"spacetimedb": "^2.0.4",
|
"spacetimedb": "^2.0.4",
|
||||||
"svelte": "^5.53.12",
|
"svelte": "^5.53.12",
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MessageData, MessageBoxMode, MessageBoxCallbacks } from '$lib/types/message';
|
import type { MessageData, MessageBoxMode, MessageBoxCallbacks } from '$lib/types/message';
|
||||||
import Editor from '$lib/components/Editor.svelte';
|
import Editor from '$lib/components/Editor.svelte';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
message,
|
message,
|
||||||
mode = 'expanded',
|
mode = 'expanded',
|
||||||
showAuthor = true,
|
showAuthor = true,
|
||||||
showTimestamp = true,
|
showTimestamp = true,
|
||||||
callbacks = {}
|
callbacks = {},
|
||||||
|
isReply = false
|
||||||
}: {
|
}: {
|
||||||
message: MessageData;
|
message: MessageData;
|
||||||
mode?: MessageBoxMode;
|
mode?: MessageBoxMode;
|
||||||
showAuthor?: boolean;
|
showAuthor?: boolean;
|
||||||
showTimestamp?: boolean;
|
showTimestamp?: boolean;
|
||||||
callbacks?: MessageBoxCallbacks;
|
callbacks?: MessageBoxCallbacks;
|
||||||
|
isReply?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
|
|
@ -69,6 +72,22 @@
|
||||||
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let editBody = $state('');
|
let editBody = $state('');
|
||||||
|
let expanded = $state(false);
|
||||||
|
let bodyEl: HTMLDivElement | undefined = $state();
|
||||||
|
let isClamped = $state(false);
|
||||||
|
|
||||||
|
function checkClamped() {
|
||||||
|
if (bodyEl) {
|
||||||
|
isClamped = bodyEl.scrollHeight > bodyEl.clientHeight + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (bodyEl && !editing && !expanded) {
|
||||||
|
// Check after render
|
||||||
|
checkClamped();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let isOwnMessage = $derived(
|
let isOwnMessage = $derived(
|
||||||
callbacks.currentUserId != null && message.author_id === callbacks.currentUserId
|
callbacks.currentUserId != null && message.author_id === callbacks.currentUserId
|
||||||
|
|
@ -122,13 +141,61 @@
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
callbacks.onConvertToCalendar?.(message.id);
|
callbacks.onConvertToCalendar?.(message.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMagic(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
callbacks.onMagic?.(message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAiProcessing = $derived(message.metadata?.ai_processing === true);
|
||||||
|
let isAiProcessed = $derived(message.metadata?.ai_processed === true);
|
||||||
|
|
||||||
|
// Revisjons-toggle for AI-behandlede meldinger
|
||||||
|
let showingOriginal = $state(false);
|
||||||
|
let originalBody = $state<string | null>(null);
|
||||||
|
let loadingRevision = $state(false);
|
||||||
|
|
||||||
|
async function toggleRevision() {
|
||||||
|
if (showingOriginal) {
|
||||||
|
showingOriginal = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (originalBody === null) {
|
||||||
|
loadingRevision = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/messages/${message.id}/revisions`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
originalBody = data.revisions?.[0]?.body ?? null;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingRevision = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showingOriginal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown → HTML for AI-behandlet innhold
|
||||||
|
function renderBody(body: string, aiProcessed: boolean): string {
|
||||||
|
if (!aiProcessed) return body;
|
||||||
|
// AI returnerer markdown — konverter til HTML
|
||||||
|
const isHtml = /<[a-z][\s\S]*>/i.test(body);
|
||||||
|
if (isHtml) return body;
|
||||||
|
return marked.parse(body, { async: false }) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayBody = $derived(
|
||||||
|
showingOriginal && originalBody !== null
|
||||||
|
? originalBody
|
||||||
|
: renderBody(message.body, isAiProcessed)
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if mode === 'expanded'}
|
{#if mode === 'expanded'}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="messagebox messagebox--expanded" id="msg-{message.id}" onclick={handleClick}>
|
<div class="messagebox messagebox--expanded" class:messagebox--ai-processing={isAiProcessing} id="msg-{message.id}" onclick={handleClick}>
|
||||||
{#if message.reply_to && message.parent_author_name}
|
{#if message.reply_to && message.parent_author_name && !isReply}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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' }); }}>
|
<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' }); }}>
|
||||||
|
|
@ -143,41 +210,37 @@
|
||||||
{#if showTimestamp}
|
{#if showTimestamp}
|
||||||
<span class="messagebox__time">{formatTime(message.created_at)}</span>
|
<span class="messagebox__time">{formatTime(message.created_at)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="messagebox__header-right">
|
{#if message.pinned}
|
||||||
{#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>
|
<span class="messagebox__pinned" title="Festet">📌</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Flytende toolbar — utenfor layout-flyten -->
|
||||||
|
<div class="messagebox__toolbar">
|
||||||
|
{#if callbacks.onReply}
|
||||||
|
<button class="messagebox__toolbar-btn" title="Svar" onclick={handleReply}>💬</button>
|
||||||
|
{/if}
|
||||||
|
{#if callbacks.onConvertToKanban}
|
||||||
|
<button class="messagebox__toolbar-btn" title="Kanban-kort" onclick={handleConvertToKanban}>📋</button>
|
||||||
|
{/if}
|
||||||
|
{#if callbacks.onConvertToCalendar}
|
||||||
|
<button class="messagebox__toolbar-btn" title="Kalenderhendelse" onclick={handleConvertToCalendar}>📅</button>
|
||||||
|
{/if}
|
||||||
|
{#if callbacks.onMagic}
|
||||||
|
<button class="messagebox__toolbar-btn" title="AI-behandling" onclick={handleMagic} disabled={isAiProcessing}>✨</button>
|
||||||
|
{/if}
|
||||||
|
{#if callbacks.onTogglePin}
|
||||||
|
<button class="messagebox__toolbar-btn" title={message.pinned ? 'Løsne' : 'Fest'} onclick={handleTogglePin}>📌</button>
|
||||||
|
{/if}
|
||||||
|
{#if isOwnMessage && !editing}
|
||||||
|
{#if callbacks.onEdit}
|
||||||
|
<button class="messagebox__toolbar-btn" title="Rediger" onclick={startEdit}>✏️</button>
|
||||||
|
{/if}
|
||||||
|
{#if callbacks.onDelete}
|
||||||
|
<button class="messagebox__toolbar-btn messagebox__toolbar-btn--danger" title="Slett" onclick={handleDelete}>🗑️</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="messagebox__edit" onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }}>
|
<div class="messagebox__edit" onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }}>
|
||||||
|
|
@ -193,7 +256,38 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="messagebox__body">{@html message.body}</div>
|
{#if expanded}
|
||||||
|
<button class="messagebox__expand-btn" onclick={(e) => { e.stopPropagation(); expanded = false; checkClamped(); }}>
|
||||||
|
Vis mindre
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<div class="messagebox__body" class:messagebox__body--clamped={!expanded} bind:this={bodyEl}>{@html displayBody}</div>
|
||||||
|
{#if isClamped && !expanded}
|
||||||
|
<button class="messagebox__expand-btn" onclick={(e) => { e.stopPropagation(); expanded = true; }}>
|
||||||
|
Vis mer
|
||||||
|
</button>
|
||||||
|
{:else if expanded}
|
||||||
|
<button class="messagebox__expand-btn" onclick={(e) => { e.stopPropagation(); expanded = false; checkClamped(); }}>
|
||||||
|
Vis mindre
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if isAiProcessing}
|
||||||
|
<div class="messagebox__ai-status">✨ AI behandler…</div>
|
||||||
|
{/if}
|
||||||
|
{#if isAiProcessed && !editing}
|
||||||
|
<div class="messagebox__ai-footer">
|
||||||
|
<span class="messagebox__ai-badge">✨ {message.metadata?.ai_action ?? 'AI'}</span>
|
||||||
|
<button class="messagebox__revision-btn" onclick={(e) => { e.stopPropagation(); toggleRevision(); }} disabled={loadingRevision}>
|
||||||
|
{#if loadingRevision}
|
||||||
|
Laster…
|
||||||
|
{:else if showingOriginal}
|
||||||
|
Vis AI-resultat
|
||||||
|
{:else}
|
||||||
|
Vis original
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasReactions || callbacks.onReaction}
|
{#if hasReactions || callbacks.onReaction}
|
||||||
<div class="messagebox__reactions">
|
<div class="messagebox__reactions">
|
||||||
|
|
@ -258,6 +352,7 @@
|
||||||
<style>
|
<style>
|
||||||
/* === Expanded mode (chat) === */
|
/* === Expanded mode (chat) === */
|
||||||
.messagebox--expanded {
|
.messagebox--expanded {
|
||||||
|
position: relative;
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
@ -283,63 +378,49 @@
|
||||||
color: #8b92a5;
|
color: #8b92a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messagebox__header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagebox__pinned {
|
.messagebox__pinned {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messagebox__pin-toggle {
|
/* === Flytende toolbar (absolutt-posisjonert, ingen layout-shift) === */
|
||||||
font-size: 0.65rem;
|
.messagebox__toolbar {
|
||||||
background: none;
|
position: absolute;
|
||||||
border: none;
|
top: -0.6rem;
|
||||||
cursor: pointer;
|
right: 0.4rem;
|
||||||
opacity: 0;
|
|
||||||
padding: 0;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagebox__pin-toggle--active {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagebox--expanded:hover .messagebox__pin-toggle {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagebox--expanded:hover .messagebox__pin-toggle:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Edit/Delete actions === */
|
|
||||||
.messagebox__actions {
|
|
||||||
display: none;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagebox--expanded:hover .messagebox__actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 0.1rem;
|
||||||
|
background: #1a1d2e;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.15rem 0.2rem;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messagebox__action {
|
.messagebox--expanded:hover .messagebox__toolbar {
|
||||||
font-size: 0.6rem;
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__toolbar-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.05rem 0.15rem;
|
padding: 0.15rem 0.25rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
opacity: 0.4;
|
line-height: 1;
|
||||||
transition: opacity 0.15s;
|
transition: background 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messagebox__action:hover {
|
.messagebox__toolbar-btn:hover {
|
||||||
opacity: 1;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
}
|
||||||
|
|
||||||
|
.messagebox__toolbar-btn--danger:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Inline edit === */
|
/* === Inline edit === */
|
||||||
|
|
@ -409,12 +490,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.messagebox__reaction-add {
|
.messagebox__reaction-add {
|
||||||
display: none;
|
display: flex;
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messagebox--expanded:hover .messagebox__reaction-add {
|
.messagebox--expanded:hover .messagebox__reaction-add {
|
||||||
display: flex;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messagebox__reaction-quick {
|
.messagebox__reaction-quick {
|
||||||
|
|
@ -457,6 +540,64 @@
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.messagebox__body--clamped {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__expand-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #7dd3fc;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__expand-btn:hover {
|
||||||
|
color: #bae6fd;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === AI footer (badge + revision toggle) === */
|
||||||
|
.messagebox__ai-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__ai-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #facc15;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__revision-btn {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #7dd3fc;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__revision-btn:hover {
|
||||||
|
color: #bae6fd;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__revision-btn:disabled {
|
||||||
|
color: #8b92a5;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tiptap HTML rendering */
|
/* Tiptap HTML rendering */
|
||||||
.messagebox__body :global(p) {
|
.messagebox__body :global(p) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -478,6 +619,50 @@
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.messagebox__body :global(h1),
|
||||||
|
.messagebox__body :global(h2),
|
||||||
|
.messagebox__body :global(h3) {
|
||||||
|
margin: 0.6em 0 0.3em;
|
||||||
|
color: #f1f3f5;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__body :global(h1) { font-size: 1.1rem; }
|
||||||
|
.messagebox__body :global(h2) { font-size: 1rem; }
|
||||||
|
.messagebox__body :global(h3) { font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.messagebox__body :global(ul),
|
||||||
|
.messagebox__body :global(ol) {
|
||||||
|
margin: 0.3em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__body :global(li) {
|
||||||
|
margin-bottom: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__body :global(blockquote) {
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
margin: 0.4em 0;
|
||||||
|
padding: 0.2em 0.6em;
|
||||||
|
color: #8b92a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__body :global(pre) {
|
||||||
|
background: #1e2235;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0.4em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__body :global(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #2d3148;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
.messagebox__body :global(.mention) {
|
.messagebox__body :global(.mention) {
|
||||||
color: #8b5cf6;
|
color: #8b5cf6;
|
||||||
background: rgba(139, 92, 246, 0.12);
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
|
@ -517,6 +702,28 @@
|
||||||
background: rgba(16, 185, 129, 0.2);
|
background: rgba(16, 185, 129, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === AI-prosessering === */
|
||||||
|
.messagebox--ai-processing {
|
||||||
|
animation: ai-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-pulse {
|
||||||
|
0%, 100% { background: transparent; }
|
||||||
|
50% { background: rgba(250, 204, 21, 0.06); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagebox__ai-status {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #facc15;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
animation: ai-pulse-text 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-pulse-text {
|
||||||
|
0%, 100% { opacity: 0.5; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
/* === Compact mode (kanban) === */
|
/* === Compact mode (kanban) === */
|
||||||
.messagebox--compact {
|
.messagebox--compact {
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
|
|
|
||||||
23
web/src/routes/api/messages/[messageId]/revisions/+server.ts
Normal file
23
web/src/routes/api/messages/[messageId]/revisions/+server.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/messages/:messageId/revisions — Hent revisjoner for en melding.
|
||||||
|
* Returnerer nyeste revisjon først (originalen før AI-behandling).
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.workspace || !locals.user) error(401);
|
||||||
|
|
||||||
|
const revisions = await sql`
|
||||||
|
SELECT r.id, r.body, r.created_at
|
||||||
|
FROM message_revisions r
|
||||||
|
JOIN messages m ON m.id = r.message_id
|
||||||
|
JOIN nodes n ON n.id = m.id
|
||||||
|
WHERE r.message_id = ${params.messageId}::uuid
|
||||||
|
AND n.workspace_id = ${locals.workspace.id}
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
return json({ revisions });
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue