journal/Journal.App/src/lib/components/SidePanel.svelte
2026-02-27 06:29:05 -06:00

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>