Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a02051b14 |
@ -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
10647
dist/index.html
vendored
File diff suppressed because it is too large
Load Diff
431
package-lock.json
generated
431
package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 |
@ -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
65
src/App.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
288
src/components/EntryDetail.js
Normal file
288
src/components/EntryDetail.js
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
275
src/components/EntryForm.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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
212
src/components/EntryList.js
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
354
src/components/ImportExport.js
Normal file
354
src/components/ImportExport.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
194
src/components/LockScreen.js
Normal file
194
src/components/LockScreen.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
298
src/components/MainLayout.js
Normal file
298
src/components/MainLayout.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
122
src/components/SettingsDialog.js
Normal file
122
src/components/SettingsDialog.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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
398
src/components/Sidebar.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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
174
src/components/component.js
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
27
src/lib/stores/app.js
Normal 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()
|
||||
@ -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()
|
||||
@ -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.
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
104
src/lib/stores/store.js
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
1061
src/styles/main.css
1061
src/styles/main.css
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||
export default {}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user