Compare commits

...

2 Commits

Author SHA1 Message Date
a6589fb1f3 Fix search not working 2026-05-15 23:53:03 +00:00
f229bb978e Autofocus more forms 2026-05-15 23:53:03 +00:00
10 changed files with 120 additions and 40 deletions

View File

@ -31,7 +31,7 @@ src/
## Key Design Decisions ## Key Design Decisions
- **Svelte 5 runes** — Use `$state`, `$derived`, `$effect`. Props-based event passing (no Svelte events). - **Svelte 5 runes** — Use `$state`, `$derived`, `$effect`. Props-based event passing (no Svelte events). Event handler attributes are all lower-case (e.g. oninput)
- **No external crypto libraries** — Uses the browser's native Web Crypto API exclusively. - **No external crypto libraries** — Uses the browser's native Web Crypto API exclusively.
- **Key never persisted** — Encryption key lives only in `$state` memory; cleared on lock, tab switch, or page close. - **Key never persisted** — Encryption key lives only in `$state` memory; cleared on lock, tab switch, or page close.
- **Single-file build**`vite-plugin-singlefile` inlines all JS/CSS; post-build script inlines favicon. - **Single-file build**`vite-plugin-singlefile` inlines all JS/CSS; post-build script inlines favicon.

97
dist/index.html vendored
View File

@ -2326,20 +2326,6 @@ function merge_text_nodes(text) {
//#endregion //#endregion
//#region node_modules/svelte/src/internal/client/dom/elements/misc.js //#region node_modules/svelte/src/internal/client/dom/elements/misc.js
/** /**
* @param {HTMLElement} dom
* @param {boolean} value
* @returns {void}
*/
function autofocus(dom, value) {
if (value) {
const body = document.body;
dom.autofocus = true;
queue_micro_task(() => {
if (document.activeElement === body) dom.focus();
});
}
}
/**
* The child of a textarea actually corresponds to the defaultValue property, so we need * The child of a textarea actually corresponds to the defaultValue property, so we need
* to remove it upon hydration to avoid a bug when someone resets the form value. * to remove it upon hydration to avoid a bug when someone resets the form value.
* @param {HTMLTextAreaElement} dom * @param {HTMLTextAreaElement} dom
@ -4192,6 +4178,36 @@ function head(hash, render_fn) {
} }
} }
//#endregion //#endregion
//#region node_modules/svelte/src/internal/client/dom/elements/actions.js
/** @import { ActionPayload } from '#client' */
/**
* @template P
* @param {Element} dom
* @param {(dom: Element, value?: P) => ActionPayload<P>} action
* @param {() => P} [get_value]
* @returns {void}
*/
function action(dom, action, get_value) {
effect(() => {
var payload = untrack(() => action(dom, get_value?.()) || {});
if (get_value && payload?.update) {
var inited = false;
/** @type {P} */
var prev = {};
render_effect(() => {
var value = get_value();
deep_read_state(value);
if (inited && safe_not_equal(prev, value)) {
prev = value;
/** @type {Function} */ payload.update(value);
}
});
inited = true;
}
if (payload?.destroy) return () => payload.destroy();
});
}
//#endregion
//#region node_modules/svelte/src/internal/shared/attributes.js //#region node_modules/svelte/src/internal/shared/attributes.js
var whitespace = [..." \n\r\f\xA0\v"]; var whitespace = [..." \n\r\f\xA0\v"];
/** /**
@ -5615,6 +5631,15 @@ async function importAll(data, mode = "merge", sourcePassword = "", targetKey =
}; };
} }
//#endregion //#endregion
//#region src/lib/autofocus.js
/**
* Action that autofocuses an element after mount when the condition is truthy.
* Uses a microtask to ensure the element is fully rendered.
*/
function autofocus(node, condition = true) {
if (condition) queueMicrotask(() => node.focus());
}
//#endregion
//#region src/components/LockScreen.svelte //#region src/components/LockScreen.svelte
var root_1$8 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-7sq1ct" role="alert"> </div>`); var root_1$8 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-7sq1ct" role="alert"> </div>`);
var root_2$6 = /* @__PURE__ */ from_html(`<div class="form-group"><label for="confirm-password">Confirm Password</label> <input id="confirm-password" type="password" placeholder="Confirm master password" autocomplete="new-password"/></div>`); var root_2$6 = /* @__PURE__ */ from_html(`<div class="form-group"><label for="confirm-password">Confirm Password</label> <input id="confirm-password" type="password" placeholder="Confirm master password" autocomplete="new-password"/></div>`);
@ -5697,7 +5722,8 @@ function LockScreen($$anchor, $$props) {
var div_3 = child(form); var div_3 = child(form);
var input = sibling(child(div_3), 2); var input = sibling(child(div_3), 2);
remove_input_defaults(input); remove_input_defaults(input);
autofocus(input, true); effect(() => bind_value(input, () => get(masterPassword), ($$value) => set(masterPassword, $$value)));
action(input, ($$node) => autofocus?.($$node));
reset(div_3); reset(div_3);
var node_1 = sibling(div_3, 2); var node_1 = sibling(div_3, 2);
var consequent_1 = ($$anchor) => { var consequent_1 = ($$anchor) => {
@ -5732,12 +5758,12 @@ function LockScreen($$anchor, $$props) {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
}); });
bind_value(input, () => get(masterPassword), ($$value) => set(masterPassword, $$value));
append($$anchor, div); append($$anchor, div);
pop(); pop();
} }
//#endregion //#endregion
//#region src/lib/stores/search.svelte.js //#region src/lib/stores/search.svelte.js
var DEBOUNCE_MS = 300;
var SearchStore = class { var SearchStore = class {
#query = /* @__PURE__ */ state(""); #query = /* @__PURE__ */ state("");
get query() { get query() {
@ -5746,6 +5772,13 @@ var SearchStore = class {
set query(value) { set query(value) {
set(this.#query, value, true); set(this.#query, value, true);
} }
#debouncedQuery = /* @__PURE__ */ state("");
get debouncedQuery() {
return get(this.#debouncedQuery);
}
set debouncedQuery(value) {
set(this.#debouncedQuery, value, true);
}
#activeGroupId = /* @__PURE__ */ state("all"); #activeGroupId = /* @__PURE__ */ state("all");
get activeGroupId() { get activeGroupId() {
return get(this.#activeGroupId); return get(this.#activeGroupId);
@ -5760,8 +5793,21 @@ var SearchStore = class {
set refreshTrigger(value) { set refreshTrigger(value) {
set(this.#refreshTrigger, value, true); set(this.#refreshTrigger, value, true);
} }
#debounceTimer = null;
setSearchQuery(value) {
this.query = value;
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
if (value === "") {
this.debouncedQuery = "";
this.#debounceTimer = null;
} else this.#debounceTimer = setTimeout(() => {
this.debouncedQuery = value;
this.#debounceTimer = null;
}, DEBOUNCE_MS);
}
clear() { clear() {
this.query = ""; this.query = "";
this.debouncedQuery = "";
this.activeGroupId = "all"; this.activeGroupId = "all";
} }
/** Force subscribed components to re-fetch data. */ /** Force subscribed components to re-fetch data. */
@ -5913,6 +5959,8 @@ function Sidebar($$anchor, $$props) {
var div_8 = sibling(node_2, 2); var div_8 = sibling(node_2, 2);
var input_1 = sibling(child(div_8), 2); var input_1 = sibling(child(div_8), 2);
remove_input_defaults(input_1); remove_input_defaults(input_1);
effect(() => bind_value(input_1, () => get(groupName), ($$value) => set(groupName, $$value)));
action(input_1, ($$node, $$action_arg) => autofocus?.($$node, $$action_arg), () => !get(editingGroupId));
reset(div_8); reset(div_8);
var div_9 = sibling(div_8, 2); var div_9 = sibling(div_8, 2);
var div_10 = sibling(child(div_9), 2); var div_10 = sibling(child(div_9), 2);
@ -5942,7 +5990,6 @@ function Sidebar($$anchor, $$props) {
}); });
delegated("click", div_5, () => set(showGroupForm, false)); delegated("click", div_5, () => set(showGroupForm, false));
delegated("click", div_6, (e) => e.stopPropagation()); delegated("click", div_6, (e) => e.stopPropagation());
bind_value(input_1, () => get(groupName), ($$value) => set(groupName, $$value));
delegated("click", button_6, saveGroup); delegated("click", button_6, saveGroup);
delegated("click", button_7, () => set(showGroupForm, false)); delegated("click", button_7, () => set(showGroupForm, false));
append($$anchor, div_5); append($$anchor, div_5);
@ -5981,13 +6028,13 @@ function Sidebar($$anchor, $$props) {
set_value(input, search.query); set_value(input, search.query);
set_class(button, 1, `group-item ${search.activeGroupId === "all" ? "active" : ""}`, "svelte-181dlmc"); set_class(button, 1, `group-item ${search.activeGroupId === "all" ? "active" : ""}`, "svelte-181dlmc");
}); });
event("Input", input, (e) => search.query = e.target.value); delegated("input", input, (e) => search.setSearchQuery(e.target.value));
delegated("click", button, () => search.activeGroupId = "all"); delegated("click", button, () => search.activeGroupId = "all");
delegated("click", button_4, () => openGroupForm(null)); delegated("click", button_4, () => openGroupForm(null));
append($$anchor, div); append($$anchor, div);
pop(); pop();
} }
delegate(["click"]); delegate(["input", "click"]);
//#endregion //#endregion
//#region src/components/EntryList.svelte //#region src/components/EntryList.svelte
var root_1$6 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Loading entries...</div>`); var root_1$6 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Loading entries...</div>`);
@ -6008,7 +6055,7 @@ function EntryList($$anchor, $$props) {
set(loading, true); set(loading, true);
set(error, ""); set(error, "");
try { try {
const query = search.query.trim(); const query = search.debouncedQuery.trim();
const groupId = search.activeGroupId; const groupId = search.activeGroupId;
if (query) set(entries, await searchEntries(query, groupId !== "all" ? { groupId } : {}), true); if (query) set(entries, await searchEntries(query, groupId !== "all" ? { groupId } : {}), true);
else if (groupId !== "all") set(entries, await getEntries({ groupId }), true); else if (groupId !== "all") set(entries, await getEntries({ groupId }), true);
@ -6020,7 +6067,7 @@ function EntryList($$anchor, $$props) {
set(loading, false); set(loading, false);
} }
user_effect(() => { user_effect(() => {
search.query; search.debouncedQuery;
search.activeGroupId; search.activeGroupId;
search.refreshTrigger; search.refreshTrigger;
loadEntries(); loadEntries();
@ -6494,6 +6541,8 @@ function EntryForm($$anchor, $$props) {
var div_5 = sibling(node_2, 2); var div_5 = sibling(node_2, 2);
var input = sibling(child(div_5), 2); var input = sibling(child(div_5), 2);
remove_input_defaults(input); remove_input_defaults(input);
effect(() => bind_value(input, () => get(title), ($$value) => set(title, $$value)));
action(input, ($$node, $$action_arg) => autofocus?.($$node, $$action_arg), () => !get(isEdit));
reset(div_5); reset(div_5);
var div_6 = sibling(div_5, 2); var div_6 = sibling(div_5, 2);
var input_1 = sibling(child(div_6), 2); var input_1 = sibling(child(div_6), 2);
@ -6551,7 +6600,6 @@ function EntryForm($$anchor, $$props) {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
}); });
bind_value(input, () => get(title), ($$value) => set(title, $$value));
bind_value(input_1, () => get(username), ($$value) => set(username, $$value)); bind_value(input_1, () => get(username), ($$value) => set(username, $$value));
bind_value(input_2, () => get(password), ($$value) => set(password, $$value)); bind_value(input_2, () => get(password), ($$value) => set(password, $$value));
delegated("click", button, () => set(passwordVisible, !get(passwordVisible))); delegated("click", button, () => set(passwordVisible, !get(passwordVisible)));
@ -6856,10 +6904,7 @@ function MainLayout($$anchor, $$props) {
if (get(sidebarOpen)) $$render(consequent); if (get(sidebarOpen)) $$render(consequent);
}); });
var aside = sibling(node, 2); var aside = sibling(node, 2);
Sidebar(child(aside), { $$events: { Sidebar(child(aside), {});
back: handleBack,
goList
} });
reset(aside); reset(aside);
var main = sibling(aside, 2); var main = sibling(aside, 2);
var div_2 = child(main); var div_2 = child(main);

View File

@ -5,6 +5,7 @@
import { generatePassword } from '../lib/crypto/crypto.js' import { generatePassword } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.svelte.js' import { app } from '../lib/stores/app.svelte.js'
import PasswordGenerator from './PasswordGenerator.svelte' import PasswordGenerator from './PasswordGenerator.svelte'
import { autofocus } from '../lib/autofocus.js'
let { entryId, onSave, onCancel } = $props() let { entryId, onSave, onCancel } = $props()
@ -114,7 +115,7 @@
<div class="form-group"> <div class="form-group">
<label for="title">Title *</label> <label for="title">Title *</label>
<input id="title" type="text" bind:value={title} placeholder="e.g. GitHub, Gmail" /> <input id="title" type="text" bind:value={title} placeholder="e.g. GitHub, Gmail" use:autofocus={!isEdit} />
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -13,7 +13,7 @@
loading = true loading = true
error = '' error = ''
try { try {
const query = searchStore.query.trim() const query = searchStore.debouncedQuery.trim()
const groupId = searchStore.activeGroupId const groupId = searchStore.activeGroupId
if (query) { if (query) {
@ -35,9 +35,9 @@
loading = false loading = false
} }
// Reload when search query, active group filter, or refresh trigger changes // Reload when debounced search query, active group filter, or refresh trigger changes
$effect(() => { $effect(() => {
searchStore.query searchStore.debouncedQuery
searchStore.activeGroupId searchStore.activeGroupId
searchStore.refreshTrigger searchStore.refreshTrigger
loadEntries() loadEntries()

View File

@ -3,6 +3,7 @@
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js' import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js'
import { saveVaultMeta, loadVaultMeta, isVaultInitialized } from '../lib/storage/db.js' import { saveVaultMeta, loadVaultMeta, isVaultInitialized } from '../lib/storage/db.js'
import { startAutoLock } from '../lib/stores/security.svelte.js' import { startAutoLock } from '../lib/stores/security.svelte.js'
import { autofocus } from '../lib/autofocus.js'
let masterPassword = $state('') let masterPassword = $state('')
let confirmPassword = $state('') let confirmPassword = $state('')
@ -95,7 +96,7 @@
bind:value={masterPassword} bind:value={masterPassword}
placeholder="Enter master password" placeholder="Enter master password"
autocomplete="current-password" autocomplete="current-password"
autofocus use:autofocus
disabled={loading} disabled={loading}
/> />
</div> </div>

View File

@ -58,7 +58,7 @@
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar {sidebarOpen ? 'open' : ''}"> <aside class="sidebar {sidebarOpen ? 'open' : ''}">
<Sidebar on:back={handleBack} on:goList={goList} /> <Sidebar />
</aside> </aside>
<!-- Main content --> <!-- Main content -->

View File

@ -2,6 +2,7 @@
import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup } from '../lib/storage/db.js' import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup } from '../lib/storage/db.js'
import { createGroup, validateGroup } from '../lib/models/schema.js' import { createGroup, validateGroup } from '../lib/models/schema.js'
import { search as searchStore } from '../lib/stores/search.svelte.js' import { search as searchStore } from '../lib/stores/search.svelte.js'
import { autofocus } from '../lib/autofocus.js'
let groups = $state([]) let groups = $state([])
let entryCounts = $state(new Map()) let entryCounts = $state(new Map())
@ -95,7 +96,7 @@
type="text" type="text"
placeholder="Search entries..." placeholder="Search entries..."
value={searchStore.query} value={searchStore.query}
onInput={(e) => searchStore.query = e.target.value} oninput={(e) => searchStore.setSearchQuery(e.target.value)}
/> />
</div> </div>
@ -142,7 +143,7 @@
<div class="form-group"> <div class="form-group">
<label for="group-name">Group Name</label> <label for="group-name">Group Name</label>
<input id="group-name" type="text" bind:value={groupName} placeholder="e.g. Work, Personal" /> <input id="group-name" type="text" bind:value={groupName} placeholder="e.g. Work, Personal" use:autofocus={!editingGroupId} />
</div> </div>
<div class="form-group"> <div class="form-group">

9
src/lib/autofocus.js Normal file
View File

@ -0,0 +1,9 @@
/**
* Action that autofocuses an element after mount when the condition is truthy.
* Uses a microtask to ensure the element is fully rendered.
*/
export function autofocus(node, condition = true) {
if (condition) {
queueMicrotask(() => node.focus())
}
}

View File

@ -3,13 +3,36 @@
* Shared between Sidebar and EntryList for coordinated filtering. * Shared between Sidebar and EntryList for coordinated filtering.
*/ */
const DEBOUNCE_MS = 300
export class SearchStore { export class SearchStore {
query = $state('') query = $state('') // raw input value — bound to the search input
debouncedQuery = $state('') // debounced value — used for actual search
activeGroupId = $state('all') // 'all' or a group id activeGroupId = $state('all') // 'all' or a group id
refreshTrigger = $state(0) // incremented to force a re-fetch refreshTrigger = $state(0) // incremented to force a re-fetch
#debounceTimer = null
/**
* Update the search query with debouncing.
* Call this from the input handler instead of setting `query` directly.
*/
setSearchQuery(value) {
this.query = value
if (this.#debounceTimer) clearTimeout(this.#debounceTimer)
if (value === '') {
this.debouncedQuery = ''
this.#debounceTimer = null
} else {
this.#debounceTimer = setTimeout(() => {
this.debouncedQuery = value
this.#debounceTimer = null
}, DEBOUNCE_MS)
}
}
clear() { clear() {
this.query = '' this.query = ''
this.debouncedQuery = ''
this.activeGroupId = 'all' this.activeGroupId = 'all'
} }

View File

@ -18,12 +18,12 @@ describe('SearchStore', () => {
describe('query', () => { describe('query', () => {
it('should update query', () => { it('should update query', () => {
search.query = 'test' search.setSearchQuery('test')
expect(search.query).toBe('test') expect(search.query).toBe('test')
}) })
it('should handle unicode query', () => { it('should handle unicode query', () => {
search.query = 'тест' search.setSearchQuery('тест')
expect(search.query).toBe('тест') expect(search.query).toBe('тест')
}) })
}) })
@ -48,7 +48,7 @@ describe('SearchStore', () => {
describe('clear()', () => { describe('clear()', () => {
it('should reset query to empty string', () => { it('should reset query to empty string', () => {
search.query = 'some search' search.setSearchQuery('some search')
search.clear() search.clear()
expect(search.query).toBe('') expect(search.query).toBe('')
}) })
@ -60,7 +60,7 @@ describe('SearchStore', () => {
}) })
it('should reset both fields simultaneously', () => { it('should reset both fields simultaneously', () => {
search.query = 'some search' search.setSearchQuery('some search')
search.activeGroupId = 'group-123' search.activeGroupId = 'group-123'
search.clear() search.clear()
expect(search.query).toBe('') expect(search.query).toBe('')