Compare commits

...

1 Commits

40 changed files with 6821 additions and 10454 deletions

View File

@ -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

10647
dist/index.html vendored

File diff suppressed because it is too large Load Diff

431
package-lock.json generated
View File

@ -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"
}
}
}

View File

@ -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"

View File

@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -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')

65
src/App.js Normal file
View File

@ -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 <style> and <script> tags from the build)
const head = document.head
// Only remove meta/link tags, keep style/script
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'
// Subscribe to isUnlocked to swap views
this.subscribe(app, 'isUnlocked', (unlocked) => this.#swapView(unlocked))
// Initial render
this.#swapView(app.isUnlocked)
return this
}
#swapView(unlocked) {
// Destroy current view
if (this._lockScreen) { this._lockScreen.destroy(); this._lockScreen = null }
if (this._mainLayout) { this._mainLayout.destroy(); this._mainLayout = null }
// Clear container
if (this.container) this.container.innerHTML = ''
// Mount new view
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()
}
}

View File

@ -1,16 +0,0 @@
<script>
import LockScreen from './components/LockScreen.svelte'
import MainLayout from './components/MainLayout.svelte'
import { app } from './lib/stores/app.svelte'
</script>
<svelte:head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</svelte:head>
{#if app.isUnlocked}
<MainLayout />
{:else}
<LockScreen />
{/if}

View File

@ -0,0 +1,288 @@
/**
* EntryDetail view single entry with copy-to-clipboard, trash, and permanent delete.
*/
import { Component } from './component.js'
import { getEntryById, moveToTrash, deleteEntry } from '../lib/storage/db.js'
import { decrypt } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.js'
import { isTrashGroup } from '../lib/models/schema.js'
export class EntryDetail 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
}
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 = ''
// Toast
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' })
// Header
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)
// Fields
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 }),
),
))
}
// Password
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)
// Meta
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)
// Wire up buttons
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() })
// Toggle password visibility
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 ? '🙈' : '👁'
})
}
// Copy buttons
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.encryptionKey) {
this.decryptedPassword = await decrypt(this.entry.encryptedPassword, app.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 = '' }, 3000)
}
async #copyToClipboard(text, label) {
try {
await navigator.clipboard.writeText(text)
this.#showToast(`${label} copied (auto-clear in 15s)`)
setTimeout(async () => {
try { await navigator.clipboard.writeText('') } catch {}
}, 15000)
} catch {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
this.#showToast(`${label} copied`)
}
}
#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(this.entryId)
this.onBack()
} catch (e) {
this.error = 'Failed to move to trash: ' + e.message
}
this.deleting = false
this.showDeleteConfirm = false
}
async #handlePermanentDelete() {
this.deleting = true
try {
await deleteEntry(this.entryId)
this.onBack()
} catch (e) {
this.error = 'Failed to permanently delete: ' + e.message
}
this.deleting = false
this.showPermanentDeleteConfirm = false
}
}

View File

@ -1,362 +0,0 @@
<script>
import { getEntryById, moveToTrash, deleteEntry } from '../lib/storage/db.js'
import { decrypt } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.svelte.js'
import { isTrashGroup } from '../lib/models/schema.js'
let { entryId, onEdit, onBack } = $props()
let entry = $state(null)
let passwordVisible = $state(false)
let decryptedPassword = $state('')
let loading = $state(true)
let error = $state('')
let showDeleteConfirm = $state(false)
let showPermanentDeleteConfirm = $state(false)
let deleting = $state(false)
let toast = $state('')
const isInTrash = $derived(entry && isTrashGroup(entry.groupId))
let toastTimer = null
async function loadEntry() {
loading = true
error = ''
try {
entry = await getEntryById(entryId)
if (entry && app.encryptionKey) {
decryptedPassword = await decrypt(entry.encryptedPassword, app.encryptionKey)
}
} catch (e) {
error = 'Failed to load entry: ' + e.message
}
loading = false
}
loadEntry()
function showToast(message) {
toast = message
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast = '' }, 3000)
}
async function copyToClipboard(text, label) {
try {
await navigator.clipboard.writeText(text)
showToast(`✓ ${label} copied (auto-clear in 15s)`)
// Auto-clear after 15 seconds
setTimeout(async () => {
try {
// Try to clear by writing spaces
await navigator.clipboard.writeText('')
} catch {
// Some browsers don't allow clearing clipboard
}
}, 15000)
} catch (e) {
// Fallback for browsers without clipboard API
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
showToast(`✓ ${label} copied`)
}
}
async function handleMoveToTrash() {
deleting = true
try {
await moveToTrash(entryId)
onBack()
} catch (e) {
error = 'Failed to move to trash: ' + e.message
}
deleting = false
showDeleteConfirm = false
}
async function handlePermanentDelete() {
deleting = true
try {
await deleteEntry(entryId)
onBack()
} catch (e) {
error = 'Failed to permanently delete: ' + e.message
}
deleting = false
showPermanentDeleteConfirm = false
}
</script>
<div class="entry-detail">
<!-- Toast notification -->
{#if toast}
<div class="toast">{toast}</div>
{/if}
{#if loading}
<div class="loading">Loading...</div>
{:else if error}
<div class="error-banner">{error}</div>
{:else if !entry}
<div class="empty-state">Entry not found</div>
{:else}
<div class="detail-card">
<div class="detail-header">
<h2>{entry.title}</h2>
<div class="header-actions">
{#if isInTrash}
<button class="btn btn-primary btn-sm" onclick={() => onEdit(entry.id)}>↩️ Restore</button>
<button class="btn btn-danger btn-sm" onclick={() => showPermanentDeleteConfirm = true}>🗑 Delete Forever</button>
{:else}
<button class="btn btn-ghost btn-sm" onclick={() => onEdit(entry.id)}>✏️ Edit</button>
<button class="btn btn-danger btn-sm" onclick={() => showDeleteConfirm = true}>🗑 Move to Trash</button>
{/if}
</div>
</div>
<div class="detail-fields">
{#if entry.username}
<div class="detail-field">
<span class="field-label">Username</span>
<div class="field-value">
<span>{entry.username}</span>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(entry.username, 'Username')} title="Copy username">📋</button>
</div>
</div>
{/if}
<div class="detail-field">
<span class="field-label">Password</span>
<div class="field-value">
<span>{passwordVisible ? decryptedPassword : '••••••••••••'}</span>
<button class="btn btn-ghost btn-sm" onclick={() => passwordVisible = !passwordVisible} title="Toggle visibility">
{passwordVisible ? '🙈' : '👁'}
</button>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(decryptedPassword, 'Password')} title="Copy password">📋</button>
</div>
</div>
{#if entry.url}
<div class="detail-field">
<span class="field-label">URL</span>
<div class="field-value">
<a href={entry.url} target="_blank" rel="noopener noreferrer">{entry.url}</a>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(entry.url, 'URL')} title="Copy URL">📋</button>
</div>
</div>
{/if}
{#if entry.notes}
<div class="detail-field">
<span class="field-label">Notes</span>
<div class="field-value notes">{entry.notes}</div>
</div>
{/if}
</div>
<div class="detail-meta">
<span class="text-xs text-muted">Created: {new Date(entry.createdAt).toLocaleString()}</span>
<span class="text-xs text-muted">Updated: {new Date(entry.updatedAt).toLocaleString()}</span>
</div>
</div>
<!-- Move to Trash confirmation modal -->
{#if showDeleteConfirm}
<div class="modal-overlay" role="presentation" onclick={() => showDeleteConfirm = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Move to Trash</h3>
<p>Move "<strong>{entry.title}</strong>" to the trash? You can restore it later.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={handleMoveToTrash} disabled={deleting}>
{deleting ? 'Moving...' : 'Move to Trash'}
</button>
<button class="btn btn-ghost" onclick={() => showDeleteConfirm = false}>Cancel</button>
</div>
</div>
</div>
{/if}
<!-- Permanent delete confirmation modal -->
{#if showPermanentDeleteConfirm}
<div class="modal-overlay" role="presentation" onclick={() => showPermanentDeleteConfirm = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Permanent delete confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Delete Forever</h3>
<p>Permanently delete "<strong>{entry.title}</strong>"? This cannot be undone.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={handlePermanentDelete} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete Forever'}
</button>
<button class="btn btn-ghost" onclick={() => showPermanentDeleteConfirm = false}>Cancel</button>
</div>
</div>
</div>
{/if}
{/if}
</div>
<style>
.loading, .empty-state {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
}
.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);
}
/* Modal */
.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;
}
.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);
}
.modal h3 {
margin-bottom: 12px;
}
.modal p {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 8px;
}
@media (max-width: 600px) {
.header-actions {
display: none;
}
}
</style>

275
src/components/EntryForm.js Normal file
View File

@ -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()
}
}

View File

@ -1,232 +0,0 @@
<script>
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.svelte.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { autofocus } from '../lib/autofocus.js'
let { entryId, onSave, onCancel } = $props()
let title = $state('')
let username = $state('')
let password = $state('')
let url = $state('')
let notes = $state('')
let groupId = $state('')
let passwordVisible = $state(false)
let groups = $state([])
let loading = $state(true)
let error = $state('')
let saving = $state(false)
let isEdit = $state(false)
let formErrors = $state([])
async function loadForm() {
loading = true
try {
groups = await getGroups()
if (entryId) {
isEdit = true
const entry = await getEntryById(entryId)
if (entry) {
title = entry.title
username = entry.username
password = await decrypt(entry.encryptedPassword, app.encryptionKey)
url = entry.url || ''
notes = entry.notes || ''
groupId = entry.groupId || ''
} else {
error = 'Entry not found'
}
} else {
// Default to the currently active group if it's a real group
const active = searchStore.activeGroupId
groupId = (active !== 'all' && active !== 'trash') ? active : ''
}
} catch (e) {
error = 'Failed to load form: ' + e.message
}
loading = false
}
loadForm()
async function handleSubmit() {
formErrors = []
error = ''
saving = true
try {
const validation = validateEntry({ title, username, encryptedPassword: password })
if (!validation.valid) {
formErrors = validation.errors
saving = false
return
}
const encryptedPassword = await encrypt(password, app.encryptionKey)
if (isEdit) {
const existing = await getEntryById(entryId)
const updated = updateEntryModel(existing, {
title,
username,
encryptedPassword,
url,
notes,
groupId,
})
await updateEntry(updated)
} else {
const entry = createEntry({
title,
username,
encryptedPassword,
url,
notes,
groupId,
})
await addEntry(entry)
}
onSave()
} catch (e) {
error = 'Failed to save: ' + e.message
}
saving = false
}
</script>
<div class="entry-form">
{#if loading}
<div class="loading">Loading...</div>
{:else}
{#if error}
<div class="error-banner">{error}</div>
{/if}
<form class="form-card" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
{#if formErrors.length > 0}
<div class="validation-errors">
{#each formErrors as err}
<div class="validation-error">{err}</div>
{/each}
</div>
{/if}
<div class="form-group">
<label for="title">Title *</label>
<input id="title" type="text" bind:value={title} placeholder="e.g. GitHub, Gmail" use:autofocus={!isEdit} />
</div>
<div class="form-group">
<label for="username">Username / Email</label>
<input id="username" type="text" bind:value={username} placeholder="username or email" />
</div>
<div class="form-group">
<label for="password">Password *</label>
<div class="password-input-group">
<input
id="password"
type={passwordVisible ? 'text' : 'password'}
bind:value={password}
placeholder="Password"
/>
<button type="button" class="btn btn-ghost btn-sm" onclick={() => passwordVisible = !passwordVisible} title="Toggle visibility">
{passwordVisible ? '🙈' : '👁'}
</button>
<button type="button" class="btn btn-ghost btn-sm" onclick={() => password = generatePassword({ length: 16 })} title="Generate password">
🎲
</button>
</div>
</div>
<div class="form-group">
<label for="url">URL</label>
<input id="url" type="url" bind:value={url} placeholder="https://example.com" />
</div>
<div class="form-group">
<label for="group">Group</label>
<select id="group" bind:value={groupId}>
<option value="">No group</option>
{#each groups as group}
{#if !isTrashGroup(group.id)}
<option value={group.id}>{group.name}</option>
{/if}
{/each}
</select>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" bind:value={notes} placeholder="Any additional notes..."></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : (isEdit ? '💾 Update' : ' Create')}
</button>
<button type="button" class="btn btn-ghost" onclick={onCancel}>Cancel</button>
</div>
</form>
{/if}
</div>
<style>
.loading {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
}
.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;
}
.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;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 20px;
}
</style>

212
src/components/EntryList.js Normal file
View File

@ -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
}
}

View File

@ -1,309 +0,0 @@
<script>
import { getEntries, searchEntries, restoreEntry, TRASH_GROUP_ID } from '../lib/storage/db.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
let entries = $state([])
let loading = $state(true)
let error = $state('')
let resultCount = $state(0)
let dragging = $state(false)
let { onSelect, onAdd } = $props()
const isTrashView = $derived(searchStore.activeGroupId === 'trash')
async function loadEntries() {
loading = true
error = ''
try {
const query = searchStore.debouncedQuery.trim()
const groupId = searchStore.activeGroupId
// Resolve 'trash' alias to the real Trash group ID for DB queries
const resolvedGroupId = groupId === 'trash' ? TRASH_GROUP_ID : groupId
if (query) {
// Search with optional group filter
const options = resolvedGroupId !== 'all' ? { groupId: resolvedGroupId } : {}
entries = await searchEntries(query, options)
} else if (resolvedGroupId !== 'all') {
// Filter by group only
entries = await getEntries({ groupId: resolvedGroupId })
} else {
// Show all (excluding trashed entries)
entries = (await getEntries()).filter(e => e.groupId !== TRASH_GROUP_ID)
}
resultCount = entries.length
} catch (e) {
error = 'Failed to load entries: ' + e.message
}
loading = false
}
async function handleRestore(entryId) {
try {
await restoreEntry(entryId)
searchStore.refresh()
} catch (e) {
error = 'Failed to restore: ' + e.message
}
}
// Reload when debounced search query, active group filter, or refresh trigger changes
$effect(() => {
searchStore.debouncedQuery
searchStore.activeGroupId
searchStore.refreshTrigger
loadEntries()
})
</script>
<div class="entry-list">
{#if loading}
<div class="loading">Loading entries...</div>
{:else if error}
<div class="error-banner">{error}</div>
{:else if entries.length === 0}
<div class="empty-state">
<p class="empty-icon">{searchStore.query ? '🔍' : (isTrashView ? '🗑' : '🔑')}</p>
<p class="empty-text">{searchStore.query ? 'No results found' : (isTrashView ? 'Trash is empty' : 'No entries yet')}</p>
<p class="empty-hint">
{searchStore.query
? 'Try a different search term'
: (isTrashView ? 'Deleted entries will appear here' : 'Add your first login credential to get started')}
</p>
{#if !searchStore.query && !isTrashView}
<button class="btn btn-primary mt-3" onclick={onAdd}>+ New Entry</button>
{/if}
</div>
{:else}
<div class="results-info">
<span class="text-sm text-muted">
{resultCount} entr{resultCount === 1 ? 'y' : 'ies'}
{#if searchStore.query}
matching "<strong>{searchStore.query}</strong>"
{/if}
</span>
</div>
<table class="entries-table">
<thead>
<tr>
<th>Title</th>
<th>Username</th>
<th>URL</th>
<th>Notes</th>
{#if isTrashView}
<th style="width: 60px"></th>
{/if}
</tr>
</thead>
<tbody>
{#each entries as entry (entry.id)}
<tr
draggable={!isTrashView}
onclick={() => 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' : ''}"
>
<td>
{#if !isTrashView}
<span class="drag-handle" aria-hidden="true"></span>
{/if}
<span class="entry-title">{entry.title}</span>
</td>
<td>
<span class="entry-username">{entry.username || '—'}</span>
</td>
<td>
<span class="entry-url truncate">{entry.url || '—'}</span>
</td>
<td>
{#if entry.notes}
<div class="notes-tooltip">
<span class="notes-icon" title="{entry.notes}">🔍</span>
<div class="tooltip-popup">{entry.notes}</div>
</div>
{:else}
<span></span>
{/if}
</td>
{#if isTrashView}
<td>
<button class="btn btn-ghost btn-sm restore-btn" onclick={(e) => { e.stopPropagation(); handleRestore(entry.id); }} title="Restore entry">↩️</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style>
.loading, .empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
}
.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;
}
@media (max-width: 768px) {
.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;
}
}
</style>

View File

@ -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()
}
}

View File

@ -1,446 +0,0 @@
<script>
import { exportSelected, importAll, getGroups, getEntries } from '../lib/storage/db.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { app } from '../lib/stores/app.svelte.js'
import { isTrashGroup } from '../lib/models/schema.js'
let showExport = $state(false)
async function openExportModal() {
const groups = (await getGroups()).filter(g => !isTrashGroup(g.id))
allGroups = [{ id: '', name: 'Ungrouped', color: '#6b7280' }, ...groups]
allEntries = await getEntries()
selectedGroupIds = allGroups.map(g => g.id)
showExport = true
}
let showImport = $state(false)
let importMode = $state('merge') // 'merge' or 'replace'
let importResult = $state(null)
let importError = $state('')
let importing = $state(false)
let exportData = $state(null)
let exporting = $state(false)
let sourcePassword = $state('')
let parsedFileData = $state(null)
// Group selection for export
let allGroups = $state([])
let allEntries = $state([])
let selectedGroupIds = $state([])
let selectAll = $derived(
allGroups.length > 0 && allGroups.every(g => selectedGroupIds.includes(g.id))
)
let exportEntryCount = $derived(
allEntries.filter(e => selectedGroupIds.includes(e.groupId)).length
)
async function handleExport() {
exporting = true
try {
exportData = await exportSelected(selectedGroupIds.length === allGroups.length ? null : 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)
showExport = false
} catch (e) {
importError = 'Export failed: ' + e.message
}
exporting = false
}
function toggleSelectAll() {
if (selectAll) {
selectedGroupIds = []
} else {
selectedGroupIds = allGroups.map(g => g.id)
}
}
function toggleGroup(groupId) {
if (selectedGroupIds.includes(groupId)) {
selectedGroupIds = selectedGroupIds.filter(id => id !== groupId)
} else {
selectedGroupIds = [...selectedGroupIds, groupId]
}
}
async function handleFileSelect(event) {
const file = event.target.files[0]
if (!file) return
importError = ''
importResult = null
sourcePassword = ''
try {
const text = await file.text()
const data = JSON.parse(text)
if (!data.entries || !data.groups) {
importError = 'Invalid file format — missing entries or groups data'
return
}
parsedFileData = data
} catch (e) {
importError = 'Failed to parse file: ' + e.message
}
// Reset file input
event.target.value = ''
}
async function handleImportSubmit() {
if (!parsedFileData) return
if (!sourcePassword.trim()) {
importError = 'Source vault password is required'
return
}
importing = true
importError = ''
importResult = null
try {
const result = await importAll(parsedFileData, importMode, sourcePassword, app.encryptionKey)
importResult = result
sourcePassword = ''
parsedFileData = null
searchStore.refresh()
} catch (e) {
importError = 'Import failed: ' + e.message
}
importing = false
}
</script>
<div class="import-export">
<!-- Export button -->
<button class="btn btn-ghost btn-sm" onclick={openExportModal} title="Export">
📤 Export
</button>
<!-- Import button -->
<button class="btn btn-ghost btn-sm" onclick={() => showImport = true} title="Import">
📥 Import
</button>
<!-- Export modal -->
{#if showExport}
<div class="modal-overlay" role="presentation" onclick={() => showExport = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Export Vault</h3>
<p>Select which groups to export. You'll need the source vault's master password when importing into another vault.</p>
<div class="group-select-header">
<label class="checkbox-label">
<input type="checkbox" checked={selectAll} onchange={toggleSelectAll} />
<span>Select all</span>
</label>
<span class="entry-count">{exportEntryCount} entries</span>
</div>
<div class="group-select-list">
{#each allGroups as group}
<label class="checkbox-label group-checkbox">
<input
type="checkbox"
checked={selectedGroupIds.includes(group.id)}
onchange={() => toggleGroup(group.id)}
/>
<span class="group-color-dot" style="background-color: {group.color || '#6c63ff'}"></span>
<span class="group-name">{group.name}</span>
</label>
{/each}
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick={handleExport} disabled={exporting || selectedGroupIds.length === 0}>
{exporting ? 'Exporting...' : '📤 Export JSON'}
</button>
<button class="btn btn-ghost" onclick={() => showExport = false}>Cancel</button>
</div>
</div>
</div>
{/if}
<!-- Import modal -->
{#if showImport}
<div class="modal-overlay" role="presentation" onclick={() => showImport = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Import vault data" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Import Vault Data</h3>
{#if importError}
<div class="error-banner">{importError}</div>
{/if}
{#if importResult}
<div class="success-banner">
✓ Imported {importResult.imported.entries} entries and {importResult.imported.groups} groups
{#if importResult.skipped > 0}
({importResult.skipped} skipped)
{/if}
</div>
{:else if parsedFileData}
<p>File loaded. Enter the <strong>source vault's master password</strong> to decrypt and re-encrypt entries under your current vault.</p>
<div class="form-group">
<label for="source-password" class="file-label">Source vault password</label>
<input
id="source-password"
type="password"
bind:value={sourcePassword}
placeholder="Enter source vault password"
autocomplete="current-password"
/>
</div>
<div class="import-mode">
<label class="radio-label">
<input type="radio" name="importMode" value="merge" bind:group={importMode} />
<span>Merge — add to existing data</span>
</label>
<label class="radio-label">
<input type="radio" name="importMode" value="replace" bind:group={importMode} />
<span>Replace — clear all existing data first</span>
</label>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick={handleImportSubmit} disabled={importing}>
{importing ? 'Importing...' : '📥 Import'}
</button>
<button class="btn btn-ghost" onclick={() => { parsedFileData = null; sourcePassword = ''; }}>Cancel</button>
</div>
{:else}
<p>Select how to handle existing data:</p>
<div class="import-mode">
<label class="radio-label">
<input type="radio" name="importMode" value="merge" bind:group={importMode} />
<span>Merge — add to existing data</span>
</label>
<label class="radio-label">
<input type="radio" name="importMode" value="replace" bind:group={importMode} />
<span>Replace — clear all existing data first</span>
</label>
</div>
<div class="form-group">
<label for="import-file" class="file-label">Choose JSON file</label>
<input
id="import-file"
type="file"
accept=".json,application/json"
onchange={handleFileSelect}
disabled={importing}
/>
</div>
{/if}
<div class="modal-actions">
<button class="btn btn-ghost" onclick={() => {
showImport = false
importResult = null
importError = ''
}}>Close</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.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;
}
.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);
}
.modal h3 {
margin-bottom: 12px;
}
.modal p {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.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;
}
.form-group {
margin-bottom: 16px;
}
.form-group input[type="password"] {
width: 100%;
padding: 8px 12px;
font-size: 0.85rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
box-sizing: border-box;
}
.form-group input[type="password"]:focus {
outline: none;
border-color: var(--color-primary);
}
/* 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;
}
</style>

View File

@ -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)
}
}

View File

@ -1,215 +0,0 @@
<script>
import { app } from '../lib/stores/app.svelte.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.svelte.js'
import { settings } from '../lib/stores/settings.svelte.js'
import { autofocus } from '../lib/autofocus.js'
let masterPassword = $state('')
let confirmPassword = $state('')
let error = $state('')
let loading = $state(false)
let isSetup = $state(false)
let notLocal = $derived(typeof window !== 'undefined' && window.location.protocol !== 'file:')
// Check if vault was already created
async function checkVault() {
isSetup = !(await isVaultInitialized())
}
checkVault()
async function handleSubmit() {
error = ''
loading = true
try {
if (isSetup) {
// First-time setup: create vault
if (!masterPassword || masterPassword.length < 4) {
error = 'Password must be at least 4 characters'
loading = false
return
}
if (masterPassword !== confirmPassword) {
error = 'Passwords do not match'
loading = false
return
}
const { salt, testEncrypted, testPlaintext } = await createTestPayload(masterPassword)
app.salt = salt
const key = await deriveKey(masterPassword, salt)
app.encryptionKey = key
await saveVaultMeta(salt, testEncrypted, testPlaintext)
await ensureTrashGroup()
await settings.load()
app.isUnlocked = true
startAutoLock()
} else {
// Unlock: verify password against stored test payload
const meta = await loadVaultMeta()
if (!meta.salt || !meta.testEncrypted || !meta.testPlaintext) {
error = 'Vault data corrupted'
loading = false
return
}
const key = await deriveKey(masterPassword, meta.salt)
const isValid = await verifyPassword(masterPassword, meta.salt, meta.testEncrypted, meta.testPlaintext)
if (!isValid) {
error = 'Incorrect password'
loading = false
return
}
app.salt = meta.salt
app.encryptionKey = key
await settings.load()
app.isUnlocked = true
startAutoLock()
}
} catch (e) {
console.error(e)
error = 'An error occurred: ' + e.message
}
loading = false
masterPassword = ''
confirmPassword = ''
}
</script>
<div class="lock-screen">
<div class="lock-card">
<div class="lock-icon">🔐</div>
<h1>Password Vault</h1>
<p class="subtitle">{isSetup ? 'Create your vault' : 'Unlock your vault'}</p>
{#if notLocal}
<div class="warning-banner" role="alert">This HTML file is intended for offline use.</div>
{/if}
{#if error}
<div class="error-banner" role="alert">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="lock-form">
<div class="form-group">
<label for="master-password">Master Password</label>
<input
id="master-password"
type="password"
bind:value={masterPassword}
placeholder="Enter master password"
autocomplete="current-password"
use:autofocus
disabled={loading}
/>
</div>
{#if isSetup}
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
placeholder="Confirm master password"
autocomplete="new-password"
disabled={loading}
/>
</div>
{/if}
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
{loading ? 'Processing...' : (isSetup ? 'Create Vault' : 'Unlock')}
</button>
</form>
<p class="hint">
{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.'}
</p>
</div>
</div>
<style>
.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;
}
h1 {
font-size: 1.5rem;
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;
}
.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;
}
</style>

View File

@ -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()
}
}

View File

@ -1,328 +0,0 @@
<script>
import { app } from '../lib/stores/app.svelte.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { TRASH_GROUP_NAME } from '../lib/models/schema.js'
import { emptyTrash } from '../lib/storage/db.js'
import Sidebar from './Sidebar.svelte'
import EntryList from './EntryList.svelte'
import EntryDetail from './EntryDetail.svelte'
import EntryForm from './EntryForm.svelte'
import ImportExport from './ImportExport.svelte'
import SettingsDialog from './SettingsDialog.svelte'
let sidebarOpen = $state(false)
let viewMode = $state('list') // 'list' | 'detail' | 'form' | 'settings'
let selectedEntryId = $state(null)
let showEmptyTrashConfirm = $state(false)
let emptyingTrash = $state(false)
const isTrashView = $derived(searchStore.activeGroupId === 'trash')
function goList() {
viewMode = 'list'
selectedEntryId = null
sidebarOpen = false
}
function goDetail(entryId) {
selectedEntryId = entryId
viewMode = 'detail'
sidebarOpen = false
}
function goForm(entryId = null) {
selectedEntryId = entryId
viewMode = 'form'
sidebarOpen = false
}
function goSettings() {
viewMode = 'settings'
sidebarOpen = false
}
function handleBack() {
if (viewMode === 'form' || viewMode === 'settings') {
goList()
} else {
goList()
}
}
async function handleEmptyTrash() {
emptyingTrash = true
try {
await emptyTrash()
searchStore.activeGroupId = 'all'
showEmptyTrashConfirm = false
} catch (e) {
console.error('Failed to empty trash:', e)
}
emptyingTrash = false
}
function handleLock() {
app.lockVault()
}
</script>
<div class="app-shell">
<!-- Mobile header -->
<div class="mobile-header">
<button class="btn btn-ghost btn-sm" onclick={() => sidebarOpen = !sidebarOpen}>
☰ Menu
</button>
<span class="mobile-title">Password Vault</span>
<button class="btn btn-ghost btn-sm" onclick={handleLock} title="Lock">🔒</button>
</div>
<!-- Sidebar overlay for mobile -->
{#if sidebarOpen}
<button class="sidebar-overlay" onclick={() => sidebarOpen = false} aria-label="Close menu"></button>
{/if}
<!-- Sidebar -->
<aside class="sidebar {sidebarOpen ? 'open' : ''}">
<Sidebar />
</aside>
<!-- Main content -->
<main class="main-content">
<!-- Top bar -->
<div class="top-bar">
{#if viewMode !== 'list'}
<button class="btn btn-ghost btn-sm" onclick={handleBack}> Back</button>
{/if}
<div class="top-bar-title">
{#if viewMode === 'list'}
<h1>{searchStore.activeGroupId === 'trash' ? TRASH_GROUP_NAME : 'All Entries'}</h1>
{:else if viewMode === 'detail'}
<h1>Entry Details</h1>
{:else if viewMode === 'form'}
<h1>{selectedEntryId ? 'Edit Entry' : 'New Entry'}</h1>
{:else if viewMode === 'settings'}
<h1>Settings</h1>
{/if}
</div>
<div class="top-bar-actions">
{#if viewMode === 'list' && isTrashView}
<button class="btn btn-danger btn-sm" onclick={() => showEmptyTrashConfirm = true} disabled={emptyingTrash}>
{emptyingTrash ? 'Emptying...' : '🗑 Empty Trash'}
</button>
{/if}
{#if viewMode === 'list' && !isTrashView}
<button class="btn btn-primary btn-sm" onclick={() => goForm(null)}>+ New Entry</button>
{/if}
<ImportExport />
<button class="btn btn-ghost btn-sm" onclick={goSettings} title="Settings">⚙️</button>
<button class="btn btn-ghost btn-sm" onclick={handleLock} title="Lock vault">🔒</button>
</div>
</div>
<!-- Content area -->
<div class="content-area">
{#if viewMode === 'list'}
<EntryList onSelect={goDetail} onAdd={() => goForm(null)} />
{:else if viewMode === 'detail' && selectedEntryId}
<EntryDetail
entryId={selectedEntryId}
onEdit={() => goForm(selectedEntryId)}
onBack={goList}
/>
{:else if viewMode === 'form'}
<EntryForm
entryId={selectedEntryId}
onSave={goList}
onCancel={handleBack}
/>
{:else if viewMode === 'settings'}
<SettingsDialog onBack={goList} />
{/if}
</div>
</main>
<!-- Empty trash confirmation -->
{#if showEmptyTrashConfirm}
<div class="modal-overlay" role="presentation" onclick={() => showEmptyTrashConfirm = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Empty trash confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Empty Trash</h3>
<p>Permanently delete all entries from the trash? This cannot be undone.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={handleEmptyTrash} disabled={emptyingTrash}>
{emptyingTrash ? 'Emptying...' : 'Yes, empty trash'}
</button>
<button class="btn btn-ghost" onclick={() => showEmptyTrashConfirm = false}>Cancel</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.app-shell {
display: flex;
min-height: 100vh;
}
/* Mobile header */
.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 */
.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 */
.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 (mobile) */
.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%;
}
/* Modal */
.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;
}
.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);
}
.modal h3 {
margin-bottom: 16px;
}
.modal p {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 8px;
}
/* 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;
}
}
</style>

View File

@ -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()
}
}

View File

@ -1,149 +0,0 @@
<script>
import { settings } from '../lib/stores/settings.svelte.js'
import { startAutoLock } from '../lib/stores/security.svelte.js'
let { onBack } = $props()
// Local copies so the user can cancel without losing values
let minutes = $state(settings.autoLockMinutes)
let lockOnTabSwitch = $state(settings.lockOnTabSwitch)
let saving = $state(false)
const minuteOptions = [1, 5, 10, 15, 30, 60]
async function handleSave() {
saving = true
try {
settings.autoLockMinutes = minutes
settings.lockOnTabSwitch = lockOnTabSwitch
await settings.save()
startAutoLock()
} catch (e) {
console.error('Failed to save settings:', e)
}
saving = false
onBack()
}
// Sync local values on mount
$effect(() => {
minutes = settings.autoLockMinutes
lockOnTabSwitch = settings.lockOnTabSwitch
})
</script>
<div class="settings-panel">
<form class="form-card" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<h3>Settings</h3>
<div class="form-group">
<label for="auto-lock-minutes">Auto-lock after</label>
<select id="auto-lock-minutes" bind:value={minutes}>
{#each minuteOptions as m}
<option value={m}>{m} {m === 1 ? 'minute' : 'minutes'}</option>
{/each}
</select>
<p class="text-muted text-xs mt-1">
Vault locks after {minutes} {minutes === 1 ? 'minute' : 'minutes'} of inactivity.
</p>
</div>
<div class="form-group">
<label class="toggle-label" for="lock-tab-switch">
<input
id="lock-tab-switch"
type="checkbox"
bind:checked={lockOnTabSwitch}
/>
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-text">Lock when tab loses focus</span>
</label>
<p class="text-muted text-xs mt-1">
{lockOnTabSwitch
? 'The vault locks immediately when you switch to another tab.'
: 'The vault stays unlocked even when you switch tabs.'}
</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<button type="button" class="btn btn-ghost" onclick={onBack}>Cancel</button>
</div>
</form>
</div>
<style>
.settings-panel {
max-width: 500px;
}
.form-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
}
.form-card h3 {
margin-bottom: 16px;
}
.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);
}
</style>

398
src/components/Sidebar.js Normal file
View File

@ -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()
}
}

View File

@ -1,436 +0,0 @@
<script>
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 as searchStore } from '../lib/stores/search.svelte.js'
import { autofocus } from '../lib/autofocus.js'
let groups = $state([])
// Group management state
let showGroupForm = $state(false)
let editingGroupId = $state(null)
let groupName = $state('')
let groupColor = $state('#6c63ff')
let groupError = $state('')
let showDeleteGroupConfirm = $state(null) // groupId being confirmed for deletion
let deletingGroup = $derived(groups.find(g => g.id === showDeleteGroupConfirm))
// Drag-and-drop state
let dragOverGroupId = $state(null)
let droppedGroupId = $state(null)
async function handleDrop(groupId, entryId) {
try {
await moveEntryToGroup(entryId, groupId)
droppedGroupId = groupId
setTimeout(() => { droppedGroupId = null }, 600)
await loadData()
searchStore.refresh()
} catch (e) {
// silent fail
}
}
function canDrop(groupId) {
return groupId !== searchStore.activeGroupId && !isTrashGroup(groupId)
}
async function loadData() {
await ensureTrashGroup()
groups = await getGroups()
}
// Initial load + refresh after import/export
$effect(() => {
searchStore.refreshTrigger
loadData()
})
function openGroupForm(group = null) {
if (group) {
editingGroupId = group.id
groupName = group.name
groupColor = group.color || '#6c63ff'
} else {
editingGroupId = null
groupName = ''
groupColor = GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)]
}
groupError = ''
showGroupForm = true
}
async function saveGroup() {
groupError = ''
const validation = validateGroup(groupName)
if (!validation.valid) {
groupError = validation.errors[0]
return
}
try {
if (editingGroupId) {
const existing = groups.find(g => g.id === editingGroupId)
const updated = { ...existing, name: groupName.trim(), color: groupColor }
await updateGroup(updated)
} else {
const group = createGroup(groupName, groupColor)
await addGroup(group)
}
showGroupForm = false
await loadData()
} catch (e) {
groupError = 'Failed to save group: ' + e.message
}
}
async function confirmDeleteGroup(groupId) {
try {
await deleteGroup(groupId)
if (searchStore.activeGroupId === groupId) {
searchStore.activeGroupId = 'all'
}
showDeleteGroupConfirm = null
await loadData()
} catch (e) {
groupError = 'Failed to delete group: ' + e.message
}
}
</script>
<div class="sidebar-content">
<div class="sidebar-header">
<h2>🔐 Vault</h2>
</div>
<div class="search-box">
<input
type="text"
placeholder="Search entries..."
value={searchStore.query}
oninput={(e) => searchStore.setSearchQuery(e.target.value)}
/>
</div>
<nav class="groups-nav">
<button
class="group-item {searchStore.activeGroupId === 'all' ? 'active' : ''}"
onclick={() => searchStore.activeGroupId = 'all'}
>
<span class="group-icon">📋</span>
<span class="group-name">All Entries</span>
</button>
{#each groups as group}
{#if !isTrashGroup(group.id)}
<div class="group-row">
<button
class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''} {dragOverGroupId === group.id ? 'drag-over' : ''} {droppedGroupId === group.id ? 'dropped' : ''}"
onclick={() => searchStore.activeGroupId = group.id}
ondragover={(e) => { if (canDrop(group.id)) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; dragOverGroupId = group.id; } }}
ondragleave={() => { if (dragOverGroupId === group.id) dragOverGroupId = null; }}
ondrop={(e) => { e.preventDefault(); dragOverGroupId = null; if (canDrop(group.id)) { const entryId = e.dataTransfer.getData('text/plain'); if (entryId) handleDrop(group.id, entryId); } }}
>
<span class="group-color" style="background-color: {group.color || '#6c63ff'}"></span>
<span class="group-name">{group.name}</span>
<span class="drop-icon">📥</span>
</button>
<div class="group-actions">
<button class="group-action-btn" onclick={() => openGroupForm(group)} title="Edit group">✏️</button>
<button class="group-action-btn" onclick={() => showDeleteGroupConfirm = group.id} title="Delete group">🗑</button>
</div>
</div>
{/if}
{/each}
</nav>
<!-- Trash pinned to bottom -->
<div class="trash-section">
<button
class="group-item {searchStore.activeGroupId === 'trash' ? 'active' : ''}"
onclick={() => searchStore.activeGroupId = 'trash'}
>
<span class="group-color" style="background-color: {TRASH_GROUP_COLOR}"></span>
<span class="group-name">{TRASH_GROUP_NAME}</span>
</button>
</div>
<div class="sidebar-footer">
<button class="btn btn-ghost btn-sm w-full" onclick={() => openGroupForm(null)}>+ New Group</button>
</div>
<!-- Group form modal -->
{#if showGroupForm}
<div class="modal-overlay" role="presentation" onclick={() => showGroupForm = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>{editingGroupId ? 'Edit Group' : 'New Group'}</h3>
{#if groupError}
<div class="error-banner">{groupError}</div>
{/if}
<div class="form-group">
<label for="group-name">Group Name</label>
<input id="group-name" type="text" bind:value={groupName} placeholder="e.g. Work, Personal" use:autofocus={!editingGroupId} />
</div>
<div class="form-group">
<span class="field-label">Color</span>
<div class="color-picker">
{#each GROUP_COLORS as color}
<button
class="color-swatch {groupColor === color ? 'selected' : ''}"
style="background-color: {color}"
onclick={() => groupColor = color}
title={color}
></button>
{/each}
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick={saveGroup}>
{editingGroupId ? 'Update' : 'Create'}
</button>
<button class="btn btn-ghost" onclick={() => showGroupForm = false}>Cancel</button>
</div>
</div>
</div>
{/if}
<!-- Delete group confirmation -->
{#if deletingGroup}
<div class="modal-overlay" role="presentation" onclick={() => showDeleteGroupConfirm = null}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Delete Group</h3>
<p>Delete "<strong>{deletingGroup.name}</strong>"? Entries in this group will become ungrouped.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={() => confirmDeleteGroup(deletingGroup.id)}>Yes, delete</button>
<button class="btn btn-ghost" onclick={() => showDeleteGroupConfirm = null}>Cancel</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.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;
opacity: 0;
transition: opacity 150ms;
}
.group-row:hover .group-actions {
opacity: 1;
}
.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);
}
/* Modal */
.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;
}
.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);
}
.modal h3 {
margin-bottom: 16px;
}
.modal p {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 8px;
}
.error-banner {
padding: 8px 12px;
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;
}
/* 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);
}
</style>

174
src/components/component.js Normal file
View File

@ -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
}
}

View File

@ -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())
}
}

27
src/lib/stores/app.js Normal file
View File

@ -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()

View File

@ -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()

View File

@ -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.

View File

@ -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)
}

View File

@ -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.

104
src/lib/stores/store.js Normal file
View File

@ -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<fn>
/** @param {Record<string, any>} 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,
})
}
}
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +0,0 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}

View File

@ -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(() => {

View File

@ -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(() => {

View File

@ -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() {

View File

@ -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(() => {

View File

@ -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,