Ka-Note/ka-note/client/src/lib/components/JournalView.svelte

309 lines
9.6 KiB
Svelte

<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/db/schema';
import { getOrCreateJournalTopic, createHistoryEntry, updateHistoryEntry, softDeleteHistoryEntry, createTopic, JOURNAL_TOPIC_ID } from '$lib/db/repositories';
import { today } from '$lib/db/helpers';
import { mention } from '$lib/actions/mention';
import MarkdownEditor from './MarkdownEditor.svelte';
import RenderedMarkdown from './RenderedMarkdown.svelte';
import DateNavigator from './DateNavigator.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import WiedervorlageSection from './WiedervorlageSection.svelte';
interface Props {
contextId: string;
}
let { contextId }: Props = $props();
const isDailyLog = $derived(contextId === 'daily-log');
// --- Daily-log journal mode ---
let entryTitle = $state('');
let entryText = $state('');
let entryEditor: MarkdownEditor;
let selectedDate = $state(today());
let selectedLinkedContextId = $state('');
let wiedervorlageChecked = $state(false);
// All meeting contexts for the link dropdown
const meetingContexts = liveQuery(() =>
db.contexts
.filter(c => !c.deletedAt && c.type === 'meeting' && c.id !== 'daily-log')
.sortBy('sortOrder')
);
// All journal entries (daily-log mode)
const journalEntries = liveQuery(async () => {
if (!isDailyLog) return [];
const topic = await db.topics.get(JOURNAL_TOPIC_ID);
if (!topic) return [];
return db.historyEntries
.where('topicId').equals(JOURNAL_TOPIC_ID)
.filter(h => !h.deletedAt)
.toArray();
});
// Filter journal entries by selected date
const filteredEntries = $derived(
($journalEntries ?? [])
.filter(e => e.date === selectedDate)
.sort((a, b) => b.sortOrder - a.sortOrder)
);
// Context name lookup for linked entries
const contextNameMap = $derived(() => {
const map = new Map<string, string>();
for (const ctx of $meetingContexts ?? []) {
map.set(ctx.id, ctx.name);
}
return map;
});
async function handleAddEntry() {
const title = entryTitle.trim();
const body = entryText.trim();
if (!title) return;
if (selectedLinkedContextId) {
const topic = await createTopic(selectedLinkedContextId, title);
if (body) {
await createHistoryEntry(topic.id, selectedDate, body);
}
} else {
const text = body ? `${title}\n${body}` : title;
await getOrCreateJournalTopic();
await createHistoryEntry(JOURNAL_TOPIC_ID, selectedDate, text, null, wiedervorlageChecked);
}
entryTitle = '';
entryText = '';
entryEditor?.clear();
selectedLinkedContextId = '';
wiedervorlageChecked = false;
}
function handleTitleKeypress(e: KeyboardEvent) {
if (e.key === 'Enter') handleAddEntry();
}
let confirmDeleteId = $state<string | null>(null);
// --- Edit mode ---
let editingId = $state<string | null>(null);
let editTitle = $state('');
let editBody = $state('');
function startEdit(entry: { id: string; text: string }) {
const lines = entry.text.split('\n');
editingId = entry.id;
editTitle = lines[0];
editBody = lines.slice(1).join('\n').trim();
}
function cancelEdit() {
editingId = null;
}
async function saveEdit() {
if (!editingId || !editTitle.trim()) return;
const text = editBody.trim() ? `${editTitle.trim()}\n${editBody.trim()}` : editTitle.trim();
await updateHistoryEntry(editingId, text);
editingId = null;
}
async function handleDelete(id: string) {
confirmDeleteId = id;
}
async function confirmDelete() {
if (confirmDeleteId) await softDeleteHistoryEntry(confirmDeleteId);
confirmDeleteId = null;
}
function formatTime(isoString: string): string {
try {
return new Date(isoString).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch {
return '';
}
}
// --- Meeting journal mode ---
const meetingEntries = liveQuery(async () => {
if (isDailyLog) return [];
const topics = await db.topics
.where('contextId').equals(contextId)
.filter(t => !t.deletedAt)
.toArray();
const topicMap = new Map(topics.map(t => [t.id, t.title]));
const history = await db.historyEntries
.where('topicId').anyOf(topics.map(t => t.id))
.filter(h => !h.deletedAt)
.toArray();
// Also fetch journal entries linked to this context
const linked = await db.historyEntries
.where('linkedContextId').equals(contextId)
.filter(h => !h.deletedAt)
.toArray();
const all = [
...history.map(h => ({ ...h, topicTitle: topicMap.get(h.topicId) ?? '', isLinked: false })),
...linked.map(h => ({ ...h, topicTitle: 'Journal', isLinked: true }))
];
return all.sort((a, b) => b.date.localeCompare(a.date));
});
const meetingByDate = $derived(() => {
const groups = new Map<string, typeof $meetingEntries>();
for (const entry of $meetingEntries ?? []) {
const existing = groups.get(entry.date) ?? [];
existing.push(entry);
groups.set(entry.date, existing);
}
return [...groups.entries()].sort(([a], [b]) => b.localeCompare(a));
});
</script>
{#if isDailyLog}
<!-- Daily-log: chronological entry log with date navigation -->
<DateNavigator {selectedDate} onchange={(d) => selectedDate = d} />
<div class="mb-8 flex flex-col gap-2.5 rounded-lg border border-border bg-sidebar p-4">
<div class="flex items-center gap-2">
<input
type="text"
class="flex-1 rounded border border-[#444] bg-bg px-2.5 py-2 font-mono text-white"
placeholder="Titel / Was ist passiert?"
bind:value={entryTitle}
onkeypress={handleTitleKeypress}
use:mention
/>
<select
class="rounded border border-[#444] bg-bg px-2 py-2 text-sm text-white"
bind:value={selectedLinkedContextId}
>
<option value="">— kein Jour-fix —</option>
{#each $meetingContexts ?? [] as ctx}
<option value={ctx.id}>{ctx.name}</option>
{/each}
</select>
</div>
<MarkdownEditor
bind:this={entryEditor}
placeholder="Details (optional)"
minHeight="60px"
onchange={(md) => entryText = md}
/>
<div class="flex items-center gap-4">
<button
class="rounded bg-accent px-4 py-2 font-bold text-white hover:brightness-110"
onclick={handleAddEntry}
>
+ {selectedLinkedContextId ? 'Thema hinzufügen' : 'Notiz hinzufügen'}
</button>
{#if !selectedLinkedContextId}
<label class="flex cursor-pointer items-center gap-1.5 text-sm text-muted">
<input type="checkbox" bind:checked={wiedervorlageChecked} class="accent-amber-500" />
Wiedervorlage
</label>
{/if}
</div>
</div>
<WiedervorlageSection date={selectedDate} />
{#if filteredEntries.length > 0}
<div class="mb-8 border-l-2 border-[#555] pl-5">
<div class="mb-4 text-xl font-bold text-accent">{selectedDate}</div>
{#each filteredEntries as entry (entry.id)}
{#if editingId === entry.id}
<div class="mb-3 flex flex-col gap-2 rounded border border-accent bg-card-bg p-2.5">
<input
type="text"
class="rounded border border-[#444] bg-bg px-2.5 py-1.5 font-mono text-white"
bind:value={editTitle}
use:mention
/>
<MarkdownEditor
content={editBody}
placeholder="Details"
minHeight="60px"
onchange={(md) => editBody = md}
/>
<div class="flex gap-2">
<button
class="rounded bg-accent px-3 py-1 text-sm font-bold text-white hover:brightness-110"
onclick={saveEdit}
>Speichern</button>
<button
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
onclick={cancelEdit}
>Abbrechen</button>
</div>
</div>
{:else}
{@const lines = entry.text.split('\n')}
{@const title = lines[0]}
{@const body = lines.slice(1).join('\n').trim()}
<div class="group mb-3 flex items-start gap-2 rounded bg-card-bg p-2.5">
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{formatTime(entry.updatedAt)}</span>
<div class="flex-1">
<div class="font-bold">{title}
{#if entry.linkedContextId}
<span class="ml-1 inline-block rounded bg-accent/20 px-1.5 py-0.5 text-xs font-normal text-accent">
{contextNameMap().get(entry.linkedContextId) ?? entry.linkedContextId}
</span>
{/if}
</div>
{#if body}
<RenderedMarkdown text={body} class="mt-1 text-sm text-[#ccc]" />
{/if}
</div>
<button
class="mt-0.5 text-xs text-[#666] opacity-0 transition-opacity group-hover:opacity-100 hover:text-[#ccc]"
onclick={() => startEdit(entry)}
title="Bearbeiten"
>&#9998;</button>
<button
class="mt-0.5 text-xs text-[#666] opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-400"
onclick={() => handleDelete(entry.id)}
title="Löschen"
>&times;</button>
</div>
{/if}
{/each}
</div>
{:else}
<div class="text-center text-muted">Keine Einträge für {selectedDate}.</div>
{/if}
{:else}
<!-- Meeting contexts: topic history + linked journal entries -->
{#each meetingByDate() as [date, items]}
<div class="mb-8 border-l-2 border-[#555] pl-5">
<div class="mb-4 text-xl font-bold text-accent">{date}</div>
{#each items as entry}
<div class="mb-4 rounded bg-card-bg p-2.5">
<span class="mb-1 block text-xs uppercase tracking-wider text-muted">
{entry.topicTitle}
{#if entry.isLinked}
<span class="ml-1 rounded bg-accent/20 px-1 py-0.5 normal-case text-accent">Journal</span>
{/if}
</span>
<RenderedMarkdown text={entry.text} />
</div>
{/each}
</div>
{:else}
<div class="text-center text-muted">Keine Einträge.</div>
{/each}
{/if}
{#if confirmDeleteId}
<ConfirmDialog
message="Eintrag wirklich löschen?"
onconfirm={confirmDelete}
oncancel={() => confirmDeleteId = null}
/>
{/if}