309 lines
9.6 KiB
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"
|
|
>✎</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"
|
|
>×</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}
|