620 lines
22 KiB
Svelte
620 lines
22 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';
|
|
import LinkTitle from './LinkTitle.svelte';
|
|
import { get } from 'svelte/store';
|
|
import { currentScope, scopeSettings } from '$lib/stores/scopeContext';
|
|
import { normalizeTitleAndBody, extractTitleAndBody } from '$lib/utils/titleUtils';
|
|
import { useUnsavedGuard } from '$lib/utils/unsavedGuard.svelte';
|
|
import EventCard from './EventCard.svelte';
|
|
import { eventsForDate } from '$lib/stores/agenda';
|
|
import { createEvent, updateEventNotes } from '$lib/db/repositories';
|
|
import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi';
|
|
import type { PersonMeta } from '@ka-note/shared';
|
|
|
|
interface Props {
|
|
contextId: string;
|
|
journalScope?: 'business' | 'private';
|
|
initialDate?: string;
|
|
}
|
|
let { contextId, journalScope = 'business', initialDate }: Props = $props();
|
|
|
|
$effect(() => { currentScope.set(journalScope); });
|
|
|
|
const isDailyLog = $derived(contextId === 'daily-log');
|
|
|
|
// --- Daily-log journal mode ---
|
|
let entryText = $state('');
|
|
let entryEditor: MarkdownEditor;
|
|
let selectedDate = $state(initialDate ?? today());
|
|
let selectedLinkedContextId = $state('');
|
|
let wiedervorlageChecked = $state(true);
|
|
let meetingPickerOpen = $state(false);
|
|
|
|
useUnsavedGuard(() => entryText.trim() !== '' || editingId !== null);
|
|
|
|
// 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 and scope
|
|
const filteredEntries = $derived(
|
|
($journalEntries ?? [])
|
|
.filter(e => e.date === selectedDate && (journalScope === 'private' ? !!e.isPrivate : !e.isPrivate))
|
|
.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 resolveUrlTitle(url: string): Promise<string | null> {
|
|
try {
|
|
const res = await fetch(`/api/fetch-title?url=${encodeURIComponent(url)}`);
|
|
const data = await res.json() as { title?: string };
|
|
return data.title || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
async function handleAddEntry() {
|
|
const raw = entryText.trim();
|
|
if (!raw) return;
|
|
const { maxTitleLength } = get(scopeSettings);
|
|
const extracted = extractTitleAndBody(raw, maxTitleLength);
|
|
let title = extracted.title;
|
|
let body = extracted.body;
|
|
|
|
// If title is a URL, try to fetch its page title
|
|
if (/^https?:\/\//i.test(title)) {
|
|
const fetched = await resolveUrlTitle(title);
|
|
if (fetched) {
|
|
body = body ? `[${title}](${title})\n${body}` : `[${title}](${title})`;
|
|
title = fetched;
|
|
}
|
|
}
|
|
|
|
const isPrivate = journalScope === 'private';
|
|
if (selectedLinkedContextId) {
|
|
const topic = await createTopic(selectedLinkedContextId, title, isPrivate);
|
|
if (body) {
|
|
await createHistoryEntry(topic.id, selectedDate, body, null, false, isPrivate);
|
|
}
|
|
} else {
|
|
const text = body ? `${title}\n\n${body}` : title;
|
|
await getOrCreateJournalTopic();
|
|
await createHistoryEntry(JOURNAL_TOPIC_ID, selectedDate, text, null, wiedervorlageChecked, isPrivate);
|
|
}
|
|
|
|
entryText = '';
|
|
entryEditor?.clear();
|
|
selectedLinkedContextId = '';
|
|
meetingPickerOpen = false;
|
|
wiedervorlageChecked = true;
|
|
}
|
|
|
|
let confirmDeleteId = $state<string | null>(null);
|
|
|
|
// --- Edit mode ---
|
|
let editingId = $state<string | null>(null);
|
|
let editTitle = $state('');
|
|
let editBody = $state('');
|
|
let editIsPrivate = $state(false);
|
|
let editDate = $state('');
|
|
|
|
function startEdit(entry: { id: string; text: string; date: string; isPrivate?: boolean }) {
|
|
const lines = entry.text.split('\n');
|
|
editingId = entry.id;
|
|
editTitle = lines[0].replace(/^#{1,6}\s+/, '');
|
|
editBody = lines.slice(1).join('\n').trim();
|
|
editIsPrivate = !!entry.isPrivate;
|
|
editDate = entry.date;
|
|
}
|
|
|
|
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, editIsPrivate, editDate || undefined);
|
|
editingId = null;
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
confirmDeleteId = id;
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (confirmDeleteId) await softDeleteHistoryEntry(confirmDeleteId);
|
|
confirmDeleteId = null;
|
|
}
|
|
|
|
function formatTime(entry: { sortOrder: number; updatedAt: string }): string {
|
|
try {
|
|
// sortOrder >= 1e12 means it was set as Date.now() (ms timestamp) → use as creation time
|
|
const ts = entry.sortOrder >= 1_000_000_000_000
|
|
? new Date(entry.sortOrder)
|
|
: new Date(entry.updatedAt);
|
|
return ts.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));
|
|
});
|
|
|
|
// --- Events (one-off meetings) ---
|
|
let showNewEventForm = $state(false);
|
|
let newEventTitle = $state('');
|
|
let newEventTime = $state('');
|
|
let newEventParticipants = $state('');
|
|
let pendingBodyPreview = $state('');
|
|
|
|
// Calendar picker
|
|
let calendarPickerOpen = $state(false);
|
|
let calendarLoading = $state(false);
|
|
let calendarError = $state<string | null>(null);
|
|
let calendarEvents = $state<CalendarEvent[]>([]);
|
|
|
|
async function openCalendarPicker() {
|
|
if (!showNewEventForm) openNewEventForm();
|
|
calendarLoading = true;
|
|
calendarError = null;
|
|
calendarPickerOpen = true;
|
|
calendarEvents = [];
|
|
try {
|
|
calendarEvents = await fetchCalendarEvents(selectedDate);
|
|
} catch {
|
|
calendarError = 'Kalender nicht verfügbar';
|
|
} finally {
|
|
calendarLoading = false;
|
|
}
|
|
}
|
|
|
|
async function selectCalendarEvent(ev: CalendarEvent) {
|
|
newEventTitle = ev.subject;
|
|
newEventTime = ev.start;
|
|
pendingBodyPreview = ev.bodyPreview;
|
|
|
|
const persons = await db.contexts
|
|
.filter(c => !c.deletedAt && c.type === 'person')
|
|
.toArray();
|
|
const mentions = ev.attendees.map(att => {
|
|
const match = persons.find(
|
|
p => (p.meta as PersonMeta | null)?.email?.toLowerCase() === att.email.toLowerCase()
|
|
);
|
|
const name = match ? match.name : att.name;
|
|
return name.includes(' ') ? `@"${name}"` : `@${name}`;
|
|
});
|
|
newEventParticipants = mentions.join(' ');
|
|
calendarPickerOpen = false;
|
|
}
|
|
|
|
const dateEvents = $derived(eventsForDate(selectedDate));
|
|
|
|
function openNewEventForm() {
|
|
const now = new Date();
|
|
newEventTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
newEventTitle = '';
|
|
newEventParticipants = '';
|
|
showNewEventForm = true;
|
|
}
|
|
|
|
function parseEventParticipants(raw: string): string[] {
|
|
const results: string[] = [];
|
|
const re = /@"([^"]+)"|@([\w\-\u00C0-\u024F]+)/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(raw)) !== null) {
|
|
results.push((m[1] ?? m[2]).trim());
|
|
}
|
|
return results.filter(Boolean);
|
|
}
|
|
|
|
async function handleCreateEvent() {
|
|
const title = newEventTitle.trim();
|
|
if (!title) return;
|
|
const participants = parseEventParticipants(newEventParticipants);
|
|
const event = await createEvent(selectedDate, newEventTime || '00:00', title, participants);
|
|
if (pendingBodyPreview.trim()) {
|
|
await updateEventNotes(event.id, pendingBodyPreview.trim());
|
|
}
|
|
newEventTitle = '';
|
|
newEventTime = '';
|
|
newEventParticipants = '';
|
|
pendingBodyPreview = '';
|
|
showNewEventForm = false;
|
|
}
|
|
|
|
// Birthday banner — filtered by scope
|
|
const allPersons = liveQuery(() =>
|
|
db.contexts.filter(c => !c.deletedAt && c.type === 'person').toArray()
|
|
);
|
|
const BUSINESS_SUBTYPES = new Set(['employee', 'colleague']);
|
|
const PRIVATE_SUBTYPES = new Set(['family', 'acquaintance']);
|
|
const birthdayPersons = $derived(
|
|
($allPersons ?? []).filter(p => {
|
|
const meta = p.meta as { birthday?: string; personSubType?: string } | null;
|
|
const bd = meta?.birthday;
|
|
if (!bd || bd.slice(5) !== selectedDate.slice(5)) return false;
|
|
const sub = meta?.personSubType;
|
|
if (journalScope === 'private') return !!sub && PRIVATE_SUBTYPES.has(sub);
|
|
// business: employee/colleague/contact/undefined
|
|
return !sub || !PRIVATE_SUBTYPES.has(sub);
|
|
})
|
|
);
|
|
</script>
|
|
|
|
{#if isDailyLog}
|
|
<!-- Daily-log: chronological entry log with date navigation -->
|
|
<DateNavigator {selectedDate} onchange={(d) => selectedDate = d} />
|
|
|
|
{#if birthdayPersons.length > 0}
|
|
<div class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-[#d4a017] bg-[#2a1f00] px-4 py-3">
|
|
<span class="text-lg">🎂</span>
|
|
<span class="text-sm font-bold text-[#d4a017]">Geburtstag heute:</span>
|
|
{#each birthdayPersons as p}
|
|
<span class="rounded-full bg-[#d4a017]/20 px-2 py-0.5 text-sm text-[#f0c040]">{p.name}</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="mb-8 flex flex-col gap-2.5 rounded-lg border border-border bg-sidebar p-4" class:hidden={editingId !== null}>
|
|
<MarkdownEditor
|
|
bind:this={entryEditor}
|
|
placeholder="Was ist passiert? (1. Zeile = Titel)"
|
|
minHeight="60px"
|
|
wikiScope={journalScope === 'private'}
|
|
onchange={(md) => entryText = md}
|
|
onsave={handleAddEntry}
|
|
/>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
class="rounded px-4 py-2 font-bold text-white hover:brightness-110"
|
|
style="background-color: {journalScope === 'private' ? $scopeSettings.privateColor : $scopeSettings.businessColor}"
|
|
onclick={handleAddEntry}
|
|
>
|
|
+ {selectedLinkedContextId ? 'Thema hinzufügen' : 'Notiz hinzufügen'}
|
|
</button>
|
|
{#if !selectedLinkedContextId}
|
|
<button
|
|
type="button"
|
|
class="rounded border p-1.5 text-base leading-none transition-colors {wiedervorlageChecked ? 'border-amber-500 bg-amber-500/20 text-amber-400' : 'border-[#444] text-muted hover:border-[#666] hover:text-white'}"
|
|
onclick={() => wiedervorlageChecked = !wiedervorlageChecked}
|
|
title="Wiedervorlage"
|
|
>{wiedervorlageChecked ? '⏰' : '○'}</button>
|
|
{/if}
|
|
<!-- Meeting picker button -->
|
|
<div class="relative {journalScope === 'private' ? 'hidden' : ''}">
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-1 rounded border px-2 py-1.5 text-sm transition-colors {selectedLinkedContextId ? 'border-accent bg-accent/20 text-accent' : 'border-[#444] bg-bg text-muted hover:text-white'}"
|
|
onclick={() => meetingPickerOpen = !meetingPickerOpen}
|
|
title="Jour-fix verknüpfen"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
|
<line x1="16" y1="2" x2="16" y2="6"/>
|
|
<line x1="8" y1="2" x2="8" y2="6"/>
|
|
<line x1="3" y1="10" x2="21" y2="10"/>
|
|
</svg>
|
|
</button>
|
|
{#if meetingPickerOpen}
|
|
<div class="absolute left-0 top-full z-50 mt-1 min-w-48 rounded border border-[#444] bg-[#1e1e1e] py-1 shadow-lg">
|
|
<button
|
|
class="w-full px-3 py-1.5 text-left text-sm text-muted hover:bg-[#2a2a2a] {!selectedLinkedContextId ? 'text-white' : ''}"
|
|
onclick={() => { selectedLinkedContextId = ''; meetingPickerOpen = false; }}
|
|
>— kein Jour-fix —</button>
|
|
{#each $meetingContexts ?? [] as ctx}
|
|
<button
|
|
class="w-full px-3 py-1.5 text-left text-sm hover:bg-[#2a2a2a] {selectedLinkedContextId === ctx.id ? 'text-accent' : 'text-white'}"
|
|
onclick={() => { selectedLinkedContextId = ctx.id; meetingPickerOpen = false; }}
|
|
>{ctx.name}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<WiedervorlageSection date={selectedDate} {journalScope} />
|
|
|
|
<!-- Events (one-off meetings) for selected date -->
|
|
{#if journalScope === 'business'}
|
|
<div class="mb-6">
|
|
<div class="mb-2 flex items-center justify-between">
|
|
<span class="text-sm font-semibold text-muted">Meetings</span>
|
|
<button
|
|
class="rounded px-2 py-1 text-sm text-muted hover:bg-[#2a2a2a] hover:text-white"
|
|
onclick={openNewEventForm}
|
|
>+ Meeting</button>
|
|
</div>
|
|
{#if showNewEventForm}
|
|
<div class="mb-3 flex flex-col gap-2 rounded border border-[#333] bg-card-bg p-3">
|
|
<input
|
|
class="w-full rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white placeholder-[#666]"
|
|
type="text"
|
|
bind:value={newEventTitle}
|
|
placeholder="Titel *"
|
|
/>
|
|
<div class="flex gap-2">
|
|
<input
|
|
class="w-28 rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white"
|
|
type="time"
|
|
bind:value={newEventTime}
|
|
/>
|
|
<div class="relative flex-1">
|
|
<input
|
|
class="w-full rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white placeholder-[#666]"
|
|
type="text"
|
|
bind:value={newEventParticipants}
|
|
placeholder="Teilnehmer (@Name, ...)"
|
|
use:mention
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="rounded bg-accent px-3 py-1 text-sm font-bold text-white hover:brightness-110 disabled:opacity-50"
|
|
onclick={handleCreateEvent}
|
|
disabled={!newEventTitle.trim()}
|
|
>Erstellen</button>
|
|
<button
|
|
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
|
|
onclick={() => (showNewEventForm = false)}
|
|
>Abbrechen</button>
|
|
<button
|
|
type="button"
|
|
class="ml-auto rounded border border-[#444] px-3 py-1 text-sm text-muted hover:border-[#666] hover:text-white"
|
|
onclick={openCalendarPicker}
|
|
>Aus Kalender</button>
|
|
</div>
|
|
</div>
|
|
{#if calendarPickerOpen}
|
|
<div class="mb-3 rounded border border-[#444] bg-[#1e1e1e] shadow-lg">
|
|
{#if calendarLoading}
|
|
<div class="px-3 py-2 text-sm text-muted">Lade...</div>
|
|
{:else if calendarError}
|
|
<div class="px-3 py-2 text-sm text-red-400">{calendarError}</div>
|
|
{:else if calendarEvents.length === 0}
|
|
<div class="px-3 py-2 text-sm text-muted">Keine Termine für diesen Tag</div>
|
|
{:else}
|
|
{#each calendarEvents as ev (ev.id)}
|
|
<button
|
|
class="w-full px-3 py-2 text-left text-sm hover:bg-[#2a2a2a]"
|
|
onclick={() => selectCalendarEvent(ev)}
|
|
>
|
|
<span class="font-mono text-[#aaa]">{ev.start}</span>
|
|
<span class="ml-2 text-white">{ev.subject}</span>
|
|
{#if ev.attendees.length > 0}
|
|
<span class="ml-2 text-xs text-muted">{ev.attendees.map(a => a.name).join(', ')}</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
<button
|
|
class="w-full border-t border-[#333] px-3 py-1.5 text-left text-xs text-muted hover:bg-[#2a2a2a]"
|
|
onclick={() => (calendarPickerOpen = false)}
|
|
>Schließen</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{#each $dateEvents ?? [] as event (event.id)}
|
|
<div class="mb-2">
|
|
<EventCard {event} />
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#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}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="mb-3 flex flex-col gap-2 rounded border border-accent bg-card-bg p-2.5" onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }}>
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<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>
|
|
<input
|
|
type="date"
|
|
class="rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white"
|
|
bind:value={editDate}
|
|
title="Datum des Eintrags"
|
|
/>
|
|
<div class="ml-auto flex gap-1 rounded-full bg-[#333] p-0.5">
|
|
<button
|
|
class="rounded-full px-2.5 py-1 text-xs font-bold transition-all {!editIsPrivate ? 'bg-accent text-white shadow' : 'text-[#aaa] hover:text-white'}"
|
|
onclick={() => editIsPrivate = false}
|
|
>Firma</button>
|
|
<button
|
|
class="rounded-full px-2.5 py-1 text-xs font-bold transition-all {editIsPrivate ? 'text-white shadow' : 'text-[#aaa] hover:text-white'}"
|
|
style={editIsPrivate ? `background-color: ${$scopeSettings.privateColor}` : ''}
|
|
onclick={() => editIsPrivate = true}
|
|
>Privat</button>
|
|
</div>
|
|
</div>
|
|
<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
|
|
onkeydown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveEdit(); } }}
|
|
/>
|
|
<MarkdownEditor
|
|
content={editBody}
|
|
placeholder="Details"
|
|
minHeight="60px"
|
|
wikiScope={journalScope === 'private'}
|
|
onchange={(md) => editBody = md}
|
|
onsave={saveEdit}
|
|
/>
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<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>
|
|
<input
|
|
type="date"
|
|
class="rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white"
|
|
bind:value={editDate}
|
|
title="Datum des Eintrags"
|
|
/>
|
|
<div class="ml-auto flex gap-1 rounded-full bg-[#333] p-0.5">
|
|
<button
|
|
class="rounded-full px-2.5 py-1 text-xs font-bold transition-all {!editIsPrivate ? 'bg-accent text-white shadow' : 'text-[#aaa] hover:text-white'}"
|
|
onclick={() => editIsPrivate = false}
|
|
>Firma</button>
|
|
<button
|
|
class="rounded-full px-2.5 py-1 text-xs font-bold transition-all {editIsPrivate ? 'text-white shadow' : 'text-[#aaa] hover:text-white'}"
|
|
style={editIsPrivate ? `background-color: ${$scopeSettings.privateColor}` : ''}
|
|
onclick={() => editIsPrivate = true}
|
|
>Privat</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
{@const lines = entry.text.split('\n')}
|
|
{@const title = lines[0].replace(/^#{1,6}\s+/, '')}
|
|
{@const body = lines.slice(1).join('\n').trim()}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="group mb-3 flex items-start gap-2 rounded bg-card-bg p-2.5" ondblclick={() => startEdit(entry)}>
|
|
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{formatTime(entry)}</span>
|
|
<div class="flex-1">
|
|
<div class="flex flex-wrap items-center gap-1.5 font-bold">
|
|
<LinkTitle text={title} />
|
|
{#if entry.wiedervorlageDate && !entry.wiedervorlageResolvedAt}
|
|
<span class="text-base leading-none text-amber-400" title="In Wiedervorlage bis {entry.wiedervorlageDate}">⏰</span>
|
|
{/if}
|
|
{#if entry.linkedContextId}
|
|
<span class="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}
|