journal/Journal.App/src/lib/components/SidePanel.svelte

1601 lines
42 KiB
Svelte

<!-- @format -->
<script lang="ts">
import {
listEntryTemplates,
type EntryTemplateItemDto,
} from "$lib/backend/templates";
import { listFragments, type FragmentDto } from "$lib/backend/fragments";
import { listLists, type ListDocumentDto } from "$lib/backend/lists";
import { listTodoLists, type TodoListDto } from "$lib/backend/todos";
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
import {
entriesBusyStore,
entriesStore,
searchEntriesAsItems,
} from "$lib/stores/entries";
import {
createFragmentDraft,
fragmentsStore,
serializeFragment,
} from "$lib/stores/fragments";
import {
createListDraft,
createListFromLabel,
listsStore,
} from "$lib/stores/lists";
import {
createTodoListDraft,
createTodoListFromLabel,
serializeTodoList,
todoListsStore,
todosStore,
} from "$lib/stores/todos";
import { vaultUnlocked } from "$lib/stores/session";
import { extractEntryTags } from "$lib/utils/metadata";
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 = () => {};
export let onCalendarStateChange: (state: {
items: SidePanelItem[];
busy: boolean;
error: string;
}) => void = () => {};
let showNewItemInput = false;
let newItemName = "";
let newItemInput: HTMLInputElement | null = null;
let createTemplateMode = false;
type SidePanelItem = {
id: string;
label: string;
initialContent: string;
sortDate?: string;
};
type CalendarViewMode = "day" | "week" | "month";
type CalendarSortMode = "asc" | "desc";
type SavedCalendarView = {
id: string;
name: string;
viewMode: CalendarViewMode;
sortMode: CalendarSortMode;
query: string;
tags: string;
types: string;
startDate: string;
endDate: string;
};
let todoDocuments: 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 calendarViewMode: CalendarViewMode = "month";
let calendarSortMode: CalendarSortMode = "desc";
let calendarQuery = "";
let calendarTags = "";
let calendarTypes = "";
let calendarStartDate = "";
let calendarEndDate = "";
let calendarTimelineItems: SidePanelItem[] = [];
let calendarBusy = false;
let calendarError = "";
let calendarSavedViews: SavedCalendarView[] = [];
let showSaveViewInput = false;
let saveViewName = "";
let hasLoadedSavedViews = false;
let calendarTimelineDebounce: ReturnType<typeof setTimeout> | null = null;
let calendarDataVersion = 0;
let calendarScheduleKey = "";
let calendarRefreshRequestId = 0;
let lastActiveSection = "";
let calendarLastRefreshedAt = "";
let calendarDateExplicitlySelected = false;
const CALENDAR_SAVED_VIEWS_KEY = "journal.calendar.savedViews";
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 toDateKey(year: number, month: number, day: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function parseCsv(input: string): string[] {
return input
.split(",")
.map((token) => token.trim())
.filter(Boolean);
}
function parseDateLabel(value: string): Date | null {
const token = value
.trim()
.split(/\s*[|·]\s*/)[0]
.trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(token)) return null;
const date = new Date(`${token}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
}
function parseIsoDate(value: string): Date | null {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function toIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function isWithinRange(
date: Date,
startDate: string,
endDate: string,
): boolean {
const start = parseDateLabel(startDate);
const end = parseDateLabel(endDate);
if (!start || !end) return true;
const day = toIsoDate(date);
return day >= toIsoDate(start) && day <= toIsoDate(end);
}
function matchesTextQuery(content: string, query: string): boolean {
const trimmed = query.trim();
if (!trimmed) return true;
return content.toLowerCase().includes(trimmed.toLowerCase());
}
function getActiveDateRange(): { startDate: string; endDate: string } {
if (calendarStartDate && calendarEndDate) {
return { startDate: calendarStartDate, endDate: calendarEndDate };
}
const selected = selectedCalendarDate ?? {
year: calendarYear,
month: calendarMonth,
day: 1,
};
const selectedDate = new Date(selected.year, selected.month, selected.day);
if (calendarViewMode === "day") {
const date = toIsoDate(selectedDate);
return { startDate: date, endDate: date };
}
if (calendarViewMode === "week") {
const weekDay = (selectedDate.getDay() + 6) % 7;
const start = new Date(selectedDate);
start.setDate(selectedDate.getDate() - weekDay);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return { startDate: toIsoDate(start), endDate: toIsoDate(end) };
}
const monthStart = new Date(calendarYear, calendarMonth, 1);
const monthEnd = new Date(calendarYear, calendarMonth + 1, 0);
return { startDate: toIsoDate(monthStart), endDate: toIsoDate(monthEnd) };
}
function toFragmentTimelineItem(fragment: FragmentDto): SidePanelItem {
const split = fragment.description.split(/\n{2,}/);
const title = (split[0] ?? "").trim() || "Untitled Fragment";
const body =
split.slice(1).join("\n\n").trim() || "Add details for this fragment.";
const date = parseIsoDate(fragment.time) ?? new Date();
const dateKey = toIsoDate(date);
return {
id: `fragments/${fragment.id}`,
label: `${dateKey} | Fragment | ${title}`,
initialContent: serializeFragment({
title,
type: fragment.type,
tags: fragment.tags ?? [],
body,
}),
sortDate: date.toISOString(),
};
}
function toListTimelineItem(list: ListDocumentDto): SidePanelItem {
const created =
parseIsoDate(list.createdAt) ??
parseIsoDate(list.updatedAt) ??
new Date();
const dateKey = toIsoDate(created);
return {
id: `lists/${list.id}`,
label: `${dateKey} | List | ${list.label}`,
initialContent: list.content || `# ${list.label}\n\n`,
sortDate: created.toISOString(),
};
}
function toTodoTimelineItem(list: TodoListDto): SidePanelItem {
const created = parseIsoDate(list.createdAt) ?? new Date();
const dateKey = toIsoDate(created);
const items = list.items.map((item, index) => ({
id: Date.now() + index,
text: item.text,
done: item.done,
}));
return {
id: `todos/${list.id}`,
label: `${dateKey} | To-Do | ${list.label}`,
initialContent: serializeTodoList(list.label, items),
sortDate: created.toISOString(),
};
}
function splitFilterTokens(input: string): string[] {
return parseCsv(input).map((token) => token.toLowerCase());
}
function matchesAnyToken(text: string, tokens: string[]): boolean {
if (tokens.length === 0) return true;
const lower = text.toLowerCase();
return tokens.some((token) => lower.includes(token));
}
function matchesTags(
itemTags: string[] | undefined,
tagTokens: string[],
): boolean {
if (tagTokens.length === 0) return true;
const normalized = (itemTags ?? [])
.map((tag) => tag.toLowerCase().trim())
.filter(Boolean);
return tagTokens.some((token) => normalized.includes(token));
}
function scheduleCalendarRefresh(delayMs = 200) {
if (activeSection !== "calendar") return;
const scheduleKey = [
selectedCalendarDate?.key ?? "",
calendarYear,
calendarMonth,
calendarViewMode,
calendarSortMode,
calendarQuery,
calendarTags,
calendarTypes,
calendarStartDate,
calendarEndDate,
calendarDataVersion,
].join("|");
if (scheduleKey === calendarScheduleKey && calendarTimelineDebounce) {
return;
}
calendarScheduleKey = scheduleKey;
if (calendarTimelineDebounce) {
clearTimeout(calendarTimelineDebounce);
}
calendarTimelineDebounce = setTimeout(() => {
void refreshCalendarTimeline();
}, delayMs);
}
async function refreshCalendarTimeline(): Promise<void> {
const requestId = ++calendarRefreshRequestId;
calendarBusy = true;
calendarError = "";
try {
if (requestId !== calendarRefreshRequestId) return;
if (!$vaultUnlocked) {
calendarTimelineItems = [];
return;
}
const { startDate, endDate } = getActiveDateRange();
const entryItems = await searchEntriesAsItems({
query: calendarQuery.trim() || undefined,
});
if (requestId !== calendarRefreshRequestId) return;
const [fragmentDtos, listDtos, todoDtos] = await Promise.all([
listFragments(),
listLists(),
listTodoLists(),
]);
if (requestId !== calendarRefreshRequestId) return;
const query = calendarQuery.trim();
const tagTokens = splitFilterTokens(calendarTags);
const typeTokens = splitFilterTokens(calendarTypes);
const hasTypeFilter = typeTokens.length > 0;
const fragmentItems = fragmentDtos
.filter((fragment) => {
const date = parseIsoDate(fragment.time);
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (!matchesTextQuery(fragment.description, query)) return false;
if (!matchesTags(fragment.tags, tagTokens)) return false;
if (
hasTypeFilter &&
!matchesAnyToken(fragment.type ?? "", typeTokens)
)
return false;
return true;
})
.map(toFragmentTimelineItem);
const listItems = listDtos.map(toListTimelineItem).filter((item) => {
if (hasTypeFilter) return false;
if (tagTokens.length > 0) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (
!matchesTextQuery(item.initialContent, query) &&
!matchesTextQuery(item.label, query)
)
return false;
return true;
});
const todoItems = todoDtos.map(toTodoTimelineItem).filter((item) => {
if (hasTypeFilter) return false;
if (tagTokens.length > 0) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (
!matchesTextQuery(item.initialContent, query) &&
!matchesTextQuery(item.label, query)
)
return false;
return true;
});
const entriesWithKind: SidePanelItem[] = entryItems
.map((item) => {
const date = parseDateLabel(item.label);
const dateKey = date ? toIsoDate(date) : item.label;
return {
id: item.id,
label: `${dateKey} | Entry | ${item.label}`,
initialContent: item.initialContent,
sortDate: date ? date.toISOString() : undefined,
};
})
.filter((item) => {
if (hasTypeFilter) return false;
if (!matchesTags(extractEntryTags(item.initialContent), tagTokens))
return false;
if (
!matchesTextQuery(item.initialContent, query) &&
!matchesTextQuery(item.label, query)
)
return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (date && !isWithinRange(date, startDate, endDate)) return false;
return true;
});
const merged = [
...entriesWithKind,
...fragmentItems,
...listItems,
...todoItems,
];
const sorted = merged.sort((a, b) => {
const aDate = a.sortDate
? (parseIsoDate(a.sortDate)?.getTime() ?? 0)
: (parseDateLabel(a.label)?.getTime() ?? 0);
const bDate = b.sortDate
? (parseIsoDate(b.sortDate)?.getTime() ?? 0)
: (parseDateLabel(b.label)?.getTime() ?? 0);
return calendarSortMode === "asc" ? aDate - bDate : bDate - aDate;
});
calendarTimelineItems = sorted;
calendarLastRefreshedAt = new Date().toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch (error) {
if (requestId !== calendarRefreshRequestId) return;
calendarError = String(error);
calendarTimelineItems = [];
} finally {
if (requestId === calendarRefreshRequestId) {
calendarBusy = false;
}
}
}
async function forceRefreshCalendar(options?: {
allowWhileBusy?: boolean;
}): Promise<void> {
if (activeSection !== "calendar") return;
if (calendarBusy && !options?.allowWhileBusy) return;
if (calendarTimelineDebounce) {
clearTimeout(calendarTimelineDebounce);
calendarTimelineDebounce = null;
}
await refreshCalendarTimeline();
}
function loadSavedViews() {
if (typeof window === "undefined") return;
const raw = window.localStorage.getItem(CALENDAR_SAVED_VIEWS_KEY);
if (!raw) {
calendarSavedViews = [];
return;
}
try {
const parsed = JSON.parse(raw) as SavedCalendarView[];
calendarSavedViews = Array.isArray(parsed) ? parsed : [];
} catch {
calendarSavedViews = [];
}
}
function persistSavedViews() {
if (typeof window === "undefined") return;
window.localStorage.setItem(
CALENDAR_SAVED_VIEWS_KEY,
JSON.stringify(calendarSavedViews),
);
}
function applySavedView(view: SavedCalendarView) {
calendarViewMode = view.viewMode;
calendarSortMode = view.sortMode;
calendarQuery = view.query;
calendarTags = view.tags;
calendarTypes = view.types;
calendarStartDate = view.startDate;
calendarEndDate = view.endDate;
}
function saveCurrentView() {
const name = saveViewName.trim();
if (!name) {
showSaveViewInput = false;
saveViewName = "";
return;
}
const view: SavedCalendarView = {
id: `view-${Date.now()}`,
name,
viewMode: calendarViewMode,
sortMode: calendarSortMode,
query: calendarQuery,
tags: calendarTags,
types: calendarTypes,
startDate: calendarStartDate,
endDate: calendarEndDate,
};
calendarSavedViews = [view, ...calendarSavedViews];
persistSavedViews();
showSaveViewInput = false;
saveViewName = "";
}
function deleteSavedView(id: string) {
calendarSavedViews = calendarSavedViews.filter((view) => view.id !== id);
persistSavedViews();
}
const builtInViews: SavedCalendarView[] = [
{
id: "builtin-week",
name: "This Week",
viewMode: "week",
sortMode: "desc",
query: "",
tags: "",
types: "",
startDate: "",
endDate: "",
},
{
id: "builtin-trigger",
name: "Trigger Review",
viewMode: "month",
sortMode: "desc",
query: "",
tags: "stress, trigger",
types: "!TRIGGER",
startDate: "",
endDate: "",
},
];
function openOrCreateDailyEntry(dateKey: string) {
const existing = $entriesStore.find(
(item) =>
item.label === dateKey &&
!item.id.startsWith("entries/template-draft-"),
);
if (existing) {
onOpenDocument(existing);
return;
}
const draft: SidePanelItem = {
id: `entries/draft-${Date.now()}`,
label: dateKey,
initialContent: `# ${dateKey}\n\n`,
};
entriesStore.update((items) => [draft, ...items]);
onEditItem(draft);
}
function handleSelectedDateChange(payload: {
year: number;
month: number;
day: number;
key: string;
}) {
selectedCalendarDate = payload;
}
function handleDateActivate(payload: {
year: number;
month: number;
day: number;
key: string;
}) {
selectedCalendarDate = payload;
if (activeSection !== "calendar") return;
calendarDateExplicitlySelected = true;
calendarStartDate = payload.key;
calendarEndDate = payload.key;
}
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;
if (!$vaultUnlocked) {
templatesBusy = false;
templateError = "";
templateItems = [];
return;
}
templatesBusy = true;
templateError = "";
try {
const templates = await listEntryTemplates();
templateItems = templates.map(mapTemplateDto);
} catch (error) {
templateError = String(error);
templateItems = [];
} finally {
templatesBusy = false;
}
}
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") {
if (calendarDateExplicitlySelected) {
const selected = selectedCalendarDate ?? {
year: calendarYear,
month: calendarMonth,
day: 1,
key: toDateKey(calendarYear, calendarMonth, 1),
};
openOrCreateDailyEntry(selected.key);
} else {
showNewItemInput = true;
newItemName = "";
queueMicrotask(() => newItemInput?.focus());
}
}
}
function handleRefreshClick() {
void forceRefreshCalendar();
}
function handleAddTemplate() {
if (activeSection !== "entries") return;
createTemplateMode = true;
showNewItemInput = true;
newItemName = "";
queueMicrotask(() => newItemInput?.focus());
}
function toDailyNoteLabel(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function handleAddDailyNote() {
if (activeSection !== "entries") return;
createTemplateMode = false;
showNewItemInput = false;
newItemName = "";
const label = toDailyNoteLabel(new Date());
const existing = $entriesStore.find(
(item) =>
item.label === label && !item.id.startsWith("entries/template-draft-"),
);
if (existing) {
onEditItem(existing);
return;
}
const id = `entries/draft-${Date.now()}`;
const item = { id, label, initialContent: `# ${label}\n\n` };
entriesStore.update((items) => [item, ...items]);
onEditItem(item);
}
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 === "calendar") {
const id = `entries/draft-${Date.now()}`;
const item = { id, label, initialContent: `# ${label}\n\n` };
entriesStore.update((items) => [item, ...items]);
onEditItem(item);
} 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";
$: 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;
}
$: if (activeSection === "calendar" && !hasLoadedSavedViews) {
hasLoadedSavedViews = true;
loadSavedViews();
}
$: if (activeSection === "calendar") {
// Track store mutations without building large string signatures.
const storeInvalidationTick = [
$entriesStore,
$fragmentsStore,
$listsStore,
$todoListsStore,
$todosStore,
];
void storeInvalidationTick;
calendarDataVersion += 1;
}
$: if (activeSection === "calendar") {
const calendarTrigger = [
selectedCalendarDate?.key ?? "",
calendarYear,
calendarMonth,
calendarViewMode,
calendarSortMode,
calendarQuery,
calendarTags,
calendarTypes,
calendarStartDate,
calendarEndDate,
calendarDataVersion,
];
void calendarTrigger;
scheduleCalendarRefresh(200);
}
$: if (activeSection === "calendar" && lastActiveSection !== "calendar") {
calendarDateExplicitlySelected = false;
calendarScheduleKey = "";
void forceRefreshCalendar({ allowWhileBusy: true });
}
$: if (activeSection !== "calendar" && calendarTimelineDebounce) {
clearTimeout(calendarTimelineDebounce);
calendarTimelineDebounce = null;
}
$: lastActiveSection = activeSection;
$: if (activeSection === "calendar") {
onCalendarStateChange({
items: calendarTimelineItems,
busy: calendarBusy,
error: calendarError,
});
}
</script>
<section class="side-panel" aria-label="Section panel">
<header class="panel-header">
<h2>{panelTitle}</h2>
<div class="panel-header-actions">
{#if activeSection === "calendar"}
<button
type="button"
class="panel-action"
aria-label="Refresh calendar"
title="Refresh calendar"
on:click={handleRefreshClick}
>
<span class="material-symbols-outlined">refresh</span>
</button>
{/if}
{#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>
<button
type="button"
class="panel-action"
aria-label="Add daily note"
title="Add daily note"
on:click={handleAddDailyNote}
>
<span class="material-symbols-outlined">calendar_month</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}
{#if showNewItemInput}
<div class="new-item-input">
<input
type="text"
bind:this={newItemInput}
bind:value={newItemName}
placeholder="Entry title..."
on:keydown={handleNewItemKeydown}
on:blur={confirmNewItem}
/>
</div>
{/if}
<div class="calendar-controls">
<div class="calendar-control-row">
<label>
View
<select bind:value={calendarViewMode}>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
</select>
</label>
<label>
Sort
<select bind:value={calendarSortMode}>
<option value="desc">Newest</option>
<option value="asc">Oldest</option>
</select>
</label>
</div>
<div class="calendar-control-row">
<label>
Start
<input type="date" bind:value={calendarStartDate} />
</label>
<label>
End
<input type="date" bind:value={calendarEndDate} />
</label>
</div>
<div class="calendar-control-row">
<input
type="text"
bind:value={calendarQuery}
placeholder="Text query"
/>
<input
type="text"
bind:value={calendarTags}
placeholder="Tags (comma)"
/>
</div>
<div class="calendar-control-row">
<input
type="text"
bind:value={calendarTypes}
placeholder="Fragment types (comma)"
/>
<span></span>
</div>
<div class="calendar-saved-views">
<h3>Saved Views</h3>
<div class="saved-view-actions">
{#each builtInViews as view}
<button
type="button"
class="saved-view-btn"
on:click={() => applySavedView(view)}>{view.name}</button
>
{/each}
<button
type="button"
class="saved-view-btn"
on:click={() => (showSaveViewInput = true)}>Save Current</button
>
</div>
{#if showSaveViewInput}
<div class="saved-view-input">
<input
type="text"
bind:value={saveViewName}
placeholder="View name"
on:keydown={(event) => {
if (event.key === "Enter") saveCurrentView();
if (event.key === "Escape") {
showSaveViewInput = false;
saveViewName = "";
}
}}
on:blur={saveCurrentView}
/>
</div>
{/if}
{#if calendarSavedViews.length > 0}
<ul class="saved-view-list">
{#each calendarSavedViews as view}
<li>
<button
type="button"
class="saved-view-btn"
on:click={() => applySavedView(view)}>{view.name}</button
>
<button
type="button"
class="saved-view-delete"
on:click={() => deleteSavedView(view.id)}
aria-label="Delete saved view"
>
<span class="material-symbols-outlined">delete</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<CalendarWidget
onVisibleMonthChange={handleVisibleMonthChange}
onSelectedDateChange={handleSelectedDateChange}
onDateActivate={handleDateActivate}
/>
<div class="calendar-entries">
<h3>{calendarMonthLabel} {calendarYear} Timeline</h3>
<p class="section-copy">
Last refreshed: {calendarLastRefreshedAt || "Not yet"}
</p>
</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-controls {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--border-soft);
border-radius: 10px;
padding: 8px;
background: var(--surface-1);
}
.calendar-control-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.calendar-control-row label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.72rem;
color: var(--text-muted);
}
.calendar-control-row input,
.calendar-control-row select {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 7px;
background-color: var(--surface-2);
color: var(--text-primary);
padding: 6px 8px;
font-size: 0.78rem;
}
.calendar-control-row select {
padding-right: 30px;
}
.calendar-saved-views {
display: flex;
flex-direction: column;
gap: 6px;
}
.calendar-saved-views h3 {
font-size: 0.76rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.01em;
}
.saved-view-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.saved-view-btn {
border: 1px solid var(--border-soft);
border-radius: 7px;
padding: 5px 8px;
font-size: 0.74rem;
color: var(--text-muted);
background: var(--surface-2);
cursor: pointer;
}
.saved-view-btn:hover {
color: var(--text-primary);
border-color: var(--border-strong);
background: var(--bg-hover);
}
.saved-view-input input {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 7px;
background: var(--surface-2);
color: var(--text-primary);
padding: 6px 8px;
font-size: 0.78rem;
}
.saved-view-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.saved-view-list li {
display: flex;
align-items: center;
gap: 6px;
}
.saved-view-delete {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 6px;
border: 1px solid transparent;
color: var(--text-dim);
background: transparent;
cursor: pointer;
}
.saved-view-delete:hover {
color: #e06c75;
border-color: var(--border-soft);
background: var(--bg-hover);
}
.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);
}
}
}
@media (max-width: 980px) {
.side-panel {
padding: 12px 10px;
}
.panel-list .item-actions {
display: flex;
}
.panel-list .item-action {
width: 28px;
height: 28px;
}
}
@media (max-width: 720px) {
.calendar-control-row {
grid-template-columns: 1fr;
}
}
</style>