diff --git a/README.md b/README.md index ab12bb7..efff93c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The build produces a **single `dist/index.html`** file with all JavaScript, CSS, The single-file output is handled by [`vite-plugin-singlefile`](https://www.npmjs.com/package/vite-plugin-singlefile) for JS/CSS inlining, plus a post-build script that inlines the favicon SVG and removes leftover asset files. + ### Encryption Flow diff --git a/dist/index.html b/dist/index.html index ca2254e..be405f3 100644 --- a/dist/index.html +++ b/dist/index.html @@ -36,4744 +36,238 @@ } })(); //#endregion -//#region node_modules/svelte/src/internal/shared/utils.js -var is_array = Array.isArray; -var index_of = Array.prototype.indexOf; -var includes = Array.prototype.includes; -var array_from = Array.from; -var define_property = Object.defineProperty; -var get_descriptor = Object.getOwnPropertyDescriptor; -var get_descriptors = Object.getOwnPropertyDescriptors; -var object_prototype = Object.prototype; -var array_prototype = Array.prototype; -var get_prototype_of = Object.getPrototypeOf; -var is_extensible = Object.isExtensible; -var noop = () => {}; -/** @param {Function} fn */ -function run(fn) { - return fn(); -} -/** @param {Array<() => void>} arr */ -function run_all(arr) { - for (var i = 0; i < arr.length; i++) arr[i](); -} -/** -* TODO replace with Promise.withResolvers once supported widely enough -* @template [T=void] -*/ -function deferred() { - /** @type {(value: T) => void} */ - var resolve; - /** @type {(reason: any) => void} */ - var reject; - return { - promise: new Promise((res, rej) => { - resolve = res; - reject = rej; - }), - resolve, - reject - }; -} -var CLEAN = 1024; -var DIRTY = 2048; -var MAYBE_DIRTY = 4096; -var INERT = 8192; -var DESTROYED = 16384; -/** Set once a reaction has run for the first time */ -var REACTION_RAN = 32768; -/** Effect is in the process of getting destroyed. Can be observed in child teardown functions */ -var DESTROYING = 1 << 25; -/** -* 'Transparent' effects do not create a transition boundary. -* This is on a block effect 99% of the time but may also be on a branch effect if its parent block effect was pruned -*/ -var EFFECT_TRANSPARENT = 65536; -var HEAD_EFFECT = 1 << 18; -var EFFECT_PRESERVED = 1 << 19; -var USER_EFFECT = 1 << 20; -var EFFECT_OFFSCREEN = 1 << 25; -/** -* Tells that we marked this derived and its reactions as visited during the "mark as (maybe) dirty"-phase. -* Will be lifted during execution of the derived and during checking its dirty state (both are necessary -* because a derived might be checked but not executed). This is a pure performance optimization flag and -* should not be used for any other purpose! -*/ -var WAS_MARKED = 65536; -var REACTION_IS_UPDATING = 1 << 21; -var ASYNC = 1 << 22; -var ERROR_VALUE = 1 << 23; -var STATE_SYMBOL = Symbol("$state"); -var LOADING_ATTR_SYMBOL = Symbol(""); -/** allow users to ignore aborted signal errors if `reason.name === 'StaleReactionError` */ -var STALE_REACTION = new class StaleReactionError extends Error { - name = "StaleReactionError"; - message = "The reaction that called `getAbortSignal()` was re-run or destroyed"; -}(); -var IS_XHTML = !!globalThis.document?.contentType && /* @__PURE__ */ globalThis.document.contentType.includes("xml"); -//#endregion -//#region node_modules/svelte/src/internal/client/errors.js -/** -* Cannot create a `$derived(...)` with an `await` expression outside of an effect tree -* @returns {never} -*/ -function async_derived_orphan() { - throw new Error(`https://svelte.dev/e/async_derived_orphan`); -} -/** -* Keyed each block has duplicate key `%value%` at indexes %a% and %b% -* @param {string} a -* @param {string} b -* @param {string | undefined | null} [value] -* @returns {never} -*/ -function each_key_duplicate(a, b, value) { - throw new Error(`https://svelte.dev/e/each_key_duplicate`); -} -/** -* `%rune%` cannot be used inside an effect cleanup function -* @param {string} rune -* @returns {never} -*/ -function effect_in_teardown(rune) { - throw new Error(`https://svelte.dev/e/effect_in_teardown`); -} -/** -* Effect cannot be created inside a `$derived` value that was not itself created inside an effect -* @returns {never} -*/ -function effect_in_unowned_derived() { - throw new Error(`https://svelte.dev/e/effect_in_unowned_derived`); -} -/** -* `%rune%` can only be used inside an effect (e.g. during component initialisation) -* @param {string} rune -* @returns {never} -*/ -function effect_orphan(rune) { - throw new Error(`https://svelte.dev/e/effect_orphan`); -} -/** -* Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state -* @returns {never} -*/ -function effect_update_depth_exceeded() { - throw new Error(`https://svelte.dev/e/effect_update_depth_exceeded`); -} -/** -* Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`. -* @returns {never} -*/ -function state_descriptors_fixed() { - throw new Error(`https://svelte.dev/e/state_descriptors_fixed`); -} -/** -* Cannot set prototype of `$state` object -* @returns {never} -*/ -function state_prototype_fixed() { - throw new Error(`https://svelte.dev/e/state_prototype_fixed`); -} -/** -* Updating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state` -* @returns {never} -*/ -function state_unsafe_mutation() { - throw new Error(`https://svelte.dev/e/state_unsafe_mutation`); -} -/** -* A `` `reset` function cannot be called while an error is still being handled -* @returns {never} -*/ -function svelte_boundary_reset_onerror() { - throw new Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`); -} -//#endregion -//#region node_modules/svelte/src/constants.js -var HYDRATION_ERROR = {}; -var UNINITIALIZED = Symbol(); -var NAMESPACE_HTML = "http://www.w3.org/1999/xhtml"; -/** -* Reading a derived belonging to a now-destroyed effect may result in stale values -*/ -function derived_inert() { - console.warn(`https://svelte.dev/e/derived_inert`); -} -/** -* Hydration failed because the initial UI does not match what was rendered on the server. The error occurred near %location% -* @param {string | undefined | null} [location] -*/ -function hydration_mismatch(location) { - console.warn(`https://svelte.dev/e/hydration_mismatch`); -} -/** -* The `value` property of a ``); -var root$7 = /* @__PURE__ */ from_html(`
🔐

Password Vault

`); -function LockScreen($$anchor, $$props) { - push($$props, true); - let masterPassword = /* @__PURE__ */ state(""); - let confirmPassword = /* @__PURE__ */ state(""); - let error = /* @__PURE__ */ state(""); - let loading = /* @__PURE__ */ state(false); - let isSetup = /* @__PURE__ */ state(false); - let notLocal = /* @__PURE__ */ user_derived(() => typeof window !== "undefined" && window.location.protocol !== "file:"); - async function checkVault() { - set(isSetup, !await isVaultInitialized()); +//#region src/components/LockScreen.js +/** +* LockScreen — master password setup + unlock UI. +*/ +var LockScreen = class extends Component { + masterPassword = ""; + confirmPassword = ""; + error = ""; + loading = false; + isSetup = false; + mount() { + super.mount(); + this.#checkVault(); + return this; } - checkVault(); - async function handleSubmit() { - set(error, ""); - set(loading, true); + render() { + const notLocal = typeof window !== "undefined" && window.location.protocol !== "file:"; + this.el = this.ce("div", { className: "lock-screen" }, this.ce("div", { className: "lock-card" }, this.ce("div", { + className: "lock-icon", + textContent: "🔐" + }), this.ce("h1", { textContent: "Password Vault" }), this.ce("p", { + className: "subtitle", + textContent: "Unlock your vault" + }), notLocal ? this.ce("div", { + className: "warning-banner", + role: "alert", + textContent: "This HTML file is intended for offline use." + }) : null, null, this.#buildForm(), this.ce("p", { + className: "hint", + textContent: "Your data is encrypted with AES-256-GCM. Key is stored only in memory." + }))); + this._subtitle = this.q(".subtitle"); + this._confirmGroup = this.q(".confirm-group"); + this._submitBtn = this.q(".submit-btn"); + this._passwordInput = this.q("#master-password"); + this._confirmInput = this.q("#confirm-password"); + this._hint = this.q(".hint"); + autofocus(this._passwordInput, true); + if (this._passwordInput) this.on(this._passwordInput, "input", (e) => { + this.masterPassword = e.target.value; + }); + if (this._confirmInput) this.on(this._confirmInput, "input", (e) => { + this.confirmPassword = e.target.value; + }); + return this.el; + } + #buildForm() { + return this.ce("form", { + className: "lock-form", + id: "lock-form" + }, this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "master-password", + textContent: "Master Password" + }), this.ce("input", { + id: "master-password", + type: "password", + placeholder: "Enter master password", + autocomplete: "current-password" + })), this.ce("div", { className: "form-group confirm-group" }, this.ce("label", { + htmlFor: "confirm-password", + textContent: "Confirm Password" + }), this.ce("input", { + id: "confirm-password", + type: "password", + placeholder: "Confirm master password", + autocomplete: "new-password" + })), this.ce("button", { + type: "submit", + className: "btn btn-primary w-full submit-btn", + textContent: "Unlock" + })); + } + #updateUI() { + if (this._subtitle) this._subtitle.textContent = this.isSetup ? "Create your vault" : "Unlock your vault"; + if (this._confirmGroup) this._confirmGroup.style.display = this.isSetup ? "" : "none"; + if (this._submitBtn) { + this._submitBtn.textContent = this.loading ? "Processing..." : this.isSetup ? "Create Vault" : "Unlock"; + this._submitBtn.disabled = this.loading; + } + if (this._passwordInput) this._passwordInput.disabled = this.loading; + if (this._confirmInput) this._confirmInput.disabled = this.loading; + if (this.error) if (!this._errorBanner) { + const banner = this.ce("div", { + className: "error-banner", + role: "alert", + textContent: this.error + }); + const form = this.q("#lock-form"); + form?.parentNode?.insertBefore(banner, form); + this._errorBanner = banner; + } else this._errorBanner.textContent = this.error; + else if (this._errorBanner) { + this._errorBanner.remove(); + this._errorBanner = null; + } + if (this._hint) this._hint.textContent = this.isSetup ? "Your master password encrypts all data locally. It cannot be recovered if lost." : "Your data is encrypted with AES-256-GCM. Key is stored only in memory."; + } + #checkVault() { + isVaultInitialized().then((init) => { + this.isSetup = !init; + this.#updateUI(); + }); + } + #handleSubmit = async (e) => { + e.preventDefault(); + this.error = ""; + this.loading = true; + this.#updateUI(); try { - if (get(isSetup)) { - if (!get(masterPassword) || get(masterPassword).length < 4) { - set(error, "Password must be at least 4 characters"); - set(loading, false); + if (this.isSetup) { + if (!this.masterPassword || this.masterPassword.length < 4) { + this.error = "Password must be at least 4 characters"; + this.loading = false; + this.#updateUI(); return; } - if (get(masterPassword) !== get(confirmPassword)) { - set(error, "Passwords do not match"); - set(loading, false); + if (this.masterPassword !== this.confirmPassword) { + this.error = "Passwords do not match"; + this.loading = false; + this.#updateUI(); return; } - const { salt, testEncrypted, testPlaintext } = await createTestPayload(get(masterPassword)); + const { salt, testEncrypted, testPlaintext } = await createTestPayload(this.masterPassword); app$1.salt = salt; - app$1.encryptionKey = await deriveKey(get(masterPassword), salt); + app$1.encryptionKey = await deriveKey(this.masterPassword, salt); await saveVaultMeta(salt, testEncrypted, testPlaintext); await ensureTrashGroup(); await settings.load(); @@ -5857,14 +1441,16 @@ function LockScreen($$anchor, $$props) { } else { const meta = await loadVaultMeta(); if (!meta.salt || !meta.testEncrypted || !meta.testPlaintext) { - set(error, "Vault data corrupted"); - set(loading, false); + this.error = "Vault data corrupted"; + this.loading = false; + this.#updateUI(); return; } - const key = await deriveKey(get(masterPassword), meta.salt); - if (!await verifyPassword(get(masterPassword), meta.salt, meta.testEncrypted, meta.testPlaintext)) { - set(error, "Incorrect password"); - set(loading, false); + const key = await deriveKey(this.masterPassword, meta.salt); + if (!await verifyPassword(this.masterPassword, meta.salt, meta.testEncrypted, meta.testPlaintext)) { + this.error = "Incorrect password"; + this.loading = false; + this.#updateUI(); return; } app$1.salt = meta.salt; @@ -5873,113 +1459,43 @@ function LockScreen($$anchor, $$props) { app$1.isUnlocked = true; startAutoLock(); } - } catch (e) { - console.error(e); - set(error, "An error occurred: " + e.message); + } catch (err) { + console.error(err); + this.error = "An error occurred: " + err.message; } - set(loading, false); - set(masterPassword, ""); - set(confirmPassword, ""); + this.loading = false; + this.masterPassword = ""; + this.confirmPassword = ""; + if (this._passwordInput) this._passwordInput.value = ""; + if (this._confirmInput) this._confirmInput.value = ""; + this.#updateUI(); + }; + afterMount() { + const form = this.q("#lock-form"); + if (form) this.on(form, "submit", this.#handleSubmit); } - var div = root$7(); - var div_1 = child(div); - var p = sibling(child(div_1), 4); - var text = child(p, true); - reset(p); - var node = sibling(p, 2); - var consequent = ($$anchor) => { - append($$anchor, root_1$7()); - }; - if_block(node, ($$render) => { - if (get(notLocal)) $$render(consequent); - }); - var node_1 = sibling(node, 2); - var consequent_1 = ($$anchor) => { - var div_3 = root_2$6(); - var text_1 = child(div_3, true); - reset(div_3); - template_effect(() => set_text(text_1, get(error))); - append($$anchor, div_3); - }; - if_block(node_1, ($$render) => { - if (get(error)) $$render(consequent_1); - }); - var form = sibling(node_1, 2); - var div_4 = child(form); - var input = sibling(child(div_4), 2); - remove_input_defaults(input); - effect(() => bind_value(input, () => get(masterPassword), ($$value) => set(masterPassword, $$value))); - action(input, ($$node) => autofocus?.($$node)); - reset(div_4); - var node_2 = sibling(div_4, 2); - var consequent_2 = ($$anchor) => { - var div_5 = root_3$6(); - var input_1 = sibling(child(div_5), 2); - remove_input_defaults(input_1); - reset(div_5); - template_effect(() => input_1.disabled = get(loading)); - bind_value(input_1, () => get(confirmPassword), ($$value) => set(confirmPassword, $$value)); - append($$anchor, div_5); - }; - if_block(node_2, ($$render) => { - if (get(isSetup)) $$render(consequent_2); - }); - var button = sibling(node_2, 2); - var text_2 = child(button, true); - reset(button); - reset(form); - var p_1 = sibling(form, 2); - var text_3 = child(p_1, true); - reset(p_1); - reset(div_1); - reset(div); - template_effect(() => { - set_text(text, get(isSetup) ? "Create your vault" : "Unlock your vault"); - input.disabled = get(loading); - button.disabled = get(loading); - set_text(text_2, get(loading) ? "Processing..." : get(isSetup) ? "Create Vault" : "Unlock"); - set_text(text_3, get(isSetup) ? "Your master password encrypts all data locally. It cannot be recovered if lost." : "Your data is encrypted with AES-256-GCM. Key is stored only in memory."); - }); - event("submit", form, (e) => { - e.preventDefault(); - handleSubmit(); - }); - append($$anchor, div); - pop(); -} +}; //#endregion -//#region src/lib/stores/search.svelte.js +//#region src/lib/stores/search.js +/** +* Search and filter state. +* Shared between Sidebar and EntryList for coordinated filtering. +*/ var DEBOUNCE_MS = 300; -var SearchStore = class { - #query = /* @__PURE__ */ state(""); - get query() { - return get(this.#query); - } - set query(value) { - 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"); - get activeGroupId() { - return get(this.#activeGroupId); - } - set activeGroupId(value) { - set(this.#activeGroupId, value, true); - } - #refreshTrigger = /* @__PURE__ */ state(0); - get refreshTrigger() { - return get(this.#refreshTrigger); - } - set refreshTrigger(value) { - set(this.#refreshTrigger, value, true); - } +var SearchStore = class extends Store { #debounceTimer = null; + constructor() { + super({ + query: "", + debouncedQuery: "", + activeGroupId: "all", + refreshTrigger: 0 + }); + } + /** + * 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); @@ -6003,544 +1519,746 @@ var SearchStore = class { }; var search = new SearchStore(); //#endregion -//#region src/components/Sidebar.svelte -var root_2$5 = /* @__PURE__ */ from_html(`
`); -var root_4$5 = /* @__PURE__ */ from_html(`
`); -var root_5$5 = /* @__PURE__ */ from_html(``); -var root_3$5 = /* @__PURE__ */ from_html(``); -var root_6$3 = /* @__PURE__ */ from_html(``); -var root$6 = /* @__PURE__ */ from_html(``); -function Sidebar($$anchor, $$props) { - push($$props, true); - let groups = /* @__PURE__ */ state(proxy([])); - let showGroupForm = /* @__PURE__ */ state(false); - let editingGroupId = /* @__PURE__ */ state(null); - let groupName = /* @__PURE__ */ state(""); - let groupColor = /* @__PURE__ */ state("#6c63ff"); - let groupError = /* @__PURE__ */ state(""); - let showDeleteGroupConfirm = /* @__PURE__ */ state(null); - let deletingGroup = /* @__PURE__ */ user_derived(() => get(groups).find((g) => g.id === get(showDeleteGroupConfirm))); - let dragOverGroupId = /* @__PURE__ */ state(null); - let droppedGroupId = /* @__PURE__ */ state(null); - async function handleDrop(groupId, entryId) { - try { - await moveEntryToGroup(entryId, groupId); - set(droppedGroupId, groupId, true); - setTimeout(() => { - set(droppedGroupId, null); - }, 600); - await loadData(); - search.refresh(); - } catch (e) {} - } - function canDrop(groupId) { - return groupId !== search.activeGroupId && !isTrashGroup(groupId); - } - async function loadData() { - await ensureTrashGroup(); - set(groups, await getGroups(), true); - } - user_effect(() => { - search.refreshTrigger; - loadData(); - }); - function openGroupForm(group = null) { - if (group) { - set(editingGroupId, group.id, true); - set(groupName, group.name, true); - set(groupColor, group.color || "#6c63ff", true); - } else { - set(editingGroupId, null); - set(groupName, ""); - set(groupColor, GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)], true); +//#region src/components/Sidebar.js +/** +* Sidebar — group list + search bar + group management. +*/ +var Sidebar = class extends Component { + groups = []; + showGroupForm = false; + editingGroupId = null; + groupName = ""; + groupColor = "#6c63ff"; + groupError = ""; + showDeleteGroupConfirm = null; + dragOverGroupId = null; + droppedGroupId = null; + mount() { + super.mount(); + this.subscribe(search, "refreshTrigger", () => this.#loadData()); + this.subscribe(search, "activeGroupId", () => { + this.#renderGroups(); + this.#updateTrashButton(); + }); + const nav = this.q(".groups-nav"); + if (nav) { + this.on(nav, "click", (e) => { + const editBtn = e.target.closest(".group-action-btn[title=\"Edit group\"]"); + if (editBtn) { + e.stopPropagation(); + const groupId = editBtn.closest(".group-row")?.querySelector("[data-group-id]")?.dataset.groupId; + const group = this.groups.find((g) => g.id === groupId); + if (group) this.#openGroupForm(group); + return; + } + const delBtn = e.target.closest(".group-action-btn[title=\"Delete group\"]"); + if (delBtn) { + e.stopPropagation(); + const groupId = delBtn.closest(".group-row")?.querySelector("[data-group-id]")?.dataset.groupId; + if (groupId) { + this.showDeleteGroupConfirm = groupId; + this.#renderDeleteModal(); + } + return; + } + const groupBtn = e.target.closest(".group-item[data-group-id]"); + if (groupBtn) { + search.activeGroupId = groupBtn.dataset.groupId; + return; + } + if (e.target.closest("#all-entries-btn")) search.activeGroupId = "all"; + }); + this.on(nav, "dragover", (e) => { + const btn = e.target.closest(".group-item[data-group-id]"); + if (btn) { + const gid = btn.dataset.groupId; + if (this.#canDrop(gid)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + this.dragOverGroupId = gid; + btn.classList.add("drag-over"); + } + } + }); + this.on(nav, "dragleave", (e) => { + const btn = e.target.closest(".group-item[data-group-id]"); + if (btn && this.dragOverGroupId === btn.dataset.groupId) { + this.dragOverGroupId = null; + btn.classList.remove("drag-over"); + } + }); + this.on(nav, "drop", (e) => { + e.preventDefault(); + const btn = e.target.closest(".group-item[data-group-id]"); + if (btn) { + btn.classList.remove("drag-over"); + const gid = btn.dataset.groupId; + if (this.#canDrop(gid)) { + const entryId = e.dataTransfer.getData("text/plain"); + if (entryId) this.#handleDrop(gid, entryId); + } + } + this.dragOverGroupId = null; + }); } - set(groupError, ""); - set(showGroupForm, true); + this.#loadData(); + return this; } - async function saveGroup() { - set(groupError, ""); - const validation = validateGroup(get(groupName)); + render() { + this.el = this.ce("div", { className: "sidebar-content" }, this.ce("div", { className: "sidebar-header" }, this.ce("h2", { textContent: "🔐 Vault" })), this.ce("div", { className: "search-box" }, this.ce("input", { + id: "sidebar-search", + type: "text", + placeholder: "Search entries..." + })), this.ce("nav", { className: "groups-nav" }), this.ce("div", { className: "trash-section" }, this.ce("button", { + className: "group-item trash-btn", + id: "trash-btn" + }, this.ce("span", { + className: "group-color", + style: `background-color: ${TRASH_GROUP_COLOR}` + }), this.ce("span", { + className: "group-name", + textContent: TRASH_GROUP_NAME + }))), this.ce("div", { className: "sidebar-footer" }, this.ce("button", { + className: "btn btn-ghost btn-sm w-full", + id: "new-group-btn", + textContent: "+ New Group" + }))); + const searchInput = this.q("#sidebar-search"); + if (searchInput) this.on(searchInput, "input", (e) => search.setSearchQuery(e.target.value)); + const trashBtn = this.q("#trash-btn"); + if (trashBtn) this.on(trashBtn, "click", () => { + search.activeGroupId = "trash"; + }); + const newGroupBtn = this.q("#new-group-btn"); + if (newGroupBtn) this.on(newGroupBtn, "click", () => this.#openGroupForm(null)); + this.#renderGroups(); + this.#updateTrashButton(); + return this.el; + } + #renderGroups() { + const nav = this.q(".groups-nav"); + if (!nav) return; + nav.innerHTML = ""; + const allBtn = this.ce("button", { + className: `group-item${search.activeGroupId === "all" ? " active" : ""}`, + id: "all-entries-btn" + }, this.ce("span", { + className: "group-icon", + textContent: "📋" + }), this.ce("span", { + className: "group-name", + textContent: "All Entries" + })); + nav.appendChild(allBtn); + for (const group of this.groups) { + if (isTrashGroup(group.id)) continue; + const row = this.ce("div", { className: "group-row" }); + const btn = this.ce("button", { + className: `group-item${search.activeGroupId === group.id ? " active" : ""}`, + "data-group-id": group.id + }, this.ce("span", { + className: "group-color", + style: `background-color: ${group.color || "#6c63ff"}` + }), this.ce("span", { + className: "group-name", + textContent: group.name + }), this.ce("span", { + className: "drop-icon", + textContent: "📥" + })); + row.appendChild(btn); + const actions = this.ce("div", { className: "group-actions" }); + actions.appendChild(this.ce("button", { + className: "group-action-btn", + title: "Edit group", + textContent: "✏️" + })); + actions.appendChild(this.ce("button", { + className: "group-action-btn", + title: "Delete group", + textContent: "🗑" + })); + row.appendChild(actions); + nav.appendChild(row); + } + } + #renderDeleteModal() { + const existing = this.q(".sb-modal-overlay"); + if (existing) existing.remove(); + if (!this.showDeleteGroupConfirm) return; + const group = this.groups.find((g) => g.id === this.showDeleteGroupConfirm); + if (!group) return; + const overlay = this.ce("div", { + className: "sb-modal-overlay", + role: "presentation" + }, this.ce("div", { + className: "sb-modal", + role: "dialog", + "aria-modal": "true", + "aria-label": "Delete group confirmation", + tabindex: "-1" + }, this.ce("h3", { textContent: "Delete Group" }), this.ce("p", {}, this.text(`Delete "`), this.ce("strong", { textContent: group.name }), this.text(`"? Entries in this group will become ungrouped.`)), this.ce("div", { className: "sb-modal-actions" }, this.ce("button", { + className: "btn btn-danger", + id: "confirm-delete-group-btn", + textContent: "Yes, delete" + }), this.ce("button", { + className: "btn btn-ghost", + id: "cancel-delete-group-btn", + textContent: "Cancel" + })))); + this.el.appendChild(overlay); + this.on(overlay, "click", () => { + this.showDeleteGroupConfirm = null; + overlay.remove(); + }); + this.on(overlay.querySelector(".sb-modal"), "click", (e) => e.stopPropagation()); + this.on(overlay.querySelector("#confirm-delete-group-btn"), "click", () => this.#confirmDeleteGroup(this.showDeleteGroupConfirm)); + this.on(overlay.querySelector("#cancel-delete-group-btn"), "click", () => { + this.showDeleteGroupConfirm = null; + overlay.remove(); + }); + } + #renderGroupFormModal() { + const existing = this.q(".sb-modal-overlay"); + if (existing) existing.remove(); + if (!this.showGroupForm) return; + const overlay = this.ce("div", { + className: "sb-modal-overlay", + role: "presentation" + }, this.ce("div", { + className: "sb-modal", + role: "dialog", + "aria-modal": "true", + "aria-label": "Group settings", + tabindex: "-1" + }, this.ce("h3", { textContent: this.editingGroupId ? "Edit Group" : "New Group" }), this.groupError ? this.ce("div", { + className: "ie-error-banner", + textContent: this.groupError + }) : null, this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "group-name", + textContent: "Group Name" + }), this.ce("input", { + id: "group-name", + type: "text", + placeholder: "e.g. Work, Personal", + value: this.groupName + })), this.ce("div", { className: "form-group" }, this.ce("span", { + className: "field-label", + textContent: "Color" + }), this.#buildColorPicker()), this.ce("div", { className: "sb-modal-actions" }, this.ce("button", { + className: "btn btn-primary", + id: "save-group-btn", + textContent: this.editingGroupId ? "Update" : "Create" + }), this.ce("button", { + className: "btn btn-ghost", + id: "cancel-group-btn", + textContent: "Cancel" + })))); + this.el.appendChild(overlay); + this.on(overlay, "click", () => { + this.showGroupForm = false; + overlay.remove(); + }); + this.on(overlay.querySelector(".sb-modal"), "click", (e) => e.stopPropagation()); + this.on(overlay.querySelector("#save-group-btn"), "click", () => this.#saveGroup()); + this.on(overlay.querySelector("#cancel-group-btn"), "click", () => { + this.showGroupForm = false; + overlay.remove(); + }); + const nameInput = overlay.querySelector("#group-name"); + if (nameInput) { + this.groupName = nameInput.value; + this.on(nameInput, "input", (e) => { + this.groupName = e.target.value; + }); + if (!this.editingGroupId) autofocus(nameInput, true); + } + } + #buildColorPicker() { + const picker = this.ce("div", { className: "color-picker" }); + for (const color of GROUP_COLORS) { + const swatch = this.ce("button", { + className: `color-swatch${this.groupColor === color ? " selected" : ""}`, + style: `background-color: ${color}`, + title: color, + type: "button" + }); + const c = color; + this.on(swatch, "click", () => { + this.groupColor = c; + this.qa(".color-swatch").forEach((s) => s.classList.remove("selected")); + swatch.classList.add("selected"); + }); + picker.appendChild(swatch); + } + return picker; + } + #openGroupForm(group) { + if (group) { + this.editingGroupId = group.id; + this.groupName = group.name; + this.groupColor = group.color || "#6c63ff"; + } else { + this.editingGroupId = null; + this.groupName = ""; + this.groupColor = GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)]; + } + this.groupError = ""; + this.showGroupForm = true; + this.#renderGroupFormModal(); + } + async #saveGroup() { + this.groupError = ""; + const nameInput = this.q("#group-name"); + if (nameInput) this.groupName = nameInput.value; + const validation = validateGroup(this.groupName); if (!validation.valid) { - set(groupError, validation.errors[0], true); + this.groupError = validation.errors[0]; + this.#renderGroupFormModal(); return; } try { - if (get(editingGroupId)) await updateGroup({ - ...get(groups).find((g) => g.id === get(editingGroupId)), - name: get(groupName).trim(), - color: get(groupColor) + if (this.editingGroupId) await updateGroup({ + ...this.groups.find((g) => g.id === this.editingGroupId), + name: this.groupName.trim(), + color: this.groupColor }); - else await addGroup(createGroup(get(groupName), get(groupColor))); - set(showGroupForm, false); - await loadData(); + else await addGroup(createGroup(this.groupName, this.groupColor)); + this.showGroupForm = false; + const overlay = this.q(".sb-modal-overlay"); + if (overlay) overlay.remove(); + await this.#loadData(); } catch (e) { - set(groupError, "Failed to save group: " + e.message); + this.groupError = "Failed to save group: " + e.message; + this.#renderGroupFormModal(); } } - async function confirmDeleteGroup(groupId) { + async #confirmDeleteGroup(groupId) { try { await deleteGroup(groupId); if (search.activeGroupId === groupId) search.activeGroupId = "all"; - set(showDeleteGroupConfirm, null); - await loadData(); + this.showDeleteGroupConfirm = null; + const overlay = this.q(".sb-modal-overlay"); + if (overlay) overlay.remove(); + await this.#loadData(); } catch (e) { - set(groupError, "Failed to delete group: " + e.message); + this.groupError = "Failed to delete group: " + e.message; } } - var div = root$6(); - var div_1 = sibling(child(div), 2); - var input = child(div_1); - remove_input_defaults(input); - reset(div_1); - var nav = sibling(div_1, 2); - var button = child(nav); - each(sibling(button, 2), 17, () => get(groups), index, ($$anchor, group) => { - var fragment = comment(); - var node_1 = first_child(fragment); - var consequent = ($$anchor) => { - var div_2 = root_2$5(); - var button_1 = child(div_2); - var span = child(button_1); - var span_1 = sibling(span, 2); - var text = child(span_1, true); - reset(span_1); - next(2); - reset(button_1); - var div_3 = sibling(button_1, 2); - var button_2 = child(div_3); - var button_3 = sibling(button_2, 2); - reset(div_3); - reset(div_2); - template_effect(() => { - set_class(button_1, 1, `group-item ${search.activeGroupId === get(group).id ? "active" : ""} ${get(dragOverGroupId) === get(group).id ? "drag-over" : ""} ${get(droppedGroupId) === get(group).id ? "dropped" : ""}`, "svelte-181dlmc"); - set_style(span, `background-color: ${(get(group).color || "#6c63ff") ?? ""}`); - set_text(text, get(group).name); - }); - delegated("click", button_1, () => search.activeGroupId = get(group).id); - event("dragover", button_1, (e) => { - if (canDrop(get(group).id)) { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - set(dragOverGroupId, get(group).id, true); - } - }); - event("dragleave", button_1, () => { - if (get(dragOverGroupId) === get(group).id) set(dragOverGroupId, null); - }); - event("drop", button_1, (e) => { - e.preventDefault(); - set(dragOverGroupId, null); - if (canDrop(get(group).id)) { - const entryId = e.dataTransfer.getData("text/plain"); - if (entryId) handleDrop(get(group).id, entryId); - } - }); - delegated("click", button_2, () => openGroupForm(get(group))); - delegated("click", button_3, () => set(showDeleteGroupConfirm, get(group).id, true)); - append($$anchor, div_2); - }; - var d = /* @__PURE__ */ user_derived(() => !isTrashGroup(get(group).id)); - if_block(node_1, ($$render) => { - if (get(d)) $$render(consequent); - }); - append($$anchor, fragment); - }); - reset(nav); - var div_4 = sibling(nav, 2); - var button_4 = child(div_4); - var span_2 = child(button_4); - var span_3 = sibling(span_2, 2); - var text_1 = child(span_3, true); - reset(span_3); - reset(button_4); - reset(div_4); - var div_5 = sibling(div_4, 2); - var button_5 = child(div_5); - reset(div_5); - var node_2 = sibling(div_5, 2); - var consequent_2 = ($$anchor) => { - var div_6 = root_3$5(); - var div_7 = child(div_6); - var h3 = child(div_7); - var text_2 = child(h3, true); - reset(h3); - var node_3 = sibling(h3, 2); - var consequent_1 = ($$anchor) => { - var div_8 = root_4$5(); - var text_3 = child(div_8, true); - reset(div_8); - template_effect(() => set_text(text_3, get(groupError))); - append($$anchor, div_8); - }; - if_block(node_3, ($$render) => { - if (get(groupError)) $$render(consequent_1); - }); - var div_9 = sibling(node_3, 2); - var input_1 = sibling(child(div_9), 2); - 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_9); - var div_10 = sibling(div_9, 2); - var div_11 = sibling(child(div_10), 2); - each(div_11, 21, () => GROUP_COLORS, index, ($$anchor, color) => { - var button_6 = root_5$5(); - template_effect(() => { - set_class(button_6, 1, `color-swatch ${get(groupColor) === get(color) ? "selected" : ""}`, "svelte-181dlmc"); - set_style(button_6, `background-color: ${get(color) ?? ""}`); - set_attribute(button_6, "title", get(color)); - }); - delegated("click", button_6, () => set(groupColor, get(color), true)); - append($$anchor, button_6); - }); - reset(div_11); - reset(div_10); - var div_12 = sibling(div_10, 2); - var button_7 = child(div_12); - var text_4 = child(button_7, true); - reset(button_7); - var button_8 = sibling(button_7, 2); - reset(div_12); - reset(div_7); - reset(div_6); - template_effect(() => { - set_text(text_2, get(editingGroupId) ? "Edit Group" : "New Group"); - set_text(text_4, get(editingGroupId) ? "Update" : "Create"); - }); - delegated("click", div_6, () => set(showGroupForm, false)); - delegated("click", div_7, (e) => e.stopPropagation()); - delegated("click", button_7, saveGroup); - delegated("click", button_8, () => set(showGroupForm, false)); - append($$anchor, div_6); - }; - if_block(node_2, ($$render) => { - if (get(showGroupForm)) $$render(consequent_2); - }); - var node_4 = sibling(node_2, 2); - var consequent_3 = ($$anchor) => { - var div_13 = root_6$3(); - var div_14 = child(div_13); - var p = sibling(child(div_14), 2); - var strong = sibling(child(p)); - var text_5 = child(strong, true); - reset(strong); - next(); - reset(p); - var div_15 = sibling(p, 2); - var button_9 = child(div_15); - var button_10 = sibling(button_9, 2); - reset(div_15); - reset(div_14); - reset(div_13); - template_effect(() => set_text(text_5, get(deletingGroup).name)); - delegated("click", div_13, () => set(showDeleteGroupConfirm, null)); - delegated("click", div_14, (e) => e.stopPropagation()); - delegated("click", button_9, () => confirmDeleteGroup(get(deletingGroup).id)); - delegated("click", button_10, () => set(showDeleteGroupConfirm, null)); - append($$anchor, div_13); - }; - if_block(node_4, ($$render) => { - if (get(deletingGroup)) $$render(consequent_3); - }); - reset(div); - template_effect(() => { - set_value(input, search.query); - set_class(button, 1, `group-item ${search.activeGroupId === "all" ? "active" : ""}`, "svelte-181dlmc"); - set_class(button_4, 1, `group-item ${search.activeGroupId === "trash" ? "active" : ""}`, "svelte-181dlmc"); - set_style(span_2, `background-color: #e5484d`); - set_text(text_1, TRASH_GROUP_NAME); - }); - delegated("input", input, (e) => search.setSearchQuery(e.target.value)); - delegated("click", button, () => search.activeGroupId = "all"); - delegated("click", button_4, () => search.activeGroupId = "trash"); - delegated("click", button_5, () => openGroupForm(null)); - append($$anchor, div); - pop(); -} -delegate(["input", "click"]); + #updateTrashButton() { + const trashBtn = this.q("#trash-btn"); + if (trashBtn) trashBtn.classList.toggle("active", search.activeGroupId === "trash"); + } + async #handleDrop(groupId, entryId) { + try { + await moveEntryToGroup(entryId, groupId); + this.droppedGroupId = groupId; + const btn = this.q(`[data-group-id="${groupId}"]`); + if (btn) { + btn.classList.add("dropped"); + setTimeout(() => btn.classList.remove("dropped"), 600); + } + await this.#loadData(); + search.refresh(); + } catch (e) {} + } + #canDrop(groupId) { + return groupId !== search.activeGroupId && !isTrashGroup(groupId); + } + async #loadData() { + await ensureTrashGroup(); + this.groups = await getGroups(); + this.#renderGroups(); + } +}; //#endregion -//#region src/components/EntryList.svelte -var root_1$6 = /* @__PURE__ */ from_html(`
Loading entries...
`); -var root_2$4 = /* @__PURE__ */ from_html(`
`); -var root_4$4 = /* @__PURE__ */ from_html(``); -var root_3$4 = /* @__PURE__ */ from_html(`

`); -var root_6$2 = /* @__PURE__ */ from_html(`matching " "`, 1); -var root_7$4 = /* @__PURE__ */ from_html(``); -var root_9$1 = /* @__PURE__ */ from_html(``); -var root_10$1 = /* @__PURE__ */ from_html(`
🔍
`); -var root_11$1 = /* @__PURE__ */ from_html(``); -var root_12$1 = /* @__PURE__ */ from_html(``); -var root_8$3 = /* @__PURE__ */ from_html(` `); -var root_5$4 = /* @__PURE__ */ from_html(`
TitleUsernameURLNotes
`, 1); -var root$5 = /* @__PURE__ */ from_html(`
`); -function EntryList($$anchor, $$props) { - push($$props, true); - let entries = /* @__PURE__ */ state(proxy([])); - let loading = /* @__PURE__ */ state(true); - let error = /* @__PURE__ */ state(""); - let resultCount = /* @__PURE__ */ state(0); - let dragging = /* @__PURE__ */ state(false); - const isTrashView = /* @__PURE__ */ user_derived(() => search.activeGroupId === "trash"); - async function loadEntries() { - set(loading, true); - set(error, ""); +//#region src/components/EntryList.js +/** +* EntryList — credential entries grid with search/filter support. +*/ +var EntryList = class extends Component { + entries = []; + loading = true; + error = ""; + resultCount = 0; + dragging = false; + /** @param {{ onSelect: Function, onAdd: Function }} props */ + constructor(container, props = {}) { + super(container); + this.onSelect = props.onSelect || (() => {}); + this.onAdd = props.onAdd || (() => {}); + } + mount() { + super.mount(); + this.subscribe(search, "debouncedQuery", () => this.#loadEntries()); + this.subscribe(search, "activeGroupId", () => this.#loadEntries()); + this.subscribe(search, "refreshTrigger", () => this.#loadEntries()); + this.#loadEntries(); + return this; + } + render() { + this.el = this.ce("div", { className: "entry-list" }); + this.#renderContent(); + return this.el; + } + #renderContent() { + this.el.innerHTML = ""; + const isTrashView = search.activeGroupId === "trash"; + if (this.loading) { + this.el.appendChild(this.ce("div", { + className: "loading", + textContent: "Loading entries..." + })); + return; + } + if (this.error) { + this.el.appendChild(this.ce("div", { + className: "error-banner", + textContent: this.error + })); + return; + } + if (this.entries.length === 0) { + const emptyState = this.ce("div", { className: "empty-state" }); + const icon = search.query ? "🔍" : isTrashView ? "🗑" : "🔑"; + const text = search.query ? "No results found" : isTrashView ? "Trash is empty" : "No entries yet"; + const hint = search.query ? "Try a different search term" : isTrashView ? "Deleted entries will appear here" : "Add your first login credential to get started"; + emptyState.appendChild(this.ce("p", { + className: "empty-icon", + textContent: icon + })); + emptyState.appendChild(this.ce("p", { + className: "empty-text", + textContent: text + })); + emptyState.appendChild(this.ce("p", { + className: "empty-hint", + textContent: hint + })); + if (!search.query && !isTrashView) { + const addBtn = this.ce("button", { + className: "btn btn-primary mt-3", + textContent: "+ New Entry" + }); + this.on(addBtn, "click", () => this.onAdd()); + emptyState.appendChild(addBtn); + } + this.el.appendChild(emptyState); + return; + } + const info = this.ce("div", { className: "results-info" }); + const countSpan = this.ce("span", { className: "text-sm text-muted" }); + countSpan.appendChild(this.text(`${this.resultCount} entr${this.resultCount === 1 ? "y" : "ies"}`)); + if (search.query) { + countSpan.appendChild(this.text(" matching \"")); + const strong = document.createElement("strong"); + strong.textContent = this.#escapeHtml(search.query); + countSpan.appendChild(strong); + countSpan.appendChild(this.text("\"")); + } + info.appendChild(countSpan); + this.el.appendChild(info); + const table = this.ce("table", { className: "entries-table" }, this.ce("thead", null, this.ce("tr", null, this.ce("th", { textContent: "Title" }), this.ce("th", { textContent: "Username" }), this.ce("th", { textContent: "URL" }), this.ce("th", { textContent: "Notes" }), isTrashView ? this.ce("th", { style: "width: 60px" }) : null)), this.ce("tbody")); + const tbody = table.querySelector("tbody"); + for (const entry of this.entries) { + const tr = this.ce("tr", { + className: `entry-row${this.dragging ? " dragging" : ""}`, + draggable: !isTrashView + }); + const tdTitle = this.ce("td"); + if (!isTrashView) tdTitle.appendChild(this.ce("span", { + className: "drag-handle", + "aria-hidden": "true", + textContent: "⠿" + })); + tdTitle.appendChild(this.ce("span", { + className: "entry-title", + textContent: entry.title + })); + tr.appendChild(tdTitle); + tr.appendChild(this.ce("td", null, this.ce("span", { + className: "entry-username", + textContent: entry.username || "—" + }))); + tr.appendChild(this.ce("td", null, this.ce("span", { + className: "entry-url truncate", + textContent: entry.url || "—" + }))); + const tdNotes = this.ce("td"); + if (entry.notes) { + const tooltip = this.ce("div", { className: "notes-tooltip" }, this.ce("span", { + className: "notes-icon", + title: entry.notes, + textContent: "🔍" + }), this.ce("div", { + className: "tooltip-popup", + textContent: entry.notes + })); + tdNotes.appendChild(tooltip); + } else tdNotes.appendChild(this.ce("span", { textContent: "—" })); + tr.appendChild(tdNotes); + if (isTrashView) { + const tdRestore = this.ce("td"); + const eid = entry.id; + const restoreBtn = this.ce("button", { + className: "btn btn-ghost btn-sm restore-btn", + title: "Restore entry", + textContent: "↩️" + }); + this.on(restoreBtn, "click", (e) => { + e.stopPropagation(); + this.#handleRestore(eid); + }); + tdRestore.appendChild(restoreBtn); + tr.appendChild(tdRestore); + } + const eid = entry.id; + this.on(tr, "click", () => this.onSelect(eid)); + if (!isTrashView) { + this.on(tr, "dragstart", (e) => { + this.dragging = true; + e.dataTransfer.setData("text/plain", eid); + e.dataTransfer.effectAllowed = "move"; + }); + this.on(tr, "dragend", () => { + this.dragging = false; + }); + } + tbody.appendChild(tr); + } + this.el.appendChild(table); + } + async #loadEntries() { + this.loading = true; + this.error = ""; try { const query = search.debouncedQuery.trim(); const groupId = search.activeGroupId; const resolvedGroupId = groupId === "trash" ? TRASH_GROUP_ID : groupId; - if (query) set(entries, await searchEntries(query, resolvedGroupId !== "all" ? { groupId: resolvedGroupId } : {}), true); - else if (resolvedGroupId !== "all") set(entries, await getEntries({ groupId: resolvedGroupId }), true); - else set(entries, (await getEntries()).filter((e) => e.groupId !== TRASH_GROUP_ID), true); - set(resultCount, get(entries).length, true); + if (query) { + const options = resolvedGroupId !== "all" ? { groupId: resolvedGroupId } : {}; + this.entries = await searchEntries(query, options); + } else if (resolvedGroupId !== "all") this.entries = await getEntries({ groupId: resolvedGroupId }); + else this.entries = (await getEntries()).filter((e) => e.groupId !== TRASH_GROUP_ID); + this.resultCount = this.entries.length; } catch (e) { - set(error, "Failed to load entries: " + e.message); + this.error = "Failed to load entries: " + e.message; } - set(loading, false); + this.loading = false; + this.#renderContent(); } - async function handleRestore(entryId) { + async #handleRestore(entryId) { try { await restoreEntry(entryId); search.refresh(); } catch (e) { - set(error, "Failed to restore: " + e.message); + this.error = "Failed to restore: " + e.message; + this.#renderContent(); } } - user_effect(() => { - search.debouncedQuery; - search.activeGroupId; - search.refreshTrigger; - loadEntries(); - }); - var div = root$5(); - var node = child(div); - var consequent = ($$anchor) => { - append($$anchor, root_1$6()); - }; - var consequent_1 = ($$anchor) => { - var div_2 = root_2$4(); - var text = child(div_2, true); - reset(div_2); - template_effect(() => set_text(text, get(error))); - append($$anchor, div_2); - }; - var consequent_3 = ($$anchor) => { - var div_3 = root_3$4(); - var p = child(div_3); - var text_1 = child(p, true); - reset(p); - var p_1 = sibling(p, 2); - var text_2 = child(p_1, true); - reset(p_1); - var p_2 = sibling(p_1, 2); - var text_3 = child(p_2, true); - reset(p_2); - var node_1 = sibling(p_2, 2); - var consequent_2 = ($$anchor) => { - var button = root_4$4(); - delegated("click", button, function(...$$args) { - $$props.onAdd?.apply(this, $$args); - }); - append($$anchor, button); - }; - if_block(node_1, ($$render) => { - if (!search.query && !get(isTrashView)) $$render(consequent_2); - }); - reset(div_3); - template_effect(() => { - set_text(text_1, search.query ? "🔍" : get(isTrashView) ? "🗑" : "🔑"); - set_text(text_2, search.query ? "No results found" : get(isTrashView) ? "Trash is empty" : "No entries yet"); - set_text(text_3, search.query ? "Try a different search term" : get(isTrashView) ? "Deleted entries will appear here" : "Add your first login credential to get started"); - }); - append($$anchor, div_3); - }; - var alternate_1 = ($$anchor) => { - var fragment = root_5$4(); - var div_4 = first_child(fragment); - var span = child(div_4); - var text_4 = child(span); - var node_2 = sibling(text_4); - var consequent_4 = ($$anchor) => { - var fragment_1 = root_6$2(); - var strong = sibling(first_child(fragment_1)); - var text_5 = child(strong, true); - reset(strong); - next(); - template_effect(() => set_text(text_5, search.query)); - append($$anchor, fragment_1); - }; - if_block(node_2, ($$render) => { - if (search.query) $$render(consequent_4); - }); - reset(span); - reset(div_4); - var table = sibling(div_4, 2); - var thead = child(table); - var tr = child(thead); - var node_3 = sibling(child(tr), 4); - var consequent_5 = ($$anchor) => { - append($$anchor, root_7$4()); - }; - if_block(node_3, ($$render) => { - if (get(isTrashView)) $$render(consequent_5); - }); - reset(tr); - reset(thead); - var tbody = sibling(thead); - each(tbody, 21, () => get(entries), (entry) => entry.id, ($$anchor, entry) => { - var tr_1 = root_8$3(); - var td = child(tr_1); - var node_4 = child(td); - var consequent_6 = ($$anchor) => { - append($$anchor, root_9$1()); - }; - if_block(node_4, ($$render) => { - if (!get(isTrashView)) $$render(consequent_6); - }); - var span_2 = sibling(node_4, 2); - var text_6 = child(span_2, true); - reset(span_2); - reset(td); - var td_1 = sibling(td); - var span_3 = child(td_1); - var text_7 = child(span_3, true); - reset(span_3); - reset(td_1); - var td_2 = sibling(td_1); - var span_4 = child(td_2); - var text_8 = child(span_4, true); - reset(span_4); - reset(td_2); - var td_3 = sibling(td_2); - var node_5 = child(td_3); - var consequent_7 = ($$anchor) => { - var div_5 = root_10$1(); - var span_5 = child(div_5); - var div_6 = sibling(span_5, 2); - var text_9 = child(div_6, true); - reset(div_6); - reset(div_5); - template_effect(() => { - set_attribute(span_5, "title", get(entry).notes); - set_text(text_9, get(entry).notes); - }); - append($$anchor, div_5); - }; - var alternate = ($$anchor) => { - append($$anchor, root_11$1()); - }; - if_block(node_5, ($$render) => { - if (get(entry).notes) $$render(consequent_7); - else $$render(alternate, -1); - }); - reset(td_3); - var node_6 = sibling(td_3); - var consequent_8 = ($$anchor) => { - var td_4 = root_12$1(); - var button_1 = child(td_4); - reset(td_4); - delegated("click", button_1, (e) => { - e.stopPropagation(); - handleRestore(get(entry).id); - }); - append($$anchor, td_4); - }; - if_block(node_6, ($$render) => { - if (get(isTrashView)) $$render(consequent_8); - }); - reset(tr_1); - template_effect(() => { - set_attribute(tr_1, "draggable", !get(isTrashView)); - set_class(tr_1, 1, `entry-row ${get(dragging) ? "dragging" : ""}`, "svelte-13s7gu4"); - set_text(text_6, get(entry).title); - set_text(text_7, get(entry).username || "—"); - set_text(text_8, get(entry).url || "—"); - }); - delegated("click", tr_1, () => $$props.onSelect(get(entry).id)); - event("dragstart", tr_1, (e) => { - if (!get(isTrashView)) { - set(dragging, true); - e.dataTransfer.setData("text/plain", get(entry).id); - e.dataTransfer.effectAllowed = "move"; - } - }); - event("dragend", tr_1, () => { - set(dragging, false); - }); - append($$anchor, tr_1); - }); - reset(tbody); - reset(table); - template_effect(() => set_text(text_4, `${get(resultCount) ?? ""} entr${get(resultCount) === 1 ? "y" : "ies"} `)); - append($$anchor, fragment); - }; - if_block(node, ($$render) => { - if (get(loading)) $$render(consequent); - else if (get(error)) $$render(consequent_1, 1); - else if (get(entries).length === 0) $$render(consequent_3, 2); - else $$render(alternate_1, -1); - }); - reset(div); - append($$anchor, div); - pop(); -} -delegate(["click"]); + #escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; + } +}; //#endregion -//#region src/components/EntryDetail.svelte -var root_1$5 = /* @__PURE__ */ from_html(`
`); -var root_2$3 = /* @__PURE__ */ from_html(`
Loading...
`); -var root_3$3 = /* @__PURE__ */ from_html(`
`); -var root_4$3 = /* @__PURE__ */ from_html(`
Entry not found
`); -var root_6$1 = /* @__PURE__ */ from_html(` `, 1); -var root_7$3 = /* @__PURE__ */ from_html(` `, 1); -var root_8$2 = /* @__PURE__ */ from_html(`
Username
`); -var root_9 = /* @__PURE__ */ from_html(`
URL
`); -var root_10 = /* @__PURE__ */ from_html(`
Notes
`); -var root_11 = /* @__PURE__ */ from_html(``); -var root_12 = /* @__PURE__ */ from_html(``); -var root_5$3 = /* @__PURE__ */ from_html(`

Password
`, 1); -var root$4 = /* @__PURE__ */ from_html(`
`); -function EntryDetail($$anchor, $$props) { - push($$props, true); - let entry = /* @__PURE__ */ state(null); - let passwordVisible = /* @__PURE__ */ state(false); - let decryptedPassword = /* @__PURE__ */ state(""); - let loading = /* @__PURE__ */ state(true); - let error = /* @__PURE__ */ state(""); - let showDeleteConfirm = /* @__PURE__ */ state(false); - let showPermanentDeleteConfirm = /* @__PURE__ */ state(false); - let deleting = /* @__PURE__ */ state(false); - let toast = /* @__PURE__ */ state(""); - const isInTrash = /* @__PURE__ */ user_derived(() => get(entry) && isTrashGroup(get(entry).groupId)); - let toastTimer = null; - async function loadEntry() { - set(loading, true); - set(error, ""); - try { - set(entry, await getEntryById($$props.entryId), true); - if (get(entry) && app$1.encryptionKey) set(decryptedPassword, await decrypt(get(entry).encryptedPassword, app$1.encryptionKey), true); - } catch (e) { - set(error, "Failed to load entry: " + e.message); - } - set(loading, false); +//#region src/components/EntryDetail.js +/** +* EntryDetail — view single entry with copy-to-clipboard, trash, and permanent delete. +*/ +var EntryDetail = class extends Component { + /** @param {{ entryId: string, onEdit: Function, onBack: Function }} props */ + constructor(container, props = {}) { + super(container); + this.entryId = props.entryId; + this.onEdit = props.onEdit || (() => {}); + this.onBack = props.onBack || (() => {}); + this.entry = null; + this.passwordVisible = false; + this.decryptedPassword = ""; + this.loading = true; + this.error = ""; + this.showDeleteConfirm = false; + this.showPermanentDeleteConfirm = false; + this.deleting = false; + this.toast = ""; + this.toastTimer = null; } - loadEntry(); - function showToast(message) { - set(toast, message, true); - if (toastTimer) clearTimeout(toastTimer); - toastTimer = setTimeout(() => { - set(toast, ""); + mount() { + super.mount(); + this.#loadEntry(); + return this; + } + render() { + this.el = this.ce("div", { className: "entry-detail" }); + this.#renderContent(); + return this.el; + } + #renderContent() { + this.el.innerHTML = ""; + if (this.toast) this.el.appendChild(this.ce("div", { + className: "toast", + textContent: this.toast + })); + if (this.loading) { + this.el.appendChild(this.ce("div", { + className: "loading", + textContent: "Loading..." + })); + return; + } + if (this.error) { + this.el.appendChild(this.ce("div", { + className: "error-banner", + textContent: this.error + })); + return; + } + if (!this.entry) { + this.el.appendChild(this.ce("div", { + className: "empty-state", + textContent: "Entry not found" + })); + return; + } + const isInTrash = isTrashGroup(this.entry.groupId); + const card = this.ce("div", { className: "detail-card" }); + const header = this.ce("div", { className: "detail-header" }, this.ce("h2", { textContent: this.entry.title }), this.ce("div", { className: "header-actions" }, isInTrash ? this.ce("button", { + className: "btn btn-primary btn-sm", + id: "restore-btn", + textContent: "↩️ Restore" + }) : this.ce("button", { + className: "btn btn-ghost btn-sm", + id: "edit-btn", + textContent: "✏️ Edit" + }), isInTrash ? this.ce("button", { + className: "btn btn-danger btn-sm", + id: "perm-delete-btn", + textContent: "🗑 Delete Forever" + }) : this.ce("button", { + className: "btn btn-danger btn-sm", + id: "trash-btn", + textContent: "🗑 Move to Trash" + }))); + card.appendChild(header); + const fields = this.ce("div", { className: "detail-fields" }); + if (this.entry.username) fields.appendChild(this.ce("div", { className: "detail-field" }, this.ce("span", { + className: "field-label", + textContent: "Username" + }), this.ce("div", { className: "field-value" }, this.ce("span", { textContent: this.entry.username }), this.ce("button", { + className: "btn btn-ghost btn-sm copy-btn", + textContent: "📋", + title: "Copy username", + "data-copy": this.entry.username + })))); + fields.appendChild(this.ce("div", { className: "detail-field" }, this.ce("span", { + className: "field-label", + textContent: "Password" + }), this.ce("div", { className: "field-value" }, this.ce("span", { + id: "pwd-display", + textContent: this.passwordVisible ? this.decryptedPassword : "••••••••••••" + }), this.ce("button", { + className: "btn btn-ghost btn-sm", + id: "toggle-pwd", + textContent: this.passwordVisible ? "🙈" : "👁", + title: "Toggle visibility" + }), this.ce("button", { + className: "btn btn-ghost btn-sm copy-btn", + textContent: "📋", + title: "Copy password", + "data-copy": this.decryptedPassword + })))); + if (this.entry.url) { + const urlField = this.ce("div", { className: "detail-field" }, this.ce("span", { + className: "field-label", + textContent: "URL" + }), this.ce("div", { className: "field-value" }, this.ce("a", { + href: this.entry.url, + target: "_blank", + rel: "noopener noreferrer", + textContent: this.entry.url + }), this.ce("button", { + className: "btn btn-ghost btn-sm copy-btn", + textContent: "📋", + title: "Copy URL", + "data-copy": this.entry.url + }))); + fields.appendChild(urlField); + } + if (this.entry.notes) fields.appendChild(this.ce("div", { className: "detail-field" }, this.ce("span", { + className: "field-label", + textContent: "Notes" + }), this.ce("div", { + className: "field-value notes", + textContent: this.entry.notes + }))); + card.appendChild(fields); + card.appendChild(this.ce("div", { className: "detail-meta" }, this.ce("span", { + className: "text-xs text-muted", + textContent: `Created: ${new Date(this.entry.createdAt).toLocaleString()}` + }), this.ce("span", { + className: "text-xs text-muted", + textContent: `Updated: ${new Date(this.entry.updatedAt).toLocaleString()}` + }))); + this.el.appendChild(card); + const editBtn = this.q("#edit-btn"); + if (editBtn) this.on(editBtn, "click", () => this.onEdit(this.entry.id)); + const restoreBtn = this.q("#restore-btn"); + if (restoreBtn) this.on(restoreBtn, "click", () => this.onEdit(this.entry.id)); + const trashBtn = this.q("#trash-btn"); + if (trashBtn) this.on(trashBtn, "click", () => { + this.showDeleteConfirm = true; + this.#renderModal(); + }); + const permDeleteBtn = this.q("#perm-delete-btn"); + if (permDeleteBtn) this.on(permDeleteBtn, "click", () => { + this.showPermanentDeleteConfirm = true; + this.#renderModal(); + }); + const togglePwd = this.q("#toggle-pwd"); + if (togglePwd) this.on(togglePwd, "click", () => { + this.passwordVisible = !this.passwordVisible; + const display = this.q("#pwd-display"); + if (display) display.textContent = this.passwordVisible ? this.decryptedPassword : "••••••••••••"; + togglePwd.textContent = this.passwordVisible ? "🙈" : "👁"; + }); + this.qa("[data-copy]").forEach((btn) => { + this.on(btn, "click", () => { + const text = btn.dataset.copy; + this.#copyToClipboard(text, btn.title || "Text"); + }); + }); + } + async #loadEntry() { + this.loading = true; + this.error = ""; + try { + this.entry = await getEntryById(this.entryId); + if (this.entry && app$1.encryptionKey) this.decryptedPassword = await decrypt(this.entry.encryptedPassword, app$1.encryptionKey); + } catch (e) { + this.error = "Failed to load entry: " + e.message; + } + this.loading = false; + this.#renderContent(); + } + #showToast(message) { + this.toast = message; + if (this.toastTimer) clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => { + this.toast = ""; }, 3e3); } - async function copyToClipboard(text, label) { + async #copyToClipboard(text, label) { try { await navigator.clipboard.writeText(text); - showToast(`✓ ${label} copied (auto-clear in 15s)`); + this.#showToast(`✓ ${label} copied (auto-clear in 15s)`); setTimeout(async () => { try { await navigator.clipboard.writeText(""); } catch {} }, 15e3); - } catch (e) { + } catch { const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; @@ -6549,503 +2267,507 @@ function EntryDetail($$anchor, $$props) { textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); - showToast(`✓ ${label} copied`); + this.#showToast(`✓ ${label} copied`); } } - async function handleMoveToTrash() { - set(deleting, true); + #renderModal() { + const existing = this.q(".ed-modal-overlay"); + if (existing) existing.remove(); + let title, message, actionId, actionLabel; + if (this.showDeleteConfirm) { + title = "Move to Trash"; + message = `Move "${this.entry.title}" to the trash? You can restore it later.`; + actionId = "confirm-trash-btn"; + actionLabel = this.deleting ? "Moving..." : "Move to Trash"; + } else { + title = "Delete Forever"; + message = `Permanently delete "${this.entry.title}"? This cannot be undone.`; + actionId = "confirm-perm-delete-btn"; + actionLabel = this.deleting ? "Deleting..." : "Delete Forever"; + } + const overlay = this.ce("div", { + className: "ed-modal-overlay", + role: "presentation" + }, this.ce("div", { + className: "ed-modal", + role: "dialog", + "aria-modal": "true", + tabindex: "-1" + }, this.ce("h3", { textContent: title }), this.ce("p", { textContent: message }), this.ce("div", { className: "ed-modal-actions" }, this.ce("button", { + className: "btn btn-danger", + id: actionId, + disabled: this.deleting, + textContent: actionLabel + }), this.ce("button", { + className: "btn btn-ghost", + id: "cancel-modal-btn", + textContent: "Cancel" + })))); + this.el.appendChild(overlay); + this.on(overlay, "click", () => { + this.showDeleteConfirm = false; + this.showPermanentDeleteConfirm = false; + overlay.remove(); + }); + this.on(overlay.querySelector(".ed-modal"), "click", (e) => e.stopPropagation()); + const actionBtn = overlay.querySelector(`#${actionId}`); + if (actionBtn) if (this.showDeleteConfirm) this.on(actionBtn, "click", () => this.#handleMoveToTrash()); + else this.on(actionBtn, "click", () => this.#handlePermanentDelete()); + const cancelBtn = overlay.querySelector("#cancel-modal-btn"); + if (cancelBtn) this.on(cancelBtn, "click", () => { + this.showDeleteConfirm = false; + this.showPermanentDeleteConfirm = false; + overlay.remove(); + }); + } + async #handleMoveToTrash() { + this.deleting = true; try { - await moveToTrash($$props.entryId); - $$props.onBack(); + await moveToTrash(this.entryId); + this.onBack(); } catch (e) { - set(error, "Failed to move to trash: " + e.message); + this.error = "Failed to move to trash: " + e.message; } - set(deleting, false); - set(showDeleteConfirm, false); + this.deleting = false; + this.showDeleteConfirm = false; } - async function handlePermanentDelete() { - set(deleting, true); + async #handlePermanentDelete() { + this.deleting = true; try { - await deleteEntry($$props.entryId); - $$props.onBack(); + await deleteEntry(this.entryId); + this.onBack(); } catch (e) { - set(error, "Failed to permanently delete: " + e.message); + this.error = "Failed to permanently delete: " + e.message; } - set(deleting, false); - set(showPermanentDeleteConfirm, false); + this.deleting = false; + this.showPermanentDeleteConfirm = false; } - var div = root$4(); - var node = child(div); - var consequent = ($$anchor) => { - var div_1 = root_1$5(); - var text_1 = child(div_1, true); - reset(div_1); - template_effect(() => set_text(text_1, get(toast))); - append($$anchor, div_1); - }; - if_block(node, ($$render) => { - if (get(toast)) $$render(consequent); - }); - var node_1 = sibling(node, 2); - var consequent_1 = ($$anchor) => { - append($$anchor, root_2$3()); - }; - var consequent_2 = ($$anchor) => { - var div_3 = root_3$3(); - var text_2 = child(div_3, true); - reset(div_3); - template_effect(() => set_text(text_2, get(error))); - append($$anchor, div_3); - }; - var consequent_3 = ($$anchor) => { - append($$anchor, root_4$3()); - }; - var alternate_1 = ($$anchor) => { - var fragment = root_5$3(); - var div_5 = first_child(fragment); - var div_6 = child(div_5); - var h2 = child(div_6); - var text_3 = child(h2, true); - reset(h2); - var div_7 = sibling(h2, 2); - var node_2 = child(div_7); - var consequent_4 = ($$anchor) => { - var fragment_1 = root_6$1(); - var button = first_child(fragment_1); - var button_1 = sibling(button, 2); - delegated("click", button, () => $$props.onEdit(get(entry).id)); - delegated("click", button_1, () => set(showPermanentDeleteConfirm, true)); - append($$anchor, fragment_1); - }; - var alternate = ($$anchor) => { - var fragment_2 = root_7$3(); - var button_2 = first_child(fragment_2); - var button_3 = sibling(button_2, 2); - delegated("click", button_2, () => $$props.onEdit(get(entry).id)); - delegated("click", button_3, () => set(showDeleteConfirm, true)); - append($$anchor, fragment_2); - }; - if_block(node_2, ($$render) => { - if (get(isInTrash)) $$render(consequent_4); - else $$render(alternate, -1); - }); - reset(div_7); - reset(div_6); - var div_8 = sibling(div_6, 2); - var node_3 = child(div_8); - var consequent_5 = ($$anchor) => { - var div_9 = root_8$2(); - var div_10 = sibling(child(div_9), 2); - var span = child(div_10); - var text_4 = child(span, true); - reset(span); - var button_4 = sibling(span, 2); - reset(div_10); - reset(div_9); - template_effect(() => set_text(text_4, get(entry).username)); - delegated("click", button_4, () => copyToClipboard(get(entry).username, "Username")); - append($$anchor, div_9); - }; - if_block(node_3, ($$render) => { - if (get(entry).username) $$render(consequent_5); - }); - var div_11 = sibling(node_3, 2); - var div_12 = sibling(child(div_11), 2); - var span_1 = child(div_12); - var text_5 = child(span_1, true); - reset(span_1); - var button_5 = sibling(span_1, 2); - var text_6 = child(button_5, true); - reset(button_5); - var button_6 = sibling(button_5, 2); - reset(div_12); - reset(div_11); - var node_4 = sibling(div_11, 2); - var consequent_6 = ($$anchor) => { - var div_13 = root_9(); - var div_14 = sibling(child(div_13), 2); - var a = child(div_14); - var text_7 = child(a, true); - reset(a); - var button_7 = sibling(a, 2); - reset(div_14); - reset(div_13); - template_effect(() => { - set_attribute(a, "href", get(entry).url); - set_text(text_7, get(entry).url); - }); - delegated("click", button_7, () => copyToClipboard(get(entry).url, "URL")); - append($$anchor, div_13); - }; - if_block(node_4, ($$render) => { - if (get(entry).url) $$render(consequent_6); - }); - var node_5 = sibling(node_4, 2); - var consequent_7 = ($$anchor) => { - var div_15 = root_10(); - var div_16 = sibling(child(div_15), 2); - var text_8 = child(div_16, true); - reset(div_16); - reset(div_15); - template_effect(() => set_text(text_8, get(entry).notes)); - append($$anchor, div_15); - }; - if_block(node_5, ($$render) => { - if (get(entry).notes) $$render(consequent_7); - }); - reset(div_8); - var div_17 = sibling(div_8, 2); - var span_2 = child(div_17); - var text_9 = child(span_2); - reset(span_2); - var span_3 = sibling(span_2, 2); - var text_10 = child(span_3); - reset(span_3); - reset(div_17); - reset(div_5); - var node_6 = sibling(div_5, 2); - var consequent_8 = ($$anchor) => { - var div_18 = root_11(); - var div_19 = child(div_18); - var p = sibling(child(div_19), 2); - var strong = sibling(child(p)); - var text_11 = child(strong, true); - reset(strong); - next(); - reset(p); - var div_20 = sibling(p, 2); - var button_8 = child(div_20); - var text_12 = child(button_8, true); - reset(button_8); - var button_9 = sibling(button_8, 2); - reset(div_20); - reset(div_19); - reset(div_18); - template_effect(() => { - set_text(text_11, get(entry).title); - button_8.disabled = get(deleting); - set_text(text_12, get(deleting) ? "Moving..." : "Move to Trash"); - }); - delegated("click", div_18, () => set(showDeleteConfirm, false)); - delegated("click", div_19, (e) => e.stopPropagation()); - delegated("click", button_8, handleMoveToTrash); - delegated("click", button_9, () => set(showDeleteConfirm, false)); - append($$anchor, div_18); - }; - if_block(node_6, ($$render) => { - if (get(showDeleteConfirm)) $$render(consequent_8); - }); - var node_7 = sibling(node_6, 2); - var consequent_9 = ($$anchor) => { - var div_21 = root_12(); - var div_22 = child(div_21); - var p_1 = sibling(child(div_22), 2); - var strong_1 = sibling(child(p_1)); - var text_13 = child(strong_1, true); - reset(strong_1); - next(); - reset(p_1); - var div_23 = sibling(p_1, 2); - var button_10 = child(div_23); - var text_14 = child(button_10, true); - reset(button_10); - var button_11 = sibling(button_10, 2); - reset(div_23); - reset(div_22); - reset(div_21); - template_effect(() => { - set_text(text_13, get(entry).title); - button_10.disabled = get(deleting); - set_text(text_14, get(deleting) ? "Deleting..." : "Delete Forever"); - }); - delegated("click", div_21, () => set(showPermanentDeleteConfirm, false)); - delegated("click", div_22, (e) => e.stopPropagation()); - delegated("click", button_10, handlePermanentDelete); - delegated("click", button_11, () => set(showPermanentDeleteConfirm, false)); - append($$anchor, div_21); - }; - if_block(node_7, ($$render) => { - if (get(showPermanentDeleteConfirm)) $$render(consequent_9); - }); - template_effect(($0, $1) => { - set_text(text_3, get(entry).title); - set_text(text_5, get(passwordVisible) ? get(decryptedPassword) : "••••••••••••"); - set_text(text_6, get(passwordVisible) ? "🙈" : "👁"); - set_text(text_9, `Created: ${$0 ?? ""}`); - set_text(text_10, `Updated: ${$1 ?? ""}`); - }, [() => new Date(get(entry).createdAt).toLocaleString(), () => new Date(get(entry).updatedAt).toLocaleString()]); - delegated("click", button_5, () => set(passwordVisible, !get(passwordVisible))); - delegated("click", button_6, () => copyToClipboard(get(decryptedPassword), "Password")); - append($$anchor, fragment); - }; - if_block(node_1, ($$render) => { - if (get(loading)) $$render(consequent_1); - else if (get(error)) $$render(consequent_2, 1); - else if (!get(entry)) $$render(consequent_3, 2); - else $$render(alternate_1, -1); - }); - reset(div); - append($$anchor, div); - pop(); -} -delegate(["click"]); +}; //#endregion -//#region src/components/EntryForm.svelte -var root_1$4 = /* @__PURE__ */ from_html(`
Loading...
`); -var root_3$2 = /* @__PURE__ */ from_html(`
`); -var root_5$2 = /* @__PURE__ */ from_html(`
`); -var root_4$2 = /* @__PURE__ */ from_html(`
`); -var root_7$2 = /* @__PURE__ */ from_html(``); -var root_2$2 = /* @__PURE__ */ from_html(`
`, 1); -var root$3 = /* @__PURE__ */ from_html(`
`); -function EntryForm($$anchor, $$props) { - push($$props, true); - let title = /* @__PURE__ */ state(""); - let username = /* @__PURE__ */ state(""); - let password = /* @__PURE__ */ state(""); - let url = /* @__PURE__ */ state(""); - let notes = /* @__PURE__ */ state(""); - let groupId = /* @__PURE__ */ state(""); - let passwordVisible = /* @__PURE__ */ state(false); - let groups = /* @__PURE__ */ state(proxy([])); - let loading = /* @__PURE__ */ state(true); - let error = /* @__PURE__ */ state(""); - let saving = /* @__PURE__ */ state(false); - let isEdit = /* @__PURE__ */ state(false); - let formErrors = /* @__PURE__ */ state(proxy([])); - async function loadForm() { - set(loading, true); +//#region src/components/EntryForm.js +/** +* EntryForm — create/edit credential form. +*/ +var EntryForm = class extends Component { + /** @param {{ entryId: string|null, onSave: Function, onCancel: Function }} props */ + constructor(container, props = {}) { + super(container); + this.entryId = props.entryId || null; + this.onSave = props.onSave || (() => {}); + this.onCancel = props.onCancel || (() => {}); + this.title = ""; + this.username = ""; + this.password = ""; + this.url = ""; + this.notes = ""; + this.groupId = ""; + this.passwordVisible = false; + this.groups = []; + this.loading = true; + this.error = ""; + this.saving = false; + this.isEdit = false; + this.formErrors = []; + } + mount() { + super.mount(); + this.#loadForm(); + return this; + } + render() { + this.el = this.ce("div", { className: "entry-form" }); + this.#renderContent(); + return this.el; + } + #renderContent() { + this.el.innerHTML = ""; + if (this.loading) { + this.el.appendChild(this.ce("div", { + className: "loading", + textContent: "Loading..." + })); + return; + } + if (this.error && !this.isEdit) { + this.el.appendChild(this.ce("div", { + className: "error-banner", + textContent: this.error + })); + return; + } + const form = this.ce("form", { + className: "ef-form-card", + id: "entry-form" + }); + if (this.formErrors.length > 0) { + const errDiv = this.ce("div", { className: "validation-errors" }); + for (const err of this.formErrors) errDiv.appendChild(this.ce("div", { + className: "validation-error", + textContent: `⚠ ${err}` + })); + form.appendChild(errDiv); + } + form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "title", + textContent: "Title *" + }), this.ce("input", { + id: "title", + type: "text", + placeholder: "e.g. GitHub, Gmail", + value: this.title + }))); + form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "username", + textContent: "Username / Email" + }), this.ce("input", { + id: "username", + type: "text", + placeholder: "username or email", + value: this.username + }))); + const pwdGroup = this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "password", + textContent: "Password *" + }), this.ce("div", { className: "password-input-group" }, this.ce("input", { + id: "password", + type: this.passwordVisible ? "text" : "password", + placeholder: "Password", + value: this.password + }), this.ce("button", { + type: "button", + className: "btn btn-ghost btn-sm", + id: "toggle-pwd", + textContent: this.passwordVisible ? "🙈" : "👁", + title: "Toggle visibility" + }), this.ce("button", { + type: "button", + className: "btn btn-ghost btn-sm", + id: "generate-pwd", + textContent: "🎲", + title: "Generate password" + }))); + form.appendChild(pwdGroup); + form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "url", + textContent: "URL" + }), this.ce("input", { + id: "url", + type: "url", + placeholder: "https://example.com", + value: this.url + }))); + const select = this.ce("select", { id: "group" }); + const defaultOpt = this.ce("option", { value: "" }, this.text("No group")); + select.appendChild(defaultOpt); + for (const group of this.groups) { + if (isTrashGroup(group.id)) continue; + const opt = this.ce("option", { value: group.id }, this.text(group.name)); + if (group.id === this.groupId) opt.selected = true; + select.appendChild(opt); + } + form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "group", + textContent: "Group" + }), select)); + form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "notes", + textContent: "Notes" + }), this.ce("textarea", { + id: "notes", + placeholder: "Any additional notes..." + }))); + form.appendChild(this.ce("div", { className: "ef-form-actions" }, this.ce("button", { + type: "submit", + className: "btn btn-primary", + disabled: this.saving, + textContent: this.saving ? "Saving..." : this.isEdit ? "💾 Update" : "➕ Create" + }), this.ce("button", { + type: "button", + className: "btn btn-ghost", + id: "cancel-btn", + textContent: "Cancel" + }))); + this.el.appendChild(form); + const formEl = this.q("#entry-form"); + if (formEl) this.on(formEl, "submit", this.#handleSubmit); + const cancelBtn = this.q("#cancel-btn"); + if (cancelBtn) this.on(cancelBtn, "click", () => this.onCancel()); + const togglePwd = this.q("#toggle-pwd"); + if (togglePwd) this.on(togglePwd, "click", () => { + this.passwordVisible = !this.passwordVisible; + const pwdInput = this.q("#password"); + if (pwdInput) pwdInput.type = this.passwordVisible ? "text" : "password"; + togglePwd.textContent = this.passwordVisible ? "🙈" : "👁"; + }); + const generatePwd = this.q("#generate-pwd"); + if (generatePwd) this.on(generatePwd, "click", () => { + this.password = generatePassword({ length: 16 }); + const pwdInput = this.q("#password"); + if (pwdInput) pwdInput.value = this.password; + }); + const titleInput = this.q("#title"); + if (titleInput) this.on(titleInput, "input", (e) => { + this.title = e.target.value; + }); + const usernameInput = this.q("#username"); + if (usernameInput) this.on(usernameInput, "input", (e) => { + this.username = e.target.value; + }); + const pwdInput = this.q("#password"); + if (pwdInput) this.on(pwdInput, "input", (e) => { + this.password = e.target.value; + }); + const urlInput = this.q("#url"); + if (urlInput) this.on(urlInput, "input", (e) => { + this.url = e.target.value; + }); + const notesInput = this.q("#notes"); + if (notesInput) this.on(notesInput, "input", (e) => { + this.notes = e.target.value; + }); + const groupSelect = this.q("#group"); + if (groupSelect) this.on(groupSelect, "change", (e) => { + this.groupId = e.target.value; + }); + if (!this.isEdit && titleInput) autofocus(titleInput, true); + } + async #loadForm() { + this.loading = true; try { - set(groups, await getGroups(), true); - if ($$props.entryId) { - set(isEdit, true); - const entry = await getEntryById($$props.entryId); + this.groups = await getGroups(); + if (this.entryId) { + this.isEdit = true; + const entry = await getEntryById(this.entryId); if (entry) { - set(title, entry.title, true); - set(username, entry.username, true); - set(password, await decrypt(entry.encryptedPassword, app$1.encryptionKey), true); - set(url, entry.url || "", true); - set(notes, entry.notes || "", true); - set(groupId, entry.groupId || "", true); - } else set(error, "Entry not found"); + this.title = entry.title; + this.username = entry.username; + this.password = await decrypt(entry.encryptedPassword, app$1.encryptionKey); + this.url = entry.url || ""; + this.notes = entry.notes || ""; + this.groupId = entry.groupId || ""; + } else this.error = "Entry not found"; } else { const active = search.activeGroupId; - set(groupId, active !== "all" && active !== "trash" ? active : "", true); + this.groupId = active !== "all" && active !== "trash" ? active : ""; } } catch (e) { - set(error, "Failed to load form: " + e.message); + this.error = "Failed to load form: " + e.message; } - set(loading, false); + this.loading = false; + this.#renderContent(); } - loadForm(); - async function handleSubmit() { - set(formErrors, [], true); - set(error, ""); - set(saving, true); + #handleSubmit = async (e) => { + e.preventDefault(); + this.formErrors = []; + this.error = ""; + this.saving = true; + const titleInput = this.q("#title"); + if (titleInput) this.title = titleInput.value; + const usernameInput = this.q("#username"); + if (usernameInput) this.username = usernameInput.value; + const pwdInput = this.q("#password"); + if (pwdInput) this.password = pwdInput.value; + const urlInput = this.q("#url"); + if (urlInput) this.url = urlInput.value; + const notesInput = this.q("#notes"); + if (notesInput) this.notes = notesInput.value; + const groupSelect = this.q("#group"); + if (groupSelect) this.groupId = groupSelect.value; try { const validation = validateEntry({ - title: get(title), - username: get(username), - encryptedPassword: get(password) + title: this.title, + username: this.username, + encryptedPassword: this.password }); if (!validation.valid) { - set(formErrors, validation.errors, true); - set(saving, false); + this.formErrors = validation.errors; + this.saving = false; + this.#renderContent(); return; } - const encryptedPassword = await encrypt(get(password), app$1.encryptionKey); - if (get(isEdit)) await updateEntry(updateEntry$1(await getEntryById($$props.entryId), { - title: get(title), - username: get(username), + const encryptedPassword = await encrypt(this.password, app$1.encryptionKey); + if (this.isEdit) await updateEntry(updateEntry$1(await getEntryById(this.entryId), { + title: this.title, + username: this.username, encryptedPassword, - url: get(url), - notes: get(notes), - groupId: get(groupId) + url: this.url, + notes: this.notes, + groupId: this.groupId })); else await addEntry(createEntry({ - title: get(title), - username: get(username), + title: this.title, + username: this.username, encryptedPassword, - url: get(url), - notes: get(notes), - groupId: get(groupId) + url: this.url, + notes: this.notes, + groupId: this.groupId })); - $$props.onSave(); + this.onSave(); } catch (e) { - set(error, "Failed to save: " + e.message); + this.error = "Failed to save: " + e.message; } - set(saving, false); - } - var div = root$3(); - var node = child(div); - var consequent = ($$anchor) => { - append($$anchor, root_1$4()); + this.saving = false; + this.#renderContent(); }; - var alternate = ($$anchor) => { - var fragment = root_2$2(); - var node_1 = first_child(fragment); - var consequent_1 = ($$anchor) => { - var div_2 = root_3$2(); - var text = child(div_2, true); - reset(div_2); - template_effect(() => set_text(text, get(error))); - append($$anchor, div_2); - }; - if_block(node_1, ($$render) => { - if (get(error)) $$render(consequent_1); - }); - var form = sibling(node_1, 2); - var node_2 = child(form); - var consequent_2 = ($$anchor) => { - var div_3 = root_4$2(); - each(div_3, 21, () => get(formErrors), index, ($$anchor, err) => { - var div_4 = root_5$2(); - var text_1 = child(div_4); - reset(div_4); - template_effect(() => set_text(text_1, `⚠ ${get(err) ?? ""}`)); - append($$anchor, div_4); - }); - reset(div_3); - append($$anchor, div_3); - }; - if_block(node_2, ($$render) => { - if (get(formErrors).length > 0) $$render(consequent_2); - }); - var div_5 = sibling(node_2, 2); - var input = sibling(child(div_5), 2); - 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); - var div_6 = sibling(div_5, 2); - var input_1 = sibling(child(div_6), 2); - remove_input_defaults(input_1); - reset(div_6); - var div_7 = sibling(div_6, 2); - var div_8 = sibling(child(div_7), 2); - var input_2 = child(div_8); - remove_input_defaults(input_2); - var button = sibling(input_2, 2); - var text_2 = child(button, true); - reset(button); - var button_1 = sibling(button, 2); - reset(div_8); - reset(div_7); - var div_9 = sibling(div_7, 2); - var input_3 = sibling(child(div_9), 2); - remove_input_defaults(input_3); - reset(div_9); - var div_10 = sibling(div_9, 2); - var select = sibling(child(div_10), 2); - var option = child(select); - option.value = option.__value = ""; - each(sibling(option), 17, () => get(groups), index, ($$anchor, group) => { - var fragment_1 = comment(); - var node_4 = first_child(fragment_1); - var consequent_3 = ($$anchor) => { - var option_1 = root_7$2(); - var text_3 = child(option_1, true); - reset(option_1); - var option_1_value = {}; - template_effect(() => { - set_text(text_3, get(group).name); - if (option_1_value !== (option_1_value = get(group).id)) option_1.value = (option_1.__value = get(group).id) ?? ""; - }); - append($$anchor, option_1); - }; - var d = /* @__PURE__ */ user_derived(() => !isTrashGroup(get(group).id)); - if_block(node_4, ($$render) => { - if (get(d)) $$render(consequent_3); - }); - append($$anchor, fragment_1); - }); - reset(select); - reset(div_10); - var div_11 = sibling(div_10, 2); - var textarea = sibling(child(div_11), 2); - remove_textarea_child(textarea); - reset(div_11); - var div_12 = sibling(div_11, 2); - var button_2 = child(div_12); - var text_4 = child(button_2, true); - reset(button_2); - var button_3 = sibling(button_2, 2); - reset(div_12); - reset(form); - template_effect(() => { - set_attribute(input_2, "type", get(passwordVisible) ? "text" : "password"); - set_text(text_2, get(passwordVisible) ? "🙈" : "👁"); - button_2.disabled = get(saving); - set_text(text_4, get(saving) ? "Saving..." : get(isEdit) ? "💾 Update" : "➕ Create"); - }); - event("submit", form, (e) => { - e.preventDefault(); - handleSubmit(); - }); - bind_value(input_1, () => get(username), ($$value) => set(username, $$value)); - bind_value(input_2, () => get(password), ($$value) => set(password, $$value)); - delegated("click", button, () => set(passwordVisible, !get(passwordVisible))); - delegated("click", button_1, () => set(password, generatePassword({ length: 16 }), true)); - bind_value(input_3, () => get(url), ($$value) => set(url, $$value)); - bind_select_value(select, () => get(groupId), ($$value) => set(groupId, $$value)); - bind_value(textarea, () => get(notes), ($$value) => set(notes, $$value)); - delegated("click", button_3, function(...$$args) { - $$props.onCancel?.apply(this, $$args); - }); - append($$anchor, fragment); - }; - if_block(node, ($$render) => { - if (get(loading)) $$render(consequent); - else $$render(alternate, -1); - }); - reset(div); - append($$anchor, div); - pop(); -} -delegate(["click"]); +}; //#endregion -//#region src/components/ImportExport.svelte -var root_2$1 = /* @__PURE__ */ from_html(``); -var root_1$3 = /* @__PURE__ */ from_html(``); -var root_4$1 = /* @__PURE__ */ from_html(`
`); -var root_5$1 = /* @__PURE__ */ from_html(`
`); -var root_7$1 = /* @__PURE__ */ from_html(`

File loaded. Enter the source vault's master password to decrypt and re-encrypt entries under your current vault.

`, 1); -var root_8$1 = /* @__PURE__ */ from_html(`

Select how to handle existing data:

`, 1); -var root_3$1 = /* @__PURE__ */ from_html(``); -var root$2 = /* @__PURE__ */ from_html(`
`); -function ImportExport($$anchor, $$props) { - push($$props, true); - const binding_group = []; - let showExport = /* @__PURE__ */ state(false); - async function openExportModal() { - set(allGroups, [{ +//#region src/components/ImportExport.js +/** +* ImportExport — export with group selection + import with source password. +*/ +var ImportExport = class extends Component { + showExport = false; + showImport = false; + importMode = "merge"; + importResult = null; + importError = ""; + importing = false; + exporting = false; + sourcePassword = ""; + parsedFileData = null; + allGroups = []; + allEntries = []; + selectedGroupIds = []; + get selectAll() { + return this.allGroups.length > 0 && this.allGroups.every((g) => this.selectedGroupIds.includes(g.id)); + } + get exportEntryCount() { + return this.allEntries.filter((e) => this.selectedGroupIds.includes(e.groupId)).length; + } + mount() { + super.mount(); + return this; + } + render() { + this.el = this.ce("div", { className: "import-export" }, this.ce("button", { + className: "btn btn-ghost btn-sm", + id: "export-btn", + title: "Export", + textContent: "📤 Export" + }), this.ce("button", { + className: "btn btn-ghost btn-sm", + id: "import-btn", + title: "Import", + textContent: "📥 Import" + })); + this.on(this.q("#export-btn"), "click", () => this.#openExportModal()); + this.on(this.q("#import-btn"), "click", () => { + this.showImport = true; + this.#renderImportModal(); + }); + return this.el; + } + async #openExportModal() { + const groups = (await getGroups()).filter((g) => !isTrashGroup(g.id)); + this.allGroups = [{ id: "", name: "Ungrouped", color: "#6b7280" - }, ...(await getGroups()).filter((g) => !isTrashGroup(g.id))], true); - set(allEntries, await getEntries(), true); - set(selectedGroupIds, get(allGroups).map((g) => g.id), true); - set(showExport, true); + }, ...groups]; + this.allEntries = await getEntries(); + this.selectedGroupIds = this.allGroups.map((g) => g.id); + this.showExport = true; + this.#renderExportModal(); } - let showImport = /* @__PURE__ */ state(false); - let importMode = /* @__PURE__ */ state("merge"); - let importResult = /* @__PURE__ */ state(null); - let importError = /* @__PURE__ */ state(""); - let importing = /* @__PURE__ */ state(false); - let exportData = /* @__PURE__ */ state(null); - let exporting = /* @__PURE__ */ state(false); - let sourcePassword = /* @__PURE__ */ state(""); - let parsedFileData = /* @__PURE__ */ state(null); - let allGroups = /* @__PURE__ */ state(proxy([])); - let allEntries = /* @__PURE__ */ state(proxy([])); - let selectedGroupIds = /* @__PURE__ */ state(proxy([])); - let selectAll = /* @__PURE__ */ user_derived(() => get(allGroups).length > 0 && get(allGroups).every((g) => get(selectedGroupIds).includes(g.id))); - let exportEntryCount = /* @__PURE__ */ user_derived(() => get(allEntries).filter((e) => get(selectedGroupIds).includes(e.groupId)).length); - async function handleExport() { - set(exporting, true); + #renderExportModal() { + const existing = this.q(".ie-modal-overlay"); + if (existing) existing.remove(); + const overlay = this.ce("div", { + className: "ie-modal-overlay", + role: "presentation" + }); + const modal = this.ce("div", { + className: "ie-modal", + role: "dialog", + "aria-modal": "true", + "aria-label": "Export vault", + tabindex: "-1" + }); + modal.appendChild(this.ce("h3", { textContent: "Export Vault" })); + modal.appendChild(this.ce("p", { textContent: "Select which groups to export. You'll need the source vault's master password when importing into another vault." })); + const header = this.ce("div", { className: "group-select-header" }, this.ce("label", { className: "checkbox-label" }, this.ce("input", { + type: "checkbox", + id: "select-all-checkbox", + checked: this.selectAll + }), this.ce("span", { textContent: "Select all" })), this.ce("span", { + className: "entry-count", + id: "export-count", + textContent: `${this.exportEntryCount} entries` + })); + modal.appendChild(header); + const list = this.ce("div", { className: "group-select-list" }); + for (const group of this.allGroups) { + const label = this.ce("label", { className: "checkbox-label group-checkbox" }, this.ce("input", { + type: "checkbox", + "data-group-id": group.id, + checked: this.selectedGroupIds.includes(group.id) + }), this.ce("span", { + className: "group-color-dot", + style: `background-color: ${group.color || "#6c63ff"}` + }), this.ce("span", { + className: "group-name", + textContent: group.name + })); + list.appendChild(label); + } + modal.appendChild(list); + const actions = this.ce("div", { className: "ie-modal-actions" }, this.ce("button", { + className: "btn btn-primary", + id: "do-export-btn", + disabled: this.exporting || this.selectedGroupIds.length === 0, + textContent: this.exporting ? "Exporting..." : "📤 Export JSON" + }), this.ce("button", { + className: "btn btn-ghost", + id: "cancel-export-btn", + textContent: "Cancel" + })); + modal.appendChild(actions); + overlay.appendChild(modal); + this.el.appendChild(overlay); + this.on(overlay, "click", () => { + this.showExport = false; + overlay.remove(); + }); + this.on(modal, "click", (e) => e.stopPropagation()); + const selectAllCb = overlay.querySelector("#select-all-checkbox"); + if (selectAllCb) this.on(selectAllCb, "change", () => this.#toggleSelectAll()); + overlay.querySelectorAll(".group-select-list input[data-group-id]").forEach((cb) => { + this.on(cb, "change", () => { + const gid = cb.dataset.groupId; + this.#toggleGroup(gid); + }); + }); + const exportBtn = overlay.querySelector("#do-export-btn"); + if (exportBtn) this.on(exportBtn, "click", () => this.#handleExport()); + const cancelBtn = overlay.querySelector("#cancel-export-btn"); + if (cancelBtn) this.on(cancelBtn, "click", () => { + this.showExport = false; + overlay.remove(); + }); + } + #toggleSelectAll() { + if (this.selectAll) this.selectedGroupIds = []; + else this.selectedGroupIds = this.allGroups.map((g) => g.id); + this.#renderExportModal(); + } + #toggleGroup(groupId) { + if (this.selectedGroupIds.includes(groupId)) this.selectedGroupIds = this.selectedGroupIds.filter((id) => id !== groupId); + else this.selectedGroupIds = [...this.selectedGroupIds, groupId]; + const overlay = this.q(".ie-modal-overlay"); + if (overlay) { + const selectAllCb = overlay.querySelector("#select-all-checkbox"); + if (selectAllCb) selectAllCb.checked = this.selectAll; + const countEl = overlay.querySelector("#export-count"); + if (countEl) countEl.textContent = `${this.exportEntryCount} entries`; + const exportBtn = overlay.querySelector("#do-export-btn"); + if (exportBtn) exportBtn.disabled = this.exporting || this.selectedGroupIds.length === 0; + } + } + async #handleExport() { + this.exporting = true; try { - set(exportData, await exportSelected(get(selectedGroupIds).length === get(allGroups).length ? null : get(selectedGroupIds)), true); - const json = JSON.stringify(get(exportData), null, 2); + const exportData = await exportSelected(this.selectedGroupIds.length === this.allGroups.length ? null : this.selectedGroupIds); + const json = JSON.stringify(exportData, null, 2); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -7053,579 +2775,645 @@ function ImportExport($$anchor, $$props) { a.download = `password-vault-export-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); - set(showExport, false); + this.showExport = false; } catch (e) { - set(importError, "Export failed: " + e.message); + this.importError = "Export failed: " + e.message; } - set(exporting, false); + this.exporting = false; } - function toggleSelectAll() { - if (get(selectAll)) set(selectedGroupIds, [], true); - else set(selectedGroupIds, get(allGroups).map((g) => g.id), true); + #renderImportModal() { + const existing = this.q(".ie-modal-overlay"); + if (existing) existing.remove(); + const overlay = this.ce("div", { + className: "ie-modal-overlay", + role: "presentation" + }); + const modal = this.ce("div", { + className: "ie-modal", + role: "dialog", + "aria-modal": "true", + "aria-label": "Import vault data", + tabindex: "-1" + }); + modal.appendChild(this.ce("h3", { textContent: "Import Vault Data" })); + if (this.importError) modal.appendChild(this.ce("div", { + className: "ie-error-banner", + textContent: this.importError + })); + if (this.importResult) { + const msg = `✓ Imported ${this.importResult.imported.entries} entries and ${this.importResult.imported.groups} groups` + (this.importResult.skipped > 0 ? ` (${this.importResult.skipped} skipped)` : ""); + modal.appendChild(this.ce("div", { + className: "success-banner", + textContent: msg + })); + } else if (this.parsedFileData) { + modal.appendChild(this.ce("p", {}, this.text("File loaded. Enter the "), this.ce("strong", { textContent: "source vault's master password" }), this.text(" to decrypt and re-encrypt entries under your current vault."))); + modal.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "source-password", + className: "file-label", + textContent: "Source vault password" + }), this.ce("input", { + id: "source-password", + type: "password", + placeholder: "Enter source vault password", + autocomplete: "current-password", + value: this.sourcePassword + }))); + const modeDiv = this.ce("div", { className: "import-mode" }); + const mergeRadio = this.ce("label", { className: "radio-label" }, this.ce("input", { + type: "radio", + name: "importMode", + value: "merge", + checked: this.importMode === "merge" + }), this.ce("span", { textContent: "Merge — add to existing data" })); + const replaceRadio = this.ce("label", { className: "radio-label" }, this.ce("input", { + type: "radio", + name: "importMode", + value: "replace", + checked: this.importMode === "replace" + }), this.ce("span", { textContent: "Replace — clear all existing data first" })); + modeDiv.appendChild(mergeRadio); + modeDiv.appendChild(replaceRadio); + modal.appendChild(modeDiv); + const actions = this.ce("div", { className: "ie-modal-actions" }, this.ce("button", { + className: "btn btn-primary", + id: "do-import-btn", + disabled: this.importing, + textContent: this.importing ? "Importing..." : "📥 Import" + }), this.ce("button", { + className: "btn btn-ghost", + id: "cancel-import-btn", + textContent: "Cancel" + })); + modal.appendChild(actions); + overlay.appendChild(modal); + this.el.appendChild(overlay); + this.on(overlay, "click", () => { + this.showImport = false; + overlay.remove(); + }); + this.on(modal, "click", (e) => e.stopPropagation()); + const srcPwdInput = overlay.querySelector("#source-password"); + if (srcPwdInput) this.on(srcPwdInput, "input", (e) => { + this.sourcePassword = e.target.value; + }); + overlay.querySelectorAll("input[name=\"importMode\"]").forEach((radio) => { + this.on(radio, "change", (e) => { + this.importMode = e.target.value; + }); + }); + const importBtn = overlay.querySelector("#do-import-btn"); + if (importBtn) this.on(importBtn, "click", () => this.#handleImportSubmit()); + const cancelBtn = overlay.querySelector("#cancel-import-btn"); + if (cancelBtn) this.on(cancelBtn, "click", () => { + this.parsedFileData = null; + this.sourcePassword = ""; + overlay.remove(); + }); + } else { + modal.appendChild(this.ce("p", { textContent: "Select how to handle existing data:" })); + const modeDiv = this.ce("div", { className: "import-mode" }); + const mergeRadio = this.ce("label", { className: "radio-label" }, this.ce("input", { + type: "radio", + name: "importMode", + value: "merge", + checked: this.importMode === "merge" + }), this.ce("span", { textContent: "Merge — add to existing data" })); + const replaceRadio = this.ce("label", { className: "radio-label" }, this.ce("input", { + type: "radio", + name: "importMode", + value: "replace", + checked: this.importMode === "replace" + }), this.ce("span", { textContent: "Replace — clear all existing data first" })); + modeDiv.appendChild(mergeRadio); + modeDiv.appendChild(replaceRadio); + modal.appendChild(modeDiv); + modal.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "import-file", + className: "file-label", + textContent: "Choose JSON file" + }), this.ce("input", { + id: "import-file", + type: "file", + accept: ".json,application/json", + disabled: this.importing + }))); + const closeActions = this.ce("div", { className: "ie-modal-actions" }, this.ce("button", { + className: "btn btn-ghost", + id: "close-import-btn", + textContent: "Close" + })); + modal.appendChild(closeActions); + overlay.appendChild(modal); + this.el.appendChild(overlay); + this.on(overlay, "click", () => { + this.showImport = false; + overlay.remove(); + }); + this.on(modal, "click", (e) => e.stopPropagation()); + overlay.querySelectorAll("input[name=\"importMode\"]").forEach((radio) => { + this.on(radio, "change", (e) => { + this.importMode = e.target.value; + }); + }); + const fileInput = overlay.querySelector("#import-file"); + if (fileInput) this.on(fileInput, "change", (e) => this.#handleFileSelect(e)); + const closeBtn = overlay.querySelector("#close-import-btn"); + if (closeBtn) this.on(closeBtn, "click", () => { + this.showImport = false; + this.importResult = null; + this.importError = ""; + overlay.remove(); + }); + } } - function toggleGroup(groupId) { - if (get(selectedGroupIds).includes(groupId)) set(selectedGroupIds, get(selectedGroupIds).filter((id) => id !== groupId), true); - else set(selectedGroupIds, [...get(selectedGroupIds), groupId], true); - } - async function handleFileSelect(event) { + async #handleFileSelect(event) { const file = event.target.files[0]; if (!file) return; - set(importError, ""); - set(importResult, null); - set(sourcePassword, ""); + this.importError = ""; + this.importResult = null; + this.sourcePassword = ""; try { const text = await file.text(); const data = JSON.parse(text); if (!data.entries || !data.groups) { - set(importError, "Invalid file format — missing entries or groups data"); + this.importError = "Invalid file format — missing entries or groups data"; + this.#renderImportModal(); return; } - set(parsedFileData, data, true); + this.parsedFileData = data; } catch (e) { - set(importError, "Failed to parse file: " + e.message); + this.importError = "Failed to parse file: " + e.message; } event.target.value = ""; + this.#renderImportModal(); } - async function handleImportSubmit() { - if (!get(parsedFileData)) return; - if (!get(sourcePassword).trim()) { - set(importError, "Source vault password is required"); + async #handleImportSubmit() { + if (!this.parsedFileData) return; + const srcPwdInput = this.q("#source-password"); + if (srcPwdInput) this.sourcePassword = srcPwdInput.value; + if (!this.sourcePassword.trim()) { + this.importError = "Source vault password is required"; + this.#renderImportModal(); return; } - set(importing, true); - set(importError, ""); - set(importResult, null); + this.importing = true; + this.importError = ""; + this.importResult = null; try { - set(importResult, await importAll(get(parsedFileData), get(importMode), get(sourcePassword), app$1.encryptionKey), true); - set(sourcePassword, ""); - set(parsedFileData, null); + this.importResult = await importAll(this.parsedFileData, this.importMode, this.sourcePassword, app$1.encryptionKey); + this.sourcePassword = ""; + this.parsedFileData = null; search.refresh(); } catch (e) { - set(importError, "Import failed: " + e.message); + this.importError = "Import failed: " + e.message; } - set(importing, false); + this.importing = false; + this.#renderImportModal(); } - var div = root$2(); - var button = child(div); - var button_1 = sibling(button, 2); - var node = sibling(button_1, 2); - var consequent = ($$anchor) => { - var div_1 = root_1$3(); - var div_2 = child(div_1); - var div_3 = sibling(child(div_2), 4); - var label = child(div_3); - var input = child(label); - remove_input_defaults(input); - next(2); - reset(label); - var span = sibling(label, 2); - var text_1 = child(span); - reset(span); - reset(div_3); - var div_4 = sibling(div_3, 2); - each(div_4, 21, () => get(allGroups), index, ($$anchor, group) => { - var label_1 = root_2$1(); - var input_1 = child(label_1); - remove_input_defaults(input_1); - var span_1 = sibling(input_1, 2); - var span_2 = sibling(span_1, 2); - var text_2 = child(span_2, true); - reset(span_2); - reset(label_1); - template_effect(($0) => { - set_checked(input_1, $0); - set_style(span_1, `background-color: ${(get(group).color || "#6c63ff") ?? ""}`); - set_text(text_2, get(group).name); - }, [() => get(selectedGroupIds).includes(get(group).id)]); - delegated("change", input_1, () => toggleGroup(get(group).id)); - append($$anchor, label_1); - }); - reset(div_4); - var div_5 = sibling(div_4, 2); - var button_2 = child(div_5); - var text_3 = child(button_2, true); - reset(button_2); - var button_3 = sibling(button_2, 2); - reset(div_5); - reset(div_2); - reset(div_1); - template_effect(() => { - set_checked(input, get(selectAll)); - set_text(text_1, `${get(exportEntryCount) ?? ""} entries`); - button_2.disabled = get(exporting) || get(selectedGroupIds).length === 0; - set_text(text_3, get(exporting) ? "Exporting..." : "📤 Export JSON"); - }); - delegated("click", div_1, () => set(showExport, false)); - delegated("click", div_2, (e) => e.stopPropagation()); - delegated("change", input, toggleSelectAll); - delegated("click", button_2, handleExport); - delegated("click", button_3, () => set(showExport, false)); - append($$anchor, div_1); - }; - if_block(node, ($$render) => { - if (get(showExport)) $$render(consequent); - }); - var node_1 = sibling(node, 2); - var consequent_5 = ($$anchor) => { - var div_6 = root_3$1(); - var div_7 = child(div_6); - var node_2 = sibling(child(div_7), 2); - var consequent_1 = ($$anchor) => { - var div_8 = root_4$1(); - var text_4 = child(div_8, true); - reset(div_8); - template_effect(() => set_text(text_4, get(importError))); - append($$anchor, div_8); - }; - if_block(node_2, ($$render) => { - if (get(importError)) $$render(consequent_1); - }); - var node_3 = sibling(node_2, 2); - var consequent_3 = ($$anchor) => { - var div_9 = root_5$1(); - var text_5 = child(div_9); - var node_4 = sibling(text_5); - var consequent_2 = ($$anchor) => { - var text_6 = text(); - template_effect(() => set_text(text_6, `(${get(importResult).skipped ?? ""} skipped)`)); - append($$anchor, text_6); - }; - if_block(node_4, ($$render) => { - if (get(importResult).skipped > 0) $$render(consequent_2); - }); - reset(div_9); - template_effect(() => set_text(text_5, `✓ Imported ${get(importResult).imported.entries ?? ""} entries and ${get(importResult).imported.groups ?? ""} groups `)); - append($$anchor, div_9); - }; - var consequent_4 = ($$anchor) => { - var fragment_1 = root_7$1(); - var div_10 = sibling(first_child(fragment_1), 2); - var input_2 = sibling(child(div_10), 2); - remove_input_defaults(input_2); - reset(div_10); - var div_11 = sibling(div_10, 2); - var label_2 = child(div_11); - var input_3 = child(label_2); - remove_input_defaults(input_3); - input_3.value = input_3.__value = "merge"; - next(2); - reset(label_2); - var label_3 = sibling(label_2, 2); - var input_4 = child(label_3); - remove_input_defaults(input_4); - input_4.value = input_4.__value = "replace"; - next(2); - reset(label_3); - reset(div_11); - var div_12 = sibling(div_11, 2); - var button_4 = child(div_12); - var text_7 = child(button_4, true); - reset(button_4); - var button_5 = sibling(button_4, 2); - reset(div_12); - template_effect(() => { - button_4.disabled = get(importing); - set_text(text_7, get(importing) ? "Importing..." : "📥 Import"); - }); - bind_value(input_2, () => get(sourcePassword), ($$value) => set(sourcePassword, $$value)); - bind_group(binding_group, [], input_3, () => get(importMode), ($$value) => set(importMode, $$value)); - bind_group(binding_group, [], input_4, () => get(importMode), ($$value) => set(importMode, $$value)); - delegated("click", button_4, handleImportSubmit); - delegated("click", button_5, () => { - set(parsedFileData, null); - set(sourcePassword, ""); - }); - append($$anchor, fragment_1); - }; - var alternate = ($$anchor) => { - var fragment_2 = root_8$1(); - var div_13 = sibling(first_child(fragment_2), 2); - var label_4 = child(div_13); - var input_5 = child(label_4); - remove_input_defaults(input_5); - input_5.value = input_5.__value = "merge"; - next(2); - reset(label_4); - var label_5 = sibling(label_4, 2); - var input_6 = child(label_5); - remove_input_defaults(input_6); - input_6.value = input_6.__value = "replace"; - next(2); - reset(label_5); - reset(div_13); - var div_14 = sibling(div_13, 2); - var input_7 = sibling(child(div_14), 2); - reset(div_14); - template_effect(() => input_7.disabled = get(importing)); - bind_group(binding_group, [], input_5, () => get(importMode), ($$value) => set(importMode, $$value)); - bind_group(binding_group, [], input_6, () => get(importMode), ($$value) => set(importMode, $$value)); - delegated("change", input_7, handleFileSelect); - append($$anchor, fragment_2); - }; - if_block(node_3, ($$render) => { - if (get(importResult)) $$render(consequent_3); - else if (get(parsedFileData)) $$render(consequent_4, 1); - else $$render(alternate, -1); - }); - var div_15 = sibling(node_3, 2); - var button_6 = child(div_15); - reset(div_15); - reset(div_7); - reset(div_6); - delegated("click", div_6, () => set(showImport, false)); - delegated("click", div_7, (e) => e.stopPropagation()); - delegated("click", button_6, () => { - set(showImport, false); - set(importResult, null); - set(importError, ""); - }); - append($$anchor, div_6); - }; - if_block(node_1, ($$render) => { - if (get(showImport)) $$render(consequent_5); - }); - reset(div); - delegated("click", button, openExportModal); - delegated("click", button_1, () => set(showImport, true)); - append($$anchor, div); - pop(); -} -delegate(["click", "change"]); +}; //#endregion -//#region src/components/SettingsDialog.svelte -var root_1$2 = /* @__PURE__ */ from_html(``); -var root$1 = /* @__PURE__ */ from_html(`

Settings

`); -function SettingsDialog($$anchor, $$props) { - push($$props, true); - let minutes = /* @__PURE__ */ state(proxy(settings.autoLockMinutes)); - let lockOnTabSwitch = /* @__PURE__ */ state(proxy(settings.lockOnTabSwitch)); - let saving = /* @__PURE__ */ state(false); - const minuteOptions = [ - 1, - 5, - 10, - 15, - 30, - 60 - ]; - async function handleSave() { - set(saving, true); +//#region src/components/SettingsDialog.js +/** +* SettingsDialog — auto-lock and tab-switch settings. +*/ +var SettingsDialog = class extends Component { + /** @param {{ onBack: Function }} props */ + constructor(container, props = {}) { + super(container); + this.onBack = props.onBack || (() => {}); + this.minutes = settings.autoLockMinutes; + this.lockOnTabSwitch = settings.lockOnTabSwitch; + this.saving = false; + } + mount() { + super.mount(); + this.minutes = settings.autoLockMinutes; + this.lockOnTabSwitch = settings.lockOnTabSwitch; + return this; + } + render() { + this.el = this.ce("div", { className: "settings-panel" }); + this.#renderContent(); + return this.el; + } + #renderContent() { + this.el.innerHTML = ""; + const form = this.ce("form", { + className: "sd-form-card", + id: "settings-form" + }, this.ce("h3", { textContent: "Settings" }), this.ce("div", { className: "form-group" }, this.ce("label", { + htmlFor: "auto-lock-minutes", + textContent: "Auto-lock after" + }), this.#buildMinuteSelect(), this.ce("p", { + className: "text-muted text-xs mt-1", + id: "lock-hint" + })), this.ce("div", { className: "form-group" }, this.ce("label", { + className: "toggle-label", + htmlFor: "lock-tab-switch" + }, this.ce("input", { + id: "lock-tab-switch", + type: "checkbox", + checked: this.lockOnTabSwitch + }), this.ce("span", { className: "toggle-track" }, this.ce("span", { className: "toggle-thumb" })), this.ce("span", { + className: "toggle-text", + textContent: "Lock when tab loses focus" + })), this.ce("p", { + className: "text-muted text-xs mt-1", + id: "tab-hint" + })), this.ce("div", { className: "sd-form-actions" }, this.ce("button", { + type: "submit", + className: "btn btn-primary", + disabled: this.saving, + id: "save-settings-btn", + textContent: this.saving ? "Saving..." : "Save" + }), this.ce("button", { + type: "button", + className: "btn btn-ghost", + id: "cancel-settings-btn", + textContent: "Cancel" + }))); + this.el.appendChild(form); + const lockHint = this.q("#lock-hint"); + if (lockHint) lockHint.textContent = `Vault locks after ${this.minutes} ${this.minutes === 1 ? "minute" : "minutes"} of inactivity.`; + const tabHint = this.q("#tab-hint"); + if (tabHint) tabHint.textContent = this.lockOnTabSwitch ? "The vault locks immediately when you switch to another tab." : "The vault stays unlocked even when you switch tabs."; + const formEl = this.q("#settings-form"); + if (formEl) this.on(formEl, "submit", this.#handleSave); + const cancelBtn = this.q("#cancel-settings-btn"); + if (cancelBtn) this.on(cancelBtn, "click", () => this.onBack()); + const minutesSelect = this.q("#auto-lock-minutes"); + if (minutesSelect) this.on(minutesSelect, "change", (e) => { + this.minutes = Number(e.target.value); + if (lockHint) lockHint.textContent = `Vault locks after ${this.minutes} ${this.minutes === 1 ? "minute" : "minutes"} of inactivity.`; + }); + const tabCheckbox = this.q("#lock-tab-switch"); + if (tabCheckbox) this.on(tabCheckbox, "change", (e) => { + this.lockOnTabSwitch = e.target.checked; + if (tabHint) tabHint.textContent = this.lockOnTabSwitch ? "The vault locks immediately when you switch to another tab." : "The vault stays unlocked even when you switch tabs."; + }); + } + #buildMinuteSelect() { + const select = this.ce("select", { id: "auto-lock-minutes" }); + for (const m of [ + 1, + 5, + 10, + 15, + 30, + 60 + ]) { + const opt = this.ce("option", { value: m }, this.text(`${m} ${m === 1 ? "minute" : "minutes"}`)); + if (m === this.minutes) opt.selected = true; + select.appendChild(opt); + } + return select; + } + #handleSave = async (e) => { + e.preventDefault(); + this.saving = true; try { - settings.autoLockMinutes = get(minutes); - settings.lockOnTabSwitch = get(lockOnTabSwitch); + settings.autoLockMinutes = this.minutes; + settings.lockOnTabSwitch = this.lockOnTabSwitch; await settings.save(); startAutoLock(); - } catch (e) { - console.error("Failed to save settings:", e); + } catch (err) { + console.error("Failed to save settings:", err); } - set(saving, false); - $$props.onBack(); - } - user_effect(() => { - set(minutes, settings.autoLockMinutes, true); - set(lockOnTabSwitch, settings.lockOnTabSwitch, true); - }); - var div = root$1(); - var form = child(div); - var div_1 = sibling(child(form), 2); - var select = sibling(child(div_1), 2); - each(select, 21, () => minuteOptions, index, ($$anchor, m) => { - var option = root_1$2(); - var text = child(option); - reset(option); - var option_value = {}; - template_effect(() => { - set_text(text, `${get(m) ?? ""} ${get(m) === 1 ? "minute" : "minutes"}`); - if (option_value !== (option_value = get(m))) option.value = (option.__value = get(m)) ?? ""; - }); - append($$anchor, option); - }); - reset(select); - var p = sibling(select, 2); - var text_1 = child(p); - reset(p); - reset(div_1); - var div_2 = sibling(div_1, 2); - var label = child(div_2); - var input = child(label); - remove_input_defaults(input); - next(4); - reset(label); - var p_1 = sibling(label, 2); - var text_2 = child(p_1, true); - reset(p_1); - reset(div_2); - var div_3 = sibling(div_2, 2); - var button = child(div_3); - var text_3 = child(button, true); - reset(button); - var button_1 = sibling(button, 2); - reset(div_3); - reset(form); - reset(div); - template_effect(() => { - set_text(text_1, `Vault locks after ${get(minutes) ?? ""} ${get(minutes) === 1 ? "minute" : "minutes"} of inactivity.`); - set_text(text_2, get(lockOnTabSwitch) ? "The vault locks immediately when you switch to another tab." : "The vault stays unlocked even when you switch tabs."); - button.disabled = get(saving); - set_text(text_3, get(saving) ? "Saving..." : "Save"); - }); - event("submit", form, (e) => { - e.preventDefault(); - handleSave(); - }); - bind_select_value(select, () => get(minutes), ($$value) => set(minutes, $$value)); - bind_checked(input, () => get(lockOnTabSwitch), ($$value) => set(lockOnTabSwitch, $$value)); - delegated("click", button_1, function(...$$args) { - $$props.onBack?.apply(this, $$args); - }); - append($$anchor, div); - pop(); -} -delegate(["click"]); + this.saving = false; + this.onBack(); + }; +}; //#endregion -//#region src/components/MainLayout.svelte -var root_1$1 = /* @__PURE__ */ from_html(``); -var root_2 = /* @__PURE__ */ from_html(``); -var root_3 = /* @__PURE__ */ from_html(`

`); -var root_4 = /* @__PURE__ */ from_html(`

Entry Details

`); -var root_5 = /* @__PURE__ */ from_html(`

`); -var root_6 = /* @__PURE__ */ from_html(`

Settings

`); -var root_7 = /* @__PURE__ */ from_html(``); -var root_8 = /* @__PURE__ */ from_html(``); -var root_13 = /* @__PURE__ */ from_html(``); -var root = /* @__PURE__ */ from_html(`
Password Vault
`); -function MainLayout($$anchor, $$props) { - push($$props, true); - let sidebarOpen = /* @__PURE__ */ state(false); - let viewMode = /* @__PURE__ */ state("list"); - let selectedEntryId = /* @__PURE__ */ state(null); - let showEmptyTrashConfirm = /* @__PURE__ */ state(false); - let emptyingTrash = /* @__PURE__ */ state(false); - const isTrashView = /* @__PURE__ */ user_derived(() => search.activeGroupId === "trash"); - function goList() { - set(viewMode, "list"); - set(selectedEntryId, null); - set(sidebarOpen, false); +//#region src/components/MainLayout.js +/** +* MainLayout — shell: sidebar + content area with view routing. +*/ +var MainLayout = class extends Component { + sidebarOpen = false; + viewMode = "list"; + selectedEntryId = null; + showEmptyTrashConfirm = false; + emptyingTrash = false; + _sidebar = null; + _importExport = null; + _contentComponent = null; + get isTrashView() { + return search.activeGroupId === "trash"; } - function goDetail(entryId) { - set(selectedEntryId, entryId, true); - set(viewMode, "detail"); - set(sidebarOpen, false); + mount() { + super.mount(); + this.subscribe(app$1, "isUnlocked", (unlocked) => { + if (!unlocked) this.emitLock(); + }); + return this; } - function goForm(entryId = null) { - set(selectedEntryId, entryId, true); - set(viewMode, "form"); - set(sidebarOpen, false); + render() { + this.el = this.ce("div", { className: "app-shell" }); + this.el.appendChild(this.ce("div", { className: "mobile-header" }, this.ce("button", { + className: "btn btn-ghost btn-sm", + id: "menu-btn", + textContent: "☰ Menu" + }), this.ce("span", { + className: "mobile-title", + textContent: "Password Vault" + }), this.ce("button", { + className: "btn btn-ghost btn-sm", + id: "mobile-lock-btn", + title: "Lock", + textContent: "🔒" + }))); + const aside = this.ce("aside", { + className: "sidebar", + id: "sidebar" + }); + this.el.appendChild(aside); + const main = this.ce("main", { className: "main-content" }, this.ce("div", { + className: "top-bar", + id: "top-bar" + }), this.ce("div", { + className: "content-area", + id: "content-area" + })); + this.el.appendChild(main); + this.on(this.q("#menu-btn"), "click", () => { + this.sidebarOpen = !this.sidebarOpen; + this.#updateSidebar(); + }); + this.on(this.q("#mobile-lock-btn"), "click", () => app$1.lockVault()); + this._sidebar = new Sidebar(this.q("#sidebar")); + this._sidebar.mount(); + this.#renderTopBar(); + this.#navigate(); + return this.el; } - function goSettings() { - set(viewMode, "settings"); - set(sidebarOpen, false); + #updateSidebar() { + const sidebar = this.q("#sidebar"); + if (sidebar) sidebar.classList.toggle("open", this.sidebarOpen); + let overlay = this.q(".sidebar-overlay"); + if (this.sidebarOpen && !overlay) { + overlay = this.ce("button", { + className: "sidebar-overlay", + "aria-label": "Close menu" + }); + this.on(overlay, "click", () => { + this.sidebarOpen = false; + this.#updateSidebar(); + }); + this.el.appendChild(overlay); + } else if (!this.sidebarOpen && overlay) overlay.remove(); } - function handleBack() { - if (get(viewMode) === "form" || get(viewMode) === "settings") goList(); - else goList(); + #renderTopBar() { + const topBar = this.q("#top-bar"); + if (!topBar) return; + topBar.innerHTML = ""; + if (this.viewMode !== "list") { + const backBtn = this.ce("button", { + className: "btn btn-ghost btn-sm", + id: "back-btn", + textContent: "← Back" + }); + this.on(backBtn, "click", () => this.#handleBack()); + topBar.appendChild(backBtn); + } + const titleDiv = this.ce("div", { className: "top-bar-title" }); + let titleText; + switch (this.viewMode) { + case "list": + titleText = this.isTrashView ? TRASH_GROUP_NAME : "All Entries"; + break; + case "detail": + titleText = "Entry Details"; + break; + case "form": + titleText = this.selectedEntryId ? "Edit Entry" : "New Entry"; + break; + case "settings": + titleText = "Settings"; + break; + default: titleText = "Password Vault"; + } + titleDiv.appendChild(this.ce("h1", { textContent: titleText })); + topBar.appendChild(titleDiv); + const actions = this.ce("div", { className: "top-bar-actions" }); + if (this.viewMode === "list" && this.isTrashView) actions.appendChild(this.ce("button", { + className: "btn btn-danger btn-sm", + id: "empty-trash-btn", + disabled: this.emptyingTrash, + textContent: this.emptyingTrash ? "Emptying..." : "🗑 Empty Trash" + })); + if (this.viewMode === "list" && !this.isTrashView) actions.appendChild(this.ce("button", { + className: "btn btn-primary btn-sm", + id: "new-entry-btn", + textContent: "+ New Entry" + })); + if (!this._importExport) { + this._importExport = new ImportExport(actions); + this._importExport.mount(); + } + actions.appendChild(this.ce("button", { + className: "btn btn-ghost btn-sm", + id: "settings-btn", + title: "Settings", + textContent: "⚙️" + })); + actions.appendChild(this.ce("button", { + className: "btn btn-ghost btn-sm", + id: "lock-btn", + title: "Lock vault", + textContent: "🔒" + })); + topBar.appendChild(actions); + const emptyTrashBtn = this.q("#empty-trash-btn"); + if (emptyTrashBtn) this.on(emptyTrashBtn, "click", () => { + this.showEmptyTrashConfirm = true; + this.#renderEmptyTrashModal(); + }); + const newEntryBtn = this.q("#new-entry-btn"); + if (newEntryBtn) this.on(newEntryBtn, "click", () => this.#goForm(null)); + const settingsBtn = this.q("#settings-btn"); + if (settingsBtn) this.on(settingsBtn, "click", () => this.#goSettings()); + const lockBtn = this.q("#lock-btn"); + if (lockBtn) this.on(lockBtn, "click", () => app$1.lockVault()); } - async function handleEmptyTrash() { - set(emptyingTrash, true); + #navigate() { + if (this._contentComponent) { + this._contentComponent.destroy(); + this._contentComponent = null; + } + const contentArea = this.q("#content-area"); + if (!contentArea) return; + switch (this.viewMode) { + case "list": + this._contentComponent = new EntryList(contentArea, { + onSelect: (id) => this.#goDetail(id), + onAdd: () => this.#goForm(null) + }); + break; + case "detail": + if (this.selectedEntryId) this._contentComponent = new EntryDetail(contentArea, { + entryId: this.selectedEntryId, + onEdit: (id) => this.#goForm(id), + onBack: () => this.#goList() + }); + break; + case "form": + this._contentComponent = new EntryForm(contentArea, { + entryId: this.selectedEntryId, + onSave: () => this.#goList(), + onCancel: () => this.#handleBack() + }); + break; + case "settings": + this._contentComponent = new SettingsDialog(contentArea, { onBack: () => this.#goList() }); + break; + } + if (this._contentComponent) this._contentComponent.mount(); + this.#renderTopBar(); + } + #goList() { + this.viewMode = "list"; + this.selectedEntryId = null; + this.sidebarOpen = false; + this.#updateSidebar(); + this.#navigate(); + } + #goDetail(entryId) { + this.selectedEntryId = entryId; + this.viewMode = "detail"; + this.sidebarOpen = false; + this.#updateSidebar(); + this.#navigate(); + } + #goForm(entryId) { + this.selectedEntryId = entryId; + this.viewMode = "form"; + this.sidebarOpen = false; + this.#updateSidebar(); + this.#navigate(); + } + #goSettings() { + this.viewMode = "settings"; + this.sidebarOpen = false; + this.#updateSidebar(); + this.#navigate(); + } + #handleBack() { + this.#goList(); + } + #renderEmptyTrashModal() { + const existing = this.q(".ml-modal-overlay"); + if (existing) existing.remove(); + const overlay = this.ce("div", { + className: "ml-modal-overlay", + role: "presentation" + }); + const modal = this.ce("div", { + className: "ml-modal", + role: "dialog", + "aria-modal": "true", + "aria-label": "Empty trash confirmation", + tabindex: "-1" + }); + modal.appendChild(this.ce("h3", { textContent: "Empty Trash" })); + modal.appendChild(this.ce("p", { textContent: "Permanently delete all entries from the trash? This cannot be undone." })); + const actions = this.ce("div", { className: "ml-modal-actions" }, this.ce("button", { + className: "btn btn-danger", + id: "confirm-empty-trash", + disabled: this.emptyingTrash, + textContent: this.emptyingTrash ? "Emptying..." : "Yes, empty trash" + }), this.ce("button", { + className: "btn btn-ghost", + id: "cancel-empty-trash", + textContent: "Cancel" + })); + modal.appendChild(actions); + overlay.appendChild(modal); + this.el.appendChild(overlay); + this.on(overlay, "click", () => { + this.showEmptyTrashConfirm = false; + overlay.remove(); + }); + this.on(modal, "click", (e) => e.stopPropagation()); + const confirmBtn = overlay.querySelector("#confirm-empty-trash"); + if (confirmBtn) this.on(confirmBtn, "click", () => this.#handleEmptyTrash()); + const cancelBtn = overlay.querySelector("#cancel-empty-trash"); + if (cancelBtn) this.on(cancelBtn, "click", () => { + this.showEmptyTrashConfirm = false; + overlay.remove(); + }); + } + async #handleEmptyTrash() { + this.emptyingTrash = true; try { await emptyTrash(); search.activeGroupId = "all"; - set(showEmptyTrashConfirm, false); + this.showEmptyTrashConfirm = false; + this.#goList(); } catch (e) { console.error("Failed to empty trash:", e); } - set(emptyingTrash, false); + this.emptyingTrash = false; } - function handleLock() { - app$1.lockVault(); + emitLock() { + this.destroy(); } - var div = root(); - var div_1 = child(div); - var button = child(div_1); - var button_1 = sibling(button, 4); - reset(div_1); - var node = sibling(div_1, 2); - var consequent = ($$anchor) => { - var button_2 = root_1$1(); - delegated("click", button_2, () => set(sidebarOpen, false)); - append($$anchor, button_2); - }; - if_block(node, ($$render) => { - if (get(sidebarOpen)) $$render(consequent); - }); - var aside = sibling(node, 2); - Sidebar(child(aside), {}); - reset(aside); - var main = sibling(aside, 2); - var div_2 = child(main); - var node_2 = child(div_2); - var consequent_1 = ($$anchor) => { - var button_3 = root_2(); - delegated("click", button_3, handleBack); - append($$anchor, button_3); - }; - if_block(node_2, ($$render) => { - if (get(viewMode) !== "list") $$render(consequent_1); - }); - var div_3 = sibling(node_2, 2); - var node_3 = child(div_3); - var consequent_2 = ($$anchor) => { - var h1 = root_3(); - var text = child(h1, true); - reset(h1); - template_effect(() => set_text(text, search.activeGroupId === "trash" ? TRASH_GROUP_NAME : "All Entries")); - append($$anchor, h1); - }; - var consequent_3 = ($$anchor) => { - append($$anchor, root_4()); - }; - var consequent_4 = ($$anchor) => { - var h1_2 = root_5(); - var text_1 = child(h1_2, true); - reset(h1_2); - template_effect(() => set_text(text_1, get(selectedEntryId) ? "Edit Entry" : "New Entry")); - append($$anchor, h1_2); - }; - var consequent_5 = ($$anchor) => { - append($$anchor, root_6()); - }; - if_block(node_3, ($$render) => { - if (get(viewMode) === "list") $$render(consequent_2); - else if (get(viewMode) === "detail") $$render(consequent_3, 1); - else if (get(viewMode) === "form") $$render(consequent_4, 2); - else if (get(viewMode) === "settings") $$render(consequent_5, 3); - }); - reset(div_3); - var div_4 = sibling(div_3, 2); - var node_4 = child(div_4); - var consequent_6 = ($$anchor) => { - var button_4 = root_7(); - var text_2 = child(button_4, true); - reset(button_4); - template_effect(() => { - button_4.disabled = get(emptyingTrash); - set_text(text_2, get(emptyingTrash) ? "Emptying..." : "🗑 Empty Trash"); - }); - delegated("click", button_4, () => set(showEmptyTrashConfirm, true)); - append($$anchor, button_4); - }; - if_block(node_4, ($$render) => { - if (get(viewMode) === "list" && get(isTrashView)) $$render(consequent_6); - }); - var node_5 = sibling(node_4, 2); - var consequent_7 = ($$anchor) => { - var button_5 = root_8(); - delegated("click", button_5, () => goForm(null)); - append($$anchor, button_5); - }; - if_block(node_5, ($$render) => { - if (get(viewMode) === "list" && !get(isTrashView)) $$render(consequent_7); - }); - var node_6 = sibling(node_5, 2); - ImportExport(node_6, {}); - var button_6 = sibling(node_6, 2); - var button_7 = sibling(button_6, 2); - reset(div_4); - reset(div_2); - var div_5 = sibling(div_2, 2); - var node_7 = child(div_5); - var consequent_8 = ($$anchor) => { - EntryList($$anchor, { - onSelect: goDetail, - onAdd: () => goForm(null) - }); - }; - var consequent_9 = ($$anchor) => { - EntryDetail($$anchor, { - get entryId() { - return get(selectedEntryId); - }, - onEdit: () => goForm(get(selectedEntryId)), - onBack: goList - }); - }; - var consequent_10 = ($$anchor) => { - EntryForm($$anchor, { - get entryId() { - return get(selectedEntryId); - }, - onSave: goList, - onCancel: handleBack - }); - }; - var consequent_11 = ($$anchor) => { - SettingsDialog($$anchor, { onBack: goList }); - }; - if_block(node_7, ($$render) => { - if (get(viewMode) === "list") $$render(consequent_8); - else if (get(viewMode) === "detail" && get(selectedEntryId)) $$render(consequent_9, 1); - else if (get(viewMode) === "form") $$render(consequent_10, 2); - else if (get(viewMode) === "settings") $$render(consequent_11, 3); - }); - reset(div_5); - reset(main); - var node_8 = sibling(main, 2); - var consequent_12 = ($$anchor) => { - var div_6 = root_13(); - var div_7 = child(div_6); - var div_8 = sibling(child(div_7), 4); - var button_8 = child(div_8); - var text_3 = child(button_8, true); - reset(button_8); - var button_9 = sibling(button_8, 2); - reset(div_8); - reset(div_7); - reset(div_6); - template_effect(() => { - button_8.disabled = get(emptyingTrash); - set_text(text_3, get(emptyingTrash) ? "Emptying..." : "Yes, empty trash"); - }); - delegated("click", div_6, () => set(showEmptyTrashConfirm, false)); - delegated("click", div_7, (e) => e.stopPropagation()); - delegated("click", button_8, handleEmptyTrash); - delegated("click", button_9, () => set(showEmptyTrashConfirm, false)); - append($$anchor, div_6); - }; - if_block(node_8, ($$render) => { - if (get(showEmptyTrashConfirm)) $$render(consequent_12); - }); - reset(div); - template_effect(() => set_class(aside, 1, `sidebar ${get(sidebarOpen) ? "open" : ""}`, "svelte-1ybayt")); - delegated("click", button, () => set(sidebarOpen, !get(sidebarOpen))); - delegated("click", button_1, handleLock); - delegated("click", button_6, goSettings); - delegated("click", button_7, handleLock); - append($$anchor, div); - pop(); -} -delegate(["click"]); + destroy() { + if (this._sidebar) this._sidebar.destroy(); + if (this._importExport) this._importExport.destroy(); + if (this._contentComponent) this._contentComponent.destroy(); + super.destroy(); + } +}; //#endregion -//#region src/App.svelte -var root_1 = /* @__PURE__ */ from_html(` `, 1); -function App($$anchor, $$props) { - push($$props, false); - init(); - var fragment_1 = comment(); - head("1n46o8q", ($$anchor) => { - var fragment = root_1(); - next(2); - append($$anchor, fragment); - }); - var node = first_child(fragment_1); - var consequent = ($$anchor) => { - MainLayout($$anchor, {}); - }; - var alternate = ($$anchor) => { - LockScreen($$anchor, {}); - }; - if_block(node, ($$render) => { - if (app$1.isUnlocked) $$render(consequent); - else $$render(alternate, -1); - }); - append($$anchor, fragment_1); - pop(); -} -mount(App, { target: document.getElementById("app") }); +//#region src/App.js +/** +* Root app component — routes between LockScreen and MainLayout. +*/ +var App = class extends Component { + _lockScreen = null; + _mainLayout = null; + mount() { + super.mount(); + const head = document.head; + head.querySelectorAll("meta, link").forEach((el) => el.remove()); + const charset = document.createElement("meta"); + charset.setAttribute("charset", "UTF-8"); + head.appendChild(charset); + const viewport = document.createElement("meta"); + viewport.setAttribute("name", "viewport"); + viewport.setAttribute("content", "width=device-width, initial-scale=1.0"); + head.appendChild(viewport); + document.title = "Password Vault"; + this.subscribe(app$1, "isUnlocked", (unlocked) => this.#swapView(unlocked)); + this.#swapView(app$1.isUnlocked); + return this; + } + #swapView(unlocked) { + if (this._lockScreen) { + this._lockScreen.destroy(); + this._lockScreen = null; + } + if (this._mainLayout) { + this._mainLayout.destroy(); + this._mainLayout = null; + } + if (this.container) this.container.innerHTML = ""; + if (unlocked) { + this._mainLayout = new MainLayout(this.container); + this._mainLayout.mount(); + } else { + this._lockScreen = new LockScreen(this.container); + this._lockScreen.mount(); + } + } + destroy() { + if (this._lockScreen) this._lockScreen.destroy(); + if (this._mainLayout) this._mainLayout.destroy(); + super.destroy(); + } +}; +new App(document.getElementById("app")).mount(); //#endregion diff --git a/package-lock.json b/package-lock.json index 0311ca0..fee7228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,9 @@ "idb": "^8.0.3" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@testing-library/svelte": "^5.3.1", "@vitest/ui": "^4.1.6", "fake-indexeddb": "^6.2.5", "jsdom": "^29.1.1", - "svelte": "^5.55.5", "vite": "^8.0.12", "vite-plugin-singlefile": "^2.3.3", "vitest": "^4.1.6" @@ -73,41 +70,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -313,38 +275,6 @@ } } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -352,17 +282,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -670,106 +589,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", - "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", - "obug": "^2.1.0", - "vitefu": "^1.1.2" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "svelte": "^5.46.4", - "vite": "^8.0.0-beta.7 || ^8.0.0" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@testing-library/svelte": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", - "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@testing-library/dom": "9.x.x || 10.x.x", - "@testing-library/svelte-core": "1.0.0" - }, - "engines": { - "node": ">= 10" - }, - "peerDependencies": { - "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", - "vite": "*", - "vitest": "*" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@testing-library/svelte-core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", - "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -781,13 +600,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -813,13 +625,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/expect": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", @@ -955,52 +760,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1011,16 +770,6 @@ "node": ">=12" } }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1054,16 +803,6 @@ "node": ">=18" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1106,26 +845,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1136,20 +855,6 @@ "node": ">=8" } }, - "node_modules/devalue": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz", - "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -1170,31 +875,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.7.tgz", - "integrity": "sha512-Dl7o7btn2YXca1VXx+PVl+lKuZdHBm8oCFuckUxqchMvNMdHMJ/qF31wtPaVyWvFYLQePkbXJrirWzbAP6Yamw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1321,23 +1001,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -1640,13 +1303,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "11.3.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", @@ -1657,16 +1313,6 @@ "node": "20 || >=22" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1820,21 +1466,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -1845,13 +1476,6 @@ "node": ">=6" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1955,34 +1579,6 @@ "dev": true, "license": "MIT" }, - "node_modules/svelte": { - "version": "5.55.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", - "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.6.4", - "esm-env": "^1.2.1", - "esrap": "^2.2.4", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2221,26 +1817,6 @@ } } }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, "node_modules/vitest": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", @@ -2412,13 +1988,6 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, - "license": "MIT" } } } diff --git a/package.json b/package.json index 2063e1c..ced5305 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,9 @@ "test:ui": "vitest --ui" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@testing-library/svelte": "^5.3.1", "@vitest/ui": "^4.1.6", "fake-indexeddb": "^6.2.5", "jsdom": "^29.1.1", - "svelte": "^5.55.5", "vite": "^8.0.12", "vite-plugin-singlefile": "^2.3.3", "vitest": "^4.1.6" diff --git a/public/icons.svg b/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/scripts/inline-assets.js b/scripts/inline-assets.js index 5c7dfd5..fbbc2b3 100644 --- a/scripts/inline-assets.js +++ b/scripts/inline-assets.js @@ -30,13 +30,6 @@ if (existsSync(faviconPath)) { console.log('[inline-assets] Inlined favicon.svg into index.html') } -// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler) -const iconsPath = join(distDir, 'icons.svg') -if (existsSync(iconsPath)) { - rmSync(iconsPath) - console.log('[inline-assets] Removed icons.svg') -} - // Remove assets directory if it exists const assetsDir = join(distDir, 'assets') if (existsSync(assetsDir)) { @@ -44,4 +37,13 @@ if (existsSync(assetsDir)) { console.log('[inline-assets] Removed assets/ directory') } +// Remove any leftover SVG files (e.g. icons.svg from old Svelte builds) +import { readdirSync } from 'fs' +for (const file of readdirSync(distDir)) { + if (file.endsWith('.svg')) { + rmSync(join(distDir, file)) + console.log(`[inline-assets] Removed ${file}`) + } +} + console.log('[inline-assets] Done — dist/ contains only index.html') diff --git a/src/App.js b/src/App.js new file mode 100644 index 0000000..2da56ba --- /dev/null +++ b/src/App.js @@ -0,0 +1,65 @@ +/** + * Root app component — routes between LockScreen and MainLayout. + */ + +import { Component } from './components/component.js' +import { LockScreen } from './components/LockScreen.js' +import { MainLayout } from './components/MainLayout.js' +import { app } from './lib/stores/app.js' + +export class App extends Component { + _lockScreen = null + _mainLayout = null + + mount() { + super.mount() + + // Set meta tags (preserve existing diff --git a/src/components/EntryForm.js b/src/components/EntryForm.js new file mode 100644 index 0000000..bde23a3 --- /dev/null +++ b/src/components/EntryForm.js @@ -0,0 +1,275 @@ +/** + * EntryForm — create/edit credential form. + */ + +import { Component } from './component.js' +import { addEntry, updateEntry, getEntryById, getGroups } from '../lib/storage/db.js' +import { encrypt, decrypt } from '../lib/crypto/crypto.js' +import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js' +import { generatePassword } from '../lib/crypto/crypto.js' +import { app } from '../lib/stores/app.js' +import { search } from '../lib/stores/search.js' +import { autofocus } from '../lib/autofocus.js' + +export class EntryForm extends Component { + /** @param {{ entryId: string|null, onSave: Function, onCancel: Function }} props */ + constructor(container, props = {}) { + super(container) + this.entryId = props.entryId || null + this.onSave = props.onSave || (() => {}) + this.onCancel = props.onCancel || (() => {}) + + this.title = '' + this.username = '' + this.password = '' + this.url = '' + this.notes = '' + this.groupId = '' + this.passwordVisible = false + this.groups = [] + this.loading = true + this.error = '' + this.saving = false + this.isEdit = false + this.formErrors = [] + } + + mount() { + super.mount() + this.#loadForm() + return this + } + + render() { + this.el = this.ce('div', { className: 'entry-form' }) + this.#renderContent() + return this.el + } + + #renderContent() { + this.el.innerHTML = '' + + if (this.loading) { + this.el.appendChild(this.ce('div', { className: 'loading', textContent: 'Loading...' })) + return + } + + if (this.error && !this.isEdit) { + this.el.appendChild(this.ce('div', { className: 'error-banner', textContent: this.error })) + return + } + + const form = this.ce('form', { className: 'ef-form-card', id: 'entry-form' }) + + // Validation errors + if (this.formErrors.length > 0) { + const errDiv = this.ce('div', { className: 'validation-errors' }) + for (const err of this.formErrors) { + errDiv.appendChild(this.ce('div', { className: 'validation-error', textContent: `⚠ ${err}` })) + } + form.appendChild(errDiv) + } + + // Title + form.appendChild(this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'title', textContent: 'Title *' }), + this.ce('input', { id: 'title', type: 'text', placeholder: 'e.g. GitHub, Gmail', value: this.title }), + )) + + // Username + form.appendChild(this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'username', textContent: 'Username / Email' }), + this.ce('input', { id: 'username', type: 'text', placeholder: 'username or email', value: this.username }), + )) + + // Password + const pwdGroup = this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'password', textContent: 'Password *' }), + this.ce('div', { className: 'password-input-group' }, + this.ce('input', { + id: 'password', + type: this.passwordVisible ? 'text' : 'password', + placeholder: 'Password', + value: this.password, + }), + this.ce('button', { type: 'button', className: 'btn btn-ghost btn-sm', id: 'toggle-pwd', textContent: this.passwordVisible ? '🙈' : '👁', title: 'Toggle visibility' }), + this.ce('button', { type: 'button', className: 'btn btn-ghost btn-sm', id: 'generate-pwd', textContent: '🎲', title: 'Generate password' }), + ), + ) + form.appendChild(pwdGroup) + + // URL + form.appendChild(this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'url', textContent: 'URL' }), + this.ce('input', { id: 'url', type: 'url', placeholder: 'https://example.com', value: this.url }), + )) + + // Group + const select = this.ce('select', { id: 'group' }) + const defaultOpt = this.ce('option', { value: '' }, this.text('No group')) + select.appendChild(defaultOpt) + for (const group of this.groups) { + if (isTrashGroup(group.id)) continue + const opt = this.ce('option', { value: group.id }, this.text(group.name)) + if (group.id === this.groupId) opt.selected = true + select.appendChild(opt) + } + form.appendChild(this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'group', textContent: 'Group' }), + select, + )) + + // Notes + form.appendChild(this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'notes', textContent: 'Notes' }), + this.ce('textarea', { id: 'notes', placeholder: 'Any additional notes...' }), + )) + + // Actions + form.appendChild(this.ce('div', { className: 'ef-form-actions' }, + this.ce('button', { + type: 'submit', + className: 'btn btn-primary', + disabled: this.saving, + textContent: this.saving ? 'Saving...' : (this.isEdit ? '💾 Update' : '➕ Create'), + }), + this.ce('button', { type: 'button', className: 'btn btn-ghost', id: 'cancel-btn', textContent: 'Cancel' }), + )) + + this.el.appendChild(form) + + // Wire up form events + const formEl = this.q('#entry-form') + if (formEl) this.on(formEl, 'submit', this.#handleSubmit) + + const cancelBtn = this.q('#cancel-btn') + if (cancelBtn) this.on(cancelBtn, 'click', () => this.onCancel()) + + const togglePwd = this.q('#toggle-pwd') + if (togglePwd) { + this.on(togglePwd, 'click', () => { + this.passwordVisible = !this.passwordVisible + const pwdInput = this.q('#password') + if (pwdInput) pwdInput.type = this.passwordVisible ? 'text' : 'password' + togglePwd.textContent = this.passwordVisible ? '🙈' : '👁' + }) + } + + const generatePwd = this.q('#generate-pwd') + if (generatePwd) { + this.on(generatePwd, 'click', () => { + this.password = generatePassword({ length: 16 }) + const pwdInput = this.q('#password') + if (pwdInput) pwdInput.value = this.password + }) + } + + // Wire input changes + const titleInput = this.q('#title') + if (titleInput) this.on(titleInput, 'input', (e) => { this.title = e.target.value }) + const usernameInput = this.q('#username') + if (usernameInput) this.on(usernameInput, 'input', (e) => { this.username = e.target.value }) + const pwdInput = this.q('#password') + if (pwdInput) this.on(pwdInput, 'input', (e) => { this.password = e.target.value }) + const urlInput = this.q('#url') + if (urlInput) this.on(urlInput, 'input', (e) => { this.url = e.target.value }) + const notesInput = this.q('#notes') + if (notesInput) this.on(notesInput, 'input', (e) => { this.notes = e.target.value }) + const groupSelect = this.q('#group') + if (groupSelect) this.on(groupSelect, 'change', (e) => { this.groupId = e.target.value }) + + // Autofocus title on new entries + if (!this.isEdit && titleInput) autofocus(titleInput, true) + } + + async #loadForm() { + this.loading = true + try { + this.groups = await getGroups() + if (this.entryId) { + this.isEdit = true + const entry = await getEntryById(this.entryId) + if (entry) { + this.title = entry.title + this.username = entry.username + this.password = await decrypt(entry.encryptedPassword, app.encryptionKey) + this.url = entry.url || '' + this.notes = entry.notes || '' + this.groupId = entry.groupId || '' + } else { + this.error = 'Entry not found' + } + } else { + const active = search.activeGroupId + this.groupId = (active !== 'all' && active !== 'trash') ? active : '' + } + } catch (e) { + this.error = 'Failed to load form: ' + e.message + } + this.loading = false + this.#renderContent() + } + + #handleSubmit = async (e) => { + e.preventDefault() + this.formErrors = [] + this.error = '' + this.saving = true + + // Read current values from inputs + const titleInput = this.q('#title') + if (titleInput) this.title = titleInput.value + const usernameInput = this.q('#username') + if (usernameInput) this.username = usernameInput.value + const pwdInput = this.q('#password') + if (pwdInput) this.password = pwdInput.value + const urlInput = this.q('#url') + if (urlInput) this.url = urlInput.value + const notesInput = this.q('#notes') + if (notesInput) this.notes = notesInput.value + const groupSelect = this.q('#group') + if (groupSelect) this.groupId = groupSelect.value + + try { + const validation = validateEntry({ title: this.title, username: this.username, encryptedPassword: this.password }) + if (!validation.valid) { + this.formErrors = validation.errors + this.saving = false + this.#renderContent() + return + } + + const encryptedPassword = await encrypt(this.password, app.encryptionKey) + + if (this.isEdit) { + const existing = await getEntryById(this.entryId) + const updated = updateEntryModel(existing, { + title: this.title, + username: this.username, + encryptedPassword, + url: this.url, + notes: this.notes, + groupId: this.groupId, + }) + await updateEntry(updated) + } else { + const entry = createEntry({ + title: this.title, + username: this.username, + encryptedPassword, + url: this.url, + notes: this.notes, + groupId: this.groupId, + }) + await addEntry(entry) + } + + this.onSave() + } catch (e) { + this.error = 'Failed to save: ' + e.message + } + + this.saving = false + this.#renderContent() + } +} diff --git a/src/components/EntryForm.svelte b/src/components/EntryForm.svelte deleted file mode 100644 index a226270..0000000 --- a/src/components/EntryForm.svelte +++ /dev/null @@ -1,232 +0,0 @@ - - -
- {#if loading} -
Loading...
- {:else} - {#if error} -
{error}
- {/if} - -
{ e.preventDefault(); handleSubmit(); }}> - {#if formErrors.length > 0} -
- {#each formErrors as err} -
⚠ {err}
- {/each} -
- {/if} - -
- - -
- -
- - -
- -
- -
- - - -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
- {/if} -
- - diff --git a/src/components/EntryList.js b/src/components/EntryList.js new file mode 100644 index 0000000..7b2d023 --- /dev/null +++ b/src/components/EntryList.js @@ -0,0 +1,212 @@ +/** + * EntryList — credential entries grid with search/filter support. + */ + +import { Component } from './component.js' +import { getEntries, searchEntries, restoreEntry, TRASH_GROUP_ID } from '../lib/storage/db.js' +import { search } from '../lib/stores/search.js' + +export class EntryList extends Component { + entries = [] + loading = true + error = '' + resultCount = 0 + dragging = false + + /** @param {{ onSelect: Function, onAdd: Function }} props */ + constructor(container, props = {}) { + super(container) + this.onSelect = props.onSelect || (() => {}) + this.onAdd = props.onAdd || (() => {}) + } + + mount() { + super.mount() + // Subscribe to reactive changes + this.subscribe(search, 'debouncedQuery', () => this.#loadEntries()) + this.subscribe(search, 'activeGroupId', () => this.#loadEntries()) + this.subscribe(search, 'refreshTrigger', () => this.#loadEntries()) + // Initial load (subscribers only fire on change, not on mount) + this.#loadEntries() + return this + } + + render() { + this.el = this.ce('div', { className: 'entry-list' }) + this.#renderContent() + return this.el + } + + #renderContent() { + this.el.innerHTML = '' + const isTrashView = search.activeGroupId === 'trash' + + if (this.loading) { + this.el.appendChild(this.ce('div', { className: 'loading', textContent: 'Loading entries...' })) + return + } + + if (this.error) { + this.el.appendChild(this.ce('div', { className: 'error-banner', textContent: this.error })) + return + } + + if (this.entries.length === 0) { + const emptyState = this.ce('div', { className: 'empty-state' }) + const icon = search.query ? '🔍' : (isTrashView ? '🗑' : '🔑') + const text = search.query ? 'No results found' : (isTrashView ? 'Trash is empty' : 'No entries yet') + const hint = search.query + ? 'Try a different search term' + : (isTrashView ? 'Deleted entries will appear here' : 'Add your first login credential to get started') + + emptyState.appendChild(this.ce('p', { className: 'empty-icon', textContent: icon })) + emptyState.appendChild(this.ce('p', { className: 'empty-text', textContent: text })) + emptyState.appendChild(this.ce('p', { className: 'empty-hint', textContent: hint })) + + if (!search.query && !isTrashView) { + const addBtn = this.ce('button', { className: 'btn btn-primary mt-3', textContent: '+ New Entry' }) + this.on(addBtn, 'click', () => this.onAdd()) + emptyState.appendChild(addBtn) + } + + this.el.appendChild(emptyState) + return + } + + // Results info + const info = this.ce('div', { className: 'results-info' }) + const countSpan = this.ce('span', { className: 'text-sm text-muted' }) + countSpan.appendChild(this.text(`${this.resultCount} entr${this.resultCount === 1 ? 'y' : 'ies'}`)) + if (search.query) { + countSpan.appendChild(this.text(' matching "')) + const strong = document.createElement('strong') + strong.textContent = this.#escapeHtml(search.query) + countSpan.appendChild(strong) + countSpan.appendChild(this.text('"')) + } + info.appendChild(countSpan) + this.el.appendChild(info) + + // Table + const table = this.ce('table', { className: 'entries-table' }, + this.ce('thead', null, + this.ce('tr', null, + this.ce('th', { textContent: 'Title' }), + this.ce('th', { textContent: 'Username' }), + this.ce('th', { textContent: 'URL' }), + this.ce('th', { textContent: 'Notes' }), + isTrashView ? this.ce('th', { style: 'width: 60px' }) : null, + ), + ), + this.ce('tbody'), + ) + + const tbody = table.querySelector('tbody') + for (const entry of this.entries) { + const tr = this.ce('tr', { + className: `entry-row${this.dragging ? ' dragging' : ''}`, + draggable: !isTrashView, + }) + + // Title cell + const tdTitle = this.ce('td') + if (!isTrashView) { + tdTitle.appendChild(this.ce('span', { className: 'drag-handle', 'aria-hidden': 'true', textContent: '⠿' })) + } + tdTitle.appendChild(this.ce('span', { className: 'entry-title', textContent: entry.title })) + tr.appendChild(tdTitle) + + // Username cell + tr.appendChild(this.ce('td', null, + this.ce('span', { className: 'entry-username', textContent: entry.username || '—' }), + )) + + // URL cell + tr.appendChild(this.ce('td', null, + this.ce('span', { className: 'entry-url truncate', textContent: entry.url || '—' }), + )) + + // Notes cell + const tdNotes = this.ce('td') + if (entry.notes) { + const tooltip = this.ce('div', { className: 'notes-tooltip' }, + this.ce('span', { className: 'notes-icon', title: entry.notes, textContent: '🔍' }), + this.ce('div', { className: 'tooltip-popup', textContent: entry.notes }), + ) + tdNotes.appendChild(tooltip) + } else { + tdNotes.appendChild(this.ce('span', { textContent: '—' })) + } + tr.appendChild(tdNotes) + + // Restore button (trash view) + if (isTrashView) { + const tdRestore = this.ce('td') + const eid = entry.id + const restoreBtn = this.ce('button', { className: 'btn btn-ghost btn-sm restore-btn', title: 'Restore entry', textContent: '↩️' }) + this.on(restoreBtn, 'click', (e) => { e.stopPropagation(); this.#handleRestore(eid) }) + tdRestore.appendChild(restoreBtn) + tr.appendChild(tdRestore) + } + + // Row click → select entry + const eid = entry.id + this.on(tr, 'click', () => this.onSelect(eid)) + + // Drag events + if (!isTrashView) { + this.on(tr, 'dragstart', (e) => { + this.dragging = true + e.dataTransfer.setData('text/plain', eid) + e.dataTransfer.effectAllowed = 'move' + }) + this.on(tr, 'dragend', () => { this.dragging = false }) + } + + tbody.appendChild(tr) + } + + this.el.appendChild(table) + } + + async #loadEntries() { + this.loading = true + this.error = '' + try { + const query = search.debouncedQuery.trim() + const groupId = search.activeGroupId + const resolvedGroupId = groupId === 'trash' ? TRASH_GROUP_ID : groupId + + if (query) { + const options = resolvedGroupId !== 'all' ? { groupId: resolvedGroupId } : {} + this.entries = await searchEntries(query, options) + } else if (resolvedGroupId !== 'all') { + this.entries = await getEntries({ groupId: resolvedGroupId }) + } else { + this.entries = (await getEntries()).filter(e => e.groupId !== TRASH_GROUP_ID) + } + + this.resultCount = this.entries.length + } catch (e) { + this.error = 'Failed to load entries: ' + e.message + } + this.loading = false + this.#renderContent() + } + + async #handleRestore(entryId) { + try { + await restoreEntry(entryId) + search.refresh() + } catch (e) { + this.error = 'Failed to restore: ' + e.message + this.#renderContent() + } + } + + #escapeHtml(str) { + const div = document.createElement('div') + div.textContent = str + return div.innerHTML + } +} diff --git a/src/components/EntryList.svelte b/src/components/EntryList.svelte deleted file mode 100644 index 9ed341f..0000000 --- a/src/components/EntryList.svelte +++ /dev/null @@ -1,309 +0,0 @@ - - -
- {#if loading} -
Loading entries...
- {:else if error} -
{error}
- {:else if entries.length === 0} -
-

{searchStore.query ? '🔍' : (isTrashView ? '🗑' : '🔑')}

-

{searchStore.query ? 'No results found' : (isTrashView ? 'Trash is empty' : 'No entries yet')}

-

- {searchStore.query - ? 'Try a different search term' - : (isTrashView ? 'Deleted entries will appear here' : 'Add your first login credential to get started')} -

- {#if !searchStore.query && !isTrashView} - - {/if} -
- {:else} -
- - {resultCount} entr{resultCount === 1 ? 'y' : 'ies'} - {#if searchStore.query} - matching "{searchStore.query}" - {/if} - -
- - - - - - - - - {#if isTrashView} - - {/if} - - - - {#each entries as entry (entry.id)} - onSelect(entry.id)} - ondragstart={(e) => { if (!isTrashView) { dragging = true; e.dataTransfer.setData('text/plain', entry.id); e.dataTransfer.effectAllowed = 'move'; } }} - ondragend={() => { dragging = false; }} - class="entry-row {dragging ? 'dragging' : ''}" - > - - - - - {#if isTrashView} - - {/if} - - {/each} - -
TitleUsernameURLNotes
- {#if !isTrashView} - - {/if} - {entry.title} - - {entry.username || '—'} - - {entry.url || '—'} - - {#if entry.notes} -
- 🔍 -
{entry.notes}
-
- {:else} - - {/if} -
- -
- {/if} -
- - diff --git a/src/components/ImportExport.js b/src/components/ImportExport.js new file mode 100644 index 0000000..9db5733 --- /dev/null +++ b/src/components/ImportExport.js @@ -0,0 +1,354 @@ +/** + * ImportExport — export with group selection + import with source password. + */ + +import { Component } from './component.js' +import { exportSelected, importAll, getGroups, getEntries } from '../lib/storage/db.js' +import { search } from '../lib/stores/search.js' +import { app } from '../lib/stores/app.js' +import { isTrashGroup } from '../lib/models/schema.js' + +export class ImportExport extends Component { + showExport = false + showImport = false + importMode = 'merge' + importResult = null + importError = '' + importing = false + exporting = false + sourcePassword = '' + parsedFileData = null + allGroups = [] + allEntries = [] + selectedGroupIds = [] + + get selectAll() { + return this.allGroups.length > 0 && this.allGroups.every(g => this.selectedGroupIds.includes(g.id)) + } + + get exportEntryCount() { + return this.allEntries.filter(e => this.selectedGroupIds.includes(e.groupId)).length + } + + mount() { + super.mount() + return this + } + + render() { + this.el = this.ce('div', { className: 'import-export' }, + this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'export-btn', title: 'Export', textContent: '📤 Export' }), + this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'import-btn', title: 'Import', textContent: '📥 Import' }), + ) + + this.on(this.q('#export-btn'), 'click', () => this.#openExportModal()) + this.on(this.q('#import-btn'), 'click', () => { this.showImport = true; this.#renderImportModal() }) + + return this.el + } + + async #openExportModal() { + const groups = (await getGroups()).filter(g => !isTrashGroup(g.id)) + this.allGroups = [{ id: '', name: 'Ungrouped', color: '#6b7280' }, ...groups] + this.allEntries = await getEntries() + this.selectedGroupIds = this.allGroups.map(g => g.id) + this.showExport = true + this.#renderExportModal() + } + + #renderExportModal() { + const existing = this.q('.ie-modal-overlay') + if (existing) existing.remove() + + const overlay = this.ce('div', { className: 'ie-modal-overlay', role: 'presentation' }) + const modal = this.ce('div', { className: 'ie-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Export vault', tabindex: '-1' }) + + modal.appendChild(this.ce('h3', { textContent: 'Export Vault' })) + modal.appendChild(this.ce('p', { textContent: "Select which groups to export. You'll need the source vault's master password when importing into another vault." })) + + // Group select header + const header = this.ce('div', { className: 'group-select-header' }, + this.ce('label', { className: 'checkbox-label' }, + this.ce('input', { type: 'checkbox', id: 'select-all-checkbox', checked: this.selectAll }), + this.ce('span', { textContent: 'Select all' }), + ), + this.ce('span', { className: 'entry-count', id: 'export-count', textContent: `${this.exportEntryCount} entries` }), + ) + modal.appendChild(header) + + // Group list + const list = this.ce('div', { className: 'group-select-list' }) + for (const group of this.allGroups) { + const label = this.ce('label', { className: 'checkbox-label group-checkbox' }, + this.ce('input', { + type: 'checkbox', + 'data-group-id': group.id, + checked: this.selectedGroupIds.includes(group.id), + }), + this.ce('span', { className: 'group-color-dot', style: `background-color: ${group.color || '#6c63ff'}` }), + this.ce('span', { className: 'group-name', textContent: group.name }), + ) + list.appendChild(label) + } + modal.appendChild(list) + + // Actions + const actions = this.ce('div', { className: 'ie-modal-actions' }, + this.ce('button', { className: 'btn btn-primary', id: 'do-export-btn', disabled: this.exporting || this.selectedGroupIds.length === 0, textContent: this.exporting ? 'Exporting...' : '📤 Export JSON' }), + this.ce('button', { className: 'btn btn-ghost', id: 'cancel-export-btn', textContent: 'Cancel' }), + ) + modal.appendChild(actions) + + overlay.appendChild(modal) + + this.el.appendChild(overlay) + + this.on(overlay, 'click', () => { this.showExport = false; overlay.remove() }) + this.on(modal, 'click', (e) => e.stopPropagation()) + + // Select all + const selectAllCb = overlay.querySelector('#select-all-checkbox') + if (selectAllCb) { + this.on(selectAllCb, 'change', () => this.#toggleSelectAll()) + } + + // Individual checkboxes + overlay.querySelectorAll('.group-select-list input[data-group-id]').forEach(cb => { + this.on(cb, 'change', () => { + const gid = cb.dataset.groupId + this.#toggleGroup(gid) + }) + }) + + // Export button + const exportBtn = overlay.querySelector('#do-export-btn') + if (exportBtn) this.on(exportBtn, 'click', () => this.#handleExport()) + + // Cancel + const cancelBtn = overlay.querySelector('#cancel-export-btn') + if (cancelBtn) this.on(cancelBtn, 'click', () => { this.showExport = false; overlay.remove() }) + } + + #toggleSelectAll() { + if (this.selectAll) { + this.selectedGroupIds = [] + } else { + this.selectedGroupIds = this.allGroups.map(g => g.id) + } + this.#renderExportModal() + } + + #toggleGroup(groupId) { + if (this.selectedGroupIds.includes(groupId)) { + this.selectedGroupIds = this.selectedGroupIds.filter(id => id !== groupId) + } else { + this.selectedGroupIds = [...this.selectedGroupIds, groupId] + } + // Update select-all checkbox and count + const overlay = this.q('.ie-modal-overlay') + if (overlay) { + const selectAllCb = overlay.querySelector('#select-all-checkbox') + if (selectAllCb) selectAllCb.checked = this.selectAll + const countEl = overlay.querySelector('#export-count') + if (countEl) countEl.textContent = `${this.exportEntryCount} entries` + const exportBtn = overlay.querySelector('#do-export-btn') + if (exportBtn) exportBtn.disabled = this.exporting || this.selectedGroupIds.length === 0 + } + } + + async #handleExport() { + this.exporting = true + try { + const exportData = await exportSelected( + this.selectedGroupIds.length === this.allGroups.length ? null : this.selectedGroupIds + ) + const json = JSON.stringify(exportData, null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `password-vault-export-${new Date().toISOString().slice(0, 10)}.json` + a.click() + URL.revokeObjectURL(url) + this.showExport = false + } catch (e) { + this.importError = 'Export failed: ' + e.message + } + this.exporting = false + } + + #renderImportModal() { + const existing = this.q('.ie-modal-overlay') + if (existing) existing.remove() + + const overlay = this.ce('div', { className: 'ie-modal-overlay', role: 'presentation' }) + const modal = this.ce('div', { className: 'ie-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Import vault data', tabindex: '-1' }) + + modal.appendChild(this.ce('h3', { textContent: 'Import Vault Data' })) + + if (this.importError) { + modal.appendChild(this.ce('div', { className: 'ie-error-banner', textContent: this.importError })) + } + + if (this.importResult) { + const msg = `✓ Imported ${this.importResult.imported.entries} entries and ${this.importResult.imported.groups} groups` + + (this.importResult.skipped > 0 ? ` (${this.importResult.skipped} skipped)` : '') + modal.appendChild(this.ce('div', { className: 'success-banner', textContent: msg })) + } else if (this.parsedFileData) { + modal.appendChild(this.ce('p', {}, + this.text('File loaded. Enter the '), + this.ce('strong', { textContent: "source vault's master password" }), + this.text(' to decrypt and re-encrypt entries under your current vault.'), + )) + + modal.appendChild(this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'source-password', className: 'file-label', textContent: 'Source vault password' }), + this.ce('input', { id: 'source-password', type: 'password', placeholder: 'Enter source vault password', autocomplete: 'current-password', value: this.sourcePassword }), + )) + + // Import mode radios + const modeDiv = this.ce('div', { className: 'import-mode' }) + const mergeRadio = this.ce('label', { className: 'radio-label' }, + this.ce('input', { type: 'radio', name: 'importMode', value: 'merge', checked: this.importMode === 'merge' }), + this.ce('span', { textContent: 'Merge — add to existing data' }), + ) + const replaceRadio = this.ce('label', { className: 'radio-label' }, + this.ce('input', { type: 'radio', name: 'importMode', value: 'replace', checked: this.importMode === 'replace' }), + this.ce('span', { textContent: 'Replace — clear all existing data first' }), + ) + modeDiv.appendChild(mergeRadio) + modeDiv.appendChild(replaceRadio) + modal.appendChild(modeDiv) + + const actions = this.ce('div', { className: 'ie-modal-actions' }, + this.ce('button', { className: 'btn btn-primary', id: 'do-import-btn', disabled: this.importing, textContent: this.importing ? 'Importing...' : '📥 Import' }), + this.ce('button', { className: 'btn btn-ghost', id: 'cancel-import-btn', textContent: 'Cancel' }), + ) + modal.appendChild(actions) + + overlay.appendChild(modal) + this.el.appendChild(overlay) + + this.on(overlay, 'click', () => { this.showImport = false; overlay.remove() }) + this.on(modal, 'click', (e) => e.stopPropagation()) + + const srcPwdInput = overlay.querySelector('#source-password') + if (srcPwdInput) this.on(srcPwdInput, 'input', (e) => { this.sourcePassword = e.target.value }) + + overlay.querySelectorAll('input[name="importMode"]').forEach(radio => { + this.on(radio, 'change', (e) => { this.importMode = e.target.value }) + }) + + const importBtn = overlay.querySelector('#do-import-btn') + if (importBtn) this.on(importBtn, 'click', () => this.#handleImportSubmit()) + + const cancelBtn = overlay.querySelector('#cancel-import-btn') + if (cancelBtn) this.on(cancelBtn, 'click', () => { + this.parsedFileData = null + this.sourcePassword = '' + overlay.remove() + }) + } else { + modal.appendChild(this.ce('p', { textContent: 'Select how to handle existing data:' })) + + const modeDiv = this.ce('div', { className: 'import-mode' }) + const mergeRadio = this.ce('label', { className: 'radio-label' }, + this.ce('input', { type: 'radio', name: 'importMode', value: 'merge', checked: this.importMode === 'merge' }), + this.ce('span', { textContent: 'Merge — add to existing data' }), + ) + const replaceRadio = this.ce('label', { className: 'radio-label' }, + this.ce('input', { type: 'radio', name: 'importMode', value: 'replace', checked: this.importMode === 'replace' }), + this.ce('span', { textContent: 'Replace — clear all existing data first' }), + ) + modeDiv.appendChild(mergeRadio) + modeDiv.appendChild(replaceRadio) + modal.appendChild(modeDiv) + + modal.appendChild(this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'import-file', className: 'file-label', textContent: 'Choose JSON file' }), + this.ce('input', { id: 'import-file', type: 'file', accept: '.json,application/json', disabled: this.importing }), + )) + + const closeActions = this.ce('div', { className: 'ie-modal-actions' }, + this.ce('button', { className: 'btn btn-ghost', id: 'close-import-btn', textContent: 'Close' }), + ) + modal.appendChild(closeActions) + + overlay.appendChild(modal) + this.el.appendChild(overlay) + + this.on(overlay, 'click', () => { this.showImport = false; overlay.remove() }) + this.on(modal, 'click', (e) => e.stopPropagation()) + + overlay.querySelectorAll('input[name="importMode"]').forEach(radio => { + this.on(radio, 'change', (e) => { this.importMode = e.target.value }) + }) + + const fileInput = overlay.querySelector('#import-file') + if (fileInput) this.on(fileInput, 'change', (e) => this.#handleFileSelect(e)) + + const closeBtn = overlay.querySelector('#close-import-btn') + if (closeBtn) this.on(closeBtn, 'click', () => { + this.showImport = false + this.importResult = null + this.importError = '' + overlay.remove() + }) + } + } + + async #handleFileSelect(event) { + const file = event.target.files[0] + if (!file) return + + this.importError = '' + this.importResult = null + this.sourcePassword = '' + + try { + const text = await file.text() + const data = JSON.parse(text) + + if (!data.entries || !data.groups) { + this.importError = 'Invalid file format — missing entries or groups data' + this.#renderImportModal() + return + } + + this.parsedFileData = data + } catch (e) { + this.importError = 'Failed to parse file: ' + e.message + } + + event.target.value = '' + this.#renderImportModal() + } + + async #handleImportSubmit() { + if (!this.parsedFileData) return + const srcPwdInput = this.q('#source-password') + if (srcPwdInput) this.sourcePassword = srcPwdInput.value + + if (!this.sourcePassword.trim()) { + this.importError = 'Source vault password is required' + this.#renderImportModal() + return + } + + this.importing = true + this.importError = '' + this.importResult = null + + try { + this.importResult = await importAll(this.parsedFileData, this.importMode, this.sourcePassword, app.encryptionKey) + this.sourcePassword = '' + this.parsedFileData = null + search.refresh() + } catch (e) { + this.importError = 'Import failed: ' + e.message + } + + this.importing = false + this.#renderImportModal() + } +} diff --git a/src/components/ImportExport.svelte b/src/components/ImportExport.svelte deleted file mode 100644 index 6f9a206..0000000 --- a/src/components/ImportExport.svelte +++ /dev/null @@ -1,446 +0,0 @@ - - -
- - - - - - - - {#if showExport} - - {/if} - - - {#if showImport} - - {/if} -
- - diff --git a/src/components/LockScreen.js b/src/components/LockScreen.js new file mode 100644 index 0000000..e3c003d --- /dev/null +++ b/src/components/LockScreen.js @@ -0,0 +1,194 @@ +/** + * LockScreen — master password setup + unlock UI. + */ + +import { Component } from './component.js' +import { app } from '../lib/stores/app.js' +import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js' +import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup } from '../lib/storage/db.js' +import { startAutoLock } from '../lib/stores/security.js' +import { settings } from '../lib/stores/settings.js' +import { autofocus } from '../lib/autofocus.js' + +export class LockScreen extends Component { + masterPassword = '' + confirmPassword = '' + error = '' + loading = false + isSetup = false + + mount() { + super.mount() + this.#checkVault() + return this + } + + render() { + const notLocal = typeof window !== 'undefined' && window.location.protocol !== 'file:' + + this.el = this.ce('div', { className: 'lock-screen' }, + this.ce('div', { className: 'lock-card' }, + this.ce('div', { className: 'lock-icon', textContent: '🔐' }), + this.ce('h1', { textContent: 'Password Vault' }), + this.ce('p', { className: 'subtitle', textContent: 'Unlock your vault' }), + notLocal ? this.ce('div', { className: 'warning-banner', role: 'alert', textContent: 'This HTML file is intended for offline use.' }) : null, + null, // error-banner placeholder + this.#buildForm(), + this.ce('p', { className: 'hint', textContent: 'Your data is encrypted with AES-256-GCM. Key is stored only in memory.' }), + ), + ) + + // Store references to dynamic elements + this._subtitle = this.q('.subtitle') + this._confirmGroup = this.q('.confirm-group') + this._submitBtn = this.q('.submit-btn') + this._passwordInput = this.q('#master-password') + this._confirmInput = this.q('#confirm-password') + this._hint = this.q('.hint') + + autofocus(this._passwordInput, true) + + // Wire input listeners so this.masterPassword / this.confirmPassword stay in sync + if (this._passwordInput) { + this.on(this._passwordInput, 'input', (e) => { this.masterPassword = e.target.value }) + } + if (this._confirmInput) { + this.on(this._confirmInput, 'input', (e) => { this.confirmPassword = e.target.value }) + } + + return this.el + } + + #buildForm() { + return this.ce('form', { className: 'lock-form', id: 'lock-form' }, + this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'master-password', textContent: 'Master Password' }), + this.ce('input', { id: 'master-password', type: 'password', placeholder: 'Enter master password', autocomplete: 'current-password' }), + ), + this.ce('div', { className: 'form-group confirm-group' }, + this.ce('label', { htmlFor: 'confirm-password', textContent: 'Confirm Password' }), + this.ce('input', { id: 'confirm-password', type: 'password', placeholder: 'Confirm master password', autocomplete: 'new-password' }), + ), + this.ce('button', { type: 'submit', className: 'btn btn-primary w-full submit-btn', textContent: 'Unlock' }), + ) + } + + #updateUI() { + if (this._subtitle) { + this._subtitle.textContent = this.isSetup ? 'Create your vault' : 'Unlock your vault' + } + if (this._confirmGroup) { + this._confirmGroup.style.display = this.isSetup ? '' : 'none' + } + if (this._submitBtn) { + this._submitBtn.textContent = this.loading ? 'Processing...' : (this.isSetup ? 'Create Vault' : 'Unlock') + this._submitBtn.disabled = this.loading + } + if (this._passwordInput) { + this._passwordInput.disabled = this.loading + } + if (this._confirmInput) { + this._confirmInput.disabled = this.loading + } + // Error banner + if (this.error) { + if (!this._errorBanner) { + const banner = this.ce('div', { className: 'error-banner', role: 'alert', textContent: this.error }) + const form = this.q('#lock-form') + form?.parentNode?.insertBefore(banner, form) + this._errorBanner = banner + } else { + this._errorBanner.textContent = this.error + } + } else if (this._errorBanner) { + this._errorBanner.remove() + this._errorBanner = null + } + if (this._hint) { + this._hint.textContent = this.isSetup + ? 'Your master password encrypts all data locally. It cannot be recovered if lost.' + : 'Your data is encrypted with AES-256-GCM. Key is stored only in memory.' + } + } + + #checkVault() { + isVaultInitialized().then(init => { + this.isSetup = !init + this.#updateUI() + }) + } + + #handleSubmit = async (e) => { + e.preventDefault() + this.error = '' + this.loading = true + this.#updateUI() + + try { + if (this.isSetup) { + if (!this.masterPassword || this.masterPassword.length < 4) { + this.error = 'Password must be at least 4 characters' + this.loading = false + this.#updateUI() + return + } + if (this.masterPassword !== this.confirmPassword) { + this.error = 'Passwords do not match' + this.loading = false + this.#updateUI() + return + } + + const { salt, testEncrypted, testPlaintext } = await createTestPayload(this.masterPassword) + app.salt = salt + const key = await deriveKey(this.masterPassword, salt) + app.encryptionKey = key + + await saveVaultMeta(salt, testEncrypted, testPlaintext) + await ensureTrashGroup() + await settings.load() + app.isUnlocked = true + startAutoLock() + } else { + const meta = await loadVaultMeta() + if (!meta.salt || !meta.testEncrypted || !meta.testPlaintext) { + this.error = 'Vault data corrupted' + this.loading = false + this.#updateUI() + return + } + + const key = await deriveKey(this.masterPassword, meta.salt) + const isValid = await verifyPassword(this.masterPassword, meta.salt, meta.testEncrypted, meta.testPlaintext) + + if (!isValid) { + this.error = 'Incorrect password' + this.loading = false + this.#updateUI() + return + } + + app.salt = meta.salt + app.encryptionKey = key + await settings.load() + app.isUnlocked = true + startAutoLock() + } + } catch (err) { + console.error(err) + this.error = 'An error occurred: ' + err.message + } + + this.loading = false + this.masterPassword = '' + this.confirmPassword = '' + if (this._passwordInput) this._passwordInput.value = '' + if (this._confirmInput) this._confirmInput.value = '' + this.#updateUI() + } + + afterMount() { + const form = this.q('#lock-form') + if (form) this.on(form, 'submit', this.#handleSubmit) + } +} diff --git a/src/components/LockScreen.svelte b/src/components/LockScreen.svelte deleted file mode 100644 index 0e5f1e2..0000000 --- a/src/components/LockScreen.svelte +++ /dev/null @@ -1,215 +0,0 @@ - - -
-
-
🔐
-

Password Vault

-

{isSetup ? 'Create your vault' : 'Unlock your vault'}

- - {#if notLocal} - - {/if} - - {#if error} - - {/if} - -
{ e.preventDefault(); handleSubmit(); }} class="lock-form"> -
- - -
- - {#if isSetup} -
- - -
- {/if} - - -
- -

- {isSetup - ? 'Your master password encrypts all data locally. It cannot be recovered if lost.' - : 'Your data is encrypted with AES-256-GCM. Key is stored only in memory.'} -

-
-
- - diff --git a/src/components/MainLayout.js b/src/components/MainLayout.js new file mode 100644 index 0000000..a35b6a7 --- /dev/null +++ b/src/components/MainLayout.js @@ -0,0 +1,298 @@ +/** + * MainLayout — shell: sidebar + content area with view routing. + */ + +import { Component } from './component.js' +import { app } from '../lib/stores/app.js' +import { search } from '../lib/stores/search.js' +import { TRASH_GROUP_NAME } from '../lib/models/schema.js' +import { emptyTrash } from '../lib/storage/db.js' +import { Sidebar } from './Sidebar.js' +import { EntryList } from './EntryList.js' +import { EntryDetail } from './EntryDetail.js' +import { EntryForm } from './EntryForm.js' +import { ImportExport } from './ImportExport.js' +import { SettingsDialog } from './SettingsDialog.js' + +export class MainLayout extends Component { + sidebarOpen = false + viewMode = 'list' // 'list' | 'detail' | 'form' | 'settings' + selectedEntryId = null + showEmptyTrashConfirm = false + emptyingTrash = false + + // Child component instances + _sidebar = null + _importExport = null + _contentComponent = null + + get isTrashView() { + return search.activeGroupId === 'trash' + } + + mount() { + super.mount() + + // Subscribe to app lock — unmount everything and emit event + this.subscribe(app, 'isUnlocked', (unlocked) => { + if (!unlocked) this.emitLock() + }) + + return this + } + + render() { + this.el = this.ce('div', { className: 'app-shell' }) + + // Mobile header + this.el.appendChild(this.ce('div', { className: 'mobile-header' }, + this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'menu-btn', textContent: '☰ Menu' }), + this.ce('span', { className: 'mobile-title', textContent: 'Password Vault' }), + this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'mobile-lock-btn', title: 'Lock', textContent: '🔒' }), + )) + + // Sidebar + const aside = this.ce('aside', { className: 'sidebar', id: 'sidebar' }) + this.el.appendChild(aside) + + // Main content + const main = this.ce('main', { className: 'main-content' }, + this.ce('div', { className: 'top-bar', id: 'top-bar' }), + this.ce('div', { className: 'content-area', id: 'content-area' }), + ) + this.el.appendChild(main) + + // Wire mobile header buttons + this.on(this.q('#menu-btn'), 'click', () => { this.sidebarOpen = !this.sidebarOpen; this.#updateSidebar() }) + this.on(this.q('#mobile-lock-btn'), 'click', () => app.lockVault()) + + // Mount sidebar + this._sidebar = new Sidebar(this.q('#sidebar')) + this._sidebar.mount() + + // Initial render + this.#renderTopBar() + this.#navigate() + + return this.el + } + + #updateSidebar() { + const sidebar = this.q('#sidebar') + if (sidebar) { + sidebar.classList.toggle('open', this.sidebarOpen) + } + + // Overlay + let overlay = this.q('.sidebar-overlay') + if (this.sidebarOpen && !overlay) { + overlay = this.ce('button', { className: 'sidebar-overlay', 'aria-label': 'Close menu' }) + this.on(overlay, 'click', () => { this.sidebarOpen = false; this.#updateSidebar() }) + this.el.appendChild(overlay) + } else if (!this.sidebarOpen && overlay) { + overlay.remove() + } + } + + #renderTopBar() { + const topBar = this.q('#top-bar') + if (!topBar) return + topBar.innerHTML = '' + + // Back button + if (this.viewMode !== 'list') { + const backBtn = this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'back-btn', textContent: '← Back' }) + this.on(backBtn, 'click', () => this.#handleBack()) + topBar.appendChild(backBtn) + } + + // Title + const titleDiv = this.ce('div', { className: 'top-bar-title' }) + let titleText + switch (this.viewMode) { + case 'list': titleText = this.isTrashView ? TRASH_GROUP_NAME : 'All Entries'; break + case 'detail': titleText = 'Entry Details'; break + case 'form': titleText = this.selectedEntryId ? 'Edit Entry' : 'New Entry'; break + case 'settings': titleText = 'Settings'; break + default: titleText = 'Password Vault' + } + titleDiv.appendChild(this.ce('h1', { textContent: titleText })) + topBar.appendChild(titleDiv) + + // Actions + const actions = this.ce('div', { className: 'top-bar-actions' }) + + if (this.viewMode === 'list' && this.isTrashView) { + actions.appendChild(this.ce('button', { + className: 'btn btn-danger btn-sm', + id: 'empty-trash-btn', + disabled: this.emptyingTrash, + textContent: this.emptyingTrash ? 'Emptying...' : '🗑 Empty Trash', + })) + } + + if (this.viewMode === 'list' && !this.isTrashView) { + actions.appendChild(this.ce('button', { + className: 'btn btn-primary btn-sm', + id: 'new-entry-btn', + textContent: '+ New Entry', + })) + } + + // ImportExport buttons + if (!this._importExport) { + this._importExport = new ImportExport(actions) + this._importExport.mount() + } + + actions.appendChild(this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'settings-btn', title: 'Settings', textContent: '⚙️' })) + actions.appendChild(this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'lock-btn', title: 'Lock vault', textContent: '🔒' })) + + topBar.appendChild(actions) + + // Wire action buttons + const emptyTrashBtn = this.q('#empty-trash-btn') + if (emptyTrashBtn) this.on(emptyTrashBtn, 'click', () => { this.showEmptyTrashConfirm = true; this.#renderEmptyTrashModal() }) + + const newEntryBtn = this.q('#new-entry-btn') + if (newEntryBtn) this.on(newEntryBtn, 'click', () => this.#goForm(null)) + + const settingsBtn = this.q('#settings-btn') + if (settingsBtn) this.on(settingsBtn, 'click', () => this.#goSettings()) + + const lockBtn = this.q('#lock-btn') + if (lockBtn) this.on(lockBtn, 'click', () => app.lockVault()) + } + + #navigate() { + // Destroy previous content component + if (this._contentComponent) { + this._contentComponent.destroy() + this._contentComponent = null + } + + const contentArea = this.q('#content-area') + if (!contentArea) return + + switch (this.viewMode) { + case 'list': + this._contentComponent = new EntryList(contentArea, { + onSelect: (id) => this.#goDetail(id), + onAdd: () => this.#goForm(null), + }) + break + case 'detail': + if (this.selectedEntryId) { + this._contentComponent = new EntryDetail(contentArea, { + entryId: this.selectedEntryId, + onEdit: (id) => this.#goForm(id), + onBack: () => this.#goList(), + }) + } + break + case 'form': + this._contentComponent = new EntryForm(contentArea, { + entryId: this.selectedEntryId, + onSave: () => this.#goList(), + onCancel: () => this.#handleBack(), + }) + break + case 'settings': + this._contentComponent = new SettingsDialog(contentArea, { + onBack: () => this.#goList(), + }) + break + } + + if (this._contentComponent) this._contentComponent.mount() + this.#renderTopBar() + } + + #goList() { + this.viewMode = 'list' + this.selectedEntryId = null + this.sidebarOpen = false + this.#updateSidebar() + this.#navigate() + } + + #goDetail(entryId) { + this.selectedEntryId = entryId + this.viewMode = 'detail' + this.sidebarOpen = false + this.#updateSidebar() + this.#navigate() + } + + #goForm(entryId) { + this.selectedEntryId = entryId + this.viewMode = 'form' + this.sidebarOpen = false + this.#updateSidebar() + this.#navigate() + } + + #goSettings() { + this.viewMode = 'settings' + this.sidebarOpen = false + this.#updateSidebar() + this.#navigate() + } + + #handleBack() { + this.#goList() + } + + #renderEmptyTrashModal() { + const existing = this.q('.ml-modal-overlay') + if (existing) existing.remove() + + const overlay = this.ce('div', { className: 'ml-modal-overlay', role: 'presentation' }) + const modal = this.ce('div', { className: 'ml-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Empty trash confirmation', tabindex: '-1' }) + + modal.appendChild(this.ce('h3', { textContent: 'Empty Trash' })) + modal.appendChild(this.ce('p', { textContent: 'Permanently delete all entries from the trash? This cannot be undone.' })) + + const actions = this.ce('div', { className: 'ml-modal-actions' }, + this.ce('button', { className: 'btn btn-danger', id: 'confirm-empty-trash', disabled: this.emptyingTrash, textContent: this.emptyingTrash ? 'Emptying...' : 'Yes, empty trash' }), + this.ce('button', { className: 'btn btn-ghost', id: 'cancel-empty-trash', textContent: 'Cancel' }), + ) + modal.appendChild(actions) + overlay.appendChild(modal) + + this.el.appendChild(overlay) + + this.on(overlay, 'click', () => { this.showEmptyTrashConfirm = false; overlay.remove() }) + this.on(modal, 'click', (e) => e.stopPropagation()) + + const confirmBtn = overlay.querySelector('#confirm-empty-trash') + if (confirmBtn) this.on(confirmBtn, 'click', () => this.#handleEmptyTrash()) + + const cancelBtn = overlay.querySelector('#cancel-empty-trash') + if (cancelBtn) this.on(cancelBtn, 'click', () => { this.showEmptyTrashConfirm = false; overlay.remove() }) + } + + async #handleEmptyTrash() { + this.emptyingTrash = true + try { + await emptyTrash() + search.activeGroupId = 'all' + this.showEmptyTrashConfirm = false + this.#goList() + } catch (e) { + console.error('Failed to empty trash:', e) + } + this.emptyingTrash = false + } + + emitLock() { + this.destroy() + } + + destroy() { + if (this._sidebar) this._sidebar.destroy() + if (this._importExport) this._importExport.destroy() + if (this._contentComponent) this._contentComponent.destroy() + super.destroy() + } +} diff --git a/src/components/MainLayout.svelte b/src/components/MainLayout.svelte deleted file mode 100644 index d64b31c..0000000 --- a/src/components/MainLayout.svelte +++ /dev/null @@ -1,328 +0,0 @@ - - -
- -
- - Password Vault - -
- - - {#if sidebarOpen} - - {/if} - - - - - -
- -
- {#if viewMode !== 'list'} - - {/if} -
- {#if viewMode === 'list'} -

{searchStore.activeGroupId === 'trash' ? TRASH_GROUP_NAME : 'All Entries'}

- {:else if viewMode === 'detail'} -

Entry Details

- {:else if viewMode === 'form'} -

{selectedEntryId ? 'Edit Entry' : 'New Entry'}

- {:else if viewMode === 'settings'} -

Settings

- {/if} -
-
- {#if viewMode === 'list' && isTrashView} - - {/if} - {#if viewMode === 'list' && !isTrashView} - - {/if} - - - -
-
- - -
- {#if viewMode === 'list'} - goForm(null)} /> - {:else if viewMode === 'detail' && selectedEntryId} - goForm(selectedEntryId)} - onBack={goList} - /> - {:else if viewMode === 'form'} - - {:else if viewMode === 'settings'} - - {/if} -
-
- - - {#if showEmptyTrashConfirm} - - {/if} -
- - diff --git a/src/components/SettingsDialog.js b/src/components/SettingsDialog.js new file mode 100644 index 0000000..27e7135 --- /dev/null +++ b/src/components/SettingsDialog.js @@ -0,0 +1,122 @@ +/** + * SettingsDialog — auto-lock and tab-switch settings. + */ + +import { Component } from './component.js' +import { settings } from '../lib/stores/settings.js' +import { startAutoLock } from '../lib/stores/security.js' + +export class SettingsDialog extends Component { + /** @param {{ onBack: Function }} props */ + constructor(container, props = {}) { + super(container) + this.onBack = props.onBack || (() => {}) + this.minutes = settings.autoLockMinutes + this.lockOnTabSwitch = settings.lockOnTabSwitch + this.saving = false + } + + mount() { + super.mount() + // Sync local values + this.minutes = settings.autoLockMinutes + this.lockOnTabSwitch = settings.lockOnTabSwitch + return this + } + + render() { + this.el = this.ce('div', { className: 'settings-panel' }) + this.#renderContent() + return this.el + } + + #renderContent() { + this.el.innerHTML = '' + + const form = this.ce('form', { className: 'sd-form-card', id: 'settings-form' }, + this.ce('h3', { textContent: 'Settings' }), + + // Auto-lock minutes + this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'auto-lock-minutes', textContent: 'Auto-lock after' }), + this.#buildMinuteSelect(), + this.ce('p', { className: 'text-muted text-xs mt-1', id: 'lock-hint' }), + ), + + // Lock on tab switch + this.ce('div', { className: 'form-group' }, + this.ce('label', { className: 'toggle-label', htmlFor: 'lock-tab-switch' }, + this.ce('input', { id: 'lock-tab-switch', type: 'checkbox', checked: this.lockOnTabSwitch }), + this.ce('span', { className: 'toggle-track' }, + this.ce('span', { className: 'toggle-thumb' }), + ), + this.ce('span', { className: 'toggle-text', textContent: 'Lock when tab loses focus' }), + ), + this.ce('p', { className: 'text-muted text-xs mt-1', id: 'tab-hint' }), + ), + + // Actions + this.ce('div', { className: 'sd-form-actions' }, + this.ce('button', { type: 'submit', className: 'btn btn-primary', disabled: this.saving, id: 'save-settings-btn', textContent: this.saving ? 'Saving...' : 'Save' }), + this.ce('button', { type: 'button', className: 'btn btn-ghost', id: 'cancel-settings-btn', textContent: 'Cancel' }), + ), + ) + + this.el.appendChild(form) + + // Update hints + const lockHint = this.q('#lock-hint') + if (lockHint) lockHint.textContent = `Vault locks after ${this.minutes} ${this.minutes === 1 ? 'minute' : 'minutes'} of inactivity.` + + const tabHint = this.q('#tab-hint') + if (tabHint) tabHint.textContent = this.lockOnTabSwitch + ? 'The vault locks immediately when you switch to another tab.' + : 'The vault stays unlocked even when you switch tabs.' + + // Wire events + const formEl = this.q('#settings-form') + if (formEl) this.on(formEl, 'submit', this.#handleSave) + + const cancelBtn = this.q('#cancel-settings-btn') + if (cancelBtn) this.on(cancelBtn, 'click', () => this.onBack()) + + const minutesSelect = this.q('#auto-lock-minutes') + if (minutesSelect) this.on(minutesSelect, 'change', (e) => { + this.minutes = Number(e.target.value) + if (lockHint) lockHint.textContent = `Vault locks after ${this.minutes} ${this.minutes === 1 ? 'minute' : 'minutes'} of inactivity.` + }) + + const tabCheckbox = this.q('#lock-tab-switch') + if (tabCheckbox) this.on(tabCheckbox, 'change', (e) => { + this.lockOnTabSwitch = e.target.checked + if (tabHint) tabHint.textContent = this.lockOnTabSwitch + ? 'The vault locks immediately when you switch to another tab.' + : 'The vault stays unlocked even when you switch tabs.' + }) + } + + #buildMinuteSelect() { + const select = this.ce('select', { id: 'auto-lock-minutes' }) + for (const m of [1, 5, 10, 15, 30, 60]) { + const opt = this.ce('option', { value: m }, this.text(`${m} ${m === 1 ? 'minute' : 'minutes'}`)) + if (m === this.minutes) opt.selected = true + select.appendChild(opt) + } + return select + } + + #handleSave = async (e) => { + e.preventDefault() + this.saving = true + try { + settings.autoLockMinutes = this.minutes + settings.lockOnTabSwitch = this.lockOnTabSwitch + await settings.save() + startAutoLock() + } catch (err) { + console.error('Failed to save settings:', err) + } + this.saving = false + this.onBack() + } +} diff --git a/src/components/SettingsDialog.svelte b/src/components/SettingsDialog.svelte deleted file mode 100644 index 520a1c2..0000000 --- a/src/components/SettingsDialog.svelte +++ /dev/null @@ -1,149 +0,0 @@ - - -
-
{ e.preventDefault(); handleSave(); }}> -

Settings

- -
- - -

- Vault locks after {minutes} {minutes === 1 ? 'minute' : 'minutes'} of inactivity. -

-
- -
- -

- {lockOnTabSwitch - ? 'The vault locks immediately when you switch to another tab.' - : 'The vault stays unlocked even when you switch tabs.'} -

-
- -
- - -
-
-
- - diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js new file mode 100644 index 0000000..72fe2d6 --- /dev/null +++ b/src/components/Sidebar.js @@ -0,0 +1,398 @@ +/** + * Sidebar — group list + search bar + group management. + */ + +import { Component } from './component.js' +import { getGroups, addGroup, updateGroup, deleteGroup, moveEntryToGroup, ensureTrashGroup } from '../lib/storage/db.js' +import { createGroup, validateGroup, isTrashGroup, TRASH_GROUP_NAME, TRASH_GROUP_COLOR, GROUP_COLORS } from '../lib/models/schema.js' +import { search } from '../lib/stores/search.js' +import { autofocus } from '../lib/autofocus.js' + +export class Sidebar extends Component { + groups = [] + + // Group management state + showGroupForm = false + editingGroupId = null + groupName = '' + groupColor = '#6c63ff' + groupError = '' + showDeleteGroupConfirm = null + dragOverGroupId = null + droppedGroupId = null + + mount() { + super.mount() + // Subscribe to store changes + this.subscribe(search, 'refreshTrigger', () => this.#loadData()) + this.subscribe(search, 'activeGroupId', () => { + this.#renderGroups() + this.#updateTrashButton() + }) + // Event delegation on the nav for all group interactions + const nav = this.q('.groups-nav') + if (nav) { + // Click: select group or handle action buttons + this.on(nav, 'click', (e) => { + const editBtn = e.target.closest('.group-action-btn[title="Edit group"]') + if (editBtn) { + e.stopPropagation() + const row = editBtn.closest('.group-row') + const groupId = row?.querySelector('[data-group-id]')?.dataset.groupId + const group = this.groups.find(g => g.id === groupId) + if (group) this.#openGroupForm(group) + return + } + const delBtn = e.target.closest('.group-action-btn[title="Delete group"]') + if (delBtn) { + e.stopPropagation() + const row = delBtn.closest('.group-row') + const groupId = row?.querySelector('[data-group-id]')?.dataset.groupId + if (groupId) { + this.showDeleteGroupConfirm = groupId + this.#renderDeleteModal() + } + return + } + // Group selection + const groupBtn = e.target.closest('.group-item[data-group-id]') + if (groupBtn) { + search.activeGroupId = groupBtn.dataset.groupId + return + } + // All entries button + if (e.target.closest('#all-entries-btn')) { + search.activeGroupId = 'all' + } + }) + + // Drag-and-drop via delegation + this.on(nav, 'dragover', (e) => { + const btn = e.target.closest('.group-item[data-group-id]') + if (btn) { + const gid = btn.dataset.groupId + if (this.#canDrop(gid)) { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + this.dragOverGroupId = gid + btn.classList.add('drag-over') + } + } + }) + this.on(nav, 'dragleave', (e) => { + const btn = e.target.closest('.group-item[data-group-id]') + if (btn && this.dragOverGroupId === btn.dataset.groupId) { + this.dragOverGroupId = null + btn.classList.remove('drag-over') + } + }) + this.on(nav, 'drop', (e) => { + e.preventDefault() + const btn = e.target.closest('.group-item[data-group-id]') + if (btn) { + btn.classList.remove('drag-over') + const gid = btn.dataset.groupId + if (this.#canDrop(gid)) { + const entryId = e.dataTransfer.getData('text/plain') + if (entryId) this.#handleDrop(gid, entryId) + } + } + this.dragOverGroupId = null + }) + } + this.#loadData() + return this + } + + render() { + this.el = this.ce('div', { className: 'sidebar-content' }, + this.ce('div', { className: 'sidebar-header' }, + this.ce('h2', { textContent: '🔐 Vault' }), + ), + this.ce('div', { className: 'search-box' }, + this.ce('input', { id: 'sidebar-search', type: 'text', placeholder: 'Search entries...' }), + ), + this.ce('nav', { className: 'groups-nav' }), // populated dynamically + this.ce('div', { className: 'trash-section' }, + this.ce('button', { className: 'group-item trash-btn', id: 'trash-btn' }, + this.ce('span', { className: 'group-color', style: `background-color: ${TRASH_GROUP_COLOR}` }), + this.ce('span', { className: 'group-name', textContent: TRASH_GROUP_NAME }), + ), + ), + this.ce('div', { className: 'sidebar-footer' }, + this.ce('button', { className: 'btn btn-ghost btn-sm w-full', id: 'new-group-btn', textContent: '+ New Group' }), + ), + ) + + // Wire search input + const searchInput = this.q('#sidebar-search') + if (searchInput) { + this.on(searchInput, 'input', (e) => search.setSearchQuery(e.target.value)) + } + + // Wire trash button + const trashBtn = this.q('#trash-btn') + if (trashBtn) { + this.on(trashBtn, 'click', () => { search.activeGroupId = 'trash' }) + } + + // Wire new group button + const newGroupBtn = this.q('#new-group-btn') + if (newGroupBtn) { + this.on(newGroupBtn, 'click', () => this.#openGroupForm(null)) + } + + this.#renderGroups() + this.#updateTrashButton() + + return this.el + } + + #renderGroups() { + const nav = this.q('.groups-nav') + if (!nav) return + + // Clear existing + nav.innerHTML = '' + + // "All Entries" button + const allBtn = this.ce('button', { + className: `group-item${search.activeGroupId === 'all' ? ' active' : ''}`, + id: 'all-entries-btn', + }, + this.ce('span', { className: 'group-icon', textContent: '📋' }), + this.ce('span', { className: 'group-name', textContent: 'All Entries' }), + ) + nav.appendChild(allBtn) + + // Group items + for (const group of this.groups) { + if (isTrashGroup(group.id)) continue + + const row = this.ce('div', { className: 'group-row' }) + + const btn = this.ce('button', { + className: `group-item${search.activeGroupId === group.id ? ' active' : ''}`, + 'data-group-id': group.id, + }, + this.ce('span', { className: 'group-color', style: `background-color: ${group.color || '#6c63ff'}` }), + this.ce('span', { className: 'group-name', textContent: group.name }), + this.ce('span', { className: 'drop-icon', textContent: '📥' }), + ) + + row.appendChild(btn) + + // Group actions (edit, delete) — handled via event delegation on .groups-nav + const actions = this.ce('div', { className: 'group-actions' }) + actions.appendChild(this.ce('button', { className: 'group-action-btn', title: 'Edit group', textContent: '✏️' })) + actions.appendChild(this.ce('button', { className: 'group-action-btn', title: 'Delete group', textContent: '🗑' })) + row.appendChild(actions) + + nav.appendChild(row) + } + } + + #renderDeleteModal() { + // Remove existing modal + const existing = this.q('.sb-modal-overlay') + if (existing) existing.remove() + + if (!this.showDeleteGroupConfirm) return + + const group = this.groups.find(g => g.id === this.showDeleteGroupConfirm) + if (!group) return + + const overlay = this.ce('div', { className: 'sb-modal-overlay', role: 'presentation' }, + this.ce('div', { className: 'sb-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Delete group confirmation', tabindex: '-1' }, + this.ce('h3', { textContent: 'Delete Group' }), + this.ce('p', {}, + this.text(`Delete "`), + this.ce('strong', { textContent: group.name }), + this.text(`"? Entries in this group will become ungrouped.`), + ), + this.ce('div', { className: 'sb-modal-actions' }, + this.ce('button', { className: 'btn btn-danger', id: 'confirm-delete-group-btn', textContent: 'Yes, delete' }), + this.ce('button', { className: 'btn btn-ghost', id: 'cancel-delete-group-btn', textContent: 'Cancel' }), + ), + ), + ) + + // Append overlay first so querySelector finds the elements + this.el.appendChild(overlay) + + this.on(overlay, 'click', () => { + this.showDeleteGroupConfirm = null + overlay.remove() + }) + this.on(overlay.querySelector('.sb-modal'), 'click', (e) => e.stopPropagation()) + this.on(overlay.querySelector('#confirm-delete-group-btn'), 'click', () => this.#confirmDeleteGroup(this.showDeleteGroupConfirm)) + this.on(overlay.querySelector('#cancel-delete-group-btn'), 'click', () => { + this.showDeleteGroupConfirm = null + overlay.remove() + }) + } + + #renderGroupFormModal() { + // Remove existing modal + const existing = this.q('.sb-modal-overlay') + if (existing) existing.remove() + + if (!this.showGroupForm) return + + const overlay = this.ce('div', { className: 'sb-modal-overlay', role: 'presentation' }, + this.ce('div', { className: 'sb-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Group settings', tabindex: '-1' }, + this.ce('h3', { textContent: this.editingGroupId ? 'Edit Group' : 'New Group' }), + this.groupError ? this.ce('div', { className: 'ie-error-banner', textContent: this.groupError }) : null, + this.ce('div', { className: 'form-group' }, + this.ce('label', { htmlFor: 'group-name', textContent: 'Group Name' }), + this.ce('input', { id: 'group-name', type: 'text', placeholder: 'e.g. Work, Personal', value: this.groupName }), + ), + this.ce('div', { className: 'form-group' }, + this.ce('span', { className: 'field-label', textContent: 'Color' }), + this.#buildColorPicker(), + ), + this.ce('div', { className: 'sb-modal-actions' }, + this.ce('button', { className: 'btn btn-primary', id: 'save-group-btn', textContent: this.editingGroupId ? 'Update' : 'Create' }), + this.ce('button', { className: 'btn btn-ghost', id: 'cancel-group-btn', textContent: 'Cancel' }), + ), + ), + ) + + // Append overlay first so querySelector finds the elements + this.el.appendChild(overlay) + + this.on(overlay, 'click', () => { + this.showGroupForm = false + overlay.remove() + }) + this.on(overlay.querySelector('.sb-modal'), 'click', (e) => e.stopPropagation()) + this.on(overlay.querySelector('#save-group-btn'), 'click', () => this.#saveGroup()) + this.on(overlay.querySelector('#cancel-group-btn'), 'click', () => { + this.showGroupForm = false + overlay.remove() + }) + + // Wire group name input + const nameInput = overlay.querySelector('#group-name') + if (nameInput) { + this.groupName = nameInput.value + this.on(nameInput, 'input', (e) => { this.groupName = e.target.value }) + if (!this.editingGroupId) autofocus(nameInput, true) + } + } + + #buildColorPicker() { + const picker = this.ce('div', { className: 'color-picker' }) + for (const color of GROUP_COLORS) { + const swatch = this.ce('button', { + className: `color-swatch${this.groupColor === color ? ' selected' : ''}`, + style: `background-color: ${color}`, + title: color, + type: 'button', + }) + const c = color + this.on(swatch, 'click', () => { + this.groupColor = c + this.qa('.color-swatch').forEach(s => s.classList.remove('selected')) + swatch.classList.add('selected') + }) + picker.appendChild(swatch) + } + return picker + } + + #openGroupForm(group) { + if (group) { + this.editingGroupId = group.id + this.groupName = group.name + this.groupColor = group.color || '#6c63ff' + } else { + this.editingGroupId = null + this.groupName = '' + this.groupColor = GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)] + } + this.groupError = '' + this.showGroupForm = true + this.#renderGroupFormModal() + } + + async #saveGroup() { + this.groupError = '' + const nameInput = this.q('#group-name') + if (nameInput) this.groupName = nameInput.value + + const validation = validateGroup(this.groupName) + if (!validation.valid) { + this.groupError = validation.errors[0] + this.#renderGroupFormModal() + return + } + + try { + if (this.editingGroupId) { + const existing = this.groups.find(g => g.id === this.editingGroupId) + const updated = { ...existing, name: this.groupName.trim(), color: this.groupColor } + await updateGroup(updated) + } else { + const group = createGroup(this.groupName, this.groupColor) + await addGroup(group) + } + this.showGroupForm = false + const overlay = this.q('.sb-modal-overlay') + if (overlay) overlay.remove() + await this.#loadData() + } catch (e) { + this.groupError = 'Failed to save group: ' + e.message + this.#renderGroupFormModal() + } + } + + async #confirmDeleteGroup(groupId) { + try { + await deleteGroup(groupId) + if (search.activeGroupId === groupId) { + search.activeGroupId = 'all' + } + this.showDeleteGroupConfirm = null + const overlay = this.q('.sb-modal-overlay') + if (overlay) overlay.remove() + await this.#loadData() + } catch (e) { + this.groupError = 'Failed to delete group: ' + e.message + } + } + + #updateTrashButton() { + const trashBtn = this.q('#trash-btn') + if (trashBtn) { + trashBtn.classList.toggle('active', search.activeGroupId === 'trash') + } + } + + async #handleDrop(groupId, entryId) { + try { + await moveEntryToGroup(entryId, groupId) + this.droppedGroupId = groupId + // Flash the dropped group + const btn = this.q(`[data-group-id="${groupId}"]`) + if (btn) { + btn.classList.add('dropped') + setTimeout(() => btn.classList.remove('dropped'), 600) + } + await this.#loadData() + search.refresh() + } catch (e) { + // silent fail + } + } + + #canDrop(groupId) { + return groupId !== search.activeGroupId && !isTrashGroup(groupId) + } + + async #loadData() { + await ensureTrashGroup() + this.groups = await getGroups() + this.#renderGroups() + } +} diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte deleted file mode 100644 index 655d185..0000000 --- a/src/components/Sidebar.svelte +++ /dev/null @@ -1,436 +0,0 @@ - - - - - diff --git a/src/components/component.js b/src/components/component.js new file mode 100644 index 0000000..22e6421 --- /dev/null +++ b/src/components/component.js @@ -0,0 +1,174 @@ +/** + * Minimal base class for custom vanilla-JS components. + * + * Subclasses override: + * - render() → returns a DOM element (or fragment) + * - destroy() → cleanup (optional, called on unmount) + * + * Helpers: + * - this.el — root DOM element + * - this.container — mount target + * - this.subscribe(store, prop, fn) — reactive binding + * - this.q(sel) — querySelector on this.el + * - this.qa(sel) — querySelectorAll on this.el + * - this.html(html) — set innerHTML (use sparingly; prefer createElement for interactive DOM) + * - this.on(el, event, fn) — addEventListener with auto-cleanup + */ + +export class Component { + /** @param {HTMLElement} container — parent to mount into */ + constructor(container) { + this.container = container + this.el = null + this._listeners = [] // { el, event, fn } + this._unsubs = [] // store unsubscribe fns + } + + /** Render and mount the component. */ + mount() { + this.el = this.render() + if (this.el) { + this.container.appendChild(this.el) + this.afterMount?.() + } + return this + } + + /** Override in subclass. Must return a DOM node. */ + render() { + return document.createElement('div') + } + + /** Called after the element is appended to the DOM. Override in subclass. */ + afterMount() {} + + /** Remove the component and clean up. */ + destroy() { + // Unsubscribe from stores + this._unsubs.forEach(fn => fn()) + this._unsubs = [] + + // Remove event listeners + this._listeners.forEach(({ el, event, fn }) => { + el.removeEventListener(event, fn) + }) + this._listeners = [] + + if (this.el && this.el.parentNode) { + this.el.parentNode.removeChild(this.el) + } + this.el = null + } + + /** Subscribe to a store property. Auto-unsubscribed on destroy. */ + subscribe(store, prop, fn) { + const unsub = store.onChange(prop, fn) + this._unsubs.push(unsub) + return unsub + } + + /** Add an event listener with auto-cleanup. */ + on(target, event, fn, options) { + target.addEventListener(event, fn, options) + this._listeners.push({ el: target, event, fn }) + return fn + } + + /** Convenience: querySelector on this.el */ + q(sel) { + return this.el?.querySelector(sel) + } + + /** Convenience: querySelectorAll on this.el */ + qa(sel) { + return this.el?.querySelectorAll(sel) || [] + } + + /** Set innerHTML. Warning: loses event listeners on replaced children. */ + html(htmlStr) { + if (this.el) this.el.innerHTML = htmlStr + } + + /** + * Create an element with tag, attributes, and children. + * @param {string} tag + * @param {Object} [attrs] — { className, id, style, textContent, innerHTML, ...dataset, ...rest } + * @param {...*} children — DOM nodes, strings, or arrays + * @returns {HTMLElement} + */ + ce(tag, attrs, ...children) { + const el = document.createElement(tag) + attrs = attrs || {} + if (attrs.className) el.className = attrs.className + if (attrs.id) el.id = attrs.id + if (attrs.style) { + if (typeof attrs.style === 'string') el.style.cssText = attrs.style + else Object.assign(el.style, attrs.style) + } + if (attrs.title) el.title = attrs.title + if (attrs.disabled !== undefined) el.disabled = attrs.disabled + if (attrs.checked !== undefined) el.checked = attrs.checked + if (attrs.value !== undefined) el.value = attrs.value + if (attrs.type) el.type = attrs.type + if (attrs.placeholder) el.placeholder = attrs.placeholder + if (attrs.autocomplete) el.autocomplete = attrs.autocomplete + if (attrs.draggable !== undefined) el.draggable = attrs.draggable + if (attrs.role) el.setAttribute('role', attrs.role) + if (attrs['aria-modal']) el.setAttribute('aria-modal', attrs['aria-modal']) + if (attrs['aria-label']) el.setAttribute('aria-label', attrs['aria-label']) + if (attrs['aria-hidden']) el.setAttribute('aria-hidden', attrs['aria-hidden']) + if (attrs['aria-labelledby']) el.setAttribute('aria-labelledby', attrs['aria-labelledby']) + if (attrs.tabindex !== undefined) el.tabIndex = attrs.tabindex + if (attrs.href) el.href = attrs.href + if (attrs.target) el.target = attrs.target + if (attrs.rel) el.rel = attrs.rel + if (attrs.accept) el.accept = attrs.accept + if (attrs.name) el.name = attrs.name + // data-* attributes (e.g. 'data-group-id': 'abc') + for (const key in attrs) { + if (Object.prototype.hasOwnProperty.call(attrs, key) && key.startsWith('data-') && key.length > 5) { + el.dataset[key.slice(5)] = attrs[key] + } + } + // dataset object (e.g. { dataset: { groupId: 'abc' } }) + if (attrs.dataset) Object.assign(el.dataset, attrs.dataset) + // innerHTML (if provided, skip children) + if (attrs.innerHTML) { + el.innerHTML = attrs.innerHTML + } else if (attrs.textContent) { + el.textContent = attrs.textContent + } else { + this._appendChildren(el, children.flat()) + } + return el + } + + _appendChildren(parent, children) { + for (const child of children) { + if (child === null || child === undefined) continue + if (typeof child === 'string' || typeof child === 'number') { + parent.appendChild(document.createTextNode(String(child))) + } else if (child instanceof Node) { + parent.appendChild(child) + } else if (Array.isArray(child)) { + this._appendChildren(parent, child) + } + } + } + + /** + * Create a text node helper. + */ + text(str) { + return document.createTextNode(String(str)) + } + + /** + * Create a fragment with children. + */ + frag(...children) { + const f = document.createDocumentFragment() + this._appendChildren(f, children.flat()) + return f + } +} diff --git a/src/lib/autofocus.js b/src/lib/autofocus.js index b90e76d..3aa4b17 100644 --- a/src/lib/autofocus.js +++ b/src/lib/autofocus.js @@ -1,9 +1,9 @@ /** - * Action that autofocuses an element after mount when the condition is truthy. + * Focus 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()) +export function autofocus(el, condition = true) { + if (el && condition) { + queueMicrotask(() => el.focus()) } } diff --git a/src/lib/stores/app.js b/src/lib/stores/app.js new file mode 100644 index 0000000..b67a432 --- /dev/null +++ b/src/lib/stores/app.js @@ -0,0 +1,27 @@ +/** + * App-level reactive state. + */ + +import { Store } from './store.js' +import { stopAutoLock } from './security.js' + +export class AppStore extends Store { + constructor() { + super({ + isUnlocked: false, + encryptionKey: null, + salt: null, + }) + } + + /** + * Lock the vault — clear the key from memory. + */ + lockVault() { + stopAutoLock() + this.encryptionKey = null + this.isUnlocked = false + } +} + +export const app = new AppStore() diff --git a/src/lib/stores/app.svelte.js b/src/lib/stores/app.svelte.js deleted file mode 100644 index f783f1d..0000000 --- a/src/lib/stores/app.svelte.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * App-level reactive state using Svelte 5 runes. - */ - -import { stopAutoLock } from './security.svelte.js' - -export class AppStore { - isUnlocked = $state(false) - encryptionKey = $state(null) - salt = $state(null) - - /** - * Lock the vault — clear the key from memory. - */ - lockVault() { - stopAutoLock() - this.encryptionKey = null - this.isUnlocked = false - } -} - -export const app = new AppStore() diff --git a/src/lib/stores/search.svelte.js b/src/lib/stores/search.js similarity index 69% rename from src/lib/stores/search.svelte.js rename to src/lib/stores/search.js index 3993d3f..f0ec4ee 100644 --- a/src/lib/stores/search.svelte.js +++ b/src/lib/stores/search.js @@ -3,17 +3,22 @@ * Shared between Sidebar and EntryList for coordinated filtering. */ -import { TRASH_GROUP_ID } from '../models/schema.js' +import { Store } from './store.js' const DEBOUNCE_MS = 300 -export class SearchStore { - query = $state('') // raw input value — bound to the search input - debouncedQuery = $state('') // debounced value — used for actual search - activeGroupId = $state('all') // 'all', 'trash', or a group id - refreshTrigger = $state(0) // incremented to force a re-fetch +export class SearchStore extends Store { #debounceTimer = null + constructor() { + super({ + query: '', // raw input value — bound to the search input + debouncedQuery: '', // debounced value — used for actual search + activeGroupId: 'all', // 'all', 'trash', or a group id + refreshTrigger: 0, // incremented to force a re-fetch + }) + } + /** * Update the search query with debouncing. * Call this from the input handler instead of setting `query` directly. diff --git a/src/lib/stores/security.svelte.js b/src/lib/stores/security.js similarity index 96% rename from src/lib/stores/security.svelte.js rename to src/lib/stores/security.js index 724b2ab..890ab70 100644 --- a/src/lib/stores/security.svelte.js +++ b/src/lib/stores/security.js @@ -2,8 +2,8 @@ * Security utilities: auto-lock timer, visibility change detection, cleanup. */ -import { app } from './app.svelte.js' -import { settings } from './settings.svelte.js' +import { app } from './app.js' +import { settings } from './settings.js' let autoLockTimer = null @@ -80,4 +80,3 @@ export function stopAutoLock() { document.removeEventListener('visibilitychange', handleVisibilityChange) window.removeEventListener('beforeunload', clearKeyOnExit) } - diff --git a/src/lib/stores/settings.svelte.js b/src/lib/stores/settings.js similarity index 83% rename from src/lib/stores/settings.svelte.js rename to src/lib/stores/settings.js index 5ee613c..4970744 100644 --- a/src/lib/stores/settings.svelte.js +++ b/src/lib/stores/settings.js @@ -5,11 +5,16 @@ * page reloads. Defaults are used until the user explicitly saves. */ +import { Store } from './store.js' import { getSetting, saveSetting } from '../storage/db.js' -export class SettingsStore { - autoLockMinutes = $state(5) - lockOnTabSwitch = $state(true) +export class SettingsStore extends Store { + constructor() { + super({ + autoLockMinutes: 5, + lockOnTabSwitch: true, + }) + } /** * Load persisted settings from IndexedDB. diff --git a/src/lib/stores/store.js b/src/lib/stores/store.js new file mode 100644 index 0000000..f390b04 --- /dev/null +++ b/src/lib/stores/store.js @@ -0,0 +1,104 @@ +/** + * Minimal reactive store — subscriber/callback pattern. + * + * Usage: + * const store = new Store({ count: 0, name: '' }) + * store.onChange('count', v => console.log('count is now', v)) + * store.set('count', 5) + * store.get('count') // 5 + * // or via proxy: store.count = 5; console.log(store.count) + */ + +export class Store { + #data + #subscribers = {} // prop -> Set + + /** @param {Record} initial */ + constructor(initial) { + this.#data = { ...initial } + // Build a live proxy so `store.prop` reads/writes go through get/set + const self = this + const proxy = new Proxy({}, { + get(_, prop) { + if (prop === '_data') return self.#data + if (prop in self.#data) return self.#data[prop] + return undefined + }, + set(_, prop, value) { + if (prop in self.#data) { + const old = self.#data[prop] + self.#data[prop] = value + if (old !== value) self.#emit(prop, value) + return true + } + return false + }, + }) + // Attach proxy methods onto the instance so `store.set(...)` works + // but property access goes through the proxy. We do this by + // forwarding store.get/set/onChange through the instance and + // exposing the proxy as `store.$` for direct property binding. + this.$ = proxy + // Also make direct property access on the instance work by + // defining getters/setters dynamically. We update them when + // new props are added. + this.#syncDescriptors() + } + + /** @param {string} prop @param {*} value */ + set(prop, value) { + const old = this.#data[prop] + this.#data[prop] = value + if (old !== value) this.#emit(prop, value) + this.#syncDescriptors() + } + + /** @param {string} prop @returns {*} */ + get(prop) { + return this.#data[prop] + } + + /** @param {string} prop @param {Function} fn @returns {Function} unsubscribe */ + onChange(prop, fn) { + if (!this.#subscribers[prop]) this.#subscribers[prop] = new Set() + this.#subscribers[prop].add(fn) + return () => this.#subscribers[prop].delete(fn) + } + + /** @param {string} prop @param {Function} fn */ + onChangeOnce(prop, fn) { + const unsub = this.onChange(prop, (...args) => { + fn(...args) + unsub() + }) + return unsub + } + + /** @param {string} prop @param {*} value */ + #emit(prop, value) { + const fns = this.#subscribers[prop] + if (fns) { + const snap = [...fns] + snap.forEach(fn => { + try { fn(value) } catch (e) { console.error('Store subscriber error:', e) } + }) + } + } + + // Keep instance-level property access in sync with internal data + #syncDescriptors() { + for (const key of Object.keys(this.#data)) { + if (Object.getOwnPropertyDescriptor(this, key)) continue + Object.defineProperty(this, key, { + get: () => this.#data[key], + set: (value) => { + const old = this.#data[key] + this.#data[key] = value + if (old !== value) this.#emit(key, value) + }, + enumerable: true, + configurable: true, + }) + } + } +} diff --git a/src/main.js b/src/main.js index 4785656..cc22493 100644 --- a/src/main.js +++ b/src/main.js @@ -1,9 +1,8 @@ -import { mount } from 'svelte' import './styles/main.css' -import App from './App.svelte' +import { App } from './App.js' -const app = mount(App, { - target: document.getElementById('app'), -}) +const root = document.getElementById('app') +const app = new App(root) +app.mount() export default app diff --git a/src/styles/main.css b/src/styles/main.css index 6147c16..58c8a7f 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -177,3 +177,1064 @@ label { text-overflow: ellipsis; white-space: nowrap; } + +/* ============================================================ + LockScreen + ============================================================ */ +.lock-screen { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 1rem; +} + +.lock-card { + width: 100%; + max-width: 400px; + padding: 2.5rem 2rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.lock-icon { + font-size: 3rem; + line-height: 1; +} + +.lock-card h1 { text-align: center; } + +.subtitle { + color: var(--color-text-muted); + font-size: 0.9rem; + text-align: center; +} + +.lock-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.lock-screen .error-banner { + width: 100%; + padding: 10px 14px; + background: rgba(229, 72, 77, 0.15); + border: 1px solid rgba(229, 72, 77, 0.4); + border-radius: var(--radius-md); + color: var(--color-danger); + font-size: 0.85rem; + text-align: center; +} + +.warning-banner { + width: 100%; + padding: 10px 14px; + background: rgba(255, 193, 7, 0.15); + border: 1px solid rgba(230, 168, 0, 0.5); + border-radius: var(--radius-md); + color: #b8860b; + font-size: 0.85rem; + text-align: center; +} + +.hint { + font-size: 0.75rem; + color: var(--color-text-muted); + text-align: center; + line-height: 1.4; + margin-top: 0.5rem; +} + +/* ============================================================ + MainLayout (shell) + ============================================================ */ +.app-shell { + display: flex; + min-height: 100vh; +} + +.mobile-header { + display: none; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 100; +} + +.mobile-title { + font-weight: 600; + font-size: 0.95rem; +} + +.sidebar { + width: 260px; + min-width: 260px; + background: var(--color-sidebar); + border-right: 1px solid var(--color-border); + height: 100vh; + position: sticky; + top: 0; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.main-content { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.top-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 10; +} + +.top-bar-title { + flex: 1; + min-width: 0; +} + +.top-bar-title h1 { + font-size: 1.1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.top-bar-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.content-area { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 49; + border: none; + cursor: pointer; + padding: 0; + margin: 0; + width: 100%; + height: 100%; +} + +/* MainLayout modals (empty-trash confirm) */ +.ml-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +.ml-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; + max-width: 380px; + width: 100%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.ml-modal h3 { margin-bottom: 16px; } + +.ml-modal p { + color: var(--color-text-muted); + font-size: 0.9rem; + margin-bottom: 20px; +} + +.ml-modal-actions { + display: flex; + gap: 8px; +} + +/* ============================================================ + Sidebar + ============================================================ */ +.sidebar-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.sidebar-header { + padding: 16px; + border-bottom: 1px solid var(--color-border); +} + +.sidebar-header h2 { + font-size: 1rem; + font-weight: 600; +} + +.search-box { + padding: 12px 16px; +} + +.search-box input { + padding: 8px 10px; + font-size: 0.85rem; +} + +.groups-nav { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.group-row { + display: flex; + align-items: center; +} + +.group-item { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + padding: 8px 12px; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-muted); + font-size: 0.875rem; + cursor: pointer; + transition: background-color 150ms, color 150ms; + text-align: left; +} + +.group-item:hover { + background: var(--color-surface-hover); + color: var(--color-text); +} + +.group-item.active { + background: rgba(108, 99, 255, 0.15); + color: var(--color-primary); +} + +.group-item.drag-over { + background: rgba(108, 99, 255, 0.2); + border: 2px dashed var(--color-primary); + outline: 2px solid rgba(108, 99, 255, 0.25); + outline-offset: -4px; + transform: scale(1.02); +} + +.group-item.drag-over .drop-icon { + opacity: 1; + transform: scale(1.2); +} + +.group-item.dropped { + animation: dropFlash 600ms ease; +} + +@keyframes dropFlash { + 0% { background: rgba(52, 211, 153, 0.35); } + 100% { background: transparent; } +} + +.drop-icon { + opacity: 0; + font-size: 0.85rem; + transition: opacity 150ms, transform 150ms; + flex-shrink: 0; +} + +.group-icon { + font-size: 1rem; +} + +.group-color { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.group-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.group-actions { + display: flex; + gap: 2px; + padding-right: 4px; +} + +.trash-section { + padding: 8px; + border-top: 1px solid var(--color-border); +} + +.group-action-btn { + background: none; + border: none; + cursor: pointer; + font-size: 0.75rem; + padding: 4px; + border-radius: var(--radius-sm); + transition: background-color 150ms; +} + +.group-action-btn:hover { + background: var(--color-surface-hover); +} + +.sidebar-footer { + padding: 12px 16px; + border-top: 1px solid var(--color-border); +} + +/* Color picker */ +.color-picker { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.color-swatch { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform 150ms, border-color 150ms; +} + +.color-swatch:hover { + transform: scale(1.15); +} + +.color-swatch.selected { + border-color: #fff; + transform: scale(1.15); +} + +/* Sidebar modals (group form, delete confirm) */ +.sb-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +.sb-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; + max-width: 380px; + width: 100%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.sb-modal h3 { margin-bottom: 16px; } + +.sb-modal p { + color: var(--color-text-muted); + font-size: 0.9rem; + margin-bottom: 20px; +} + +.sb-modal-actions { + display: flex; + gap: 8px; +} + +/* ============================================================ + EntryList + ============================================================ */ +.entry-list .loading, +.entry-list .empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-muted); +} + +.entry-list .error-banner { + padding: 12px 16px; + background: rgba(229, 72, 77, 0.15); + border: 1px solid rgba(229, 72, 77, 0.4); + border-radius: var(--radius-md); + color: var(--color-danger); + font-size: 0.85rem; +} + +.empty-icon { + font-size: 3rem; + margin-bottom: 0.5rem; +} + +.empty-text { + font-size: 1.1rem; + font-weight: 500; + color: var(--color-text); +} + +.empty-hint { + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.results-info { + padding: 8px 0; + margin-bottom: 8px; +} + +.entries-table { + width: 100%; + border-collapse: collapse; +} + +.entries-table th { + text-align: left; + padding: 8px 12px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + border-bottom: 1px solid var(--color-border); +} + +.entry-row { + cursor: grab; + transition: background-color 150ms, opacity 150ms; +} + +.entry-row:active { + cursor: grabbing; +} + +.entry-row:hover { + background: var(--color-surface-hover); +} + +.entry-row.dragging { + opacity: 0.35; +} + +.entry-row td { + padding: 10px 12px; + font-size: 0.875rem; + border-bottom: 1px solid var(--color-border); +} + +.drag-handle { + color: var(--color-text-muted); + opacity: 0.3; + margin-right: 6px; + font-size: 0.9rem; + user-select: none; + transition: opacity 150ms; +} + +.entry-row:hover .drag-handle { + opacity: 0.7; +} + +.restore-btn { + font-size: 0.85rem; + padding: 4px 6px; +} + +.entry-title { + font-weight: 500; +} + +.entry-username { + color: var(--color-text-muted); +} + +.entry-url { + color: var(--color-text-muted); + max-width: 200px; +} + +.notes-icon { + color: var(--color-text-muted); + cursor: help; + font-size: 0.95rem; + opacity: 0.5; + transition: opacity 150ms; +} + +.notes-icon:hover { + opacity: 1; +} + +.notes-tooltip { + position: relative; + display: inline-block; +} + +.tooltip-popup { + visibility: hidden; + opacity: 0; + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 100; + background: var(--color-surface, #1e1e2e); + color: var(--color-text, #cdd6f4); + border: 1px solid var(--color-border, #45475a); + border-radius: var(--radius-md, 8px); + padding: 8px 12px; + font-size: 0.8rem; + min-width: 150px; + max-width: 300px; + white-space: pre-wrap; + word-break: break-word; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + pointer-events: none; + transition: opacity 150ms, visibility 150ms; +} + +.notes-tooltip:hover .tooltip-popup { + visibility: visible; + opacity: 1; +} + +/* ============================================================ + EntryForm + ============================================================ */ +.entry-form .loading { + text-align: center; + padding: 3rem; + color: var(--color-text-muted); +} + +.entry-form .error-banner { + padding: 12px 16px; + background: rgba(229, 72, 77, 0.15); + border: 1px solid rgba(229, 72, 77, 0.4); + border-radius: var(--radius-md); + color: var(--color-danger); + font-size: 0.85rem; + margin-bottom: 16px; +} + +.ef-form-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; + max-width: 500px; +} + +.validation-errors { + margin-bottom: 16px; + padding: 12px; + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: var(--radius-md); +} + +.validation-error { + font-size: 0.85rem; + color: var(--color-warning); +} + +.password-input-group { + display: flex; + gap: 8px; +} + +.password-input-group input { + flex: 1; +} + +.ef-form-actions { + display: flex; + gap: 8px; + margin-top: 20px; +} + +/* ============================================================ + EntryDetail + ============================================================ */ +.entry-detail .loading, +.entry-detail .empty-state { + text-align: center; + padding: 3rem; + color: var(--color-text-muted); +} + +.entry-detail .error-banner { + padding: 12px 16px; + background: rgba(229, 72, 77, 0.15); + border: 1px solid rgba(229, 72, 77, 0.4); + border-radius: var(--radius-md); + color: var(--color-danger); + font-size: 0.85rem; +} + +.toast { + position: fixed; + bottom: 20px; + right: 20px; + padding: 10px 16px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: 0.85rem; + color: var(--color-success); + box-shadow: var(--shadow); + z-index: 1000; + animation: slideIn 200ms ease; +} + +@keyframes slideIn { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.detail-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; + max-width: 600px; +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid var(--color-border); + gap: 12px; +} + +.detail-header h2 { + font-size: 1.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.header-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.detail-fields { + display: flex; + flex-direction: column; + gap: 16px; +} + +.field-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + margin-bottom: 4px; + display: block; +} + +.field-value { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.95rem; + word-break: break-all; +} + +.field-value.notes { + white-space: pre-wrap; +} + +.field-value a { + color: var(--color-primary); + text-decoration: none; +} + +.field-value a:hover { + text-decoration: underline; +} + +.copy-btn { + flex-shrink: 0; +} + +.detail-meta { + display: flex; + gap: 16px; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--color-border); +} + +/* EntryDetail modals */ +.ed-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +.ed-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; + max-width: 400px; + width: 100%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.ed-modal h3 { margin-bottom: 12px; } + +.ed-modal p { + color: var(--color-text-muted); + font-size: 0.9rem; + margin-bottom: 20px; +} + +.ed-modal-actions { + display: flex; + gap: 8px; +} + +/* ============================================================ + ImportExport + ============================================================ */ +.ie-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +.ie-modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; + max-width: 420px; + width: 100%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.ie-modal h3 { margin-bottom: 12px; } + +.ie-modal p { + color: var(--color-text-muted); + font-size: 0.9rem; + margin-bottom: 16px; +} + +.ie-modal-actions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.ie-error-banner { + padding: 10px 14px; + background: rgba(229, 72, 77, 0.15); + border: 1px solid rgba(229, 72, 77, 0.4); + border-radius: var(--radius-md); + color: var(--color-danger); + font-size: 0.85rem; + margin-bottom: 12px; +} + +.success-banner { + padding: 10px 14px; + background: rgba(52, 211, 153, 0.15); + border: 1px solid rgba(52, 211, 153, 0.4); + border-radius: var(--radius-md); + color: var(--color-success); + font-size: 0.85rem; + margin-bottom: 12px; +} + +.import-mode { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.radio-label { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color 150ms, background-color 150ms; +} + +.radio-label:hover { + border-color: var(--color-primary); + background: var(--color-surface-hover); +} + +.radio-label input[type="radio"] { + accent-color: var(--color-primary); + cursor: pointer; +} + +.radio-label span { + font-size: 0.85rem; +} + +.file-label { + font-size: 0.8rem; + font-weight: 500; + color: var(--color-text-muted); + margin-bottom: 4px; +} + +input[type="file"] { + font-size: 0.85rem; + padding: 8px; +} + +/* Group selection for export */ +.group-select-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); + margin-bottom: 4px; +} + +.group-select-header .entry-count { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.group-select-list { + max-height: 240px; + overflow-y: auto; + margin-bottom: 8px; +} + +.group-select-list::-webkit-scrollbar { + width: 6px; +} + +.group-select-list::-webkit-scrollbar-track { + background: transparent; +} + +.group-select-list::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 0.85rem; +} + +.checkbox-label input[type="checkbox"] { + accent-color: var(--color-primary); + cursor: pointer; +} + +.group-checkbox { + padding: 6px 12px; + border-radius: var(--radius-md); + transition: background-color 150ms; +} + +.group-checkbox:hover { + background: var(--color-surface-hover); +} + +.group-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +/* ============================================================ + SettingsDialog + ============================================================ */ +.settings-panel { + max-width: 500px; +} + +.sd-form-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; +} + +.sd-form-card h3 { + margin-bottom: 16px; +} + +.sd-form-actions { + display: flex; + gap: 8px; + margin-top: 20px; +} + +.toggle-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + margin-bottom: 0; +} + +.toggle-label input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.toggle-track { + width: 40px; + height: 22px; + background: var(--color-border); + border-radius: 11px; + position: relative; + transition: background-color 150ms; + flex-shrink: 0; +} + +.toggle-track .toggle-thumb { + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + transition: transform 150ms; +} + +.toggle-label input:checked + .toggle-track { + background: var(--color-primary); +} + +.toggle-label input:checked + .toggle-track .toggle-thumb { + transform: translateX(18px); +} + +.toggle-text { + font-size: 0.875rem; + color: var(--color-text); +} + +/* ============================================================ + Responsive + ============================================================ */ +@media (max-width: 768px) { + .mobile-header { + display: flex; + } + + .sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 50; + transform: translateX(-100%); + transition: transform 200ms ease; + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-overlay { + display: block; + } + + .content-area { + padding: 12px; + } + + .top-bar { + padding: 10px 12px; + } + + .entries-table th:nth-child(4), + .entry-row td:nth-child(4) { + display: none; + } +} + +@media (max-width: 600px) { + .entries-table th:nth-child(3), + .entry-row td:nth-child(3) { + display: none; + } + + .header-actions { + display: none; + } +} diff --git a/svelte.config.js b/svelte.config.js deleted file mode 100644 index 0cf7db3..0000000 --- a/svelte.config.js +++ /dev/null @@ -1,2 +0,0 @@ -/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ -export default {} diff --git a/tests/lib/stores/app.test.js b/tests/lib/stores/app.test.js index fb37f99..cfc9831 100644 --- a/tests/lib/stores/app.test.js +++ b/tests/lib/stores/app.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { app } from '../../../src/lib/stores/app.svelte.js' +import { app } from '../../../src/lib/stores/app.js' describe('AppStore', () => { beforeEach(() => { diff --git a/tests/lib/stores/search.test.js b/tests/lib/stores/search.test.js index eb3e916..12d12c2 100644 --- a/tests/lib/stores/search.test.js +++ b/tests/lib/stores/search.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { search } from '../../../src/lib/stores/search.svelte.js' +import { search } from '../../../src/lib/stores/search.js' describe('SearchStore', () => { beforeEach(() => { diff --git a/tests/lib/stores/security.test.js b/tests/lib/stores/security.test.js index 6bcd13d..121e882 100644 --- a/tests/lib/stores/security.test.js +++ b/tests/lib/stores/security.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { startAutoLock, stopAutoLock } from '../../../src/lib/stores/security.svelte.js' -import { app } from '../../../src/lib/stores/app.svelte.js' -import { settings } from '../../../src/lib/stores/settings.svelte.js' +import { startAutoLock, stopAutoLock } from '../../../src/lib/stores/security.js' +import { app } from '../../../src/lib/stores/app.js' +import { settings } from '../../../src/lib/stores/settings.js' // Reset singleton state before each test function resetState() { diff --git a/tests/lib/stores/settings.test.js b/tests/lib/stores/settings.test.js index 6a14f47..b181833 100644 --- a/tests/lib/stores/settings.test.js +++ b/tests/lib/stores/settings.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { settings } from '../../../src/lib/stores/settings.svelte.js' +import { settings } from '../../../src/lib/stores/settings.js' import { saveSetting, getSetting } from '../../../src/lib/storage/db.js' beforeEach(() => { diff --git a/vite.config.js b/vite.config.js index 9982e50..39f73cd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,11 +1,9 @@ import { defineConfig } from 'vite' -import { svelte } from '@sveltejs/vite-plugin-svelte' import { viteSingleFile } from 'vite-plugin-singlefile' // https://vite.dev/config/ export default defineConfig(({ command }) => ({ plugins: [ - svelte(), ...(command === 'build' ? [viteSingleFile({ inlineStyles: true,