Compare commits
3 Commits
c2c2f656db
...
d6dc384e9f
| Author | SHA1 | Date | |
|---|---|---|---|
| d6dc384e9f | |||
| c0231fcd26 | |||
| 87aed17092 |
72
AGENTS.md
Normal file
72
AGENTS.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Password Vault — Agent Context
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
An offline-first, single-file password manager built with **Svelte 5** (runes-based reactivity). All data is encrypted in-browser with AES-256-GCM and stored in IndexedDB. The production build is a single `dist/index.html` with zero network dependencies — it works from `file://`, USB, or any static server.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── App.svelte # Root component — routes between LockScreen, MainLayout
|
||||||
|
├── main.js # Entry point
|
||||||
|
├── components/
|
||||||
|
│ ├── LockScreen.svelte # Master password setup + unlock UI
|
||||||
|
│ ├── MainLayout.svelte # Shell: sidebar + content area
|
||||||
|
│ ├── Sidebar.svelte # Group list + search bar
|
||||||
|
│ ├── EntryList.svelte # Credential entries grid
|
||||||
|
│ ├── EntryForm.svelte # Create/edit credential form
|
||||||
|
│ ├── EntryDetail.svelte # View single entry (copy password/username)
|
||||||
|
│ ├── PasswordGenerator.svelte# Configurable password generator
|
||||||
|
│ └── ImportExport.svelte # JSON import/export with merge/replace
|
||||||
|
├── lib/
|
||||||
|
│ ├── crypto/crypto.js # Web Crypto API: PBKDF2 key derivation, AES-GCM encrypt/decrypt, password generator
|
||||||
|
│ ├── storage/db.js # IndexedDB layer (idb wrapper): entries, groups, meta stores
|
||||||
|
│ ├── models/schema.js # Data models: CredentialEntry, Group; validation; ID generation
|
||||||
|
│ └── stores/
|
||||||
|
│ ├── app.svelte.js # AppStore: $state(isUnlocked, encryptionKey, salt)
|
||||||
|
│ ├── security.svelte.js # Auto-lock timer, visibility change, beforeunload cleanup
|
||||||
|
│ └── search.svelte.js # Reactive search state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- **Svelte 5 runes** — Use `$state`, `$derived`, `$effect`. Props-based event passing (no Svelte events).
|
||||||
|
- **No external crypto libraries** — Uses the browser's native Web Crypto API exclusively.
|
||||||
|
- **Key never persisted** — Encryption key lives only in `$state` memory; cleared on lock, tab switch, or page close.
|
||||||
|
- **Single-file build** — `vite-plugin-singlefile` inlines all JS/CSS; post-build script inlines favicon.
|
||||||
|
- **IndexedDB via `idb`** — Three stores: `entries`, `groups`, `meta`. Only `encryptedPassword` is encrypted at rest; titles, usernames, URLs, and notes are plaintext for searchability.
|
||||||
|
- **PBKDF2 key derivation** — 600,000 iterations, SHA-256, 256-bit AES-GCM key.
|
||||||
|
|
||||||
|
## Encryption Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Master Password → PBKDF2 (600k iters, 16-byte salt) → AES-256-GCM Key → encrypt/decrypt credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Password verification uses a test payload (random string encrypted at vault creation). On unlock, the entered password derives a key that must successfully decrypt the test payload.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Vite dev server with HMR on `:5173` |
|
||||||
|
| `npm run build` | Production build → `dist/index.html` (single self-contained file) |
|
||||||
|
| `npm run preview` | Preview production build |
|
||||||
|
| `npm run test` | Vitest (watch mode) |
|
||||||
|
| `npm run test:run` | Vitest (single run) |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Framework: **Vitest** with **jsdom** environment
|
||||||
|
- Test setup: `tests/setup.js` (fake-indexeddb polyfill)
|
||||||
|
- Test files: `tests/lib/stores/*.test.js`
|
||||||
|
- Run with `npm run test` or `npm run test:run`
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Only `encryptedPassword` is encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB.
|
||||||
|
- `testPlaintext` for password verification is stored unencrypted in the `meta` store.
|
||||||
|
- Auto-lock triggers on tab visibility change and 5-minute inactivity.
|
||||||
|
- Clipboard auto-clears after 15 seconds.
|
||||||
|
- No browser fingerprinting or anti-keylogger protections.
|
||||||
247
dist/index.html
vendored
247
dist/index.html
vendored
@ -5756,13 +5756,12 @@ var SearchStore = class {
|
|||||||
var search = new SearchStore();
|
var search = new SearchStore();
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/components/Sidebar.svelte
|
//#region src/components/Sidebar.svelte
|
||||||
var root_1$7 = /* @__PURE__ */ from_html(`<div role="button" tabindex="0"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button> <div class="group-actions svelte-181dlmc"><button class="group-action-btn svelte-181dlmc" title="Edit group">✏️</button> <button class="group-action-btn svelte-181dlmc" title="Delete group">🗑</button></div></div>`);
|
var root_1$7 = /* @__PURE__ */ from_html(`<div class="group-row svelte-181dlmc"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button> <div class="group-actions svelte-181dlmc"><button class="group-action-btn svelte-181dlmc" title="Edit group">✏️</button> <button class="group-action-btn svelte-181dlmc" title="Delete group">🗑</button></div></div>`);
|
||||||
var root_3$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlmc"> </div>`);
|
var root_3$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlmc"> </div>`);
|
||||||
var root_4$5 = /* @__PURE__ */ from_html(`<button></button>`);
|
var root_4$5 = /* @__PURE__ */ from_html(`<button></button>`);
|
||||||
var root_2$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1"><h3 class="svelte-181dlmc"> </h3> <!> <div class="form-group"><label for="group-name">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal"/></div> <div class="form-group"><span class="field-label">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
var root_2$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1"><h3 class="svelte-181dlmc"> </h3> <!> <div class="form-group"><label for="group-name">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal"/></div> <div class="form-group"><span class="field-label">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
||||||
var root_5$4 = /* @__PURE__ */ from_html(`<div class="drop-indicator svelte-181dlmc">Drop here to move</div>`);
|
var root_5$4 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger">Yes, delete</button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
||||||
var root_6$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger">Yes, delete</button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
var root$6 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button> <!></nav> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full">+ New Group</button></div> <!> <!></div>`);
|
||||||
var root$6 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><div role="button" tabindex="0" aria-label="Drop here to remove from group"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button></div> <!></nav> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full">+ New Group</button></div> <!> <!> <!></div>`);
|
|
||||||
function Sidebar($$anchor, $$props) {
|
function Sidebar($$anchor, $$props) {
|
||||||
push($$props, true);
|
push($$props, true);
|
||||||
let groups = /* @__PURE__ */ state(proxy([]));
|
let groups = /* @__PURE__ */ state(proxy([]));
|
||||||
@ -5774,7 +5773,6 @@ function Sidebar($$anchor, $$props) {
|
|||||||
let groupError = /* @__PURE__ */ state("");
|
let groupError = /* @__PURE__ */ state("");
|
||||||
let showDeleteGroupConfirm = /* @__PURE__ */ state(null);
|
let showDeleteGroupConfirm = /* @__PURE__ */ state(null);
|
||||||
let deletingGroup = /* @__PURE__ */ user_derived(() => get(groups).find((g) => g.id === get(showDeleteGroupConfirm)));
|
let deletingGroup = /* @__PURE__ */ user_derived(() => get(groups).find((g) => g.id === get(showDeleteGroupConfirm)));
|
||||||
let dropTargetGroupId = /* @__PURE__ */ state(null);
|
|
||||||
const GROUP_COLORS = [
|
const GROUP_COLORS = [
|
||||||
"#6c63ff",
|
"#6c63ff",
|
||||||
"#e5484d",
|
"#e5484d",
|
||||||
@ -5844,111 +5842,65 @@ function Sidebar($$anchor, $$props) {
|
|||||||
set(groupError, "Failed to delete group: " + e.message);
|
set(groupError, "Failed to delete group: " + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onDragOverGroup(e, groupId) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = "move";
|
|
||||||
set(dropTargetGroupId, groupId, true);
|
|
||||||
}
|
|
||||||
function onDragLeaveGroup() {
|
|
||||||
set(dropTargetGroupId, null);
|
|
||||||
}
|
|
||||||
function resetDropTarget() {
|
|
||||||
set(dropTargetGroupId, null);
|
|
||||||
}
|
|
||||||
async function onDropGroup(groupId, e) {
|
|
||||||
e.preventDefault();
|
|
||||||
set(dropTargetGroupId, groupId, true);
|
|
||||||
const entryId = e.dataTransfer.getData("text/plain");
|
|
||||||
if (!entryId) return;
|
|
||||||
try {
|
|
||||||
const entry = await getEntryById(entryId);
|
|
||||||
if (!entry) return;
|
|
||||||
const newGroupId = groupId === "all" ? "" : groupId;
|
|
||||||
await updateEntry({
|
|
||||||
...entry,
|
|
||||||
groupId: newGroupId,
|
|
||||||
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
||||||
});
|
|
||||||
await loadData();
|
|
||||||
resetDropTarget();
|
|
||||||
search.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to move entry:", err);
|
|
||||||
resetDropTarget();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var div = root$6();
|
var div = root$6();
|
||||||
var div_1 = sibling(child(div), 2);
|
var div_1 = sibling(child(div), 2);
|
||||||
var input = child(div_1);
|
var input = child(div_1);
|
||||||
remove_input_defaults(input);
|
remove_input_defaults(input);
|
||||||
reset(div_1);
|
reset(div_1);
|
||||||
var nav = sibling(div_1, 2);
|
var nav = sibling(div_1, 2);
|
||||||
var div_2 = child(nav);
|
var button = child(nav);
|
||||||
var button = child(div_2);
|
each(sibling(button, 2), 17, () => get(groups), index, ($$anchor, group) => {
|
||||||
reset(div_2);
|
var div_2 = root_1$7();
|
||||||
each(sibling(div_2, 2), 17, () => get(groups), index, ($$anchor, group) => {
|
var button_1 = child(div_2);
|
||||||
var div_3 = root_1$7();
|
|
||||||
var button_1 = child(div_3);
|
|
||||||
var span = child(button_1);
|
var span = child(button_1);
|
||||||
var span_1 = sibling(span, 2);
|
var span_1 = sibling(span, 2);
|
||||||
var text = child(span_1, true);
|
var text = child(span_1, true);
|
||||||
reset(span_1);
|
reset(span_1);
|
||||||
reset(button_1);
|
reset(button_1);
|
||||||
var div_4 = sibling(button_1, 2);
|
var div_3 = sibling(button_1, 2);
|
||||||
var button_2 = child(div_4);
|
var button_2 = child(div_3);
|
||||||
var button_3 = sibling(button_2, 2);
|
var button_3 = sibling(button_2, 2);
|
||||||
reset(div_4);
|
|
||||||
reset(div_3);
|
reset(div_3);
|
||||||
|
reset(div_2);
|
||||||
template_effect(() => {
|
template_effect(() => {
|
||||||
set_class(div_3, 1, `group-row ${get(dropTargetGroupId) === get(group).id ? "drop-target" : ""}`, "svelte-181dlmc");
|
|
||||||
set_attribute(div_3, "aria-label", `Drop here to move to ${get(group).name}`);
|
|
||||||
set_class(button_1, 1, `group-item ${search.activeGroupId === get(group).id ? "active" : ""}`, "svelte-181dlmc");
|
set_class(button_1, 1, `group-item ${search.activeGroupId === get(group).id ? "active" : ""}`, "svelte-181dlmc");
|
||||||
set_style(span, `background-color: ${(get(group).color || "#6c63ff") ?? ""}`);
|
set_style(span, `background-color: ${(get(group).color || "#6c63ff") ?? ""}`);
|
||||||
set_text(text, get(group).name);
|
set_text(text, get(group).name);
|
||||||
});
|
});
|
||||||
event("dragover", div_3, (e) => onDragOverGroup(e, get(group).id));
|
delegated("click", button_1, () => search.activeGroupId = get(group).id);
|
||||||
event("dragleave", div_3, onDragLeaveGroup);
|
|
||||||
event("drop", div_3, (e) => onDropGroup(get(group).id, e));
|
|
||||||
delegated("pointerup", button_1, () => {
|
|
||||||
if (get(dropTargetGroupId) === get(group).id) {
|
|
||||||
resetDropTarget();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
search.activeGroupId = get(group).id;
|
|
||||||
});
|
|
||||||
delegated("click", button_2, () => openGroupForm(get(group)));
|
delegated("click", button_2, () => openGroupForm(get(group)));
|
||||||
delegated("click", button_3, () => set(showDeleteGroupConfirm, get(group).id, true));
|
delegated("click", button_3, () => set(showDeleteGroupConfirm, get(group).id, true));
|
||||||
append($$anchor, div_3);
|
append($$anchor, div_2);
|
||||||
});
|
});
|
||||||
reset(nav);
|
reset(nav);
|
||||||
var div_5 = sibling(nav, 2);
|
var div_4 = sibling(nav, 2);
|
||||||
var button_4 = child(div_5);
|
var button_4 = child(div_4);
|
||||||
reset(div_5);
|
reset(div_4);
|
||||||
var node_1 = sibling(div_5, 2);
|
var node_1 = sibling(div_4, 2);
|
||||||
var consequent_1 = ($$anchor) => {
|
var consequent_1 = ($$anchor) => {
|
||||||
var div_6 = root_2$5();
|
var div_5 = root_2$5();
|
||||||
var div_7 = child(div_6);
|
var div_6 = child(div_5);
|
||||||
var h3 = child(div_7);
|
var h3 = child(div_6);
|
||||||
var text_1 = child(h3, true);
|
var text_1 = child(h3, true);
|
||||||
reset(h3);
|
reset(h3);
|
||||||
var node_2 = sibling(h3, 2);
|
var node_2 = sibling(h3, 2);
|
||||||
var consequent = ($$anchor) => {
|
var consequent = ($$anchor) => {
|
||||||
var div_8 = root_3$5();
|
var div_7 = root_3$5();
|
||||||
var text_2 = child(div_8, true);
|
var text_2 = child(div_7, true);
|
||||||
reset(div_8);
|
reset(div_7);
|
||||||
template_effect(() => set_text(text_2, get(groupError)));
|
template_effect(() => set_text(text_2, get(groupError)));
|
||||||
append($$anchor, div_8);
|
append($$anchor, div_7);
|
||||||
};
|
};
|
||||||
if_block(node_2, ($$render) => {
|
if_block(node_2, ($$render) => {
|
||||||
if (get(groupError)) $$render(consequent);
|
if (get(groupError)) $$render(consequent);
|
||||||
});
|
});
|
||||||
var div_9 = sibling(node_2, 2);
|
var div_8 = sibling(node_2, 2);
|
||||||
var input_1 = sibling(child(div_9), 2);
|
var input_1 = sibling(child(div_8), 2);
|
||||||
remove_input_defaults(input_1);
|
remove_input_defaults(input_1);
|
||||||
reset(div_9);
|
reset(div_8);
|
||||||
var div_10 = sibling(div_9, 2);
|
var div_9 = sibling(div_8, 2);
|
||||||
var div_11 = sibling(child(div_10), 2);
|
var div_10 = sibling(child(div_9), 2);
|
||||||
each(div_11, 21, () => GROUP_COLORS, index, ($$anchor, color) => {
|
each(div_10, 21, () => GROUP_COLORS, index, ($$anchor, color) => {
|
||||||
var button_5 = root_4$5();
|
var button_5 = root_4$5();
|
||||||
template_effect(() => {
|
template_effect(() => {
|
||||||
set_class(button_5, 1, `color-swatch ${get(groupColor) === get(color) ? "selected" : ""}`, "svelte-181dlmc");
|
set_class(button_5, 1, `color-swatch ${get(groupColor) === get(color) ? "selected" : ""}`, "svelte-181dlmc");
|
||||||
@ -5958,85 +5910,68 @@ function Sidebar($$anchor, $$props) {
|
|||||||
delegated("click", button_5, () => set(groupColor, get(color), true));
|
delegated("click", button_5, () => set(groupColor, get(color), true));
|
||||||
append($$anchor, button_5);
|
append($$anchor, button_5);
|
||||||
});
|
});
|
||||||
reset(div_11);
|
|
||||||
reset(div_10);
|
reset(div_10);
|
||||||
var div_12 = sibling(div_10, 2);
|
reset(div_9);
|
||||||
var button_6 = child(div_12);
|
var div_11 = sibling(div_9, 2);
|
||||||
|
var button_6 = child(div_11);
|
||||||
var text_3 = child(button_6, true);
|
var text_3 = child(button_6, true);
|
||||||
reset(button_6);
|
reset(button_6);
|
||||||
var button_7 = sibling(button_6, 2);
|
var button_7 = sibling(button_6, 2);
|
||||||
reset(div_12);
|
reset(div_11);
|
||||||
reset(div_7);
|
|
||||||
reset(div_6);
|
reset(div_6);
|
||||||
|
reset(div_5);
|
||||||
template_effect(() => {
|
template_effect(() => {
|
||||||
set_text(text_1, get(editingGroupId) ? "Edit Group" : "New Group");
|
set_text(text_1, get(editingGroupId) ? "Edit Group" : "New Group");
|
||||||
set_text(text_3, get(editingGroupId) ? "Update" : "Create");
|
set_text(text_3, get(editingGroupId) ? "Update" : "Create");
|
||||||
});
|
});
|
||||||
delegated("click", div_6, () => set(showGroupForm, false));
|
delegated("click", div_5, () => set(showGroupForm, false));
|
||||||
delegated("click", div_7, (e) => e.stopPropagation());
|
delegated("click", div_6, (e) => e.stopPropagation());
|
||||||
bind_value(input_1, () => get(groupName), ($$value) => set(groupName, $$value));
|
bind_value(input_1, () => get(groupName), ($$value) => set(groupName, $$value));
|
||||||
delegated("click", button_6, saveGroup);
|
delegated("click", button_6, saveGroup);
|
||||||
delegated("click", button_7, () => set(showGroupForm, false));
|
delegated("click", button_7, () => set(showGroupForm, false));
|
||||||
append($$anchor, div_6);
|
append($$anchor, div_5);
|
||||||
};
|
};
|
||||||
if_block(node_1, ($$render) => {
|
if_block(node_1, ($$render) => {
|
||||||
if (get(showGroupForm)) $$render(consequent_1);
|
if (get(showGroupForm)) $$render(consequent_1);
|
||||||
});
|
});
|
||||||
var node_3 = sibling(node_1, 2);
|
var node_3 = sibling(node_1, 2);
|
||||||
var consequent_2 = ($$anchor) => {
|
var consequent_2 = ($$anchor) => {
|
||||||
append($$anchor, root_5$4());
|
var div_12 = root_5$4();
|
||||||
};
|
var div_13 = child(div_12);
|
||||||
if_block(node_3, ($$render) => {
|
var p = sibling(child(div_13), 2);
|
||||||
if (get(dropTargetGroupId)) $$render(consequent_2);
|
|
||||||
});
|
|
||||||
var node_4 = sibling(node_3, 2);
|
|
||||||
var consequent_3 = ($$anchor) => {
|
|
||||||
var div_14 = root_6$5();
|
|
||||||
var div_15 = child(div_14);
|
|
||||||
var p = sibling(child(div_15), 2);
|
|
||||||
var strong = sibling(child(p));
|
var strong = sibling(child(p));
|
||||||
var text_4 = child(strong, true);
|
var text_4 = child(strong, true);
|
||||||
reset(strong);
|
reset(strong);
|
||||||
next();
|
next();
|
||||||
reset(p);
|
reset(p);
|
||||||
var div_16 = sibling(p, 2);
|
var div_14 = sibling(p, 2);
|
||||||
var button_8 = child(div_16);
|
var button_8 = child(div_14);
|
||||||
var button_9 = sibling(button_8, 2);
|
var button_9 = sibling(button_8, 2);
|
||||||
reset(div_16);
|
|
||||||
reset(div_15);
|
|
||||||
reset(div_14);
|
reset(div_14);
|
||||||
|
reset(div_13);
|
||||||
|
reset(div_12);
|
||||||
template_effect(() => set_text(text_4, get(deletingGroup).name));
|
template_effect(() => set_text(text_4, get(deletingGroup).name));
|
||||||
delegated("click", div_14, () => set(showDeleteGroupConfirm, null));
|
delegated("click", div_12, () => set(showDeleteGroupConfirm, null));
|
||||||
delegated("click", div_15, (e) => e.stopPropagation());
|
delegated("click", div_13, (e) => e.stopPropagation());
|
||||||
delegated("click", button_8, () => confirmDeleteGroup(get(deletingGroup).id));
|
delegated("click", button_8, () => confirmDeleteGroup(get(deletingGroup).id));
|
||||||
delegated("click", button_9, () => set(showDeleteGroupConfirm, null));
|
delegated("click", button_9, () => set(showDeleteGroupConfirm, null));
|
||||||
append($$anchor, div_14);
|
append($$anchor, div_12);
|
||||||
};
|
};
|
||||||
if_block(node_4, ($$render) => {
|
if_block(node_3, ($$render) => {
|
||||||
if (get(deletingGroup)) $$render(consequent_3);
|
if (get(deletingGroup)) $$render(consequent_2);
|
||||||
});
|
});
|
||||||
reset(div);
|
reset(div);
|
||||||
template_effect(() => {
|
template_effect(() => {
|
||||||
set_value(input, search.query);
|
set_value(input, search.query);
|
||||||
set_class(div_2, 1, `group-wrapper ${get(dropTargetGroupId) === "all" ? "drop-target" : ""}`, "svelte-181dlmc");
|
|
||||||
set_class(button, 1, `group-item ${search.activeGroupId === "all" ? "active" : ""}`, "svelte-181dlmc");
|
set_class(button, 1, `group-item ${search.activeGroupId === "all" ? "active" : ""}`, "svelte-181dlmc");
|
||||||
});
|
});
|
||||||
event("Input", input, (e) => search.query = e.target.value);
|
event("Input", input, (e) => search.query = e.target.value);
|
||||||
event("dragover", div_2, (e) => onDragOverGroup(e, "all"));
|
delegated("click", button, () => search.activeGroupId = "all");
|
||||||
event("dragleave", div_2, onDragLeaveGroup);
|
|
||||||
event("drop", div_2, (e) => onDropGroup("all", e));
|
|
||||||
delegated("pointerup", button, () => {
|
|
||||||
if (get(dropTargetGroupId) === "all") {
|
|
||||||
resetDropTarget();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
search.activeGroupId = "all";
|
|
||||||
});
|
|
||||||
delegated("click", button_4, () => openGroupForm(null));
|
delegated("click", button_4, () => openGroupForm(null));
|
||||||
append($$anchor, div);
|
append($$anchor, div);
|
||||||
pop();
|
pop();
|
||||||
}
|
}
|
||||||
delegate(["pointerup", "click"]);
|
delegate(["click"]);
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/components/EntryList.svelte
|
//#region src/components/EntryList.svelte
|
||||||
var root_1$6 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Loading entries...</div>`);
|
var root_1$6 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Loading entries...</div>`);
|
||||||
@ -6044,7 +5979,7 @@ var root_2$4 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-13s7gu
|
|||||||
var root_4$4 = /* @__PURE__ */ from_html(`<button class="btn btn-primary mt-3">+ New Entry</button>`);
|
var root_4$4 = /* @__PURE__ */ from_html(`<button class="btn btn-primary mt-3">+ New Entry</button>`);
|
||||||
var root_3$4 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-13s7gu4"><p class="empty-icon svelte-13s7gu4"> </p> <p class="empty-text svelte-13s7gu4"> </p> <p class="empty-hint svelte-13s7gu4"> </p> <!></div>`);
|
var root_3$4 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-13s7gu4"><p class="empty-icon svelte-13s7gu4"> </p> <p class="empty-text svelte-13s7gu4"> </p> <p class="empty-hint svelte-13s7gu4"> </p> <!></div>`);
|
||||||
var root_6$4 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
|
var root_6$4 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
|
||||||
var root_7$2 = /* @__PURE__ */ from_html(`<tr draggable="true"><td class="svelte-13s7gu4"><span class="drag-handle svelte-13s7gu4" title="Drag to move to another group">⠿</span> <span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td></tr>`);
|
var root_7$2 = /* @__PURE__ */ from_html(`<tr class="entry-row svelte-13s7gu4"><td class="svelte-13s7gu4"><span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td></tr>`);
|
||||||
var root_5$3 = /* @__PURE__ */ from_html(`<div class="results-info svelte-13s7gu4"><span class="text-sm text-muted"> <!></span></div> <table class="entries-table svelte-13s7gu4"><thead><tr><th class="svelte-13s7gu4">Title</th><th class="svelte-13s7gu4">Username</th><th class="svelte-13s7gu4">URL</th></tr></thead><tbody></tbody></table>`, 1);
|
var root_5$3 = /* @__PURE__ */ from_html(`<div class="results-info svelte-13s7gu4"><span class="text-sm text-muted"> <!></span></div> <table class="entries-table svelte-13s7gu4"><thead><tr><th class="svelte-13s7gu4">Title</th><th class="svelte-13s7gu4">Username</th><th class="svelte-13s7gu4">URL</th></tr></thead><tbody></tbody></table>`, 1);
|
||||||
var root$5 = /* @__PURE__ */ from_html(`<div class="entry-list"><!></div>`);
|
var root$5 = /* @__PURE__ */ from_html(`<div class="entry-list"><!></div>`);
|
||||||
function EntryList($$anchor, $$props) {
|
function EntryList($$anchor, $$props) {
|
||||||
@ -6053,13 +5988,6 @@ function EntryList($$anchor, $$props) {
|
|||||||
let loading = /* @__PURE__ */ state(true);
|
let loading = /* @__PURE__ */ state(true);
|
||||||
let error = /* @__PURE__ */ state("");
|
let error = /* @__PURE__ */ state("");
|
||||||
let resultCount = /* @__PURE__ */ state(0);
|
let resultCount = /* @__PURE__ */ state(0);
|
||||||
let draggedEntryId = /* @__PURE__ */ state(null);
|
|
||||||
function handleDragStart(entryId) {
|
|
||||||
set(draggedEntryId, entryId, true);
|
|
||||||
}
|
|
||||||
function handleDragEnd() {
|
|
||||||
set(draggedEntryId, null);
|
|
||||||
}
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
set(loading, true);
|
set(loading, true);
|
||||||
set(error, "");
|
set(error, "");
|
||||||
@ -6148,7 +6076,7 @@ function EntryList($$anchor, $$props) {
|
|||||||
each(tbody, 21, () => get(entries), (entry) => entry.id, ($$anchor, entry) => {
|
each(tbody, 21, () => get(entries), (entry) => entry.id, ($$anchor, entry) => {
|
||||||
var tr = root_7$2();
|
var tr = root_7$2();
|
||||||
var td = child(tr);
|
var td = child(tr);
|
||||||
var span_1 = sibling(child(td), 2);
|
var span_1 = child(td);
|
||||||
var text_6 = child(span_1, true);
|
var text_6 = child(span_1, true);
|
||||||
reset(span_1);
|
reset(span_1);
|
||||||
reset(td);
|
reset(td);
|
||||||
@ -6164,18 +6092,11 @@ function EntryList($$anchor, $$props) {
|
|||||||
reset(td_2);
|
reset(td_2);
|
||||||
reset(tr);
|
reset(tr);
|
||||||
template_effect(() => {
|
template_effect(() => {
|
||||||
set_class(tr, 1, `entry-row ${get(draggedEntryId) === get(entry).id ? "dragging" : ""}`, "svelte-13s7gu4");
|
|
||||||
set_text(text_6, get(entry).title);
|
set_text(text_6, get(entry).title);
|
||||||
set_text(text_7, get(entry).username);
|
set_text(text_7, get(entry).username);
|
||||||
set_text(text_8, get(entry).url || "—");
|
set_text(text_8, get(entry).url || "—");
|
||||||
});
|
});
|
||||||
delegated("click", tr, () => $$props.onSelect(get(entry).id));
|
delegated("click", tr, () => $$props.onSelect(get(entry).id));
|
||||||
event("DragStart", tr, (e) => {
|
|
||||||
e.dataTransfer.setData("text/plain", get(entry).id);
|
|
||||||
e.dataTransfer.effectAllowed = "move";
|
|
||||||
handleDragStart(get(entry).id);
|
|
||||||
});
|
|
||||||
event("DragEnd", tr, handleDragEnd);
|
|
||||||
append($$anchor, tr);
|
append($$anchor, tr);
|
||||||
});
|
});
|
||||||
reset(tbody);
|
reset(tbody);
|
||||||
@ -7340,25 +7261,6 @@ label {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-wrapper.drop-target.svelte-181dlmc .group-item:where(.svelte-181dlmc),
|
|
||||||
.group-row.drop-target.svelte-181dlmc .group-item:where(.svelte-181dlmc) {
|
|
||||||
background: rgba(108, 99, 255, 0.3);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-wrapper.drop-target.svelte-181dlmc,
|
|
||||||
.group-row.drop-target.svelte-181dlmc {
|
|
||||||
background: rgba(108, 99, 255, 0.12);
|
|
||||||
border: 2px dashed var(--color-primary);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: 0 0 12px rgba(108, 99, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-wrapper.svelte-181dlmc {
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: background-color 150ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-icon.svelte-181dlmc {
|
.group-icon.svelte-181dlmc {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
@ -7408,27 +7310,6 @@ label {
|
|||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-indicator.svelte-181dlmc {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
z-index: 100;
|
|
||||||
pointer-events: none;
|
|
||||||
animation: svelte-181dlmc-fadeIn 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes svelte-181dlmc-fadeIn {
|
|
||||||
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
|
||||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
.modal-overlay.svelte-181dlmc {
|
.modal-overlay.svelte-181dlmc {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -7554,7 +7435,7 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entry-row.svelte-13s7gu4 {
|
.entry-row.svelte-13s7gu4 {
|
||||||
cursor: grab;
|
cursor: pointer;
|
||||||
transition: background-color 150ms;
|
transition: background-color 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7562,32 +7443,12 @@ label {
|
|||||||
background: var(--color-surface-hover);
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-row.dragging.svelte-13s7gu4 {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-row.svelte-13s7gu4:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-row.svelte-13s7gu4 td:where(.svelte-13s7gu4) {
|
.entry-row.svelte-13s7gu4 td:where(.svelte-13s7gu4) {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle.svelte-13s7gu4 {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-right: 6px;
|
|
||||||
user-select: none;
|
|
||||||
opacity: 0.5;
|
|
||||||
transition: opacity 150ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-row.svelte-13s7gu4:hover .drag-handle:where(.svelte-13s7gu4) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-title.svelte-13s7gu4 {
|
.entry-title.svelte-13s7gu4 {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,16 +9,6 @@
|
|||||||
|
|
||||||
let { onSelect, onAdd } = $props()
|
let { onSelect, onAdd } = $props()
|
||||||
|
|
||||||
let draggedEntryId = $state(null)
|
|
||||||
|
|
||||||
function handleDragStart(entryId) {
|
|
||||||
draggedEntryId = entryId
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragEnd() {
|
|
||||||
draggedEntryId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
loading = true
|
loading = true
|
||||||
error = ''
|
error = ''
|
||||||
@ -92,19 +82,8 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each entries as entry (entry.id)}
|
{#each entries as entry (entry.id)}
|
||||||
<tr
|
<tr onclick={() => onSelect(entry.id)} class="entry-row">
|
||||||
onclick={() => onSelect(entry.id)}
|
|
||||||
class="entry-row {draggedEntryId === entry.id ? 'dragging' : ''}"
|
|
||||||
draggable="true"
|
|
||||||
onDragStart={(e) => {
|
|
||||||
e.dataTransfer.setData('text/plain', entry.id)
|
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
|
||||||
handleDragStart(entry.id)
|
|
||||||
}}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<td>
|
<td>
|
||||||
<span class="drag-handle" title="Drag to move to another group">⠿</span>
|
|
||||||
<span class="entry-title">{entry.title}</span>
|
<span class="entry-title">{entry.title}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -174,7 +153,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entry-row {
|
.entry-row {
|
||||||
cursor: grab;
|
cursor: pointer;
|
||||||
transition: background-color 150ms;
|
transition: background-color 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,32 +161,12 @@
|
|||||||
background: var(--color-surface-hover);
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-row.dragging {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-row:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-row td {
|
.entry-row td {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-right: 6px;
|
|
||||||
user-select: none;
|
|
||||||
opacity: 0.5;
|
|
||||||
transition: opacity 150ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-row:hover .drag-handle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-title {
|
.entry-title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -95,6 +95,7 @@
|
|||||||
bind:value={masterPassword}
|
bind:value={masterPassword}
|
||||||
placeholder="Enter master password"
|
placeholder="Enter master password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
|
autofocus
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup, getEntryById, updateEntry as updateEntryDb } from '../lib/storage/db.js'
|
import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup } from '../lib/storage/db.js'
|
||||||
import { createGroup, validateGroup } from '../lib/models/schema.js'
|
import { createGroup, validateGroup } from '../lib/models/schema.js'
|
||||||
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
||||||
|
|
||||||
@ -14,7 +14,6 @@
|
|||||||
let groupError = $state('')
|
let groupError = $state('')
|
||||||
let showDeleteGroupConfirm = $state(null) // groupId being confirmed for deletion
|
let showDeleteGroupConfirm = $state(null) // groupId being confirmed for deletion
|
||||||
let deletingGroup = $derived(groups.find(g => g.id === showDeleteGroupConfirm))
|
let deletingGroup = $derived(groups.find(g => g.id === showDeleteGroupConfirm))
|
||||||
let dropTargetGroupId = $state(null) // groupId currently being hovered during drag
|
|
||||||
|
|
||||||
const GROUP_COLORS = [
|
const GROUP_COLORS = [
|
||||||
'#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6',
|
'#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6',
|
||||||
@ -84,50 +83,6 @@
|
|||||||
groupError = 'Failed to delete group: ' + e.message
|
groupError = 'Failed to delete group: ' + e.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag-and-drop: move entry to a group
|
|
||||||
function onDragOverGroup(e, groupId) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.dataTransfer.dropEffect = 'move'
|
|
||||||
dropTargetGroupId = groupId
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragLeaveGroup() {
|
|
||||||
dropTargetGroupId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDropTarget() {
|
|
||||||
dropTargetGroupId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDropGroup(groupId, e) {
|
|
||||||
e.preventDefault()
|
|
||||||
dropTargetGroupId = groupId // signal to onpointerup that a drop occurred here
|
|
||||||
const entryId = e.dataTransfer.getData('text/plain')
|
|
||||||
if (!entryId) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entry = await getEntryById(entryId)
|
|
||||||
if (!entry) return
|
|
||||||
|
|
||||||
// If dropping on "All Entries" (no group), clear the groupId
|
|
||||||
const newGroupId = groupId === 'all' ? '' : groupId
|
|
||||||
|
|
||||||
const updated = {
|
|
||||||
...entry,
|
|
||||||
groupId: newGroupId,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
await updateEntryDb(updated)
|
|
||||||
await loadData()
|
|
||||||
resetDropTarget()
|
|
||||||
// Force the entry list to re-fetch so the moved entry appears immediately.
|
|
||||||
searchStore.refresh()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to move entry:', err)
|
|
||||||
resetDropTarget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
@ -140,54 +95,24 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search entries..."
|
placeholder="Search entries..."
|
||||||
value={searchStore.query}
|
value={searchStore.query}
|
||||||
oninput={(e) => searchStore.query = e.target.value}
|
onInput={(e) => searchStore.query = e.target.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="groups-nav">
|
<nav class="groups-nav">
|
||||||
<div
|
|
||||||
class="group-wrapper {dropTargetGroupId === 'all' ? 'drop-target' : ''}"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="Drop here to remove from group"
|
|
||||||
ondragover={(e) => onDragOverGroup(e, 'all')}
|
|
||||||
ondragleave={onDragLeaveGroup}
|
|
||||||
ondrop={(e) => onDropGroup('all', e)}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
class="group-item {searchStore.activeGroupId === 'all' ? 'active' : ''}"
|
class="group-item {searchStore.activeGroupId === 'all' ? 'active' : ''}"
|
||||||
onclick={() => {
|
onclick={() => searchStore.activeGroupId = 'all'}
|
||||||
if (dropTargetGroupId === 'all') {
|
|
||||||
resetDropTarget()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
searchStore.activeGroupId = 'all'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span class="group-icon">📋</span>
|
<span class="group-icon">📋</span>
|
||||||
<span class="group-name">All Entries</span>
|
<span class="group-name">All Entries</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each groups as group}
|
{#each groups as group}
|
||||||
<div
|
<div class="group-row">
|
||||||
class="group-row {dropTargetGroupId === group.id ? 'drop-target' : ''}"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label={`Drop here to move to ${group.name}`}
|
|
||||||
ondragover={(e) => onDragOverGroup(e, group.id)}
|
|
||||||
ondragleave={onDragLeaveGroup}
|
|
||||||
ondrop={(e) => onDropGroup(group.id, e)}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''}"
|
class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''}"
|
||||||
onclick={() => {
|
onclick={() => searchStore.activeGroupId = group.id}
|
||||||
if (dropTargetGroupId === group.id) {
|
|
||||||
resetDropTarget()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
searchStore.activeGroupId = group.id
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span class="group-color" style="background-color: {group.color || '#6c63ff'}"></span>
|
<span class="group-color" style="background-color: {group.color || '#6c63ff'}"></span>
|
||||||
<span class="group-name">{group.name}</span>
|
<span class="group-name">{group.name}</span>
|
||||||
@ -244,11 +169,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Entry moved toast -->
|
|
||||||
{#if dropTargetGroupId}
|
|
||||||
<div class="drop-indicator">Drop here to move</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Delete group confirmation -->
|
<!-- Delete group confirmation -->
|
||||||
{#if deletingGroup}
|
{#if deletingGroup}
|
||||||
<div class="modal-overlay" role="presentation" onclick={() => showDeleteGroupConfirm = null}>
|
<div class="modal-overlay" role="presentation" onclick={() => showDeleteGroupConfirm = null}>
|
||||||
@ -329,25 +249,6 @@
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-wrapper.drop-target .group-item,
|
|
||||||
.group-row.drop-target .group-item {
|
|
||||||
background: rgba(108, 99, 255, 0.3);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-wrapper.drop-target,
|
|
||||||
.group-row.drop-target {
|
|
||||||
background: rgba(108, 99, 255, 0.12);
|
|
||||||
border: 2px dashed var(--color-primary);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: 0 0 12px rgba(108, 99, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-wrapper {
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: background-color 150ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-icon {
|
.group-icon {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
@ -397,27 +298,6 @@
|
|||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-indicator {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
z-index: 100;
|
|
||||||
pointer-events: none;
|
|
||||||
animation: fadeIn 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
|
||||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { generateId } from '../models/schema.js'
|
import { generateId } from '../models/schema.js'
|
||||||
|
|
||||||
const PBKDF2_ITERATIONS = 100_000
|
const PBKDF2_ITERATIONS = 600_000
|
||||||
const SALT_LENGTH = 16 // bytes
|
const SALT_LENGTH = 16 // bytes
|
||||||
const IV_LENGTH = 12 // bytes (recommended for AES-GCM)
|
const IV_LENGTH = 12 // bytes (recommended for AES-GCM)
|
||||||
|
|
||||||
@ -135,7 +135,7 @@ export async function createTestPayload(masterPassword) {
|
|||||||
|
|
||||||
// --- Utility: Uint8Array ↔ Base64 ---
|
// --- Utility: Uint8Array ↔ Base64 ---
|
||||||
|
|
||||||
function uint8ArrayToBase64(buffer) {
|
export function uint8ArrayToBase64(buffer) {
|
||||||
const bytes = new Uint8Array(buffer)
|
const bytes = new Uint8Array(buffer)
|
||||||
let binary = ''
|
let binary = ''
|
||||||
for (let i = 0; i < bytes.byteLength; i++) {
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
@ -144,7 +144,7 @@ function uint8ArrayToBase64(buffer) {
|
|||||||
return btoa(binary)
|
return btoa(binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64ToUint8Array(base64) {
|
export function base64ToUint8Array(base64) {
|
||||||
const binary = atob(base64)
|
const binary = atob(base64)
|
||||||
const bytes = new Uint8Array(binary.length)
|
const bytes = new Uint8Array(binary.length)
|
||||||
for (let i = 0; i < binary.length; i++) {
|
for (let i = 0; i < binary.length; i++) {
|
||||||
@ -189,10 +189,21 @@ export function generatePassword({
|
|||||||
throw new Error('Password charset is empty — enable at least one character type')
|
throw new Error('Password charset is empty — enable at least one character type')
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomValues = crypto.getRandomValues(new Uint8Array(length))
|
const charsetLength = charset.length
|
||||||
|
const maxValid = 256 - (256 % charsetLength)
|
||||||
|
const randomBytes = new Uint8Array(length * 2)
|
||||||
let password = ''
|
let password = ''
|
||||||
for (let i = 0; i < length; i++) {
|
let byteIdx = 0
|
||||||
password += charset[randomValues[i] % charset.length]
|
|
||||||
|
while (password.length < length) {
|
||||||
|
if (byteIdx >= randomBytes.length) {
|
||||||
|
crypto.getRandomValues(randomBytes)
|
||||||
|
byteIdx = 0
|
||||||
|
}
|
||||||
|
const byte = randomBytes[byteIdx++]
|
||||||
|
if (byte < maxValid) {
|
||||||
|
password += charset[byte % charsetLength]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return password
|
return password
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,9 @@
|
|||||||
*/
|
*/
|
||||||
export function generateId() {
|
export function generateId() {
|
||||||
const timestamp = Date.now().toString(36)
|
const timestamp = Date.now().toString(36)
|
||||||
const random = Math.random().toString(36).slice(2, 10)
|
const randomBytes = new Uint8Array(4)
|
||||||
|
crypto.getRandomValues(randomBytes)
|
||||||
|
const random = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 8)
|
||||||
return `${timestamp}_${random}`
|
return `${timestamp}_${random}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -310,19 +310,6 @@ export async function exportAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a base64 string back to Uint8Array.
|
|
||||||
* @param {string} base64
|
|
||||||
* @returns {Uint8Array}
|
|
||||||
*/
|
|
||||||
function base64ToUint8Array(base64) {
|
|
||||||
const binary = atob(base64)
|
|
||||||
const bytes = new Uint8Array(binary.length)
|
|
||||||
for (let i = 0; i < binary.length; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import data from a previously exported JSON object.
|
* Import data from a previously exported JSON object.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user