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

340 lines
9.1 KiB
Svelte

<script lang="ts">
import type { Topic, HistoryEntry } from "@ka-note/shared";
import { get } from "svelte/store";
import { liveQuery } from "dexie";
import { db } from "$lib/db/schema";
import {
createHistoryEntry,
updateHistoryEntry,
updateTopic,
archiveTopic,
softDeleteTopic,
} from "$lib/db/repositories";
import { today } from "$lib/db/helpers";
import { processedTopicIds, collapsedTopicIds } from "$lib/stores/agenda";
import { scopeSettings } from "$lib/stores/scopeContext";
import { normalizeTitleAndBody } from "$lib/utils/titleUtils";
import HistoryItem from "./HistoryItem.svelte";
import LinkTitle from "./LinkTitle.svelte";
import MeetingControls from "./MeetingControls.svelte";
import MarkdownEditor from "./MarkdownEditor.svelte";
import ConfirmDialog from "./ConfirmDialog.svelte";
interface Props {
topic: Topic;
contextId: string;
isDailyLog: boolean;
isMeetingMode: boolean;
isProcessed: boolean;
}
let { topic, contextId, isDailyLog, isMeetingMode, isProcessed }: Props =
$props();
const history = liveQuery(() =>
db.historyEntries
.where("topicId")
.equals(topic.id)
.filter((h) => !h.deletedAt)
.toArray()
.then((entries) => entries.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))),
);
let noteText = $state("");
let noteEditor: MarkdownEditor;
let collapsed = $state(false);
let confirmDeleteTopic = $state(false);
let editingTitle = $state(false);
let titleInput = $state("");
let showSnoozeDialog = $state(false);
let snoozeDate = $state("");
let snoozeDateText = $state("");
// Sync collapsed state with global store
$effect(() => {
const unsub = collapsedTopicIds.subscribe((s) => {
collapsed = s.has(topic.id);
});
return unsub;
});
function toggleCollapse() {
collapsedTopicIds.update((s) => {
const next = new Set(s);
if (next.has(topic.id)) next.delete(topic.id);
else next.add(topic.id);
return next;
});
}
function saveNote(): boolean {
const text = noteText.trim();
if (!text) return false;
createHistoryEntry(topic.id, today(), text);
noteText = "";
noteEditor?.clear();
return true;
}
function handleNote() {
saveNote();
updateTopic(topic.id, { isNew: false });
processedTopicIds.add(topic.id);
}
function handleSkip() {
updateTopic(topic.id, { isNew: false });
processedTopicIds.add(topic.id);
}
function handleSnooze() {
snoozeDate = "";
snoozeDateText = "";
showSnoozeDialog = true;
}
function commitSnooze() {
const date = snoozeDate || snoozeDateText.trim();
if (!date) return;
saveNote();
updateTopic(topic.id, { snoozeUntil: date, status: "snoozed", isNew: false });
// Do NOT add to processedTopicIds — snoozed topics are filtered out of openTopics
// by the status check; adding here would wrongly place them in "Bereits abgelegt heute"
showSnoozeDialog = false;
}
function handleDone() {
saveNote();
updateTopic(topic.id, { isNew: false });
archiveTopic(topic.id);
}
function handleSaveOnly() {
if (saveNote()) {
// Saved
}
}
function handleEditHistory(id: string, text: string, date: string) {
updateHistoryEntry(id, text, undefined, date);
}
function startTitleEdit() {
titleInput = topic.title;
editingTitle = true;
}
async function saveTitleEdit() {
const raw = titleInput.trim();
if (raw) {
const { maxTitleLength } = get(scopeSettings);
const { title, body } = normalizeTitleAndBody(raw, '', maxTitleLength);
await updateTopic(topic.id, { title });
if (body) {
await createHistoryEntry(topic.id, today(), body);
}
}
editingTitle = false;
}
function handleTitleKeydown(e: KeyboardEvent) {
if (e.key === "Enter") saveTitleEdit();
if (e.key === "Escape") editingTitle = false;
}
// Border color based on status
const borderColor = $derived(
isProcessed
? "border-l-muted"
: topic.isNew
? "border-l-success"
: "border-l-accent",
);
const cardBg = $derived(isProcessed ? "bg-[#222]" : "bg-card-bg");
const cardOpacity = $derived(isProcessed ? "opacity-60" : "");
const createdDate = $derived(
$history?.find((h) => h.text === 'Thema angelegt.')?.date ?? null,
);
// Determine which controls to show
const showDailyControls = $derived(isDailyLog);
const showMeetingControls = $derived(
!isDailyLog && isMeetingMode && !isProcessed,
);
const showPrepControls = $derived(
!isDailyLog && (!isMeetingMode || isProcessed),
);
</script>
<div
class="rounded-2xl mb-5 shadow-lg border-l-[4px] {borderColor} {cardBg} {cardOpacity} transition-all"
>
<!-- Header -->
<div
class="flex items-center justify-between px-5 py-4 cursor-pointer select-none hover:bg-white/5"
onclick={toggleCollapse}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && toggleCollapse()}
>
<div class="flex items-center flex-1 text-lg font-bold">
<span
class="mr-2.5 text-xs text-[#aaa] transition-transform inline-block {collapsed
? '-rotate-90'
: ''}"></span
>
{#if editingTitle}
<!-- svelte-ignore a11y_autofocus -->
<input
class="bg-[#111] text-white border border-accent rounded px-2 py-1 text-lg font-bold flex-1"
bind:value={titleInput}
onkeydown={handleTitleKeydown}
onblur={saveTitleEdit}
onclick={(e) => e.stopPropagation()}
autofocus
/>
{:else}
<LinkTitle text={topic.title} />
<button
class="opacity-40 group-hover:opacity-100 ml-2.5 bg-transparent border-none text-[#aaa] cursor-pointer text-xs hover:text-white"
onclick={(e) => {
e.stopPropagation();
startTitleEdit();
}}>✎</button
>
{/if}
{#if createdDate}
<span class="ml-2 text-[0.7em] text-[#555]">{createdDate}</span>
{/if}
{#if topic.isNew}
<span
class="ml-2.5 rounded bg-success px-1.5 py-0.5 text-[0.7em] font-bold uppercase text-white"
>NEU</span
>
{/if}
{#if isProcessed}
<button
class="ml-2.5 rounded bg-[#444] px-1.5 py-0.5 text-[0.7em] text-[#aaa] hover:bg-[#555] hover:text-white"
title="Als aktiv markieren"
onclick={(e) => { e.stopPropagation(); processedTopicIds.remove(topic.id); }}
>BESPROCHEN</button
>
{/if}
</div>
<button
class="ml-2 text-[#666] hover:text-red-400 text-lg leading-none"
onclick={(e) => {
e.stopPropagation();
confirmDeleteTopic = true;
}}
title="Thema löschen">&times;</button
>
</div>
<!-- Body -->
{#if !collapsed}
<div class="px-5 pb-5 border-t border-[#444]">
<!-- Note input -->
<div class="mt-2.5 flex flex-col gap-1">
<MarkdownEditor
bind:this={noteEditor}
placeholder="- Notiz... (z.B. '-> NAME' or '@P:PROJECT')"
wikiScope={null}
onchange={(md) => (noteText = md)}
onsave={handleSaveOnly}
onsavearchive={handleDone}
/>
{#if showDailyControls}
<MeetingControls
{contextId}
{isDailyLog}
{isMeetingMode}
{isProcessed}
onnote={handleNote}
onsnooze={handleSnooze}
ondone={handleDone}
/>
{:else if showMeetingControls}
<MeetingControls
{contextId}
{isDailyLog}
{isMeetingMode}
{isProcessed}
onnote={handleNote}
onskip={handleSkip}
onsnooze={handleSnooze}
ondone={handleDone}
/>
{:else if showPrepControls}
<MeetingControls
{contextId}
{isDailyLog}
{isMeetingMode}
{isProcessed}
onnote={handleSaveOnly}
onsnooze={handleSnooze}
ondone={handleDone}
/>
{/if}
</div>
{#if showSnoozeDialog}
<div class="mt-2 rounded border border-[#555] bg-[#2a2a2a] p-3">
<div class="mb-2 text-sm font-semibold text-[#ccc]">Verschieben auf</div>
<div class="flex flex-wrap items-center gap-2">
<input
type="date"
class="rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white"
bind:value={snoozeDate}
oninput={() => (snoozeDateText = "")}
/>
<span class="text-xs text-[#888]">oder</span>
<input
type="text"
placeholder="YYYY-MM-DD"
class="w-32 rounded border border-[#444] bg-bg px-2 py-1 text-sm text-white placeholder-[#666]"
bind:value={snoozeDateText}
oninput={() => (snoozeDate = "")}
onkeydown={(e) => e.key === 'Enter' && commitSnooze()}
/>
<button
class="rounded bg-warning px-3 py-1 text-sm font-bold text-[#222] hover:brightness-110"
onclick={commitSnooze}
>Verschieben</button>
<button
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
onclick={() => (showSnoozeDialog = false)}
>Abbrechen</button>
</div>
</div>
{/if}
<!-- History -->
{#if $history?.some((h) => h.text !== 'Thema angelegt.')}
<div
class="mt-4 max-h-[300px] overflow-y-auto rounded border border-[#333] bg-[#222] p-2.5"
>
{#each $history as entry (entry.id)}
<HistoryItem {entry} onedit={handleEditHistory} />
{/each}
</div>
{/if}
</div>
{/if}
</div>
{#if confirmDeleteTopic}
<ConfirmDialog
message="Thema wirklich löschen?"
onconfirm={() => {
softDeleteTopic(topic.id);
confirmDeleteTopic = false;
}}
oncancel={() => (confirmDeleteTopic = false)}
/>
{/if}