Move template management into Entries side panel

This commit is contained in:
Jacob Schmidt 2026-02-27 06:29:05 -06:00
parent 64c06081f0
commit a72ddf6aec
3 changed files with 288 additions and 202 deletions

View File

@ -1,12 +1,14 @@
<script lang="ts">
import { listEntryTemplates, type EntryTemplateItemDto } from "$lib/backend/templates";
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
import { entriesStore } from "$lib/stores/entries";
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 = () => {};
@ -14,6 +16,7 @@
let showNewItemInput = false;
let newItemName = "";
let newItemInput: HTMLInputElement | null = null;
let createTemplateMode = false;
type SidePanelItem = {
id: string;
@ -22,6 +25,11 @@
};
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",
@ -62,6 +70,37 @@
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();
}
@ -126,6 +165,9 @@
function handleAddItem() {
if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") {
if (activeSection === "entries") {
createTemplateMode = false;
}
showNewItemInput = true;
newItemName = "";
queueMicrotask(() => newItemInput?.focus());
@ -158,6 +200,14 @@
}
}
function handleAddTemplate() {
if (activeSection !== "entries") return;
createTemplateMode = true;
showNewItemInput = true;
newItemName = "";
queueMicrotask(() => newItemInput?.focus());
}
async function confirmNewItem() {
const label = newItemName.trim();
if (!label) {
@ -168,10 +218,17 @@
newItemName = "";
if (activeSection === "entries") {
const id = `entries/draft-${Date.now()}`;
const item = { id, label, initialContent: `# ${label}\n\n` };
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);
@ -208,6 +265,7 @@
function cancelNewItem() {
showNewItemInput = false;
newItemName = "";
createTemplateMode = false;
}
function handleNewItemKeydown(event: KeyboardEvent) {
@ -237,14 +295,38 @@
$: 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>
<button type="button" class="panel-action" aria-label="Add item" on:click={handleAddItem}>
<span class="material-symbols-outlined">add</span>
</button>
<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}
@ -281,36 +363,111 @@
type="text"
bind:this={newItemInput}
bind:value={newItemName}
placeholder={activeSection === "entries" ? "Entry name..." : activeSection === "todos" ? "Todo list name..." : "List name..."}
placeholder={activeSection === "entries"
? createTemplateMode
? "Template name..."
: "Entry name..."
: activeSection === "todos"
? "Todo list name..."
: "List name..."}
on:keydown={handleNewItemKeydown}
on:blur={confirmNewItem}
/>
</div>
{/if}
<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 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}
</li>
{/each}
</ul>
{/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>
@ -332,6 +489,12 @@
justify-content: space-between;
}
.panel-header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.panel-header h2 {
font-size: 0.95rem;
font-weight: 600;
@ -481,6 +644,30 @@
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;

View File

@ -2,6 +2,7 @@
import { goto } from "$app/navigation";
import { unlockVaultWorkspace } from "$lib/backend/auth";
import AppModal from "$lib/components/AppModal.svelte";
import { deleteEntryTemplate, loadEntryTemplate, saveEntryTemplate } from "$lib/backend/templates";
import { deleteEntryByStoreId, entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries";
import { deleteFragmentByStoreId, hydrateFragments } from "$lib/stores/fragments";
import { deleteListByStoreId, hydrateLists, updateListByStoreId } from "$lib/stores/lists";
@ -45,6 +46,25 @@
let unlockResolver: ((password: string | null) => void) | null = null;
let fragmentBootstrapInFlight = false;
let pendingDeleteItemId = "";
let templateRefreshToken = 0;
function toTemplatePath(id: string): string | null {
const prefix = "entries/template-file/";
if (!id.startsWith(prefix)) return null;
const encoded = id.slice(prefix.length).trim();
if (!encoded) return null;
try {
const decoded = decodeURIComponent(encoded);
return decoded || null;
} catch {
return null;
}
}
function templateNameFromPath(path: string): string {
const fileName = path.split(/[\\/]/).pop() ?? path;
return fileName.replace(/\.template\.md$/i, "");
}
function showModal(options: {
action: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm";
@ -160,6 +180,7 @@
if (isVaultReady()) {
try {
await hydrateEntries();
templateRefreshToken += 1;
const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry);
@ -185,6 +206,7 @@
setVaultSession(password);
await hydrateEntries();
templateRefreshToken += 1;
const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry);
@ -211,7 +233,31 @@
if (!content?.trim()) return;
try {
if (selectedSection === "entries" && (activeDocumentId.startsWith("entries/file/") || activeDocumentId.startsWith("entries/draft-"))) {
if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-draft-")) {
const draft = get(entriesStore).find((item) => item.id === activeDocumentId);
const draftLabel = (draft?.label ?? activeDocumentLabel ?? "").trim();
const templateName = draftLabel.replace(/_template$/i, "").trim() || draftLabel;
await saveEntryTemplate({
name: templateName,
content
});
templateRefreshToken += 1;
entriesStore.update((items) => items.filter((item) => item.id !== activeDocumentId));
const { [activeDocumentId]: _, ...rest } = openDocuments;
openDocuments = rest;
activeDocumentId = "";
activeDocumentLabel = "";
editMode = false;
} else if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-file/")) {
const filePath = toTemplatePath(activeDocumentId);
if (!filePath) return;
await saveEntryTemplate({
name: templateNameFromPath(filePath),
content,
filePath
});
templateRefreshToken += 1;
} else if (selectedSection === "entries" && (activeDocumentId.startsWith("entries/file/") || activeDocumentId.startsWith("entries/draft-"))) {
const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite");
if (saved && saved.id !== activeDocumentId) {
const { [activeDocumentId]: _, ...rest } = openDocuments;
@ -281,6 +327,20 @@
} catch {
// entry content will use initialContent fallback
}
} else if (selectedSection === "entries" && doc.id.startsWith("entries/template-file/") && !doc.initialContent) {
try {
const filePath = toTemplatePath(doc.id);
if (filePath) {
const loaded = await loadEntryTemplate(filePath);
resolvedDoc = {
id: doc.id,
label: loaded.fileName.replace(/\.template\.md$/i, ""),
initialContent: loaded.content
};
}
} catch {
// template content will use initialContent fallback
}
}
if (!(resolvedDoc.id in openDocuments)) {
@ -303,7 +363,16 @@
try {
let ok = false;
if (selectedSection === "entries") {
ok = await deleteEntryByStoreId(id);
const templatePath = toTemplatePath(id);
if (templatePath) {
ok = await deleteEntryTemplate(templatePath);
if (ok) templateRefreshToken += 1;
} else if (id.startsWith("entries/template-draft-")) {
entriesStore.update((items) => items.filter((item) => item.id !== id));
ok = true;
} else {
ok = await deleteEntryByStoreId(id);
}
} else if (selectedSection === "todos") {
ok = await deleteTodoListByStoreId(id);
} else if (selectedSection === "lists") {
@ -356,6 +425,7 @@
<SidePanel
activeSection={selectedSection}
{activeDocumentId}
{templateRefreshToken}
onOpenDocument={handleOpenDocument}
onEditItem={handleEditItem}
onDeleteItem={handleDeleteItem}
@ -402,4 +472,3 @@
min-width: 0;
}
</style>

View File

@ -2,13 +2,6 @@
import { goto } from "$app/navigation";
import AppModal from "$lib/components/AppModal.svelte";
import Navbar from "$lib/components/Navbar.svelte";
import {
deleteEntryTemplate,
listEntryTemplates,
loadEntryTemplate,
saveEntryTemplate,
type EntryTemplateItemDto
} from "$lib/backend/templates";
import {
addFragmentType,
addSettingsTag,
@ -41,12 +34,6 @@
let sidecarRoot = "";
let sidecarRootIsCustom = false;
let sidecarRootError = "";
let templates: EntryTemplateItemDto[] = [];
let templateName = "";
let templateContent = "";
let templateFilePath: string | null = null;
let templateError = "";
let templatesBusy = false;
onMount(async () => {
try {
@ -56,8 +43,6 @@
} catch (e) {
sidecarRootError = String(e);
}
await refreshTemplates();
});
async function saveSidecarRoot() {
@ -208,83 +193,6 @@
}
}
async function refreshTemplates() {
templatesBusy = true;
templateError = "";
try {
templates = await listEntryTemplates();
} catch (error) {
templateError = String(error);
} finally {
templatesBusy = false;
}
}
function resetTemplateEditor() {
templateName = "";
templateContent = "";
templateFilePath = null;
}
async function editTemplate(item: EntryTemplateItemDto) {
templateError = "";
try {
const loaded = await loadEntryTemplate(item.filePath);
templateFilePath = loaded.filePath;
templateName = loaded.fileName.replace(/\.template\.md$/i, "");
templateContent = loaded.content;
} catch (error) {
templateError = String(error);
}
}
async function saveTemplate() {
templateError = "";
if (!templateName.trim()) {
templateError = "Template name is required.";
return;
}
if (!templateContent.trim()) {
templateError = "Template content is required.";
return;
}
try {
const wasCreate = !templateFilePath;
const saved = await saveEntryTemplate({
name: templateName,
content: templateContent,
filePath: templateFilePath ?? undefined
});
if (wasCreate) {
resetTemplateEditor();
} else {
templateFilePath = saved.filePath;
}
await refreshTemplates();
} catch (error) {
templateError = String(error);
}
}
async function removeTemplate(item: EntryTemplateItemDto) {
templateError = "";
try {
const ok = await deleteEntryTemplate(item.filePath);
if (!ok) {
templateError = "Failed to delete template.";
return;
}
if (templateFilePath === item.filePath) {
resetTemplateEditor();
}
await refreshTemplates();
} catch (error) {
templateError = String(error);
}
}
</script>
<div class="app-shell panel-closed">
@ -392,57 +300,6 @@
</ul>
</section>
<section class="route-card">
<h2>Entry Templates</h2>
<p class="section-copy">Create reusable markdown templates stored as <code>.template.md</code> files in your data directory.</p>
<div class="template-grid">
<div class="template-editor">
<input
type="text"
placeholder="Template name (example: Weekly Review)"
bind:value={templateName}
/>
<textarea
rows="8"
placeholder="Template markdown content"
bind:value={templateContent}
></textarea>
<div class="row-actions">
<button type="button" class="secondary-btn" on:click={saveTemplate}>
{templateFilePath ? "Update Template" : "Create Template"}
</button>
<button type="button" class="ghost-btn" on:click={resetTemplateEditor}>New</button>
<button type="button" class="ghost-btn" on:click={refreshTemplates}>Refresh</button>
</div>
</div>
<div class="template-list">
{#if templatesBusy}
<p class="section-copy">Loading templates...</p>
{:else if templates.length === 0}
<p class="section-copy">No templates found.</p>
{:else}
<ul class="item-list">
{#each templates as item}
<li class="item-row">
<span>{item.fileName.replace(/\.template\.md$/i, "")}</span>
<div class="row-actions">
<button type="button" class="ghost-btn" on:click={() => editTemplate(item)}>Edit</button>
<button type="button" class="danger-btn" on:click={() => removeTemplate(item)}>Delete</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if templateError}
<p class="error-text">{templateError}</p>
{/if}
</section>
<section class="route-card">
<h2>Sidecar</h2>
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
@ -625,31 +482,4 @@
color: #e74c3c;
font-size: 0.82rem;
}
.template-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.template-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.template-editor textarea {
border: 1px solid var(--border-soft);
border-radius: 7px;
background: var(--bg-app);
color: var(--text-primary);
padding: 8px 9px;
resize: vertical;
}
@media (min-width: 980px) {
.template-grid {
grid-template-columns: 1.2fr 1fr;
}
}
</style>