- 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>
300 lines
8.7 KiB
Svelte
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}
|
|
/>
|
|
|
|
|