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([
|
||||
`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(() => {
|
||||
|
|
@ -153,7 +153,7 @@ export function createSpacetimeChat(
|
|||
conn.db.chat_message.onUpdate((_ctx: EventContext, _oldRow, newRow) => {
|
||||
if (destroyed || newRow.channelId !== channelId) return;
|
||||
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 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);
|
||||
// --- Revisjonshistorikk (generell — gjelder alle redigerte meldinger) ---
|
||||
|
||||
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;
|
||||
interface Revision {
|
||||
id: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 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);
|
||||
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 {
|
||||
const res = await fetch(`/api/messages/${message.id}/revisions`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
revisions = data.revisions ?? [];
|
||||
}
|
||||
} finally {
|
||||
loadingRevisions = false;
|
||||
revisionsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRevisions(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (revisionIndex >= 0) {
|
||||
// Tilbake til nåværende
|
||||
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;
|
||||
// Ellers: behandle som markdown
|
||||
return marked.parse(body, { async: false }) as string;
|
||||
}
|
||||
|
||||
let displayBody = $derived(
|
||||
showingOriginal && originalBody !== null
|
||||
? originalBody
|
||||
: renderBody(message.body, isAiProcessed)
|
||||
let activeBody = $derived(
|
||||
showingRevision ? revisions[revisionIndex]?.body ?? message.body : message.body
|
||||
);
|
||||
let displayBody = $derived(renderBody(activeBody));
|
||||
|
||||
let isEdited = $derived(
|
||||
!!(message.edited_at || message.metadata?.ai_processed)
|
||||
);
|
||||
</script>
|
||||
|
||||
|
|
@ -274,18 +312,29 @@
|
|||
{#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
|
||||
{#if isEdited && !editing}
|
||||
<div class="messagebox__revision-footer">
|
||||
<button class="messagebox__revision-toggle" onclick={toggleRevisions} disabled={loadingRevisions}>
|
||||
{#if loadingRevisions}
|
||||
…
|
||||
{:else if showingRevision}
|
||||
versjon {currentVersionNumber}/{totalVersions}
|
||||
{:else}
|
||||
Vis original
|
||||
redigert
|
||||
{/if}
|
||||
</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>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -564,23 +613,17 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* === AI footer (badge + revision toggle) === */
|
||||
.messagebox__ai-footer {
|
||||
/* === Revisjonshistorikk === */
|
||||
.messagebox__revision-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.3rem;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.messagebox__ai-badge {
|
||||
.messagebox__revision-toggle {
|
||||
font-size: 0.65rem;
|
||||
color: #facc15;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.messagebox__revision-btn {
|
||||
font-size: 0.65rem;
|
||||
color: #7dd3fc;
|
||||
color: #8b92a5;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
|
@ -588,16 +631,42 @@
|
|||
font-family: inherit;
|
||||
}
|
||||
|
||||
.messagebox__revision-btn:hover {
|
||||
color: #bae6fd;
|
||||
text-decoration: underline;
|
||||
.messagebox__revision-toggle:hover {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.messagebox__revision-btn:disabled {
|
||||
color: #8b92a5;
|
||||
.messagebox__revision-toggle:disabled {
|
||||
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 */
|
||||
.messagebox__body :global(p) {
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ export interface MessageData {
|
|||
all_day: boolean;
|
||||
color: string | null;
|
||||
} | null;
|
||||
edited_at?: string | null;
|
||||
revision_count?: number;
|
||||
metadata?: {
|
||||
ai_processing?: boolean;
|
||||
ai_processed?: boolean;
|
||||
ai_action?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ReactionSummary {
|
||||
|
|
@ -42,5 +49,6 @@ export interface MessageBoxCallbacks {
|
|||
onReply?: (messageId: string) => void;
|
||||
onConvertToKanban?: (messageId: string) => void;
|
||||
onConvertToCalendar?: (messageId: string) => void;
|
||||
onMagic?: (messageId: string) => void;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue