406 lines
12 KiB
Svelte
406 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { goto } from "$app/navigation";
|
|
import { unlockVaultWorkspace } from "$lib/backend/auth";
|
|
import AppModal from "$lib/components/AppModal.svelte";
|
|
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";
|
|
import { isVaultReady, setFlushCallback, setVaultSession } from "$lib/stores/session";
|
|
import { deleteTodoListByStoreId, 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 editMode = false;
|
|
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" | "delete-confirm" | 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;
|
|
let pendingDeleteItemId = "";
|
|
|
|
function showModal(options: {
|
|
action: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm";
|
|
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 = "";
|
|
pendingDeleteItemId = "";
|
|
}
|
|
|
|
async 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;
|
|
}
|
|
|
|
if (modalAction === "delete-confirm") {
|
|
const id = pendingDeleteItemId;
|
|
closeModal();
|
|
await performDelete(id);
|
|
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;
|
|
}
|
|
}
|
|
|
|
async function saveCurrentDocument() {
|
|
if (!activeDocumentId) return;
|
|
const content = openDocuments[activeDocumentId];
|
|
if (!content?.trim()) return;
|
|
|
|
try {
|
|
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;
|
|
openDocuments = { ...rest, [saved.id]: saved.initialContent };
|
|
activeDocumentId = saved.id;
|
|
activeDocumentLabel = saved.label;
|
|
}
|
|
} else if (selectedSection === "lists" && activeDocumentId.startsWith("lists/") && !activeDocumentId.startsWith("lists/draft-")) {
|
|
await updateListByStoreId(activeDocumentId, undefined, content);
|
|
}
|
|
} catch {
|
|
// best-effort save
|
|
}
|
|
}
|
|
|
|
async 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;
|
|
}
|
|
|
|
await saveCurrentDocument();
|
|
selectedSection = id;
|
|
panelOpen = true;
|
|
activeDocumentId = "";
|
|
activeDocumentLabel = "";
|
|
editMode = false;
|
|
}
|
|
|
|
async function handleOpenDocument(doc: OpenDocument) {
|
|
const prevActiveId = activeDocumentId;
|
|
await saveCurrentDocument();
|
|
editMode = false;
|
|
|
|
// If saveCurrentDocument promoted a draft to a file-backed entry and the
|
|
// caller passed the now-stale draft reference, the editor is already
|
|
// showing the promoted entry — nothing more to do.
|
|
if (doc.id === prevActiveId && activeDocumentId !== prevActiveId) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async function performDelete(id: string) {
|
|
try {
|
|
let ok = false;
|
|
if (selectedSection === "entries") {
|
|
ok = await deleteEntryByStoreId(id);
|
|
} else if (selectedSection === "todos") {
|
|
ok = await deleteTodoListByStoreId(id);
|
|
} else if (selectedSection === "lists") {
|
|
ok = await deleteListByStoreId(id);
|
|
} else if (selectedSection === "fragments") {
|
|
ok = await deleteFragmentByStoreId(id);
|
|
}
|
|
if (!ok) return;
|
|
|
|
handleDeleteDocument(id);
|
|
if (activeDocumentId === id) {
|
|
activeDocumentId = "";
|
|
activeDocumentLabel = "";
|
|
editMode = false;
|
|
}
|
|
} catch (error) {
|
|
console.error("Delete failed:", error);
|
|
}
|
|
}
|
|
|
|
async function handleEditItem(doc: OpenDocument) {
|
|
if (doc.id !== activeDocumentId) {
|
|
await handleOpenDocument(doc);
|
|
}
|
|
editMode = true;
|
|
}
|
|
|
|
function handleDeleteItem(doc: { id: string; label: string }) {
|
|
pendingDeleteItemId = doc.id;
|
|
showModal({
|
|
action: "delete-confirm",
|
|
title: "Confirm Delete",
|
|
message: `Are you sure you want to delete "${doc.label}"? This action cannot be undone.`,
|
|
confirmText: "Delete",
|
|
cancelText: "Cancel",
|
|
showCancel: true,
|
|
tone: "danger"
|
|
});
|
|
}
|
|
|
|
onMount(() => {
|
|
setFlushCallback(saveCurrentDocument);
|
|
bootstrapFragmentsWithUnlock();
|
|
});
|
|
</script>
|
|
|
|
<div class="app-shell" class:panel-closed={!panelOpen}>
|
|
<Navbar activeSection={selectedSection} onSelect={handleSelect} />
|
|
{#if panelOpen}
|
|
<SidePanel
|
|
activeSection={selectedSection}
|
|
{activeDocumentId}
|
|
onOpenDocument={handleOpenDocument}
|
|
onEditItem={handleEditItem}
|
|
onDeleteItem={handleDeleteItem}
|
|
/>
|
|
{/if}
|
|
<EditorPanel
|
|
activeSection={selectedSection}
|
|
openDocumentId={activeDocumentId}
|
|
openDocumentName={activeDocumentLabel}
|
|
openDocumentContent={openDocuments[activeDocumentId] ?? ""}
|
|
onDocumentContentChange={handleDocumentContentChange}
|
|
onOpenDocument={handleOpenDocument}
|
|
onDeleteDocument={handleDeleteDocument}
|
|
previewOnly={!editMode}
|
|
/>
|
|
</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}
|
|
/>
|
|
|
|
<style>
|
|
.app-shell {
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.app-shell :global(.side-panel),
|
|
.app-shell :global(.editor-panel) {
|
|
min-height: 0;
|
|
min-width: 0;
|
|
}
|
|
</style>
|
|
|