journal/Journal.App/src/routes/+page.svelte
Jacob Schmidt c7933aeeec Lists & todos backend, editor refactor, inline create, UX improvements
- Wire up lists and todos to C# backend with full CRUD persistence
- Add models, DTOs, repositories, and services for lists and todo lists/items
- Preserve SQLite DB across vault rebuild/load cycles
- Add session store for vault password persistence across navigation
- Add inline name input for creating lists and todo lists in SidePanel
- Clear editor panel on section change with empty state placeholder
- Default markdown editor to preview mode on item selection
- Decompose EditorPanel into sub-components:
  - editor/FragmentEditor, editor/TodoEditor, editor/MarkdownEditor
  - Shared markdown utilities in utils/markdown.ts
- Strip verbose console/eprintln logging from frontend and Tauri backend
- Add graceful shutdown with vault persistence on window close

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-02-26 17:33:27 -06:00

300 lines
8.7 KiB
Svelte

<script lang="ts">
import { goto } from "$app/navigation";
import { unlockVaultWorkspace } from "$lib/backend/auth";
import AppModal from "$lib/components/AppModal.svelte";
import { entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId } from "$lib/stores/entries";
import { hydrateFragments } from "$lib/stores/fragments";
import { hydrateLists } from "$lib/stores/lists";
import { isVaultReady, setVaultSession } from "$lib/stores/session";
import { hydrateTodos } from "$lib/stores/todos";
import Navbar from "$lib/components/Navbar.svelte";
import SidePanel from "$lib/components/SidePanel.svelte";
import EditorPanel from "$lib/components/EditorPanel.svelte";
import { onMount } from "svelte";
import { get } from "svelte/store";
type OpenDocument = {
id: string;
label: string;
initialContent: string;
};
const initialEntry = getDefaultEntry(get(entriesStore));
let selectedSection = "entries";
let panelOpen = true;
let activeDocumentId = initialEntry?.id ?? "entries/daily-notes";
let activeDocumentLabel = initialEntry?.label ?? "Daily Notes";
let openDocuments: Record<string, string> = initialEntry
? { [initialEntry.id]: initialEntry.initialContent }
: { "entries/daily-notes": "# Daily Notes\n\nStart writing today's entry..." };
let modalOpen = false;
let modalTitle = "";
let modalMessage = "";
let modalConfirmText = "OK";
let modalCancelText = "Cancel";
let modalShowCancel = false;
let modalTone: "default" | "danger" = "default";
let modalAction: "logout-confirm" | "logout-info" | "unlock-vault" | null = null;
let modalInputEnabled = false;
let modalInputType = "text";
let modalInputPlaceholder = "";
let modalInputAriaLabel = "Modal input";
let modalInputValue = "";
let unlockResolver: ((password: string | null) => void) | null = null;
let fragmentBootstrapInFlight = false;
function showModal(options: {
action: "logout-confirm" | "logout-info" | "unlock-vault";
title: string;
message: string;
confirmText?: string;
cancelText?: string;
showCancel?: boolean;
tone?: "default" | "danger";
inputEnabled?: boolean;
inputType?: string;
inputPlaceholder?: string;
inputAriaLabel?: string;
inputValue?: string;
}) {
modalAction = options.action;
modalTitle = options.title;
modalMessage = options.message;
modalConfirmText = options.confirmText ?? "OK";
modalCancelText = options.cancelText ?? "Cancel";
modalShowCancel = options.showCancel ?? false;
modalTone = options.tone ?? "default";
modalInputEnabled = options.inputEnabled ?? false;
modalInputType = options.inputType ?? "text";
modalInputPlaceholder = options.inputPlaceholder ?? "";
modalInputAriaLabel = options.inputAriaLabel ?? "Modal input";
modalInputValue = options.inputValue ?? "";
modalOpen = true;
}
function closeModal() {
modalOpen = false;
modalAction = null;
modalInputEnabled = false;
modalInputType = "text";
modalInputPlaceholder = "";
modalInputAriaLabel = "Modal input";
modalInputValue = "";
}
function handleModalConfirm() {
if (modalAction === "logout-confirm") {
showModal({
action: "logout-info",
title: "Logout Requested",
message: "You have been logged out.",
confirmText: "Close"
});
return;
}
if (modalAction === "unlock-vault") {
const value = modalInputValue.trim();
if (!value) return;
const resolve = unlockResolver;
unlockResolver = null;
closeModal();
resolve?.(value);
return;
}
closeModal();
}
function handleModalCancel() {
if (modalAction === "unlock-vault") {
const resolve = unlockResolver;
unlockResolver = null;
closeModal();
resolve?.(null);
return;
}
closeModal();
}
function requestVaultPassword(): Promise<string | null> {
return new Promise((resolve) => {
unlockResolver = resolve;
showModal({
action: "unlock-vault",
title: "Unlock Vault",
message: "Enter your vault password to load journal data.",
confirmText: "Unlock",
cancelText: "Cancel",
showCancel: true,
inputEnabled: true,
inputType: "password",
inputPlaceholder: "Vault password",
inputAriaLabel: "Vault password",
inputValue: ""
});
});
}
function isLockedError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
return normalized.includes("database is locked") || normalized.includes("incorrect vault password");
}
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
if (fragmentBootstrapInFlight) return;
if (isVaultReady()) {
try {
await hydrateEntries();
const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry);
}
await hydrateFragments();
await hydrateLists().catch(() => {});
await hydrateTodos().catch(() => {});
} catch (error) {
console.error("Hydration failed:", error);
}
return;
}
fragmentBootstrapInFlight = true;
try {
let attempts = 0;
while (attempts < maxAttempts) {
try {
const password = await requestVaultPassword();
if (!password) return;
await unlockVaultWorkspace(password);
setVaultSession(password);
await hydrateEntries();
const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry);
}
await hydrateFragments();
await hydrateLists().catch(() => {});
await hydrateTodos().catch(() => {});
return;
} catch (error) {
if (!isLockedError(error)) return;
attempts += 1;
}
}
} finally {
fragmentBootstrapInFlight = false;
}
}
function handleSelect(id: string) {
if (id === "account" || id === "settings") {
goto(`/${id}`);
return;
}
if (id === "logout") {
showModal({
action: "logout-confirm",
title: "Confirm Logout",
message: "Are you sure you want to log out?",
confirmText: "Log Out",
cancelText: "Cancel",
showCancel: true,
tone: "danger"
});
return;
}
if (selectedSection === id) {
panelOpen = !panelOpen;
return;
}
selectedSection = id;
panelOpen = true;
activeDocumentId = "";
activeDocumentLabel = "";
}
async function handleOpenDocument(doc: OpenDocument) {
let resolvedDoc = doc;
if (selectedSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
try {
const loaded = await loadEntryByStoreId(doc.id);
if (loaded) {
resolvedDoc = loaded;
}
} catch {
// entry content will use initialContent fallback
}
}
if (!(resolvedDoc.id in openDocuments)) {
openDocuments = { ...openDocuments, [resolvedDoc.id]: resolvedDoc.initialContent };
}
activeDocumentId = resolvedDoc.id;
activeDocumentLabel = resolvedDoc.label;
}
function handleDocumentContentChange(content: string) {
openDocuments = { ...openDocuments, [activeDocumentId]: content };
}
function handleDeleteDocument(id: string) {
const { [id]: _, ...remaining } = openDocuments;
openDocuments = remaining;
}
onMount(() => {
bootstrapFragmentsWithUnlock();
});
</script>
<div class="app-shell" class:panel-closed={!panelOpen}>
<Navbar activeSection={selectedSection} onSelect={handleSelect} />
{#if panelOpen}
<SidePanel
activeSection={selectedSection}
{activeDocumentId}
onOpenDocument={handleOpenDocument}
/>
{/if}
<EditorPanel
activeSection={selectedSection}
openDocumentId={activeDocumentId}
openDocumentName={activeDocumentLabel}
openDocumentContent={openDocuments[activeDocumentId] ?? ""}
onDocumentContentChange={handleDocumentContentChange}
onOpenDocument={handleOpenDocument}
onDeleteDocument={handleDeleteDocument}
/>
</div>
<AppModal
open={modalOpen}
title={modalTitle}
message={modalMessage}
confirmText={modalConfirmText}
cancelText={modalCancelText}
showCancel={modalShowCancel}
tone={modalTone}
inputEnabled={modalInputEnabled}
inputType={modalInputType}
inputPlaceholder={modalInputPlaceholder}
inputAriaLabel={modalInputAriaLabel}
bind:inputValue={modalInputValue}
onConfirm={handleModalConfirm}
onCancel={handleModalCancel}
/>