694 lines
20 KiB
Svelte
694 lines
20 KiB
Svelte
<script lang="ts">
|
|
import { listEntryTemplates, type EntryTemplateItemDto } from "$lib/backend/templates";
|
|
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
|
|
import { entriesBusyStore, entriesStore } from "$lib/stores/entries";
|
|
import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
|
|
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
|
|
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
|
|
|
export let activeSection = "entries";
|
|
export let activeDocumentId = "";
|
|
export let templateRefreshToken = 0;
|
|
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
|
export let onEditItem: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
|
export let onDeleteItem: (doc: { id: string; label: string }) => void = () => {};
|
|
|
|
let showNewItemInput = false;
|
|
let newItemName = "";
|
|
let newItemInput: HTMLInputElement | null = null;
|
|
let createTemplateMode = false;
|
|
|
|
type SidePanelItem = {
|
|
id: string;
|
|
label: string;
|
|
initialContent: string;
|
|
};
|
|
let todoDocuments: SidePanelItem[] = [];
|
|
let customCalendarEntries: SidePanelItem[] = [];
|
|
let templateItems: SidePanelItem[] = [];
|
|
let templatesBusy = false;
|
|
let templateError = "";
|
|
let wasEntriesSection = false;
|
|
let lastTemplateRefreshToken = -1;
|
|
|
|
const sectionTitles: Record<string, string> = {
|
|
entries: "Entries",
|
|
calendar: "Calendar",
|
|
fragments: "Fragments",
|
|
todos: "To-Do List",
|
|
lists: "Lists"
|
|
};
|
|
|
|
const today = new Date();
|
|
let calendarYear = today.getFullYear();
|
|
let calendarMonth = today.getMonth();
|
|
let calendarMonthLabel = new Date(calendarYear, calendarMonth, 1).toLocaleString(undefined, {
|
|
month: "long"
|
|
});
|
|
let selectedCalendarDate: { year: number; month: number; day: number; key: string } | null = {
|
|
year: today.getFullYear(),
|
|
month: today.getMonth(),
|
|
day: today.getDate(),
|
|
key: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
|
|
};
|
|
|
|
function handleVisibleMonthChange(payload: { year: number; month: number; label: string }) {
|
|
calendarYear = payload.year;
|
|
calendarMonth = payload.month;
|
|
calendarMonthLabel = payload.label;
|
|
}
|
|
|
|
function toMonthKey(year: number, month: number): string {
|
|
return `${year}-${String(month + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
function toDateKey(year: number, month: number, day: number): string {
|
|
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
}
|
|
|
|
function handleSelectedDateChange(payload: { year: number; month: number; day: number; key: string }) {
|
|
selectedCalendarDate = payload;
|
|
}
|
|
|
|
function toTemplateStoreId(filePath: string): string {
|
|
return `entries/template-file/${encodeURIComponent(filePath)}`;
|
|
}
|
|
|
|
function toTemplateLabel(fileName: string): string {
|
|
return fileName.replace(/\.template\.md$/i, "");
|
|
}
|
|
|
|
function mapTemplateDto(item: EntryTemplateItemDto): SidePanelItem {
|
|
return {
|
|
id: toTemplateStoreId(item.filePath),
|
|
label: toTemplateLabel(item.fileName),
|
|
initialContent: ""
|
|
};
|
|
}
|
|
|
|
async function refreshTemplates() {
|
|
if (activeSection !== "entries") return;
|
|
templatesBusy = true;
|
|
templateError = "";
|
|
try {
|
|
const templates = await listEntryTemplates();
|
|
templateItems = templates.map(mapTemplateDto);
|
|
} catch (error) {
|
|
templateError = String(error);
|
|
templateItems = [];
|
|
} finally {
|
|
templatesBusy = false;
|
|
}
|
|
}
|
|
|
|
function daysInMonth(year: number, month: number): number {
|
|
return new Date(year, month + 1, 0).getDate();
|
|
}
|
|
|
|
function getCalendarEntries(year: number, month: number): SidePanelItem[] {
|
|
const monthKey = toMonthKey(year, month);
|
|
const shortLabel = new Date(year, month, 1).toLocaleString(undefined, {
|
|
month: "short",
|
|
year: "numeric"
|
|
});
|
|
const totalDays = daysInMonth(year, month);
|
|
const anchors = [3, 10, 17, 24].map((day) => Math.min(day, totalDays));
|
|
|
|
const entries: Array<SidePanelItem & { day: number; dateKey: string }> = [
|
|
{
|
|
id: `calendar/${monthKey}/overview`,
|
|
label: `${shortLabel} Overview`,
|
|
initialContent: `# ${shortLabel} Overview\n\nMonthly highlights and notes.`,
|
|
day: anchors[0],
|
|
dateKey: toDateKey(year, month, anchors[0])
|
|
},
|
|
{
|
|
id: `calendar/${monthKey}/goals`,
|
|
label: `${shortLabel} Goals`,
|
|
initialContent: `# ${shortLabel} Goals\n\n- Goal 1\n- Goal 2\n- Goal 3`,
|
|
day: anchors[1],
|
|
dateKey: toDateKey(year, month, anchors[1])
|
|
},
|
|
{
|
|
id: `calendar/${monthKey}/review`,
|
|
label: `${shortLabel} Review`,
|
|
initialContent: `# ${shortLabel} Review\n\nWhat went well and what to improve next month.`,
|
|
day: anchors[2],
|
|
dateKey: toDateKey(year, month, anchors[2])
|
|
},
|
|
{
|
|
id: `calendar/${monthKey}/notes`,
|
|
label: `${shortLabel} Notes`,
|
|
initialContent: `# ${shortLabel} Notes\n\nFreeform notes for this month.`,
|
|
day: anchors[3],
|
|
dateKey: toDateKey(year, month, anchors[3])
|
|
}
|
|
];
|
|
|
|
const selected = selectedCalendarDate;
|
|
const isSelectedInVisibleMonth = selected && selected.year === year && selected.month === month;
|
|
|
|
if (!isSelectedInVisibleMonth || !selected) {
|
|
return entries.sort((a, b) => a.day - b.day);
|
|
}
|
|
|
|
const selectedDay = selected.day;
|
|
return entries
|
|
.sort((a, b) => {
|
|
const distA = Math.abs(a.day - selectedDay);
|
|
const distB = Math.abs(b.day - selectedDay);
|
|
if (distA !== distB) return distA - distB;
|
|
return a.day - b.day;
|
|
})
|
|
.map(({ day, dateKey, ...item }) => item);
|
|
}
|
|
|
|
function handleAddItem() {
|
|
if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") {
|
|
if (activeSection === "entries") {
|
|
createTemplateMode = false;
|
|
}
|
|
showNewItemInput = true;
|
|
newItemName = "";
|
|
queueMicrotask(() => newItemInput?.focus());
|
|
return;
|
|
}
|
|
|
|
if (activeSection === "fragments") {
|
|
onOpenDocument(createFragmentDraft());
|
|
return;
|
|
}
|
|
|
|
if (activeSection === "calendar") {
|
|
const selected = selectedCalendarDate ?? {
|
|
year: calendarYear,
|
|
month: calendarMonth,
|
|
day: 1,
|
|
key: toDateKey(calendarYear, calendarMonth, 1)
|
|
};
|
|
const monthLabel = new Date(selected.year, selected.month, selected.day).toLocaleString(undefined, {
|
|
month: "short"
|
|
});
|
|
const label = `${monthLabel} ${selected.day} Note`;
|
|
const item: SidePanelItem = {
|
|
id: `calendar/${selected.key}/note-${Date.now()}`,
|
|
label,
|
|
initialContent: `# ${label}\n\nDate: ${selected.key}\n\nAdd your note...`
|
|
};
|
|
customCalendarEntries = [item, ...customCalendarEntries];
|
|
onOpenDocument(item);
|
|
}
|
|
}
|
|
|
|
function handleAddTemplate() {
|
|
if (activeSection !== "entries") return;
|
|
createTemplateMode = true;
|
|
showNewItemInput = true;
|
|
newItemName = "";
|
|
queueMicrotask(() => newItemInput?.focus());
|
|
}
|
|
|
|
async function confirmNewItem() {
|
|
const label = newItemName.trim();
|
|
if (!label) {
|
|
cancelNewItem();
|
|
return;
|
|
}
|
|
showNewItemInput = false;
|
|
newItemName = "";
|
|
|
|
if (activeSection === "entries") {
|
|
const isTemplate = createTemplateMode;
|
|
const displayLabel = isTemplate
|
|
? label.endsWith("_template")
|
|
? label
|
|
: `${label}_template`
|
|
: label;
|
|
const id = isTemplate ? `entries/template-draft-${Date.now()}` : `entries/draft-${Date.now()}`;
|
|
const item = { id, label: displayLabel, initialContent: `# ${displayLabel}\n\n` };
|
|
entriesStore.update((items) => [item, ...items]);
|
|
onEditItem(item);
|
|
createTemplateMode = false;
|
|
} else if (activeSection === "todos") {
|
|
try {
|
|
const { meta, items: todoItems } = await createTodoListFromLabel(label);
|
|
onOpenDocument({
|
|
id: meta.id,
|
|
label: meta.label,
|
|
initialContent: serializeTodoList(meta.label, todoItems)
|
|
});
|
|
} catch (error) {
|
|
const draft = createTodoListDraft();
|
|
draft.meta.label = label;
|
|
todoListsStore.update((lists) => [draft.meta, ...lists]);
|
|
todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items }));
|
|
onOpenDocument({
|
|
id: draft.meta.id,
|
|
label: draft.meta.label,
|
|
initialContent: serializeTodoList(draft.meta.label, draft.items)
|
|
});
|
|
}
|
|
} else if (activeSection === "lists") {
|
|
try {
|
|
const item = await createListFromLabel(label);
|
|
onOpenDocument(item);
|
|
} catch (error) {
|
|
const item = createListDraft();
|
|
item.label = label;
|
|
item.initialContent = `# ${label}\n\n`;
|
|
listsStore.update((items) => [item, ...items]);
|
|
onOpenDocument(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
function cancelNewItem() {
|
|
showNewItemInput = false;
|
|
newItemName = "";
|
|
createTemplateMode = false;
|
|
}
|
|
|
|
function handleNewItemKeydown(event: KeyboardEvent) {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
confirmNewItem();
|
|
} else if (event.key === "Escape") {
|
|
cancelNewItem();
|
|
}
|
|
}
|
|
|
|
$: panelTitle = sectionTitles[activeSection] ?? "Entries";
|
|
$: todoDocuments = $todoListsStore.map(({ id, label }) => ({
|
|
id,
|
|
label,
|
|
initialContent: serializeTodoList(label, $todosStore[id] ?? [])
|
|
}));
|
|
$: items = activeSection === "entries"
|
|
? $entriesStore
|
|
: activeSection === "todos"
|
|
? todoDocuments
|
|
: activeSection === "fragments"
|
|
? $fragmentsStore
|
|
: activeSection === "lists"
|
|
? $listsStore
|
|
: [];
|
|
$: isCalendarSection = activeSection === "calendar";
|
|
$: calendarEntries = [...customCalendarEntries, ...getCalendarEntries(calendarYear, calendarMonth)];
|
|
$: showItemActions = activeSection === "entries" || activeSection === "todos" || activeSection === "lists" || activeSection === "fragments";
|
|
$: entryItems = activeSection === "entries"
|
|
? $entriesStore.filter((item) => !item.id.startsWith("entries/template-draft-"))
|
|
: [];
|
|
$: templateDraftItems = activeSection === "entries"
|
|
? $entriesStore.filter((item) => item.id.startsWith("entries/template-draft-"))
|
|
: [];
|
|
$: allTemplateItems = [...templateDraftItems, ...templateItems];
|
|
|
|
$: if (activeSection === "entries" && (!wasEntriesSection || templateRefreshToken !== lastTemplateRefreshToken)) {
|
|
wasEntriesSection = true;
|
|
lastTemplateRefreshToken = templateRefreshToken;
|
|
void refreshTemplates();
|
|
}
|
|
|
|
$: if (activeSection !== "entries") {
|
|
wasEntriesSection = false;
|
|
}
|
|
</script>
|
|
|
|
<section class="side-panel" aria-label="Section panel">
|
|
<header class="panel-header">
|
|
<h2>{panelTitle}</h2>
|
|
<div class="panel-header-actions">
|
|
{#if activeSection === "entries"}
|
|
<button type="button" class="panel-action" aria-label="Add template" title="Add template" on:click={handleAddTemplate}>
|
|
<span class="material-symbols-outlined">palette</span>
|
|
</button>
|
|
{/if}
|
|
<button type="button" class="panel-action" aria-label="Add item" title="Add item" on:click={handleAddItem}>
|
|
<span class="material-symbols-outlined">add</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{#if isCalendarSection}
|
|
<CalendarWidget
|
|
onVisibleMonthChange={handleVisibleMonthChange}
|
|
onSelectedDateChange={handleSelectedDateChange}
|
|
/>
|
|
|
|
<div class="calendar-entries">
|
|
<h3>{calendarMonthLabel} {calendarYear} Entries</h3>
|
|
<ul class="panel-list">
|
|
{#each calendarEntries as item}
|
|
<li class:is-active={item.id === activeDocumentId}>
|
|
<button
|
|
type="button"
|
|
class="item-label"
|
|
on:click={() => onOpenDocument(item)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{:else}
|
|
<div class="panel-search">
|
|
<span class="material-symbols-outlined">search</span>
|
|
<input type="text" placeholder={`Search ${panelTitle.toLowerCase()}...`} />
|
|
</div>
|
|
|
|
{#if showNewItemInput}
|
|
<div class="new-item-input">
|
|
<input
|
|
type="text"
|
|
bind:this={newItemInput}
|
|
bind:value={newItemName}
|
|
placeholder={activeSection === "entries"
|
|
? createTemplateMode
|
|
? "Template name..."
|
|
: "Entry name..."
|
|
: activeSection === "todos"
|
|
? "Todo list name..."
|
|
: "List name..."}
|
|
on:keydown={handleNewItemKeydown}
|
|
on:blur={confirmNewItem}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if activeSection === "entries"}
|
|
<div class="panel-subsection">
|
|
<h3>Entries</h3>
|
|
{#if $entriesBusyStore}
|
|
<p class="section-copy">Loading entries...</p>
|
|
{:else}
|
|
<ul class="panel-list">
|
|
{#each entryItems as item}
|
|
<li class:is-active={item.id === activeDocumentId}>
|
|
<button
|
|
type="button"
|
|
class="item-label"
|
|
on:click={() => onOpenDocument(item)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
<div class="item-actions">
|
|
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
|
|
<span class="material-symbols-outlined">edit</span>
|
|
</button>
|
|
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
|
|
<span class="material-symbols-outlined">delete</span>
|
|
</button>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{#if !entryItems.length}
|
|
<p class="section-copy">No entries found.</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="panel-subsection">
|
|
<h3>Templates</h3>
|
|
{#if templatesBusy}
|
|
<p class="section-copy">Loading templates...</p>
|
|
{:else}
|
|
<ul class="panel-list">
|
|
{#each allTemplateItems as item}
|
|
<li class:is-active={item.id === activeDocumentId}>
|
|
<button
|
|
type="button"
|
|
class="item-label"
|
|
on:click={() => onOpenDocument(item)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
<div class="item-actions">
|
|
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
|
|
<span class="material-symbols-outlined">edit</span>
|
|
</button>
|
|
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
|
|
<span class="material-symbols-outlined">delete</span>
|
|
</button>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{#if !allTemplateItems.length}
|
|
<p class="section-copy">No templates found.</p>
|
|
{/if}
|
|
{/if}
|
|
{#if templateError}
|
|
<p class="template-error">{templateError}</p>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<ul class="panel-list">
|
|
{#each items as item}
|
|
<li class:is-active={item.id === activeDocumentId}>
|
|
<button
|
|
type="button"
|
|
class="item-label"
|
|
on:click={() => onOpenDocument(item)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
{#if showItemActions}
|
|
<div class="item-actions">
|
|
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
|
|
<span class="material-symbols-outlined">edit</span>
|
|
</button>
|
|
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
|
|
<span class="material-symbols-outlined">delete</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.side-panel {
|
|
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-panel) 100%);
|
|
border-right: 1px solid var(--border-soft);
|
|
padding: 16px 14px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.panel-header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.panel-header h2 {
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.01em;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.panel-action {
|
|
width: 28px;
|
|
height: 28px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 6px;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
}
|
|
|
|
.panel-action:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--border-soft);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.panel-action .material-symbols-outlined {
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
.panel-search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: var(--surface-1);
|
|
border: 1px solid var(--border-soft);
|
|
border-radius: 8px;
|
|
padding: 7px 9px;
|
|
}
|
|
|
|
.panel-search .material-symbols-outlined {
|
|
color: var(--text-dim);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.panel-search input {
|
|
width: 100%;
|
|
font-size: 0.84rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.panel-search input::placeholder {
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.panel-list {
|
|
list-style: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
|
|
li {
|
|
display: flex;
|
|
align-items: center;
|
|
border-radius: 7px;
|
|
border: 1px solid transparent;
|
|
|
|
&:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--border-soft);
|
|
}
|
|
|
|
&.is-active {
|
|
background: var(--bg-active);
|
|
border-color: var(--border-strong);
|
|
}
|
|
}
|
|
|
|
.item-label {
|
|
flex: 1;
|
|
min-width: 0;
|
|
text-align: left;
|
|
padding: 7px 9px;
|
|
font-size: 0.84rem;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
li:hover .item-label,
|
|
li.is-active .item-label {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.item-actions {
|
|
display: none;
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding-right: 4px;
|
|
}
|
|
|
|
li:hover .item-actions,
|
|
li.is-active .item-actions {
|
|
display: flex;
|
|
}
|
|
|
|
.item-action {
|
|
width: 24px;
|
|
height: 24px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 4px;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
|
|
&:hover {
|
|
background: var(--surface-2);
|
|
color: var(--text-primary);
|
|
border-color: var(--border-soft);
|
|
}
|
|
|
|
&.item-action-danger:hover {
|
|
color: #e06c75;
|
|
}
|
|
|
|
.material-symbols-outlined {
|
|
font-size: 0.85rem;
|
|
}
|
|
}
|
|
}
|
|
|
|
.calendar-entries {
|
|
margin-top: 2px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.calendar-entries h3 {
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
.panel-subsection {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
min-height: 0;
|
|
}
|
|
|
|
.panel-subsection h3 {
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
.section-copy {
|
|
color: var(--text-dim);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.template-error {
|
|
color: #e74c3c;
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.new-item-input {
|
|
padding: 0 2px;
|
|
|
|
input {
|
|
width: 100%;
|
|
font-size: 0.84rem;
|
|
color: var(--text-primary);
|
|
background: var(--surface-1);
|
|
border: 1px solid var(--border-strong);
|
|
border-radius: 7px;
|
|
padding: 7px 9px;
|
|
outline: none;
|
|
|
|
&:focus {
|
|
border-color: var(--accent, #6b8afd);
|
|
}
|
|
|
|
&::placeholder {
|
|
color: var(--text-dim);
|
|
}
|
|
}
|
|
}
|
|
</style>
|