From abbb323da18519f1f478e7ae17a19cba0561638b Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Mon, 5 Oct 2026 17:16:38 -0400 Subject: [PATCH] Implement delete confirmation/guard, end of changes for v0.2.1 --- README.md | 4 +- src/pages.rs | 8 ++- static/app.js | 83 +++++++++++++++++++++++++++++ tests/auth_gating.rs | 124 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 946fae5..a064309 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ authenticator app. No telemetry, no accounts, no third-party services. - **Collapsible folder navigation** — a pull-tab button on mobile and a `View → Hide/Show Folder Nav` toggle at any width let you collapse the folder list to give the note list more room - **Independent mobile scrolling** — the folder nav and note list each scroll within their own bounded area on phones, instead of sharing one scroll position - **Save Note moved** — the action now lives in the `File` menu alongside `New Note`, rather than under `Edit` +- **Folder-nav state persists** — hiding the folder nav saves the preference to browser storage and switches the sidebar to All Notes without navigating away from an open note, so unsaved edits survive +- **Delete confirmation** — deleting a note now asks you to confirm, showing the note's folder and title so you know exactly what you are about to remove ## What's new in 0.2 @@ -33,7 +35,7 @@ authenticator app. No telemetry, no accounts, no third-party services. - **TOTP-locked by default** — authenticator app required on first launch and after every restart - **Server-side rendered** with Axum and Maud — fast, type-safe HTML templates -- **Works without JavaScript** — note editing, search, and the login form are plain HTML; the menus, mobile tabs, live preview, and drag-and-drop re-filing are the JS-dependent parts +- **Uses a small amount of JavaScript** — note editing, search, and the login form are plain HTML; the menus, mobile tabs, live preview, and drag-and-drop re-filing are the JS-dependent parts - **Alpine.js enhancements** — live Markdown preview, instant search, keyboard shortcuts - **File-based storage** — notes are persisted as Markdown files in `notes/` - **Virtual folders** — organize notes into folders without real directories; folders are encoded as `[Folder]uuid.md` filename prefixes diff --git a/src/pages.rs b/src/pages.rs index 166ec16..90095d9 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -357,7 +357,13 @@ fn render_toolbar( button type="submit" class="btn btn-primary" { "+ New Note" } } @if let Some(note) = current_note { - form action=(base.url(&format!("/notes/{}", note.id))) method="post" { + form + action=(base.url(&format!("/notes/{}", note.id))) + method="post" + data-delete-label=(format!("[{}-{}]", + note.folder.as_deref().unwrap_or("Unfiled"), + note.title)) + onsubmit="return confirm('Are you sure you want to delete ' + this.dataset.deleteLabel + '? This cannot be undone.')" { input type="hidden" name="_method" value="delete"; button type="submit" class="btn btn-danger" { "Delete" } } diff --git a/static/app.js b/static/app.js index 5555f58..aebd8bf 100644 --- a/static/app.js +++ b/static/app.js @@ -34,6 +34,13 @@ function app() { }; document.addEventListener('click', this._onDocClick); + // Restore the persisted folder-nav preference. If the nav was hidden + // last time, refresh the sidebar to All Notes so the state is consistent. + this.folderNavCollapsed = this._loadFolderNavPreference(); + if (this.folderNavCollapsed) { + this.refreshSidebarToAllNotes(); + } + this.initNoteDragging(); }, @@ -263,6 +270,82 @@ function app() { toggleFolderNav() { this.folderNavCollapsed = !this.folderNavCollapsed; + this._saveFolderNavPreference(this.folderNavCollapsed); + if (this.folderNavCollapsed) { + this.refreshSidebarToAllNotes(); + } + }, + + /// localStorage key for the folder-nav preference, scoped by base path + /// so separate app installs on one host do not share the setting. + _folderNavStorageKey() { + const base = window.__BASE_PATH__ || ''; + return 'eternalibre:folderNavCollapsed:' + base; + }, + + _loadFolderNavPreference() { + try { + return localStorage.getItem(this._folderNavStorageKey()) === 'true'; + } catch (e) { + return false; + } + }, + + _saveFolderNavPreference(collapsed) { + try { + localStorage.setItem(this._folderNavStorageKey(), collapsed ? 'true' : 'false'); + } catch (e) { + // Storage unavailable (private mode, quota, etc.) — the toggle still + // works for this session, it just won't persist. + } + }, + + /// Refresh the sidebar to the All Notes view without navigating away. + /// + /// Fetches the server-rendered index page and swaps in the folder nav, + /// note list, toolbar folder field, and status count. The editor pane and + /// note form are untouched, so unsaved edits survive. + refreshSidebarToAllNotes() { + const base = window.__BASE_PATH__ || ''; + const searchInput = document.querySelector('.search-form input[name="q"]'); + const query = searchInput ? searchInput.value.trim() : ''; + const url = base + '/' + (query ? '?q=' + encodeURIComponent(query) : ''); + + fetch(url) + .then(r => r.text()) + .then(html => { + const doc = new DOMParser().parseFromString(html, 'text/html'); + if (!doc.querySelector('.sidebar')) return; // not the app page + + // Swap the folder nav list + const newNav = doc.querySelector('#folder-nav-list'); + const oldNav = document.querySelector('#folder-nav-list'); + if (newNav && oldNav) { + oldNav.innerHTML = newNav.innerHTML; + } + + // Swap the note list + const newList = doc.querySelector('.note-list'); + const oldList = document.querySelector('.note-list'); + if (newList && oldList) { + oldList.innerHTML = newList.innerHTML; + } + + // Update the toolbar's hidden folder field + const newFolderInput = doc.querySelector('.toolbar input[name="folder"]'); + const oldFolderInput = document.querySelector('.toolbar input[name="folder"]'); + if (newFolderInput && oldFolderInput) { + oldFolderInput.value = newFolderInput.value; + } + + // Update the status bar note count + const newCount = doc.querySelector('.status-bar span'); + const oldCount = document.querySelector('.status-bar span'); + if (newCount && oldCount) { + oldCount.textContent = newCount.textContent; + } + }) + .catch(err => console.error('Sidebar refresh failed:', err)); }, focusEditor() { diff --git a/tests/auth_gating.rs b/tests/auth_gating.rs index efc890c..3a70f1f 100644 --- a/tests/auth_gating.rs +++ b/tests/auth_gating.rs @@ -643,6 +643,50 @@ async fn deleting_a_note_requires_authentication() { assert_eq!(status, StatusCode::OK); } +/// The delete form asks for confirmation before submitting, using the note's +/// folder and title in the prompt label. +#[tokio::test] +async fn delete_form_has_confirmation_prompt() { + let f = Fixture::provisioned("delete-confirm"); + let (_, _, loc) = f.post_authed("/folders", "name=Projects").await; + let loc = loc.expect("folder creation redirects"); + let id = loc.rsplit('/').next().unwrap().to_string(); + + let (status, body) = f.get_authed(&format!("/notes/{id}")).await; + assert_eq!(status, StatusCode::OK); + + // Find the delete form by its note-specific action + let form_start = body + .find(&format!("/notes/{id}\"")) + .expect("delete form should exist"); + let form_end = body[form_start..] + .find("") + .map(|i| form_start + i + "".len()) + .expect("delete form should close"); + let form = &body[form_start..form_end]; + + assert!( + form.contains("onsubmit"), + "delete form should have an onsubmit handler" + ); + assert!( + form.contains("confirm("), + "delete form should show a confirmation prompt" + ); + assert!( + form.contains("data-delete-label"), + "delete form should carry the folder-title label" + ); + assert!( + form.contains("[Projects-"), + "label should include the folder name" + ); + assert!( + form.contains("_method") && form.contains("delete"), + "form should still submit the delete method" + ); +} + // ---------------------------------------------------------------- folders ---- #[tokio::test] @@ -2141,6 +2185,86 @@ async fn alpine_is_available_without_a_static_directory() { let _ = std::fs::remove_file(&secret_file); } +/// The folder-nav collapsed preference is persisted in localStorage, scoped +/// by base path so separate installs on one host do not share the setting. +#[tokio::test] +async fn folder_nav_preference_is_persisted() { + let f = Fixture::provisioned("folder-nav-persist"); + + let (status, body) = f.get_authed("/").await; + assert_eq!(status, StatusCode::OK); + + // The app.js source must contain the localStorage key and the load/save + // calls. Anchor on the storage key pattern and the preference read/write. + assert!( + body.contains("eternalibre:folderNavCollapsed:"), + "localStorage key should be scoped by base path" + ); + assert!( + body.contains("localStorage.getItem"), + "should load the persisted preference" + ); + assert!( + body.contains("localStorage.setItem"), + "should save the persisted preference" + ); +} + +/// When the folder nav is hidden, the sidebar refreshes to All Notes without +/// navigating away. The editor and its unsaved contents must survive. +#[tokio::test] +async fn hiding_folder_nav_refreshes_sidebar_to_all_notes() { + let f = Fixture::provisioned("folder-nav-refresh"); + f.post_authed("/folders", "name=Alpha").await; + let id = f.create_note().await; + + // Open the note in its folder + let (status, body) = f.get_authed(&format!("/notes/{id}")).await; + assert_eq!(status, StatusCode::OK); + + // The app.js source must call refreshSidebarToAllNotes when hiding + assert!( + body.contains("refreshSidebarToAllNotes"), + "hiding the nav should refresh the sidebar" + ); + + // The refresh must fetch the index page and swap sidebar elements + assert!(body.contains("DOMParser"), "should parse the fetched page"); + assert!( + body.contains("#folder-nav-list"), + "should swap the folder nav list" + ); + assert!(body.contains(".note-list"), "should swap the note list"); + + // The editor form must NOT be replaced — unsaved edits survive. + // Scope to the toggle and refresh functions: moveNote still reloads. + let toggle_start = body + .find("toggleFolderNav()") + .expect("toggleFolderNav should exist"); + let toggle_end = body[toggle_start..] + .find("refreshSidebarToAllNotes()") + .map(|i| toggle_start + i) + .expect("refreshSidebarToAllNotes should follow toggleFolderNav"); + let toggle_fn = &body[toggle_start..toggle_end]; + assert!( + !toggle_fn.contains("location.reload"), + "toggleFolderNav should not reload the page" + ); + + let refresh_start = body + .find("refreshSidebarToAllNotes() {") + .expect("refreshSidebarToAllNotes should exist"); + let refresh_end = body[refresh_start..] + .find("focusEditor()") + .map(|i| refresh_start + i) + .expect("focusEditor should follow refreshSidebarToAllNotes"); + let refresh_fn = &body[refresh_start..refresh_end]; + assert!( + !refresh_fn.contains("location.reload"), + "refreshSidebarToAllNotes should not reload the page" + ); +} + /// GET a path and return its status and body. async fn fetch(app: &axum::Router, path: &str) -> (StatusCode, String) { let res = app