Generell revisjonshistorikk + markdown-rendering i meldinger

- Markdown rendres automatisk for all ikke-HTML body (detekterer <p>, <div> osv.)
- «redigert»-indikator med bla-navigasjon (← →) for alle redigerte meldinger
- Henter revisjoner fra PG via /api/messages/:id/revisions ved behov
- SpacetimeDB onUpdate setter edited_at slik at frontend vet meldingen er redigert
- AI-badge ( action) vises fortsatt for AI-behandlede meldinger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 03:33:04 +01:00
parent d5882c8c45
commit aeda5e7527
3 changed files with 137 additions and 60 deletions

View file

@ -128,7 +128,7 @@ export function createSpacetimeChat(
}) })
.subscribe([ .subscribe([
`SELECT * FROM chat_message WHERE channel_id = '${channelId}'`, `SELECT * FROM chat_message WHERE channel_id = '${channelId}'`,
`SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE cm.channel_id = '${channelId}'` `SELECT * FROM message_reaction`
]); ]);
}) })
.onDisconnect(() => { .onDisconnect(() => {
@ -153,7 +153,7 @@ export function createSpacetimeChat(
conn.db.chat_message.onUpdate((_ctx: EventContext, _oldRow, newRow) => { conn.db.chat_message.onUpdate((_ctx: EventContext, _oldRow, newRow) => {
if (destroyed || newRow.channelId !== channelId) return; if (destroyed || newRow.channelId !== channelId) return;
messages = messages.map(m => messages = messages.map(m =>
m.id === newRow.id ? { ...m, body: newRow.body } : m m.id === newRow.id ? { ...m, body: newRow.body, edited_at: new Date().toISOString() } : m
); );
}); });

View file

@ -148,46 +148,84 @@
} }
let isAiProcessing = $derived(message.metadata?.ai_processing === true); let isAiProcessing = $derived(message.metadata?.ai_processing === true);
let isAiProcessed = $derived(message.metadata?.ai_processed === true);
// Revisjons-toggle for AI-behandlede meldinger // --- Revisjonshistorikk (generell — gjelder alle redigerte meldinger) ---
let showingOriginal = $state(false);
let originalBody = $state<string | null>(null);
let loadingRevision = $state(false);
async function toggleRevision() { interface Revision {
if (showingOriginal) { id: string;
showingOriginal = false; body: string;
return; created_at: string;
} }
if (originalBody === null) {
loadingRevision = true; let revisions = $state<Revision[]>([]);
let revisionIndex = $state(-1); // -1 = nåværende versjon
let loadingRevisions = $state(false);
let revisionsLoaded = $state(false);
async function loadRevisions() {
if (revisionsLoaded) return;
loadingRevisions = true;
try { try {
const res = await fetch(`/api/messages/${message.id}/revisions`); const res = await fetch(`/api/messages/${message.id}/revisions`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
originalBody = data.revisions?.[0]?.body ?? null; revisions = data.revisions ?? [];
} }
} finally { } finally {
loadingRevision = false; loadingRevisions = false;
revisionsLoaded = true;
} }
} }
showingOriginal = true;
}
// Markdown → HTML for AI-behandlet innhold async function toggleRevisions(e: MouseEvent) {
function renderBody(body: string, aiProcessed: boolean): string { e.stopPropagation();
if (!aiProcessed) return body; if (revisionIndex >= 0) {
// AI returnerer markdown — konverter til HTML // Tilbake til nåværende
const isHtml = /<[a-z][\s\S]*>/i.test(body); revisionIndex = -1;
return;
}
await loadRevisions();
if (revisions.length > 0) {
revisionIndex = 0; // Vis nyeste revisjon (original)
}
}
function prevRevision(e: MouseEvent) {
e.stopPropagation();
if (revisionIndex < revisions.length - 1) revisionIndex++;
}
function nextRevision(e: MouseEvent) {
e.stopPropagation();
if (revisionIndex > 0) revisionIndex--;
else revisionIndex = -1; // Tilbake til nåværende
}
let showingRevision = $derived(revisionIndex >= 0);
let totalVersions = $derived(revisions.length + 1); // revisjoner + nåværende
let currentVersionNumber = $derived(
revisionIndex < 0
? totalVersions
: totalVersions - revisionIndex - 1
);
// --- Body-rendering med markdown-deteksjon ---
function renderBody(body: string): string {
// Sjekk om body allerede er HTML (fra Tiptap-editor)
const isHtml = /<(?:p|div|br|span|h[1-6]|ul|ol|li|a|strong|em|code)\b/i.test(body);
if (isHtml) return body; if (isHtml) return body;
// Ellers: behandle som markdown
return marked.parse(body, { async: false }) as string; return marked.parse(body, { async: false }) as string;
} }
let displayBody = $derived( let activeBody = $derived(
showingOriginal && originalBody !== null showingRevision ? revisions[revisionIndex]?.body ?? message.body : message.body
? originalBody );
: renderBody(message.body, isAiProcessed) let displayBody = $derived(renderBody(activeBody));
let isEdited = $derived(
!!(message.edited_at || message.metadata?.ai_processed)
); );
</script> </script>
@ -274,18 +312,29 @@
{#if isAiProcessing} {#if isAiProcessing}
<div class="messagebox__ai-status">✨ AI behandler…</div> <div class="messagebox__ai-status">✨ AI behandler…</div>
{/if} {/if}
{#if isAiProcessed && !editing} {#if isEdited && !editing}
<div class="messagebox__ai-footer"> <div class="messagebox__revision-footer">
<span class="messagebox__ai-badge">{message.metadata?.ai_action ?? 'AI'}</span> <button class="messagebox__revision-toggle" onclick={toggleRevisions} disabled={loadingRevisions}>
<button class="messagebox__revision-btn" onclick={(e) => { e.stopPropagation(); toggleRevision(); }} disabled={loadingRevision}> {#if loadingRevisions}
{#if loadingRevision}
Laster… {:else if showingRevision}
{:else if showingOriginal} versjon {currentVersionNumber}/{totalVersions}
Vis AI-resultat
{:else} {:else}
Vis original redigert
{/if} {/if}
</button> </button>
{#if showingRevision}
<button class="messagebox__revision-nav" onclick={prevRevision} disabled={revisionIndex >= revisions.length - 1}>&larr;</button>
<button class="messagebox__revision-nav" onclick={nextRevision}>&rarr;</button>
{#if revisionIndex >= 0}
<button class="messagebox__revision-toggle" onclick={(e) => { e.stopPropagation(); revisionIndex = -1; }}>
nåværende
</button>
{/if}
{/if}
{#if message.metadata?.ai_action}
<span class="messagebox__ai-badge">{message.metadata.ai_action}</span>
{/if}
</div> </div>
{/if} {/if}
{/if} {/if}
@ -564,23 +613,17 @@
text-decoration: underline; text-decoration: underline;
} }
/* === AI footer (badge + revision toggle) === */ /* === Revisjonshistorikk === */
.messagebox__ai-footer { .messagebox__revision-footer {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.4rem;
margin-top: 0.3rem; margin-top: 0.2rem;
} }
.messagebox__ai-badge { .messagebox__revision-toggle {
font-size: 0.65rem; font-size: 0.65rem;
color: #facc15; color: #8b92a5;
opacity: 0.7;
}
.messagebox__revision-btn {
font-size: 0.65rem;
color: #7dd3fc;
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
@ -588,16 +631,42 @@
font-family: inherit; font-family: inherit;
} }
.messagebox__revision-btn:hover { .messagebox__revision-toggle:hover {
color: #bae6fd; color: #7dd3fc;
text-decoration: underline;
} }
.messagebox__revision-btn:disabled { .messagebox__revision-toggle:disabled {
color: #8b92a5;
cursor: wait; cursor: wait;
} }
.messagebox__revision-nav {
font-size: 0.65rem;
color: #8b92a5;
background: none;
border: 1px solid #2d3148;
border-radius: 3px;
cursor: pointer;
padding: 0 0.3rem;
font-family: inherit;
line-height: 1.4;
}
.messagebox__revision-nav:hover:not(:disabled) {
color: #7dd3fc;
border-color: #7dd3fc;
}
.messagebox__revision-nav:disabled {
opacity: 0.3;
cursor: default;
}
.messagebox__ai-badge {
font-size: 0.65rem;
color: #facc15;
opacity: 0.7;
}
/* Tiptap HTML rendering */ /* Tiptap HTML rendering */
.messagebox__body :global(p) { .messagebox__body :global(p) {
margin: 0; margin: 0;

View file

@ -22,6 +22,13 @@ export interface MessageData {
all_day: boolean; all_day: boolean;
color: string | null; color: string | null;
} | null; } | null;
edited_at?: string | null;
revision_count?: number;
metadata?: {
ai_processing?: boolean;
ai_processed?: boolean;
ai_action?: string;
} | null;
} }
export interface ReactionSummary { export interface ReactionSummary {
@ -42,5 +49,6 @@ export interface MessageBoxCallbacks {
onReply?: (messageId: string) => void; onReply?: (messageId: string) => void;
onConvertToKanban?: (messageId: string) => void; onConvertToKanban?: (messageId: string) => void;
onConvertToCalendar?: (messageId: string) => void; onConvertToCalendar?: (messageId: string) => void;
onMagic?: (messageId: string) => void;
currentUserId?: string; currentUserId?: string;
} }