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:
parent
d5882c8c45
commit
aeda5e7527
3 changed files with 137 additions and 60 deletions
|
|
@ -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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>←</button>
|
||||||
|
<button class="messagebox__revision-nav" onclick={nextRevision}>→</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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue