4664 lines
143 KiB
HTML
4664 lines
143 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0NiIgZmlsbD0ibm9uZSIgdmlld0JveD0iMCAwIDQ4IDQ2Ij48cGF0aCBmaWxsPSIjODYzYmZmIiBkPSJNMjUuOTQ2IDQ0LjkzOGMtLjY2NC44NDUtMi4wMjEuMzc1LTIuMDIxLS42OThWMzMuOTM3YTIuMjYgMi4yNiAwIDAgMC0yLjI2Mi0yLjI2MkgxMC4yODdjLS45MiAwLTEuNDU2LTEuMDQtLjkyLTEuNzg4bDcuNDgtMTAuNDcxYzEuMDctMS40OTcgMC0zLjU3OC0xLjg0Mi0zLjU3OEgxLjIzN2MtLjkyIDAtMS40NTYtMS4wNC0uOTItMS43ODhMMTAuMDEzLjQ3NGMuMjE0LS4yOTcuNTU2LS40NzQuOTItLjQ3NGgyOC44OTRjLjkyIDAgMS40NTYgMS4wNC45MiAxLjc4OGwtNy40OCAxMC40NzFjLTEuMDcgMS40OTggMCAzLjU3OSAxLjg0MiAzLjU3OWgxMS4zNzdjLjk0MyAwIDEuNDczIDEuMDg4Ljg5IDEuODNMMjUuOTQ3IDQ0Ljk0eiIgc3R5bGU9ImZpbGw6Izg2M2JmZjtmaWxsOmNvbG9yKGRpc3BsYXktcDMgLjUyNTIgLjIzIDEpO2ZpbGwtb3BhY2l0eToxIi8+PG1hc2sgaWQ9ImEiIHdpZHRoPSI0OCIgaGVpZ2h0PSI0NiIgeD0iMCIgeT0iMCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTI1Ljg0MiA0NC45MzhjLS42NjQuODQ0LTIuMDIxLjM3NS0yLjAyMS0uNjk4VjMzLjkzN2EyLjI2IDIuMjYgMCAwIDAtMi4yNjItMi4yNjJIMTAuMTgzYy0uOTIgMC0xLjQ1Ni0xLjA0LS45Mi0xLjc4OGw3LjQ4LTEwLjQ3MWMxLjA3LTEuNDk4IDAtMy41NzktMS44NDItMy41NzlIMS4xMzNjLS45MiAwLTEuNDU2LTEuMDQtLjkyLTEuNzg3TDkuOTEuNDczYy4yMTQtLjI5Ny41NTYtLjQ3NC45Mi0uNDc0aDI4Ljg5NGMuOTIgMCAxLjQ1NiAxLjA0LjkyIDEuNzg4bC03LjQ4IDEwLjQ3MWMtMS4wNyAxLjQ5OCAwIDMuNTc4IDEuODQyIDMuNTc4aDExLjM3N2MuOTQzIDAgMS40NzMgMS4wODguODkgMS44MzJMMjUuODQzIDQ0Ljk0eiIgc3R5bGU9ImZpbGw6IzAwMDtmaWxsLW9wYWNpdHk6MSIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2EpIj48ZyBmaWx0ZXI9InVybCgjYikiPjxlbGxpcHNlIGN4PSI1LjUwOCIgY3k9IjE0LjcwNCIgZmlsbD0iI2VkZTZmZiIgcng9IjUuNTA4IiByeT0iMTQuNzA0IiBzdHlsZT0iZmlsbDojZWRlNmZmO2ZpbGw6Y29sb3IoZGlzcGxheS1wMyAuOTI3NSAuOTAzMyAxKTtmaWxsLW9wYWNpdHk6MSIgdHJhbnNmb3JtPSJtYXRyaXgoLjAwMzI0IDEgMSAtLjAwMzI0IC00LjQ3IDMxLjUxNikiLz48L2c+PGcgZmlsdGVyPSJ1cmwoI2MpIj48ZWxsaXBzZSBjeD0iMTAuMzk5IiBjeT0iMjkuODUxIiBmaWxsPSIjZWRlNmZmIiByeD0iMTAuMzk5IiByeT0iMjkuODUxIiBzdHlsZT0iZmlsbDojZWRlNmZmO2ZpbGw6Y29sb3IoZGlzcGxheS1wMyAuOTI3NSAuOTAzMyAxKTtmaWxsLW9wYWNpdHk6MSIgdHJhbnNmb3JtPSJtYXRyaXgoLjAwMzI0IDEgMSAtLjAwMzI0IC0zOS4zMjggNy44ODMpIi8+PC9nPjxnIGZpbHRlcj0idXJsKCNkKSI+PGVsbGlwc2UgY3g9IjUuNTA4IiBjeT0iMzAuNDg3IiBmaWxsPSIjN2UxNGZmIiByeD0iNS41MDgiIHJ5PSIzMC40ODciIHN0eWxlPSJmaWxsOiM3ZTE0ZmY7ZmlsbDpjb2xvcihkaXNwbGF5LXAzIC40OTIyIC4wNzY3IDEpO2ZpbGwtb3BhY2l0eToxIiB0cmFuc2Zvcm09InJvdGF0ZSg4OS44MTQgLTI1LjkxMyAtMTQuNjM5KXNjYWxlKDEgLTEpIi8+PC9nPjxnIGZpbHRlcj0idXJsKCNlKSI+PGVsbGlwc2UgY3g9IjUuNTA4IiBjeT0iMzAuNTk5IiBmaWxsPSIjN2UxNGZmIiByeD0iNS41MDgiIHJ5PSIzMC41OTkiIHN0eWxlPSJmaWxsOiM3ZTE0ZmY7ZmlsbDpjb2xvcihkaXNwbGF5LXAzIC40OTIyIC4wNzY3IDEpO2ZpbGwtb3BhY2l0eToxIiB0cmFuc2Zvcm09InJvdGF0ZSg4OS44MTQgLTMyLjY0NCAtMy4zMzQpc2NhbGUoMSAtMSkiLz48L2c+PGcgZmlsdGVyPSJ1cmwoI2YpIj48ZWxsaXBzZSBjeD0iNS41MDgiIGN5PSIzMC41OTkiIGZpbGw9IiM3ZTE0ZmYiIHJ4PSI1LjUwOCIgcnk9IjMwLjU5OSIgc3R5bGU9ImZpbGw6IzdlMTRmZjtmaWxsOmNvbG9yKGRpc3BsYXktcDMgLjQ5MjIgLjA3NjcgMSk7ZmlsbC1vcGFjaXR5OjEiIHRyYW5zZm9ybT0ibWF0cml4KC4wMDMyNCAxIDEgLS4wMDMyNCAtMzQuMzQgMzAuNDcpIi8+PC9nPjxnIGZpbHRlcj0idXJsKCNnKSI+PGVsbGlwc2UgY3g9IjE0LjA3MiIgY3k9IjIyLjA3OCIgZmlsbD0iI2VkZTZmZiIgcng9IjE0LjA3MiIgcnk9IjIyLjA3OCIgc3R5bGU9ImZpbGw6I2VkZTZmZjtmaWxsOmNvbG9yKGRpc3BsYXktcDMgLjkyNzUgLjkwMzMgMSk7ZmlsbC1vcGFjaXR5OjEiIHRyYW5zZm9ybT0icm90YXRlKDkzLjM1IDI0LjUwNiA0OC40OTMpc2NhbGUoLTEgMSkiLz48L2c+PGcgZmlsdGVyPSJ1cmwoI2gpIj48ZWxsaXBzZSBjeD0iMy40NyIgY3k9IjIxLjUwMSIgZmlsbD0iIzdlMTRmZiIgcng9IjMuNDciIHJ5PSIyMS41MDEiIHN0eWxlPSJmaWxsOiM3ZTE0ZmY7ZmlsbDpjb2xvcihkaXNwbGF5LXAzIC40OTIyIC4wNzY3IDEpO2ZpbGwtb3BhY2l0eToxIiB0cmFuc2Zvcm09InJvdGF0ZSg4OS4wMDkgMjguNzA4IDQ3LjU5KXNjYWxlKC0xIDEpIi8+PC9nPjxnIGZpbHRlcj0idXJsKCNpKSI+PGVsbGlwc2UgY3g9IjMuNDciIGN5PSIyMS41MDEiIGZpbGw9IiM3ZTE0ZmYiIHJ4PSIzLjQ3IiByeT0iMjEuNTAxIiBzdHlsZT0iZmlsbDojN2UxNGZmO2ZpbGw6Y29sb3IoZGlzcGxheS1wMyAuNDkyMiAuMDc2NyAxKTtmaWxsLW9wYWNpdHk6MSIgdHJhbnNmb3JtPSJyb3RhdGUoODkuMDA5IDI4LjcwOCA0Ny41OSlzY2FsZSgtMSAxKSIvPjwvZz48ZyBmaWx0ZXI9InVybCgjaikiPjxlbGxpcHNlIGN4PSIuMzg3IiBjeT0iOC45NzIiIGZpbGw9IiM3ZTE0ZmYiIHJ4PSI0LjQwNyIgcnk9IjI5LjEwOCIgc3R5bGU9ImZpbGw6IzdlMTRmZjtmaWxsOmNvbG9yKGRpc3BsYXktcDMgLjQ5MjIgLjA3NjcgMSk7ZmlsbC1vcGFjaXR5OjEiIHRyYW5zZm9ybT0icm90YXRlKDM5LjUxIC4zODcgOC45NzIpIi8+PC9nPjxnIGZpbHRlcj0idXJsKCNrKSI+PGVsbGlwc2UgY3g9IjQ3LjUyMyIgY3k9Ii02LjA5MiIgZmlsbD0iIzdlMTRmZiIgcng9IjQuNDA3IiByeT0iMjkuMTA4IiBzdHlsZT0iZmlsbDojN2UxNGZmO2ZpbGw6Y29sb3IoZGlzcGxheS1wMyAuNDkyMiAuMDc2NyAxKTtmaWxsLW9wYWNpdHk6MSIgdHJhbnNmb3JtPSJyb3RhdGUoMzcuODkyIDQ3LjUyMyAtNi4wOTIpIi8+PC9nPjxnIGZpbHRlcj0idXJsKCNsKSI+PGVsbGlwc2UgY3g9IjQxLjQxMiIgY3k9IjYuMzMzIiBmaWxsPSIjNDdiZmZmIiByeD0iNS45NzEiIHJ5PSI5LjY2NSIgc3R5bGU9ImZpbGw6IzQ3YmZmZjtmaWxsOmNvbG9yKGRpc3BsYXktcDMgLjI3OTkgLjc0OCAxKTtmaWxsLW9wYWNpdHk6MSIgdHJhbnNmb3JtPSJyb3RhdGUoMzcuODkyIDQxLjQxMiA2LjMzMykiLz48L2c+PGcgZmlsdGVyPSJ1cmwoI20pIj48ZWxsaXBzZSBjeD0iLTEuODc5IiBjeT0iMzguMzMyIiBmaWxsPSIjN2UxNGZmIiByeD0iNC40MDciIHJ5PSIyOS4xMDgiIHN0eWxlPSJmaWxsOiM3ZTE0ZmY7ZmlsbDpjb2xvcihkaXNwbGF5LXAzIC40OTIyIC4wNzY3IDEpO2ZpbGwtb3BhY2l0eToxIiB0cmFuc2Zvcm09InJvdGF0ZSgzNy44OTIgLTEuODggMzguMzMyKSIvPjwvZz48ZyBmaWx0ZXI9InVybCgjbikiPjxlbGxpcHNlIGN4PSItMS44NzkiIGN5PSIzOC4zMzIiIGZpbGw9IiM3ZTE0ZmYiIHJ4PSI0LjQwNyIgcnk9IjI5LjEwOCIgc3R5bGU9ImZpbGw6IzdlMTRmZjtmaWxsOmNvbG9yKGRpc3BsYXktcDMgLjQ5MjIgLjA3NjcgMSk7ZmlsbC1vcGFjaXR5OjEiIHRyYW5zZm9ybT0icm90YXRlKDM3Ljg5MiAtMS44OCAzOC4zMzIpIi8+PC9nPjxnIGZpbHRlcj0idXJsKCNvKSI+PGVsbGlwc2UgY3g9IjM1LjY1MSIgY3k9IjI5LjkwNyIgZmlsbD0iIzdlMTRmZiIgcng9IjQuNDA3IiByeT0iMjkuMTA4IiBzdHlsZT0iZmlsbDojN2UxNGZmO2ZpbGw6Y29sb3IoZGlzcGxheS1wMyAuNDkyMiAuMDc2NyAxKTtmaWxsLW9wYWNpdHk6MSIgdHJhbnNmb3JtPSJyb3RhdGUoMzcuODkyIDM1LjY1MSAyOS45MDcpIi8+PC9nPjxnIGZpbHRlcj0idXJsKCNwKSI+PGVsbGlwc2UgY3g9IjM4LjQxOCIgY3k9IjMyLjQiIGZpbGw9IiM0N2JmZmYiIHJ4PSI1Ljk3MSIgcnk9IjE1LjI5NyIgc3R5bGU9ImZpbGw6IzQ3YmZmZjtmaWxsOmNvbG9yKGRpc3BsYXktcDMgLjI3OTkgLjc0OCAxKTtmaWxsLW9wYWNpdHk6MSIgdHJhbnNmb3JtPSJyb3RhdGUoMzcuODkyIDM4LjQxOCAzMi40KSIvPjwvZz48L2c+PGRlZnM+PGZpbHRlciBpZD0iYiIgd2lkdGg9IjYwLjA0NSIgaGVpZ2h0PSI0MS42NTQiIHg9Ii0xOS43NyIgeT0iMTYuMTQ5IiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz48ZmVCbGVuZCBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0ic2hhcGUiLz48ZmVHYXVzc2lhbkJsdXIgcmVzdWx0PSJlZmZlY3QxX2ZvcmVncm91bmRCbHVyXzIwMDJfMTcxNTgiIHN0ZERldmlhdGlvbj0iNy42NTkiLz48L2ZpbHRlcj48ZmlsdGVyIGlkPSJjIiB3aWR0aD0iOTAuMzQiIGhlaWdodD0iNTEuNDM3IiB4PSItNTQuNjEzIiB5PSItNy41MzMiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48ZmVGbG9vZCBmbG9vZC1vcGFjaXR5PSIwIiByZXN1bHQ9IkJhY2tncm91bmRJbWFnZUZpeCIvPjxmZUJsZW5kIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJzaGFwZSIvPjxmZUdhdXNzaWFuQmx1ciByZXN1bHQ9ImVmZmVjdDFfZm9yZWdyb3VuZEJsdXJfMjAwMl8xNzE1OCIgc3RkRGV2aWF0aW9uPSI3LjY1OSIvPjwvZmlsdGVyPjxmaWx0ZXIgaWQ9ImQiIHdpZHRoPSI3OS4zNTUiIGhlaWdodD0iMjkuNCIgeD0iLTQ5LjY0IiB5PSIyLjAzIiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz48ZmVCbGVuZCBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0ic2hhcGUiLz48ZmVHYXVzc2lhbkJsdXIgcmVzdWx0PSJlZmZlY3QxX2ZvcmVncm91bmRCbHVyXzIwMDJfMTcxNTgiIHN0ZERldmlhdGlvbj0iNC41OTYiLz48L2ZpbHRlcj48ZmlsdGVyIGlkPSJlIiB3aWR0aD0iNzkuNTc5IiBoZWlnaHQ9IjI5LjQiIHg9Ii00NS4wNDUiIHk9IjIwLjAyOSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiPjxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+PGZlQmxlbmQgaW49IlNvdXJjZUdyYXBoaWMiIGluMj0iQmFja2dyb3VuZEltYWdlRml4IiByZXN1bHQ9InNoYXBlIi8+PGZlR2F1c3NpYW5CbHVyIHJlc3VsdD0iZWZmZWN0MV9mb3JlZ3JvdW5kQmx1cl8yMDAyXzE3MTU4IiBzdGREZXZpYXRpb249IjQuNTk2Ii8+PC9maWx0ZXI+PGZpbHRlciBpZD0iZiIgd2lkdGg9Ijc5LjU3OSIgaGVpZ2h0PSIyOS40IiB4PSItNDMuNTEzIiB5PSIyMS4xNzgiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48ZmVGbG9vZCBmbG9vZC1vcGFjaXR5PSIwIiByZXN1bHQ9IkJhY2tncm91bmRJbWFnZUZpeCIvPjxmZUJsZW5kIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJzaGFwZSIvPjxmZUdhdXNzaWFuQmx1ciByZXN1bHQ9ImVmZmVjdDFfZm9yZWdyb3VuZEJsdXJfMjAwMl8xNzE1OCIgc3RkRGV2aWF0aW9uPSI0LjU5NiIvPjwvZmlsdGVyPjxmaWx0ZXIgaWQ9ImciIHdpZHRoPSI3NC43NDkiIGhlaWdodD0iNTguODUyIiB4PSIxNS43NTYiIHk9Ii0xNy45MDEiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48ZmVGbG9vZCBmbG9vZC1vcGFjaXR5PSIwIiByZXN1bHQ9IkJhY2tncm91bmRJbWFnZUZpeCIvPjxmZUJsZW5kIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJzaGFwZSIvPjxmZUdhdXNzaWFuQmx1ciByZXN1bHQ9ImVmZmVjdDFfZm9yZWdyb3VuZEJsdXJfMjAwMl8xNzE1OCIgc3RkRGV2aWF0aW9uPSI3LjY1OSIvPjwvZmlsdGVyPjxmaWx0ZXIgaWQ9ImgiIHdpZHRoPSI2MS4zNzciIGhlaWdodD0iMjUuMzYyIiB4PSIyMy41NDgiIHk9IjIuMjg0IiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz48ZmVCbGVuZCBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0ic2hhcGUiLz48ZmVHYXVzc2lhbkJsdXIgcmVzdWx0PSJlZmZlY3QxX2ZvcmVncm91bmRCbHVyXzIwMDJfMTcxNTgiIHN0ZERldmlhdGlvbj0iNC41OTYiLz48L2ZpbHRlcj48ZmlsdGVyIGlkPSJpIiB3aWR0aD0iNjEuMzc3IiBoZWlnaHQ9IjI1LjM2MiIgeD0iMjMuNTQ4IiB5PSIyLjI4NCIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiPjxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+PGZlQmxlbmQgaW49IlNvdXJjZUdyYXBoaWMiIGluMj0iQmFja2dyb3VuZEltYWdlRml4IiByZXN1bHQ9InNoYXBlIi8+PGZlR2F1c3NpYW5CbHVyIHJlc3VsdD0iZWZmZWN0MV9mb3JlZ3JvdW5kQmx1cl8yMDAyXzE3MTU4IiBzdGREZXZpYXRpb249IjQuNTk2Ii8+PC9maWx0ZXI+PGZpbHRlciBpZD0iaiIgd2lkdGg9IjU2LjA0NSIgaGVpZ2h0PSI2My42NDkiIHg9Ii0yNy42MzYiIHk9Ii0yMi44NTMiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48ZmVGbG9vZCBmbG9vZC1vcGFjaXR5PSIwIiByZXN1bHQ9IkJhY2tncm91bmRJbWFnZUZpeCIvPjxmZUJsZW5kIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJzaGFwZSIvPjxmZUdhdXNzaWFuQmx1ciByZXN1bHQ9ImVmZmVjdDFfZm9yZWdyb3VuZEJsdXJfMjAwMl8xNzE1OCIgc3RkRGV2aWF0aW9uPSI0LjU5NiIvPjwvZmlsdGVyPjxmaWx0ZXIgaWQ9ImsiIHdpZHRoPSI1NC44MTQiIGhlaWdodD0iNjQuNjQ2IiB4PSIyMC4xMTYiIHk9Ii0zOC40MTUiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48ZmVGbG9vZCBmbG9vZC1vcGFjaXR5PSIwIiByZXN1bHQ9IkJhY2tncm91bmRJbWFnZUZpeCIvPjxmZUJsZW5kIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJzaGFwZSIvPjxmZUdhdXNzaWFuQmx1ciByZXN1bHQ9ImVmZmVjdDFfZm9yZWdyb3VuZEJsdXJfMjAwMl8xNzE1OCIgc3RkRGV2aWF0aW9uPSI0LjU5NiIvPjwvZmlsdGVyPjxmaWx0ZXIgaWQ9ImwiIHdpZHRoPSIzMy41NDEiIGhlaWdodD0iMzUuMzEzIiB4PSIyNC42NDEiIHk9Ii0xMS4zMjMiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiIgZmlsdGVyVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48ZmVGbG9vZCBmbG9vZC1vcGFjaXR5PSIwIiByZXN1bHQ9IkJhY2tncm91bmRJbWFnZUZpeCIvPjxmZUJsZW5kIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJzaGFwZSIvPjxmZUdhdXNzaWFuQmx1ciByZXN1bHQ9ImVmZmVjdDFfZm9yZWdyb3VuZEJsdXJfMjAwMl8xNzE1OCIgc3RkRGV2aWF0aW9uPSI0LjU5NiIvPjwvZmlsdGVyPjxmaWx0ZXIgaWQ9Im0iIHdpZHRoPSI1NC44MTQiIGhlaWdodD0iNjQuNjQ2IiB4PSItMjkuMjg2IiB5PSI2LjAwOSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiPjxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+PGZlQmxlbmQgaW49IlNvdXJjZUdyYXBoaWMiIGluMj0iQmFja2dyb3VuZEltYWdlRml4IiByZXN1bHQ9InNoYXBlIi8+PGZlR2F1c3NpYW5CbHVyIHJlc3VsdD0iZWZmZWN0MV9mb3JlZ3JvdW5kQmx1cl8yMDAyXzE3MTU4IiBzdGREZXZpYXRpb249IjQuNTk2Ii8+PC9maWx0ZXI+PGZpbHRlciBpZD0ibiIgd2lkdGg9IjU0LjgxNCIgaGVpZ2h0PSI2NC42NDYiIHg9Ii0yOS4yODYiIHk9IjYuMDA5IiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz48ZmVCbGVuZCBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0ic2hhcGUiLz48ZmVHYXVzc2lhbkJsdXIgcmVzdWx0PSJlZmZlY3QxX2ZvcmVncm91bmRCbHVyXzIwMDJfMTcxNTgiIHN0ZERldmlhdGlvbj0iNC41OTYiLz48L2ZpbHRlcj48ZmlsdGVyIGlkPSJvIiB3aWR0aD0iNTQuODE0IiBoZWlnaHQ9IjY0LjY0NiIgeD0iOC4yNDQiIHk9Ii0yLjQxNiIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiPjxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+PGZlQmxlbmQgaW49IlNvdXJjZUdyYXBoaWMiIGluMj0iQmFja2dyb3VuZEltYWdlRml4IiByZXN1bHQ9InNoYXBlIi8+PGZlR2F1c3NpYW5CbHVyIHJlc3VsdD0iZWZmZWN0MV9mb3JlZ3JvdW5kQmx1cl8yMDAyXzE3MTU4IiBzdGREZXZpYXRpb249IjQuNTk2Ii8+PC9maWx0ZXI+PGZpbHRlciBpZD0icCIgd2lkdGg9IjM5LjQwOSIgaGVpZ2h0PSI0My42MjMiIHg9IjE4LjcxMyIgeT0iMTAuNTg4IiBjb2xvci1pbnRlcnBvbGF0aW9uLWZpbHRlcnM9InNSR0IiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz48ZmVCbGVuZCBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0ic2hhcGUiLz48ZmVHYXVzc2lhbkJsdXIgcmVzdWx0PSJlZmZlY3QxX2ZvcmVncm91bmRCbHVyXzIwMDJfMTcxNTgiIHN0ZERldmlhdGlvbj0iNC41OTYiLz48L2ZpbHRlcj48L2RlZnM+PC9zdmc+" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Password Vault</title>
|
||
<script type="module" crossorigin>//#region \0vite/modulepreload-polyfill.js
|
||
(function polyfill() {
|
||
const relList = document.createElement("link").relList;
|
||
if (relList && relList.supports && relList.supports("modulepreload")) return;
|
||
for (const link of document.querySelectorAll("link[rel=\"modulepreload\"]")) processPreload(link);
|
||
new MutationObserver((mutations) => {
|
||
for (const mutation of mutations) {
|
||
if (mutation.type !== "childList") continue;
|
||
for (const node of mutation.addedNodes) if (node.tagName === "LINK" && node.rel === "modulepreload") processPreload(node);
|
||
}
|
||
}).observe(document, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
function getFetchOpts(link) {
|
||
const fetchOpts = {};
|
||
if (link.integrity) fetchOpts.integrity = link.integrity;
|
||
if (link.referrerPolicy) fetchOpts.referrerPolicy = link.referrerPolicy;
|
||
if (link.crossOrigin === "use-credentials") fetchOpts.credentials = "include";
|
||
else if (link.crossOrigin === "anonymous") fetchOpts.credentials = "omit";
|
||
else fetchOpts.credentials = "same-origin";
|
||
return fetchOpts;
|
||
}
|
||
function processPreload(link) {
|
||
if (link.ep) return;
|
||
link.ep = true;
|
||
const fetchOpts = getFetchOpts(link);
|
||
fetch(link.href, fetchOpts);
|
||
}
|
||
})();
|
||
//#endregion
|
||
//#region src/components/component.js
|
||
/**
|
||
* 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
|
||
*/
|
||
var Component = class {
|
||
/** @param {HTMLElement} container — parent to mount into */
|
||
constructor(container) {
|
||
this.container = container;
|
||
this.el = null;
|
||
this._listeners = [];
|
||
this._unsubs = [];
|
||
}
|
||
/** 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() {
|
||
this._unsubs.forEach((fn) => fn());
|
||
this._unsubs = [];
|
||
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 !== void 0) el.disabled = attrs.disabled;
|
||
if (attrs.checked !== void 0) el.checked = attrs.checked;
|
||
if (attrs.value !== void 0) 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 !== void 0) 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 !== void 0) 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;
|
||
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];
|
||
if (attrs.dataset) Object.assign(el.dataset, attrs.dataset);
|
||
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 === void 0) 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;
|
||
}
|
||
};
|
||
//#endregion
|
||
//#region src/lib/stores/store.js
|
||
/**
|
||
* 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)
|
||
*/
|
||
var Store = class {
|
||
#data;
|
||
#subscribers = {};
|
||
/** @param {Record<string, any>} initial */
|
||
constructor(initial) {
|
||
this.#data = { ...initial };
|
||
const self = this;
|
||
const proxy = new Proxy({}, {
|
||
get(_, prop) {
|
||
if (prop === "_data") return self.#data;
|
||
if (prop in self.#data) return self.#data[prop];
|
||
},
|
||
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;
|
||
}
|
||
});
|
||
this.$ = proxy;
|
||
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] = /* @__PURE__ */ 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) [...fns].forEach((fn) => {
|
||
try {
|
||
fn(value);
|
||
} catch (e) {
|
||
console.error("Store subscriber error:", e);
|
||
}
|
||
});
|
||
}
|
||
#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
|
||
});
|
||
}
|
||
}
|
||
};
|
||
//#endregion
|
||
//#region node_modules/idb/build/index.js
|
||
var instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c);
|
||
var idbProxyableTypes;
|
||
var cursorAdvanceMethods;
|
||
function getIdbProxyableTypes() {
|
||
return idbProxyableTypes || (idbProxyableTypes = [
|
||
IDBDatabase,
|
||
IDBObjectStore,
|
||
IDBIndex,
|
||
IDBCursor,
|
||
IDBTransaction
|
||
]);
|
||
}
|
||
function getCursorAdvanceMethods() {
|
||
return cursorAdvanceMethods || (cursorAdvanceMethods = [
|
||
IDBCursor.prototype.advance,
|
||
IDBCursor.prototype.continue,
|
||
IDBCursor.prototype.continuePrimaryKey
|
||
]);
|
||
}
|
||
var transactionDoneMap = /* @__PURE__ */ new WeakMap();
|
||
var transformCache = /* @__PURE__ */ new WeakMap();
|
||
var reverseTransformCache = /* @__PURE__ */ new WeakMap();
|
||
function promisifyRequest(request) {
|
||
const promise = new Promise((resolve, reject) => {
|
||
const unlisten = () => {
|
||
request.removeEventListener("success", success);
|
||
request.removeEventListener("error", error);
|
||
};
|
||
const success = () => {
|
||
resolve(wrap(request.result));
|
||
unlisten();
|
||
};
|
||
const error = () => {
|
||
reject(request.error);
|
||
unlisten();
|
||
};
|
||
request.addEventListener("success", success);
|
||
request.addEventListener("error", error);
|
||
});
|
||
reverseTransformCache.set(promise, request);
|
||
return promise;
|
||
}
|
||
function cacheDonePromiseForTransaction(tx) {
|
||
if (transactionDoneMap.has(tx)) return;
|
||
const done = new Promise((resolve, reject) => {
|
||
const unlisten = () => {
|
||
tx.removeEventListener("complete", complete);
|
||
tx.removeEventListener("error", error);
|
||
tx.removeEventListener("abort", error);
|
||
};
|
||
const complete = () => {
|
||
resolve();
|
||
unlisten();
|
||
};
|
||
const error = () => {
|
||
reject(tx.error || new DOMException("AbortError", "AbortError"));
|
||
unlisten();
|
||
};
|
||
tx.addEventListener("complete", complete);
|
||
tx.addEventListener("error", error);
|
||
tx.addEventListener("abort", error);
|
||
});
|
||
transactionDoneMap.set(tx, done);
|
||
}
|
||
var idbProxyTraps = {
|
||
get(target, prop, receiver) {
|
||
if (target instanceof IDBTransaction) {
|
||
if (prop === "done") return transactionDoneMap.get(target);
|
||
if (prop === "store") return receiver.objectStoreNames[1] ? void 0 : receiver.objectStore(receiver.objectStoreNames[0]);
|
||
}
|
||
return wrap(target[prop]);
|
||
},
|
||
set(target, prop, value) {
|
||
target[prop] = value;
|
||
return true;
|
||
},
|
||
has(target, prop) {
|
||
if (target instanceof IDBTransaction && (prop === "done" || prop === "store")) return true;
|
||
return prop in target;
|
||
}
|
||
};
|
||
function replaceTraps(callback) {
|
||
idbProxyTraps = callback(idbProxyTraps);
|
||
}
|
||
function wrapFunction(func) {
|
||
if (getCursorAdvanceMethods().includes(func)) return function(...args) {
|
||
func.apply(unwrap(this), args);
|
||
return wrap(this.request);
|
||
};
|
||
return function(...args) {
|
||
return wrap(func.apply(unwrap(this), args));
|
||
};
|
||
}
|
||
function transformCachableValue(value) {
|
||
if (typeof value === "function") return wrapFunction(value);
|
||
if (value instanceof IDBTransaction) cacheDonePromiseForTransaction(value);
|
||
if (instanceOfAny(value, getIdbProxyableTypes())) return new Proxy(value, idbProxyTraps);
|
||
return value;
|
||
}
|
||
function wrap(value) {
|
||
if (value instanceof IDBRequest) return promisifyRequest(value);
|
||
if (transformCache.has(value)) return transformCache.get(value);
|
||
const newValue = transformCachableValue(value);
|
||
if (newValue !== value) {
|
||
transformCache.set(value, newValue);
|
||
reverseTransformCache.set(newValue, value);
|
||
}
|
||
return newValue;
|
||
}
|
||
var unwrap = (value) => reverseTransformCache.get(value);
|
||
/**
|
||
* Open a database.
|
||
*
|
||
* @param name Name of the database.
|
||
* @param version Schema version.
|
||
* @param callbacks Additional callbacks.
|
||
*/
|
||
function openDB(name, version, { blocked, upgrade, blocking, terminated } = {}) {
|
||
const request = indexedDB.open(name, version);
|
||
const openPromise = wrap(request);
|
||
if (upgrade) request.addEventListener("upgradeneeded", (event) => {
|
||
upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event);
|
||
});
|
||
if (blocked) request.addEventListener("blocked", (event) => blocked(event.oldVersion, event.newVersion, event));
|
||
openPromise.then((db) => {
|
||
if (terminated) db.addEventListener("close", () => terminated());
|
||
if (blocking) db.addEventListener("versionchange", (event) => blocking(event.oldVersion, event.newVersion, event));
|
||
}).catch(() => {});
|
||
return openPromise;
|
||
}
|
||
var readMethods = [
|
||
"get",
|
||
"getKey",
|
||
"getAll",
|
||
"getAllKeys",
|
||
"count"
|
||
];
|
||
var writeMethods = [
|
||
"put",
|
||
"add",
|
||
"delete",
|
||
"clear"
|
||
];
|
||
var cachedMethods = /* @__PURE__ */ new Map();
|
||
function getMethod(target, prop) {
|
||
if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === "string")) return;
|
||
if (cachedMethods.get(prop)) return cachedMethods.get(prop);
|
||
const targetFuncName = prop.replace(/FromIndex$/, "");
|
||
const useIndex = prop !== targetFuncName;
|
||
const isWrite = writeMethods.includes(targetFuncName);
|
||
if (!(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))) return;
|
||
const method = async function(storeName, ...args) {
|
||
const tx = this.transaction(storeName, isWrite ? "readwrite" : "readonly");
|
||
let target = tx.store;
|
||
if (useIndex) target = target.index(args.shift());
|
||
return (await Promise.all([target[targetFuncName](...args), isWrite && tx.done]))[0];
|
||
};
|
||
cachedMethods.set(prop, method);
|
||
return method;
|
||
}
|
||
replaceTraps((oldTraps) => ({
|
||
...oldTraps,
|
||
get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver),
|
||
has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop)
|
||
}));
|
||
var advanceMethodProps = [
|
||
"continue",
|
||
"continuePrimaryKey",
|
||
"advance"
|
||
];
|
||
var methodMap = {};
|
||
var advanceResults = /* @__PURE__ */ new WeakMap();
|
||
var ittrProxiedCursorToOriginalProxy = /* @__PURE__ */ new WeakMap();
|
||
var cursorIteratorTraps = { get(target, prop) {
|
||
if (!advanceMethodProps.includes(prop)) return target[prop];
|
||
let cachedFunc = methodMap[prop];
|
||
if (!cachedFunc) cachedFunc = methodMap[prop] = function(...args) {
|
||
advanceResults.set(this, ittrProxiedCursorToOriginalProxy.get(this)[prop](...args));
|
||
};
|
||
return cachedFunc;
|
||
} };
|
||
async function* iterate(...args) {
|
||
let cursor = this;
|
||
if (!(cursor instanceof IDBCursor)) cursor = await cursor.openCursor(...args);
|
||
if (!cursor) return;
|
||
cursor = cursor;
|
||
const proxiedCursor = new Proxy(cursor, cursorIteratorTraps);
|
||
ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor);
|
||
reverseTransformCache.set(proxiedCursor, unwrap(cursor));
|
||
while (cursor) {
|
||
yield proxiedCursor;
|
||
cursor = await (advanceResults.get(proxiedCursor) || cursor.continue());
|
||
advanceResults.delete(proxiedCursor);
|
||
}
|
||
}
|
||
function isIteratorProp(target, prop) {
|
||
return prop === Symbol.asyncIterator && instanceOfAny(target, [
|
||
IDBIndex,
|
||
IDBObjectStore,
|
||
IDBCursor
|
||
]) || prop === "iterate" && instanceOfAny(target, [IDBIndex, IDBObjectStore]);
|
||
}
|
||
replaceTraps((oldTraps) => ({
|
||
...oldTraps,
|
||
get(target, prop, receiver) {
|
||
if (isIteratorProp(target, prop)) return iterate;
|
||
return oldTraps.get(target, prop, receiver);
|
||
},
|
||
has(target, prop) {
|
||
return isIteratorProp(target, prop) || oldTraps.has(target, prop);
|
||
}
|
||
}));
|
||
//#endregion
|
||
//#region src/lib/models/schema.js
|
||
/**
|
||
* Data model definitions and factory functions.
|
||
*
|
||
* All IDs are generated with a simple ULID-like timestamp + random suffix
|
||
* to keep things sortable and collision-resistant without external deps.
|
||
*/
|
||
/**
|
||
* Well-known ID for the permanent Trash group.
|
||
* This is a fixed constant so it survives re-creates.
|
||
*/
|
||
var TRASH_GROUP_ID = "__trash__";
|
||
var TRASH_GROUP_NAME = "Trash";
|
||
var TRASH_GROUP_COLOR = "#e5484d";
|
||
/**
|
||
* Generate a unique ID (timestamp-based, sortable).
|
||
* @returns {string}
|
||
*/
|
||
function generateId() {
|
||
const timestamp = Date.now().toString(36);
|
||
const randomBytes = new Uint8Array(4);
|
||
crypto.getRandomValues(randomBytes);
|
||
return `${timestamp}_${Array.from(randomBytes).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 8)}`;
|
||
}
|
||
/**
|
||
* @typedef {Object} CredentialEntry
|
||
* @property {string} id - Unique identifier
|
||
* @property {string} title - Display name (e.g. "GitHub", "Gmail")
|
||
* @property {string} [username] - Login username or email (optional)
|
||
* @property {string} encryptedPassword - AES-GCM encrypted password blob (JSON string)
|
||
* @property {string} [url] - Website URL
|
||
* @property {string} [notes] - Free-form notes
|
||
* @property {string} [groupId] - Reference to a Group id (empty string = no group)
|
||
* @property {string[]} [tags] - Free-form tags
|
||
* @property {string} createdAt - ISO timestamp
|
||
* @property {string} updatedAt - ISO timestamp
|
||
*/
|
||
/**
|
||
* Create a new CredentialEntry.
|
||
*
|
||
* @param {Object} data
|
||
* @param {string} data.title
|
||
* @param {string} [data.username]
|
||
* @param {string} data.encryptedPassword - Must already be encrypted
|
||
* @param {string} [data.url]
|
||
* @param {string} [data.notes]
|
||
* @param {string} [data.groupId]
|
||
* @param {string[]} [data.tags]
|
||
* @returns {CredentialEntry}
|
||
*/
|
||
function createEntry(data) {
|
||
const now = (/* @__PURE__ */ new Date()).toISOString();
|
||
return {
|
||
id: generateId(),
|
||
title: data.title.trim(),
|
||
username: data.username?.trim() || "",
|
||
encryptedPassword: data.encryptedPassword,
|
||
url: data.url?.trim() || "",
|
||
notes: data.notes?.trim() || "",
|
||
groupId: data.groupId || "",
|
||
tags: data.tags || [],
|
||
createdAt: now,
|
||
updatedAt: now
|
||
};
|
||
}
|
||
/**
|
||
* Update an existing entry, preserving id and createdAt.
|
||
*
|
||
* @param {CredentialEntry} existing
|
||
* @param {Object} data - Fields to update
|
||
* @returns {CredentialEntry}
|
||
*/
|
||
function updateEntry$1(existing, data) {
|
||
return {
|
||
...existing,
|
||
title: data.title !== void 0 ? data.title.trim() : existing.title,
|
||
username: data.username !== void 0 ? data.username?.trim() || "" : existing.username,
|
||
encryptedPassword: data.encryptedPassword !== void 0 ? data.encryptedPassword : existing.encryptedPassword,
|
||
url: data.url !== void 0 ? data.url.trim() : existing.url,
|
||
notes: data.notes !== void 0 ? data.notes.trim() : existing.notes,
|
||
groupId: data.groupId !== void 0 ? data.groupId : existing.groupId,
|
||
tags: data.tags !== void 0 ? data.tags : existing.tags,
|
||
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
||
};
|
||
}
|
||
/**
|
||
* @typedef {Object} Group
|
||
* @property {string} id - Unique identifier
|
||
* @property {string} name - Display name
|
||
* @property {string} [color] - Hex color for UI accent
|
||
* @property {string} createdAt - ISO timestamp
|
||
*/
|
||
var GROUP_COLORS = [
|
||
"#6c63ff",
|
||
"#e5484d",
|
||
"#34d399",
|
||
"#fbbf24",
|
||
"#3b82f6",
|
||
"#ec4899",
|
||
"#8b5cf6",
|
||
"#14b8a6",
|
||
"#f97316",
|
||
"#06b6d4"
|
||
];
|
||
/**
|
||
* Create a new Group.
|
||
*
|
||
* @param {string} name
|
||
* @param {string} [color]
|
||
* @returns {Group}
|
||
*/
|
||
function createGroup(name, color) {
|
||
return {
|
||
id: generateId(),
|
||
name: name.trim(),
|
||
color: color || GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)],
|
||
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
||
};
|
||
}
|
||
/**
|
||
* Validate a credential entry form submission.
|
||
*
|
||
* @param {Object} data
|
||
* @returns {{ valid: boolean, errors: string[] }}
|
||
*/
|
||
function validateEntry(data) {
|
||
const errors = [];
|
||
if (!data.title || !data.title.trim()) errors.push("Title is required");
|
||
if (!data.encryptedPassword) errors.push("Password is required");
|
||
return {
|
||
valid: errors.length === 0,
|
||
errors
|
||
};
|
||
}
|
||
/**
|
||
* Validate a group name.
|
||
*
|
||
* @param {string} name
|
||
* @returns {{ valid: boolean, errors: string[] }}
|
||
*/
|
||
function validateGroup(name) {
|
||
const errors = [];
|
||
if (!name || !name.trim()) errors.push("Group name is required");
|
||
else if (name.trim().length > 50) errors.push("Group name must be 50 characters or less");
|
||
return {
|
||
valid: errors.length === 0,
|
||
errors
|
||
};
|
||
}
|
||
/**
|
||
* Create the permanent Trash group.
|
||
* @returns {Group}
|
||
*/
|
||
function createTrashGroup() {
|
||
return {
|
||
id: TRASH_GROUP_ID,
|
||
name: TRASH_GROUP_NAME,
|
||
color: TRASH_GROUP_COLOR,
|
||
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
||
};
|
||
}
|
||
/**
|
||
* Check if a group ID refers to the Trash group.
|
||
* @param {string} groupId
|
||
* @returns {boolean}
|
||
*/
|
||
function isTrashGroup(groupId) {
|
||
return groupId === TRASH_GROUP_ID;
|
||
}
|
||
//#endregion
|
||
//#region src/lib/crypto/crypto.js
|
||
/**
|
||
* Crypto module - Web Crypto API wrapper.
|
||
*
|
||
* Uses PBKDF2 for key derivation and AES-GCM for symmetric encryption.
|
||
* All operations are async and use the browser's native Web Crypto API.
|
||
*
|
||
* The derived encryption key is kept in memory only - never written to disk.
|
||
*/
|
||
var PBKDF2_ITERATIONS = 6e5;
|
||
var SALT_LENGTH = 16;
|
||
var IV_LENGTH = 12;
|
||
/**
|
||
* Generate a random salt.
|
||
* @returns {Uint8Array}
|
||
*/
|
||
function generateSalt() {
|
||
return crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
||
}
|
||
/**
|
||
* Derive an AES-GCM encryption key from a master password and salt.
|
||
*
|
||
* @param {string} masterPassword
|
||
* @param {Uint8Array} salt
|
||
* @returns {Promise<CryptoKey>}
|
||
*/
|
||
async function deriveKey(masterPassword, salt) {
|
||
const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(masterPassword), "PBKDF2", false, ["deriveKey"]);
|
||
return crypto.subtle.deriveKey({
|
||
name: "PBKDF2",
|
||
salt,
|
||
iterations: PBKDF2_ITERATIONS,
|
||
hash: "SHA-256"
|
||
}, keyMaterial, {
|
||
name: "AES-GCM",
|
||
length: 256
|
||
}, false, ["encrypt", "decrypt"]);
|
||
}
|
||
/**
|
||
* Encrypt a plaintext string.
|
||
*
|
||
* @param {string} plaintext
|
||
* @param {CryptoKey} key
|
||
* @returns {Promise<string>} JSON string containing { iv, ciphertext }
|
||
* (salt is stored separately in the app store)
|
||
*/
|
||
async function encrypt(plaintext, key) {
|
||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||
const encoded = new TextEncoder().encode(plaintext);
|
||
const ciphertext = await crypto.subtle.encrypt({
|
||
name: "AES-GCM",
|
||
iv
|
||
}, key, encoded);
|
||
return JSON.stringify({
|
||
iv: uint8ArrayToBase64(iv),
|
||
ciphertext: uint8ArrayToBase64(new Uint8Array(ciphertext))
|
||
});
|
||
}
|
||
/**
|
||
* Decrypt an encrypted blob back to plaintext.
|
||
*
|
||
* @param {string} encryptedJson - JSON string from encrypt()
|
||
* @param {CryptoKey} key
|
||
* @returns {Promise<string>}
|
||
*/
|
||
async function decrypt(encryptedJson, key) {
|
||
const { iv, ciphertext } = JSON.parse(encryptedJson);
|
||
const ciphertextBuffer = base64ToUint8Array(ciphertext);
|
||
const ivBuffer = base64ToUint8Array(iv);
|
||
const decrypted = await crypto.subtle.decrypt({
|
||
name: "AES-GCM",
|
||
iv: ivBuffer
|
||
}, key, ciphertextBuffer);
|
||
return new TextDecoder().decode(decrypted);
|
||
}
|
||
/**
|
||
* Verify that a master password is correct by attempting to decrypt a test payload.
|
||
*
|
||
* @param {string} masterPassword
|
||
* @param {Uint8Array} salt
|
||
* @param {string} testEncrypted - A known encrypted test string
|
||
* @param {string} testPlaintext - The expected plaintext
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
async function verifyPassword(masterPassword, salt, testEncrypted, testPlaintext) {
|
||
try {
|
||
return await decrypt(testEncrypted, await deriveKey(masterPassword, salt)) === testPlaintext;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
/**
|
||
* Create a test payload for password verification on first setup.
|
||
*
|
||
* @param {string} masterPassword
|
||
* @returns {Promise<{ salt: Uint8Array, testEncrypted: string }>}
|
||
*/
|
||
async function createTestPayload(masterPassword) {
|
||
const salt = generateSalt();
|
||
const key = await deriveKey(masterPassword, salt);
|
||
const testPlaintext = "vault_test_" + generateId();
|
||
return {
|
||
salt,
|
||
testEncrypted: await encrypt(testPlaintext, key),
|
||
testPlaintext
|
||
};
|
||
}
|
||
function uint8ArrayToBase64(buffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
let binary = "";
|
||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||
return btoa(binary);
|
||
}
|
||
function base64ToUint8Array(base64) {
|
||
const binary = atob(base64);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||
return bytes;
|
||
}
|
||
/**
|
||
* Generate a random password.
|
||
*
|
||
* @param {Object} options
|
||
* @param {number} [options.length=16]
|
||
* @param {boolean} [options.uppercase=true]
|
||
* @param {boolean} [options.lowercase=true]
|
||
* @param {boolean} [options.digits=true]
|
||
* @param {boolean} [options.symbols=true]
|
||
* @param {string} [options.exclude='']
|
||
* @returns {string}
|
||
*/
|
||
function generatePassword({ length = 16, uppercase = true, lowercase = true, digits = true, symbols = true, exclude = "" } = {}) {
|
||
let charset = "";
|
||
if (uppercase) charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||
if (lowercase) charset += "abcdefghijklmnopqrstuvwxyz";
|
||
if (digits) charset += "0123456789";
|
||
if (symbols) charset += "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||
if (exclude) {
|
||
const excludeSet = new Set(exclude.split(""));
|
||
charset = charset.split("").filter((c) => !excludeSet.has(c)).join("");
|
||
}
|
||
if (!charset) throw new Error("Password charset is empty - enable at least one character type");
|
||
const charsetLength = charset.length;
|
||
const maxValid = 256 - 256 % charsetLength;
|
||
const randomBytes = new Uint8Array(length * 2);
|
||
crypto.getRandomValues(randomBytes);
|
||
let password = "";
|
||
let byteIdx = 0;
|
||
while (password.length < length) {
|
||
if (byteIdx >= randomBytes.length) {
|
||
crypto.getRandomValues(randomBytes);
|
||
byteIdx = 0;
|
||
}
|
||
const byte = randomBytes[byteIdx++];
|
||
if (byte < maxValid) password += charset[byte % charsetLength];
|
||
}
|
||
return password;
|
||
}
|
||
//#endregion
|
||
//#region src/lib/storage/db.js
|
||
/**
|
||
* IndexedDB storage layer using the `idb` wrapper.
|
||
*
|
||
* Database: "password-vault"
|
||
* - Object store "entries": stores CredentialEntry objects
|
||
* - Object store "groups": stores Group objects
|
||
* - Object store "meta": stores app metadata (salt, test payload, version)
|
||
*
|
||
* All passwords are stored as encrypted blobs (encryptedPassword field).
|
||
* The encryption key is never stored — only the salt and a test payload
|
||
* for password verification.
|
||
*/
|
||
var DB_NAME = "password-vault";
|
||
var DB_VERSION = 1;
|
||
/**
|
||
* Open (or create) the database.
|
||
* @returns {Promise<IDBPDatabase>}
|
||
*/
|
||
async function getDb() {
|
||
return openDB(DB_NAME, DB_VERSION, { upgrade(db) {
|
||
if (!db.objectStoreNames.contains("entries")) {
|
||
const entryStore = db.createObjectStore("entries", { keyPath: "id" });
|
||
entryStore.createIndex("groupId", "groupId");
|
||
entryStore.createIndex("updatedAt", "updatedAt");
|
||
}
|
||
if (!db.objectStoreNames.contains("groups")) db.createObjectStore("groups", { keyPath: "id" });
|
||
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta", { keyPath: "key" });
|
||
} });
|
||
}
|
||
/**
|
||
* Store the vault's salt and test payload (used for password verification).
|
||
*
|
||
* @param {Uint8Array} salt
|
||
* @param {string} testEncrypted
|
||
* @param {string} testPlaintext
|
||
*/
|
||
async function saveVaultMeta(salt, testEncrypted, testPlaintext) {
|
||
const tx = (await getDb()).transaction("meta", "readwrite");
|
||
let binary = "";
|
||
for (let i = 0; i < salt.byteLength; i++) binary += String.fromCharCode(salt[i]);
|
||
const saltBase64 = btoa(binary);
|
||
await tx.store.put({
|
||
key: "salt",
|
||
value: saltBase64
|
||
});
|
||
await tx.store.put({
|
||
key: "testEncrypted",
|
||
value: testEncrypted
|
||
});
|
||
await tx.store.put({
|
||
key: "testPlaintext",
|
||
value: testPlaintext
|
||
});
|
||
await tx.store.put({
|
||
key: "dbVersion",
|
||
value: DB_VERSION
|
||
});
|
||
await tx.done;
|
||
}
|
||
/**
|
||
* Load the vault's salt and test payload.
|
||
*
|
||
* @returns {Promise<{ salt: Uint8Array|null, testEncrypted: string|null, testPlaintext: string|null }>}
|
||
*/
|
||
async function loadVaultMeta() {
|
||
const store = (await getDb()).transaction("meta", "readonly").store;
|
||
const saltRow = await store.get("salt");
|
||
const testEncryptedRow = await store.get("testEncrypted");
|
||
const testPlaintextRow = await store.get("testPlaintext");
|
||
let salt = null;
|
||
if (saltRow?.value) {
|
||
const binary = atob(saltRow.value);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||
salt = bytes;
|
||
}
|
||
return {
|
||
salt,
|
||
testEncrypted: testEncryptedRow?.value || null,
|
||
testPlaintext: testPlaintextRow?.value || null
|
||
};
|
||
}
|
||
/**
|
||
* Check if the vault has been initialized (has a salt stored).
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
async function isVaultInitialized() {
|
||
return (await loadVaultMeta()).salt !== null;
|
||
}
|
||
/**
|
||
* Save a single setting as a key/value pair in the meta store.
|
||
* @param {string} key
|
||
* @param {*} value
|
||
*/
|
||
async function saveSetting(key, value) {
|
||
await (await getDb()).put("meta", {
|
||
key: "setting:" + key,
|
||
value
|
||
});
|
||
}
|
||
/**
|
||
* Load a single setting from the meta store.
|
||
* @param {string} key
|
||
* @returns {Promise<*>} The value, or undefined if not set
|
||
*/
|
||
async function getSetting(key) {
|
||
return (await (await getDb()).get("meta", "setting:" + key))?.value ?? void 0;
|
||
}
|
||
/**
|
||
* @typedef {import('../models/schema.js').Group} Group
|
||
*/
|
||
/**
|
||
* Add a group.
|
||
* @param {Group} group
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function addGroup(group) {
|
||
await (await getDb()).put("groups", group);
|
||
}
|
||
/**
|
||
* Update a group.
|
||
* @param {Group} group
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function updateGroup(group) {
|
||
await (await getDb()).put("groups", group);
|
||
}
|
||
/**
|
||
* Delete a group (does NOT delete associated entries — they become ungrouped).
|
||
* @param {string} groupId
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function deleteGroup(groupId) {
|
||
if (isTrashGroup(groupId)) throw new Error("Cannot delete the Trash group");
|
||
await (await getDb()).delete("groups", groupId);
|
||
}
|
||
/**
|
||
* Ensure the Trash group exists in the database.
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function ensureTrashGroup() {
|
||
const db = await getDb();
|
||
if (!await db.get("groups", "__trash__")) await db.put("groups", createTrashGroup());
|
||
}
|
||
/**
|
||
* Get all groups, sorted by creation date.
|
||
* @returns {Promise<Group[]>}
|
||
*/
|
||
async function getGroups() {
|
||
return (await (await getDb()).transaction("groups", "readonly").store.getAll()).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||
}
|
||
/**
|
||
* Move an entry to the Trash group.
|
||
* @param {string} entryId
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function moveToTrash(entryId) {
|
||
await ensureTrashGroup();
|
||
const db = await getDb();
|
||
const entry = await db.get("entries", entryId);
|
||
if (!entry) throw new Error("Entry not found");
|
||
entry.groupId = TRASH_GROUP_ID;
|
||
entry.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
||
await db.put("entries", entry);
|
||
}
|
||
/**
|
||
* Permanently delete all entries in the Trash group.
|
||
* @returns {Promise<number>} Number of entries deleted
|
||
*/
|
||
async function emptyTrash() {
|
||
const db = await getDb();
|
||
const trashed = await db.transaction("entries").store.index("groupId").getAll(TRASH_GROUP_ID);
|
||
const tx = db.transaction("entries", "readwrite");
|
||
for (const entry of trashed) await tx.store.delete(entry.id);
|
||
await tx.done;
|
||
return trashed.length;
|
||
}
|
||
/**
|
||
* Restore a trashed entry to its original group (or ungrouped if unknown).
|
||
* @param {string} entryId
|
||
* @param {string} [restoreGroupId] - Group to restore to (default: empty/ungrouped)
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function restoreEntry(entryId, restoreGroupId = "") {
|
||
const db = await getDb();
|
||
const entry = await db.get("entries", entryId);
|
||
if (!entry) throw new Error("Entry not found");
|
||
entry.groupId = restoreGroupId;
|
||
entry.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
||
await db.put("entries", entry);
|
||
}
|
||
/**
|
||
* @typedef {import('../models/schema.js').CredentialEntry} CredentialEntry
|
||
*/
|
||
/**
|
||
* Add an entry.
|
||
* @param {CredentialEntry} entry
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function addEntry(entry) {
|
||
await (await getDb()).put("entries", entry);
|
||
}
|
||
/**
|
||
* Update an entry.
|
||
* @param {CredentialEntry} entry
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function updateEntry(entry) {
|
||
await (await getDb()).put("entries", entry);
|
||
}
|
||
/**
|
||
* Delete an entry.
|
||
* @param {string} entryId
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function deleteEntry(entryId) {
|
||
await (await getDb()).delete("entries", entryId);
|
||
}
|
||
/**
|
||
* Get a single entry by ID.
|
||
* @param {string} entryId
|
||
* @returns {Promise<CredentialEntry | undefined>}
|
||
*/
|
||
async function getEntryById(entryId) {
|
||
return (await getDb()).get("entries", entryId);
|
||
}
|
||
/**
|
||
* Get all entries. Optionally filter by groupId.
|
||
* Results sorted by updatedAt descending (most recent first).
|
||
*
|
||
* @param {Object} [options]
|
||
* @param {string} [options.groupId] - Filter by group (empty string = ungrouped)
|
||
* @returns {Promise<CredentialEntry[]>}
|
||
*/
|
||
async function getEntries(options = {}) {
|
||
const db = await getDb();
|
||
let entries;
|
||
if (options.groupId !== void 0) entries = await db.transaction("entries").store.index("groupId").getAll(options.groupId);
|
||
else entries = await db.getAll("entries");
|
||
return entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||
}
|
||
/**
|
||
* Search entries by query string (matches title, username, url, notes).
|
||
*
|
||
* @param {string} query
|
||
* @param {Object} [options]
|
||
* @param {string} [options.groupId]
|
||
* @returns {Promise<CredentialEntry[]>}
|
||
*/
|
||
async function searchEntries(query, options = {}) {
|
||
const entries = await getEntries(options);
|
||
const lower = query.toLowerCase();
|
||
return entries.filter((e) => e.title.toLowerCase().includes(lower) || e.username.toLowerCase().includes(lower) || e.url && e.url.toLowerCase().includes(lower) || e.notes && e.notes.toLowerCase().includes(lower));
|
||
}
|
||
/**
|
||
* Move an entry to a different group (or ungroup it by passing empty string).
|
||
* @param {string} entryId
|
||
* @param {string} groupId
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function moveEntryToGroup(entryId, groupId) {
|
||
const db = await getDb();
|
||
const entry = await db.get("entries", entryId);
|
||
if (!entry) throw new Error("Entry not found");
|
||
entry.groupId = groupId;
|
||
entry.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
||
await db.put("entries", entry);
|
||
}
|
||
/**
|
||
* Export data (entries + groups + meta) as a JSON object.
|
||
* Entries remain encrypted with the source vault's key. The import function
|
||
* requires the source vault's master password to decrypt and re-encrypt
|
||
* entries under the target vault's key.
|
||
*
|
||
* @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything.
|
||
* Include '' to export ungrouped entries.
|
||
* @returns {Promise<Object>}
|
||
*/
|
||
async function exportSelected(groupIds) {
|
||
const db = await getDb();
|
||
const allEntries = await db.getAll("entries");
|
||
const allGroups = await db.getAll("groups");
|
||
const saltRow = await db.get("meta", "salt");
|
||
const testEncryptedRow = await db.get("meta", "testEncrypted");
|
||
const testPlaintextRow = await db.get("meta", "testPlaintext");
|
||
if (!groupIds || groupIds.length === 0) return {
|
||
version: DB_VERSION,
|
||
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
||
meta: {
|
||
salt: saltRow?.value || null,
|
||
testEncrypted: testEncryptedRow?.value || null,
|
||
testPlaintext: testPlaintextRow?.value || null
|
||
},
|
||
groups: allGroups,
|
||
entries: allEntries
|
||
};
|
||
const entries = allEntries.filter((e) => groupIds.includes(e.groupId));
|
||
const groups = allGroups.filter((g) => groupIds.includes(g.id));
|
||
return {
|
||
version: DB_VERSION,
|
||
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
||
meta: {
|
||
salt: saltRow?.value || null,
|
||
testEncrypted: testEncryptedRow?.value || null,
|
||
testPlaintext: testPlaintextRow?.value || null
|
||
},
|
||
groups,
|
||
entries
|
||
};
|
||
}
|
||
/**
|
||
* Import data from a previously exported JSON object.
|
||
*
|
||
* Requires the source vault's master password to decrypt entries, then
|
||
* re-encrypts them under the target vault's current encryption key.
|
||
* The target vault's meta (salt, test payload) is never overwritten.
|
||
*
|
||
* @param {Object} data
|
||
* @param {'merge'|'replace'} mode - 'merge' adds to existing, 'replace' clears first
|
||
* @param {string} sourcePassword - Master password of the source vault
|
||
* @param {CryptoKey} targetKey - Current encryption key of the target vault
|
||
* @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
|
||
*/
|
||
async function importAll(data, mode = "merge", sourcePassword = "", targetKey = null) {
|
||
if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) throw new Error("Invalid import data format");
|
||
let sourceKey = null;
|
||
if (data.meta?.salt && sourcePassword) sourceKey = await deriveKey(sourcePassword, base64ToUint8Array(data.meta.salt));
|
||
const db = await getDb();
|
||
if (mode === "replace") {
|
||
await db.clear("entries");
|
||
await db.clear("groups");
|
||
}
|
||
let skipped = 0;
|
||
let importedEntries = 0;
|
||
let importedGroups = 0;
|
||
for (const group of data.groups) try {
|
||
await db.put("groups", group);
|
||
importedGroups++;
|
||
} catch {
|
||
skipped++;
|
||
}
|
||
for (const entry of data.entries) try {
|
||
let reencryptedEntry = { ...entry };
|
||
if (sourceKey && targetKey && entry.encryptedPassword) reencryptedEntry.encryptedPassword = await encrypt(await decrypt(entry.encryptedPassword, sourceKey), targetKey);
|
||
else if (!sourceKey || !targetKey) {
|
||
console.warn("Skipping entry (missing source password or target key):", entry.title);
|
||
skipped++;
|
||
continue;
|
||
}
|
||
await db.put("entries", reencryptedEntry);
|
||
importedEntries++;
|
||
} catch (e) {
|
||
console.warn("Failed to import entry:", entry.title, e);
|
||
skipped++;
|
||
}
|
||
return {
|
||
imported: {
|
||
entries: importedEntries,
|
||
groups: importedGroups
|
||
},
|
||
skipped
|
||
};
|
||
}
|
||
//#endregion
|
||
//#region src/lib/stores/settings.js
|
||
/**
|
||
* Reactive settings store for vault security preferences.
|
||
*
|
||
* Settings are persisted in IndexedDB (meta store) so they survive
|
||
* page reloads. Defaults are used until the user explicitly saves.
|
||
*/
|
||
var SettingsStore = class extends Store {
|
||
constructor() {
|
||
super({
|
||
autoLockMinutes: 5,
|
||
lockOnTabSwitch: true
|
||
});
|
||
}
|
||
/**
|
||
* Load persisted settings from IndexedDB.
|
||
* Falls back to defaults for any missing keys.
|
||
*/
|
||
async load() {
|
||
const minutes = await getSetting("autoLockMinutes");
|
||
const tabSwitch = await getSetting("lockOnTabSwitch");
|
||
this.autoLockMinutes = minutes != null ? Number(minutes) : 5;
|
||
this.lockOnTabSwitch = tabSwitch != null ? Boolean(tabSwitch) : true;
|
||
}
|
||
/**
|
||
* Persist current settings to IndexedDB.
|
||
*/
|
||
async save() {
|
||
await saveSetting("autoLockMinutes", this.autoLockMinutes);
|
||
await saveSetting("lockOnTabSwitch", this.lockOnTabSwitch);
|
||
}
|
||
};
|
||
var settings = new SettingsStore();
|
||
//#endregion
|
||
//#region src/lib/stores/security.js
|
||
/**
|
||
* Security utilities: auto-lock timer, visibility change detection, cleanup.
|
||
*/
|
||
var autoLockTimer = null;
|
||
var activityHandler = null;
|
||
var activityEvents = null;
|
||
/**
|
||
* Start (or restart) the auto-lock timer using current settings.
|
||
*/
|
||
function startAutoLock() {
|
||
resetAutoLock();
|
||
if (!activityHandler) {
|
||
activityEvents = [
|
||
"mousedown",
|
||
"keydown",
|
||
"scroll",
|
||
"touchstart"
|
||
];
|
||
activityHandler = () => resetAutoLock();
|
||
activityEvents.forEach((evt) => window.addEventListener(evt, activityHandler, { passive: true }));
|
||
}
|
||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||
window.addEventListener("beforeunload", clearKeyOnExit);
|
||
window.__vaultCleanup = stopAutoLock;
|
||
}
|
||
/**
|
||
* Reset the auto-lock timer using the current settings value.
|
||
*/
|
||
function resetAutoLock() {
|
||
if (autoLockTimer) clearTimeout(autoLockTimer);
|
||
const minutes = settings.autoLockMinutes ?? 5;
|
||
autoLockTimer = setTimeout(() => {
|
||
app$1.lockVault();
|
||
}, minutes * 60 * 1e3);
|
||
}
|
||
/**
|
||
* Handle visibility change — lock when user switches away from tab
|
||
* (only if lockOnTabSwitch is enabled).
|
||
*/
|
||
function handleVisibilityChange() {
|
||
if (document.hidden && app$1.isUnlocked && settings.lockOnTabSwitch) app$1.lockVault();
|
||
}
|
||
/**
|
||
* Clear the encryption key when the page is closing.
|
||
*/
|
||
function clearKeyOnExit() {
|
||
app$1.encryptionKey = null;
|
||
}
|
||
/**
|
||
* Stop auto-lock and remove all listeners.
|
||
*/
|
||
function stopAutoLock() {
|
||
if (autoLockTimer) {
|
||
clearTimeout(autoLockTimer);
|
||
autoLockTimer = null;
|
||
}
|
||
if (activityHandler && activityEvents) {
|
||
activityEvents.forEach((evt) => window.removeEventListener(evt, activityHandler));
|
||
activityHandler = null;
|
||
activityEvents = null;
|
||
}
|
||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||
window.removeEventListener("beforeunload", clearKeyOnExit);
|
||
}
|
||
//#endregion
|
||
//#region src/lib/stores/app.js
|
||
/**
|
||
* App-level reactive state.
|
||
*/
|
||
var AppStore = class 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;
|
||
}
|
||
};
|
||
var app$1 = new AppStore();
|
||
//#endregion
|
||
//#region src/lib/autofocus.js
|
||
/**
|
||
* Focus an element after mount when the condition is truthy.
|
||
* Uses a microtask to ensure the element is fully rendered.
|
||
*/
|
||
function autofocus(el, condition = true) {
|
||
if (el && condition) queueMicrotask(() => el.focus());
|
||
}
|
||
//#endregion
|
||
//#region src/components/LockScreen.js
|
||
/**
|
||
* LockScreen — master password setup + unlock UI.
|
||
*/
|
||
var LockScreen = class extends Component {
|
||
masterPassword = "";
|
||
confirmPassword = "";
|
||
error = "";
|
||
loading = false;
|
||
isSetup = false;
|
||
mount() {
|
||
super.mount();
|
||
this.#checkVault();
|
||
return this;
|
||
}
|
||
render() {
|
||
const notLocal = typeof window !== "undefined" && window.location.protocol !== "file:";
|
||
this.el = this.ce("div", { className: "lock-screen" }, this.ce("div", { className: "lock-card" }, this.ce("div", {
|
||
className: "lock-icon",
|
||
textContent: "🔐"
|
||
}), this.ce("h1", { textContent: "Password Vault" }), this.ce("p", {
|
||
className: "subtitle",
|
||
textContent: "Unlock your vault"
|
||
}), notLocal ? this.ce("div", {
|
||
className: "warning-banner",
|
||
role: "alert",
|
||
textContent: "This HTML file is intended for offline use."
|
||
}) : null, null, this.#buildForm(), this.ce("p", {
|
||
className: "hint",
|
||
textContent: "Your data is encrypted with AES-256-GCM. Key is stored only in memory."
|
||
})));
|
||
this._subtitle = this.q(".subtitle");
|
||
this._confirmGroup = this.q(".confirm-group");
|
||
this._submitBtn = this.q(".submit-btn");
|
||
this._passwordInput = this.q("#master-password");
|
||
this._confirmInput = this.q("#confirm-password");
|
||
this._hint = this.q(".hint");
|
||
autofocus(this._passwordInput, true);
|
||
if (this._passwordInput) this.on(this._passwordInput, "input", (e) => {
|
||
this.masterPassword = e.target.value;
|
||
});
|
||
if (this._confirmInput) this.on(this._confirmInput, "input", (e) => {
|
||
this.confirmPassword = e.target.value;
|
||
});
|
||
return this.el;
|
||
}
|
||
#buildForm() {
|
||
return this.ce("form", {
|
||
className: "lock-form",
|
||
id: "lock-form"
|
||
}, this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
htmlFor: "master-password",
|
||
textContent: "Master Password"
|
||
}), this.ce("input", {
|
||
id: "master-password",
|
||
type: "password",
|
||
placeholder: "Enter master password",
|
||
autocomplete: "current-password"
|
||
})), this.ce("div", { className: "form-group confirm-group" }, this.ce("label", {
|
||
htmlFor: "confirm-password",
|
||
textContent: "Confirm Password"
|
||
}), this.ce("input", {
|
||
id: "confirm-password",
|
||
type: "password",
|
||
placeholder: "Confirm master password",
|
||
autocomplete: "new-password"
|
||
})), this.ce("button", {
|
||
type: "submit",
|
||
className: "btn btn-primary w-full submit-btn",
|
||
textContent: "Unlock"
|
||
}));
|
||
}
|
||
#updateUI() {
|
||
if (this._subtitle) this._subtitle.textContent = this.isSetup ? "Create your vault" : "Unlock your vault";
|
||
if (this._confirmGroup) this._confirmGroup.style.display = this.isSetup ? "" : "none";
|
||
if (this._submitBtn) {
|
||
this._submitBtn.textContent = this.loading ? "Processing..." : this.isSetup ? "Create Vault" : "Unlock";
|
||
this._submitBtn.disabled = this.loading;
|
||
}
|
||
if (this._passwordInput) this._passwordInput.disabled = this.loading;
|
||
if (this._confirmInput) this._confirmInput.disabled = this.loading;
|
||
if (this.error) if (!this._errorBanner) {
|
||
const banner = this.ce("div", {
|
||
className: "error-banner",
|
||
role: "alert",
|
||
textContent: this.error
|
||
});
|
||
const form = this.q("#lock-form");
|
||
form?.parentNode?.insertBefore(banner, form);
|
||
this._errorBanner = banner;
|
||
} else this._errorBanner.textContent = this.error;
|
||
else if (this._errorBanner) {
|
||
this._errorBanner.remove();
|
||
this._errorBanner = null;
|
||
}
|
||
if (this._hint) this._hint.textContent = this.isSetup ? "Your master password encrypts all data locally. It cannot be recovered if lost." : "Your data is encrypted with AES-256-GCM. Key is stored only in memory.";
|
||
}
|
||
#checkVault() {
|
||
isVaultInitialized().then((init) => {
|
||
this.isSetup = !init;
|
||
this.#updateUI();
|
||
});
|
||
}
|
||
#handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
this.error = "";
|
||
this.loading = true;
|
||
this.#updateUI();
|
||
try {
|
||
if (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$1.salt = salt;
|
||
app$1.encryptionKey = await deriveKey(this.masterPassword, salt);
|
||
await saveVaultMeta(salt, testEncrypted, testPlaintext);
|
||
await ensureTrashGroup();
|
||
await settings.load();
|
||
app$1.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);
|
||
if (!await verifyPassword(this.masterPassword, meta.salt, meta.testEncrypted, meta.testPlaintext)) {
|
||
this.error = "Incorrect password";
|
||
this.loading = false;
|
||
this.#updateUI();
|
||
return;
|
||
}
|
||
app$1.salt = meta.salt;
|
||
app$1.encryptionKey = key;
|
||
await settings.load();
|
||
app$1.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);
|
||
}
|
||
};
|
||
//#endregion
|
||
//#region src/lib/stores/search.js
|
||
/**
|
||
* Search and filter state.
|
||
* Shared between Sidebar and EntryList for coordinated filtering.
|
||
*/
|
||
var DEBOUNCE_MS = 300;
|
||
var SearchStore = class extends Store {
|
||
#debounceTimer = null;
|
||
constructor() {
|
||
super({
|
||
query: "",
|
||
debouncedQuery: "",
|
||
activeGroupId: "all",
|
||
refreshTrigger: 0
|
||
});
|
||
}
|
||
/**
|
||
* Update the search query with debouncing.
|
||
* Call this from the input handler instead of setting `query` directly.
|
||
*/
|
||
setSearchQuery(value) {
|
||
this.query = value;
|
||
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
|
||
if (value === "") {
|
||
this.debouncedQuery = "";
|
||
this.#debounceTimer = null;
|
||
} else this.#debounceTimer = setTimeout(() => {
|
||
this.debouncedQuery = value;
|
||
this.#debounceTimer = null;
|
||
}, DEBOUNCE_MS);
|
||
}
|
||
clear() {
|
||
this.query = "";
|
||
this.debouncedQuery = "";
|
||
this.activeGroupId = "all";
|
||
}
|
||
/** Force subscribed components to re-fetch data. */
|
||
refresh() {
|
||
this.refreshTrigger++;
|
||
}
|
||
};
|
||
var search = new SearchStore();
|
||
//#endregion
|
||
//#region src/components/Sidebar.js
|
||
/**
|
||
* Sidebar — group list + search bar + group management.
|
||
*/
|
||
var Sidebar = class extends Component {
|
||
groups = [];
|
||
showGroupForm = false;
|
||
editingGroupId = null;
|
||
groupName = "";
|
||
groupColor = "#6c63ff";
|
||
groupError = "";
|
||
showDeleteGroupConfirm = null;
|
||
dragOverGroupId = null;
|
||
droppedGroupId = null;
|
||
mount() {
|
||
super.mount();
|
||
this.subscribe(search, "refreshTrigger", () => this.#loadData());
|
||
this.subscribe(search, "activeGroupId", () => {
|
||
this.#renderGroups();
|
||
this.#updateTrashButton();
|
||
});
|
||
const nav = this.q(".groups-nav");
|
||
if (nav) {
|
||
this.on(nav, "click", (e) => {
|
||
const editBtn = e.target.closest(".group-action-btn[title=\"Edit group\"]");
|
||
if (editBtn) {
|
||
e.stopPropagation();
|
||
const groupId = editBtn.closest(".group-row")?.querySelector("[data-group-id]")?.dataset.groupId;
|
||
const group = this.groups.find((g) => g.id === groupId);
|
||
if (group) this.#openGroupForm(group);
|
||
return;
|
||
}
|
||
const delBtn = e.target.closest(".group-action-btn[title=\"Delete group\"]");
|
||
if (delBtn) {
|
||
e.stopPropagation();
|
||
const groupId = delBtn.closest(".group-row")?.querySelector("[data-group-id]")?.dataset.groupId;
|
||
if (groupId) {
|
||
this.showDeleteGroupConfirm = groupId;
|
||
this.#renderDeleteModal();
|
||
}
|
||
return;
|
||
}
|
||
const groupBtn = e.target.closest(".group-item[data-group-id]");
|
||
if (groupBtn) {
|
||
search.activeGroupId = groupBtn.dataset.groupId;
|
||
return;
|
||
}
|
||
if (e.target.closest("#all-entries-btn")) search.activeGroupId = "all";
|
||
});
|
||
this.on(nav, "dragover", (e) => {
|
||
const btn = e.target.closest(".group-item[data-group-id]");
|
||
if (btn) {
|
||
const gid = btn.dataset.groupId;
|
||
if (this.#canDrop(gid)) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
this.dragOverGroupId = gid;
|
||
btn.classList.add("drag-over");
|
||
}
|
||
}
|
||
});
|
||
this.on(nav, "dragleave", (e) => {
|
||
const btn = e.target.closest(".group-item[data-group-id]");
|
||
if (btn && this.dragOverGroupId === btn.dataset.groupId) {
|
||
this.dragOverGroupId = null;
|
||
btn.classList.remove("drag-over");
|
||
}
|
||
});
|
||
this.on(nav, "drop", (e) => {
|
||
e.preventDefault();
|
||
const btn = e.target.closest(".group-item[data-group-id]");
|
||
if (btn) {
|
||
btn.classList.remove("drag-over");
|
||
const gid = btn.dataset.groupId;
|
||
if (this.#canDrop(gid)) {
|
||
const entryId = e.dataTransfer.getData("text/plain");
|
||
if (entryId) this.#handleDrop(gid, entryId);
|
||
}
|
||
}
|
||
this.dragOverGroupId = null;
|
||
});
|
||
}
|
||
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" }), this.ce("div", { className: "trash-section" }, this.ce("button", {
|
||
className: "group-item trash-btn",
|
||
id: "trash-btn"
|
||
}, this.ce("span", {
|
||
className: "group-color",
|
||
style: `background-color: ${TRASH_GROUP_COLOR}`
|
||
}), this.ce("span", {
|
||
className: "group-name",
|
||
textContent: TRASH_GROUP_NAME
|
||
}))), this.ce("div", { className: "sidebar-footer" }, this.ce("button", {
|
||
className: "btn btn-ghost btn-sm w-full",
|
||
id: "new-group-btn",
|
||
textContent: "+ New Group"
|
||
})));
|
||
const searchInput = this.q("#sidebar-search");
|
||
if (searchInput) this.on(searchInput, "input", (e) => search.setSearchQuery(e.target.value));
|
||
const trashBtn = this.q("#trash-btn");
|
||
if (trashBtn) this.on(trashBtn, "click", () => {
|
||
search.activeGroupId = "trash";
|
||
});
|
||
const newGroupBtn = this.q("#new-group-btn");
|
||
if (newGroupBtn) this.on(newGroupBtn, "click", () => this.#openGroupForm(null));
|
||
this.#renderGroups();
|
||
this.#updateTrashButton();
|
||
return this.el;
|
||
}
|
||
#renderGroups() {
|
||
const nav = this.q(".groups-nav");
|
||
if (!nav) return;
|
||
nav.innerHTML = "";
|
||
const allBtn = this.ce("button", {
|
||
className: `group-item${search.activeGroupId === "all" ? " active" : ""}`,
|
||
id: "all-entries-btn"
|
||
}, this.ce("span", {
|
||
className: "group-icon",
|
||
textContent: "📋"
|
||
}), this.ce("span", {
|
||
className: "group-name",
|
||
textContent: "All Entries"
|
||
}));
|
||
nav.appendChild(allBtn);
|
||
for (const group of this.groups) {
|
||
if (isTrashGroup(group.id)) continue;
|
||
const row = this.ce("div", { className: "group-row" });
|
||
const btn = this.ce("button", {
|
||
className: `group-item${search.activeGroupId === group.id ? " active" : ""}`,
|
||
"data-group-id": group.id
|
||
}, this.ce("span", {
|
||
className: "group-color",
|
||
style: `background-color: ${group.color || "#6c63ff"}`
|
||
}), this.ce("span", {
|
||
className: "group-name",
|
||
textContent: group.name
|
||
}), this.ce("span", {
|
||
className: "drop-icon",
|
||
textContent: "📥"
|
||
}));
|
||
row.appendChild(btn);
|
||
const actions = this.ce("div", { className: "group-actions" });
|
||
actions.appendChild(this.ce("button", {
|
||
className: "group-action-btn",
|
||
title: "Edit group",
|
||
textContent: "✏️"
|
||
}));
|
||
actions.appendChild(this.ce("button", {
|
||
className: "group-action-btn",
|
||
title: "Delete group",
|
||
textContent: "🗑"
|
||
}));
|
||
row.appendChild(actions);
|
||
nav.appendChild(row);
|
||
}
|
||
}
|
||
#renderDeleteModal() {
|
||
const existing = this.q(".sb-modal-overlay");
|
||
if (existing) existing.remove();
|
||
if (!this.showDeleteGroupConfirm) return;
|
||
const group = this.groups.find((g) => g.id === this.showDeleteGroupConfirm);
|
||
if (!group) return;
|
||
const overlay = this.ce("div", {
|
||
className: "sb-modal-overlay",
|
||
role: "presentation"
|
||
}, this.ce("div", {
|
||
className: "sb-modal",
|
||
role: "dialog",
|
||
"aria-modal": "true",
|
||
"aria-label": "Delete group confirmation",
|
||
tabindex: "-1"
|
||
}, this.ce("h3", { textContent: "Delete Group" }), this.ce("p", {}, this.text(`Delete "`), this.ce("strong", { textContent: group.name }), this.text(`"? Entries in this group will become ungrouped.`)), this.ce("div", { className: "sb-modal-actions" }, this.ce("button", {
|
||
className: "btn btn-danger",
|
||
id: "confirm-delete-group-btn",
|
||
textContent: "Yes, delete"
|
||
}), this.ce("button", {
|
||
className: "btn btn-ghost",
|
||
id: "cancel-delete-group-btn",
|
||
textContent: "Cancel"
|
||
}))));
|
||
this.el.appendChild(overlay);
|
||
this.on(overlay, "click", () => {
|
||
this.showDeleteGroupConfirm = null;
|
||
overlay.remove();
|
||
});
|
||
this.on(overlay.querySelector(".sb-modal"), "click", (e) => e.stopPropagation());
|
||
this.on(overlay.querySelector("#confirm-delete-group-btn"), "click", () => this.#confirmDeleteGroup(this.showDeleteGroupConfirm));
|
||
this.on(overlay.querySelector("#cancel-delete-group-btn"), "click", () => {
|
||
this.showDeleteGroupConfirm = null;
|
||
overlay.remove();
|
||
});
|
||
}
|
||
#renderGroupFormModal() {
|
||
const existing = this.q(".sb-modal-overlay");
|
||
if (existing) existing.remove();
|
||
if (!this.showGroupForm) return;
|
||
const overlay = this.ce("div", {
|
||
className: "sb-modal-overlay",
|
||
role: "presentation"
|
||
}, this.ce("div", {
|
||
className: "sb-modal",
|
||
role: "dialog",
|
||
"aria-modal": "true",
|
||
"aria-label": "Group settings",
|
||
tabindex: "-1"
|
||
}, this.ce("h3", { textContent: this.editingGroupId ? "Edit Group" : "New Group" }), this.groupError ? this.ce("div", {
|
||
className: "ie-error-banner",
|
||
textContent: this.groupError
|
||
}) : null, this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
htmlFor: "group-name",
|
||
textContent: "Group Name"
|
||
}), this.ce("input", {
|
||
id: "group-name",
|
||
type: "text",
|
||
placeholder: "e.g. Work, Personal",
|
||
value: this.groupName
|
||
})), this.ce("div", { className: "form-group" }, this.ce("span", {
|
||
className: "field-label",
|
||
textContent: "Color"
|
||
}), this.#buildColorPicker()), this.ce("div", { className: "sb-modal-actions" }, this.ce("button", {
|
||
className: "btn btn-primary",
|
||
id: "save-group-btn",
|
||
textContent: this.editingGroupId ? "Update" : "Create"
|
||
}), this.ce("button", {
|
||
className: "btn btn-ghost",
|
||
id: "cancel-group-btn",
|
||
textContent: "Cancel"
|
||
}))));
|
||
this.el.appendChild(overlay);
|
||
this.on(overlay, "click", () => {
|
||
this.showGroupForm = false;
|
||
overlay.remove();
|
||
});
|
||
this.on(overlay.querySelector(".sb-modal"), "click", (e) => e.stopPropagation());
|
||
this.on(overlay.querySelector("#save-group-btn"), "click", () => this.#saveGroup());
|
||
this.on(overlay.querySelector("#cancel-group-btn"), "click", () => {
|
||
this.showGroupForm = false;
|
||
overlay.remove();
|
||
});
|
||
const nameInput = overlay.querySelector("#group-name");
|
||
if (nameInput) {
|
||
this.groupName = nameInput.value;
|
||
this.on(nameInput, "input", (e) => {
|
||
this.groupName = e.target.value;
|
||
});
|
||
if (!this.editingGroupId) autofocus(nameInput, true);
|
||
}
|
||
}
|
||
#buildColorPicker() {
|
||
const picker = this.ce("div", { className: "color-picker" });
|
||
for (const color of GROUP_COLORS) {
|
||
const swatch = this.ce("button", {
|
||
className: `color-swatch${this.groupColor === color ? " selected" : ""}`,
|
||
style: `background-color: ${color}`,
|
||
title: color,
|
||
type: "button"
|
||
});
|
||
const c = color;
|
||
this.on(swatch, "click", () => {
|
||
this.groupColor = c;
|
||
this.qa(".color-swatch").forEach((s) => s.classList.remove("selected"));
|
||
swatch.classList.add("selected");
|
||
});
|
||
picker.appendChild(swatch);
|
||
}
|
||
return picker;
|
||
}
|
||
#openGroupForm(group) {
|
||
if (group) {
|
||
this.editingGroupId = group.id;
|
||
this.groupName = group.name;
|
||
this.groupColor = group.color || "#6c63ff";
|
||
} else {
|
||
this.editingGroupId = null;
|
||
this.groupName = "";
|
||
this.groupColor = GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)];
|
||
}
|
||
this.groupError = "";
|
||
this.showGroupForm = true;
|
||
this.#renderGroupFormModal();
|
||
}
|
||
async #saveGroup() {
|
||
this.groupError = "";
|
||
const nameInput = this.q("#group-name");
|
||
if (nameInput) this.groupName = nameInput.value;
|
||
const validation = validateGroup(this.groupName);
|
||
if (!validation.valid) {
|
||
this.groupError = validation.errors[0];
|
||
this.#renderGroupFormModal();
|
||
return;
|
||
}
|
||
try {
|
||
if (this.editingGroupId) await updateGroup({
|
||
...this.groups.find((g) => g.id === this.editingGroupId),
|
||
name: this.groupName.trim(),
|
||
color: this.groupColor
|
||
});
|
||
else await addGroup(createGroup(this.groupName, this.groupColor));
|
||
this.showGroupForm = false;
|
||
const overlay = this.q(".sb-modal-overlay");
|
||
if (overlay) overlay.remove();
|
||
await this.#loadData();
|
||
} catch (e) {
|
||
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;
|
||
const btn = this.q(`[data-group-id="${groupId}"]`);
|
||
if (btn) {
|
||
btn.classList.add("dropped");
|
||
setTimeout(() => btn.classList.remove("dropped"), 600);
|
||
}
|
||
await this.#loadData();
|
||
search.refresh();
|
||
} catch (e) {}
|
||
}
|
||
#canDrop(groupId) {
|
||
return groupId !== search.activeGroupId && !isTrashGroup(groupId);
|
||
}
|
||
async #loadData() {
|
||
await ensureTrashGroup();
|
||
this.groups = await getGroups();
|
||
this.#renderGroups();
|
||
}
|
||
};
|
||
//#endregion
|
||
//#region src/components/EntryList.js
|
||
/**
|
||
* EntryList — credential entries grid with search/filter support.
|
||
*/
|
||
var EntryList = class extends Component {
|
||
entries = [];
|
||
loading = true;
|
||
error = "";
|
||
resultCount = 0;
|
||
dragging = false;
|
||
/** @param {{ onSelect: Function, onAdd: Function }} props */
|
||
constructor(container, props = {}) {
|
||
super(container);
|
||
this.onSelect = props.onSelect || (() => {});
|
||
this.onAdd = props.onAdd || (() => {});
|
||
}
|
||
mount() {
|
||
super.mount();
|
||
this.subscribe(search, "debouncedQuery", () => this.#loadEntries());
|
||
this.subscribe(search, "activeGroupId", () => this.#loadEntries());
|
||
this.subscribe(search, "refreshTrigger", () => this.#loadEntries());
|
||
this.#loadEntries();
|
||
return this;
|
||
}
|
||
render() {
|
||
this.el = this.ce("div", { className: "entry-list" });
|
||
this.#renderContent();
|
||
return this.el;
|
||
}
|
||
#renderContent() {
|
||
this.el.innerHTML = "";
|
||
const isTrashView = search.activeGroupId === "trash";
|
||
if (this.loading) {
|
||
this.el.appendChild(this.ce("div", {
|
||
className: "loading",
|
||
textContent: "Loading entries..."
|
||
}));
|
||
return;
|
||
}
|
||
if (this.error) {
|
||
this.el.appendChild(this.ce("div", {
|
||
className: "error-banner",
|
||
textContent: this.error
|
||
}));
|
||
return;
|
||
}
|
||
if (this.entries.length === 0) {
|
||
const emptyState = this.ce("div", { className: "empty-state" });
|
||
const icon = search.query ? "🔍" : isTrashView ? "🗑" : "🔑";
|
||
const text = search.query ? "No results found" : isTrashView ? "Trash is empty" : "No entries yet";
|
||
const hint = search.query ? "Try a different search term" : isTrashView ? "Deleted entries will appear here" : "Add your first login credential to get started";
|
||
emptyState.appendChild(this.ce("p", {
|
||
className: "empty-icon",
|
||
textContent: icon
|
||
}));
|
||
emptyState.appendChild(this.ce("p", {
|
||
className: "empty-text",
|
||
textContent: text
|
||
}));
|
||
emptyState.appendChild(this.ce("p", {
|
||
className: "empty-hint",
|
||
textContent: hint
|
||
}));
|
||
if (!search.query && !isTrashView) {
|
||
const addBtn = this.ce("button", {
|
||
className: "btn btn-primary mt-3",
|
||
textContent: "+ New Entry"
|
||
});
|
||
this.on(addBtn, "click", () => this.onAdd());
|
||
emptyState.appendChild(addBtn);
|
||
}
|
||
this.el.appendChild(emptyState);
|
||
return;
|
||
}
|
||
const info = this.ce("div", { className: "results-info" });
|
||
const countSpan = this.ce("span", { className: "text-sm text-muted" });
|
||
countSpan.appendChild(this.text(`${this.resultCount} entr${this.resultCount === 1 ? "y" : "ies"}`));
|
||
if (search.query) {
|
||
countSpan.appendChild(this.text(" matching \""));
|
||
const strong = document.createElement("strong");
|
||
strong.textContent = this.#escapeHtml(search.query);
|
||
countSpan.appendChild(strong);
|
||
countSpan.appendChild(this.text("\""));
|
||
}
|
||
info.appendChild(countSpan);
|
||
this.el.appendChild(info);
|
||
const table = this.ce("table", { className: "entries-table" }, this.ce("thead", null, this.ce("tr", null, this.ce("th", { textContent: "Title" }), this.ce("th", { textContent: "Username" }), this.ce("th", { textContent: "URL" }), this.ce("th", { textContent: "Notes" }), isTrashView ? this.ce("th", { style: "width: 60px" }) : null)), this.ce("tbody"));
|
||
const tbody = table.querySelector("tbody");
|
||
for (const entry of this.entries) {
|
||
const tr = this.ce("tr", {
|
||
className: `entry-row${this.dragging ? " dragging" : ""}`,
|
||
draggable: !isTrashView
|
||
});
|
||
const tdTitle = this.ce("td");
|
||
if (!isTrashView) tdTitle.appendChild(this.ce("span", {
|
||
className: "drag-handle",
|
||
"aria-hidden": "true",
|
||
textContent: "⠿"
|
||
}));
|
||
tdTitle.appendChild(this.ce("span", {
|
||
className: "entry-title",
|
||
textContent: entry.title
|
||
}));
|
||
tr.appendChild(tdTitle);
|
||
tr.appendChild(this.ce("td", null, this.ce("span", {
|
||
className: "entry-username",
|
||
textContent: entry.username || "—"
|
||
})));
|
||
tr.appendChild(this.ce("td", null, this.ce("span", {
|
||
className: "entry-url truncate",
|
||
textContent: entry.url || "—"
|
||
})));
|
||
const tdNotes = this.ce("td");
|
||
if (entry.notes) {
|
||
const tooltip = this.ce("div", { className: "notes-tooltip" }, this.ce("span", {
|
||
className: "notes-icon",
|
||
title: entry.notes,
|
||
textContent: "🔍"
|
||
}), this.ce("div", {
|
||
className: "tooltip-popup",
|
||
textContent: entry.notes
|
||
}));
|
||
tdNotes.appendChild(tooltip);
|
||
} else tdNotes.appendChild(this.ce("span", { textContent: "—" }));
|
||
tr.appendChild(tdNotes);
|
||
if (isTrashView) {
|
||
const tdRestore = this.ce("td");
|
||
const eid = entry.id;
|
||
const restoreBtn = this.ce("button", {
|
||
className: "btn btn-ghost btn-sm restore-btn",
|
||
title: "Restore entry",
|
||
textContent: "↩️"
|
||
});
|
||
this.on(restoreBtn, "click", (e) => {
|
||
e.stopPropagation();
|
||
this.#handleRestore(eid);
|
||
});
|
||
tdRestore.appendChild(restoreBtn);
|
||
tr.appendChild(tdRestore);
|
||
}
|
||
const eid = entry.id;
|
||
this.on(tr, "click", () => this.onSelect(eid));
|
||
if (!isTrashView) {
|
||
this.on(tr, "dragstart", (e) => {
|
||
this.dragging = true;
|
||
e.dataTransfer.setData("text/plain", eid);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
});
|
||
this.on(tr, "dragend", () => {
|
||
this.dragging = false;
|
||
});
|
||
}
|
||
tbody.appendChild(tr);
|
||
}
|
||
this.el.appendChild(table);
|
||
}
|
||
async #loadEntries() {
|
||
this.loading = true;
|
||
this.error = "";
|
||
try {
|
||
const query = search.debouncedQuery.trim();
|
||
const groupId = search.activeGroupId;
|
||
const resolvedGroupId = groupId === "trash" ? TRASH_GROUP_ID : groupId;
|
||
if (query) {
|
||
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;
|
||
}
|
||
};
|
||
//#endregion
|
||
//#region src/components/EntryDetail.js
|
||
/**
|
||
* EntryDetail — view single entry with copy-to-clipboard, trash, and permanent delete.
|
||
*/
|
||
var EntryDetail = class extends Component {
|
||
/** @param {{ entryId: string, onEdit: Function, onBack: Function }} props */
|
||
constructor(container, props = {}) {
|
||
super(container);
|
||
this.entryId = props.entryId;
|
||
this.onEdit = props.onEdit || (() => {});
|
||
this.onBack = props.onBack || (() => {});
|
||
this.entry = null;
|
||
this.passwordVisible = false;
|
||
this.decryptedPassword = "";
|
||
this.loading = true;
|
||
this.error = "";
|
||
this.showDeleteConfirm = false;
|
||
this.showPermanentDeleteConfirm = false;
|
||
this.deleting = false;
|
||
this.toast = "";
|
||
this.toastTimer = null;
|
||
}
|
||
mount() {
|
||
super.mount();
|
||
this.#loadEntry();
|
||
return this;
|
||
}
|
||
render() {
|
||
this.el = this.ce("div", { className: "entry-detail" });
|
||
this.#renderContent();
|
||
return this.el;
|
||
}
|
||
#renderContent() {
|
||
this.el.innerHTML = "";
|
||
if (this.toast) this.el.appendChild(this.ce("div", {
|
||
className: "toast",
|
||
textContent: this.toast
|
||
}));
|
||
if (this.loading) {
|
||
this.el.appendChild(this.ce("div", {
|
||
className: "loading",
|
||
textContent: "Loading..."
|
||
}));
|
||
return;
|
||
}
|
||
if (this.error) {
|
||
this.el.appendChild(this.ce("div", {
|
||
className: "error-banner",
|
||
textContent: this.error
|
||
}));
|
||
return;
|
||
}
|
||
if (!this.entry) {
|
||
this.el.appendChild(this.ce("div", {
|
||
className: "empty-state",
|
||
textContent: "Entry not found"
|
||
}));
|
||
return;
|
||
}
|
||
const isInTrash = isTrashGroup(this.entry.groupId);
|
||
const card = this.ce("div", { className: "detail-card" });
|
||
const header = this.ce("div", { className: "detail-header" }, this.ce("h2", { textContent: this.entry.title }), this.ce("div", { className: "header-actions" }, isInTrash ? this.ce("button", {
|
||
className: "btn btn-primary btn-sm",
|
||
id: "restore-btn",
|
||
textContent: "↩️ Restore"
|
||
}) : this.ce("button", {
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "edit-btn",
|
||
textContent: "✏️ Edit"
|
||
}), isInTrash ? this.ce("button", {
|
||
className: "btn btn-danger btn-sm",
|
||
id: "perm-delete-btn",
|
||
textContent: "🗑 Delete Forever"
|
||
}) : this.ce("button", {
|
||
className: "btn btn-danger btn-sm",
|
||
id: "trash-btn",
|
||
textContent: "🗑 Move to Trash"
|
||
})));
|
||
card.appendChild(header);
|
||
const fields = this.ce("div", { className: "detail-fields" });
|
||
if (this.entry.username) fields.appendChild(this.ce("div", { className: "detail-field" }, this.ce("span", {
|
||
className: "field-label",
|
||
textContent: "Username"
|
||
}), this.ce("div", { className: "field-value" }, this.ce("span", { textContent: this.entry.username }), this.ce("button", {
|
||
className: "btn btn-ghost btn-sm copy-btn",
|
||
textContent: "📋",
|
||
title: "Copy username",
|
||
"data-copy": this.entry.username
|
||
}))));
|
||
fields.appendChild(this.ce("div", { className: "detail-field" }, this.ce("span", {
|
||
className: "field-label",
|
||
textContent: "Password"
|
||
}), this.ce("div", { className: "field-value" }, this.ce("span", {
|
||
id: "pwd-display",
|
||
textContent: this.passwordVisible ? this.decryptedPassword : "••••••••••••"
|
||
}), this.ce("button", {
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "toggle-pwd",
|
||
textContent: this.passwordVisible ? "🙈" : "👁",
|
||
title: "Toggle visibility"
|
||
}), this.ce("button", {
|
||
className: "btn btn-ghost btn-sm copy-btn",
|
||
textContent: "📋",
|
||
title: "Copy password",
|
||
"data-copy": this.decryptedPassword
|
||
}))));
|
||
if (this.entry.url) {
|
||
const urlField = this.ce("div", { className: "detail-field" }, this.ce("span", {
|
||
className: "field-label",
|
||
textContent: "URL"
|
||
}), this.ce("div", { className: "field-value" }, this.ce("a", {
|
||
href: this.entry.url,
|
||
target: "_blank",
|
||
rel: "noopener noreferrer",
|
||
textContent: this.entry.url
|
||
}), this.ce("button", {
|
||
className: "btn btn-ghost btn-sm copy-btn",
|
||
textContent: "📋",
|
||
title: "Copy URL",
|
||
"data-copy": this.entry.url
|
||
})));
|
||
fields.appendChild(urlField);
|
||
}
|
||
if (this.entry.notes) fields.appendChild(this.ce("div", { className: "detail-field" }, this.ce("span", {
|
||
className: "field-label",
|
||
textContent: "Notes"
|
||
}), this.ce("div", {
|
||
className: "field-value notes",
|
||
textContent: this.entry.notes
|
||
})));
|
||
card.appendChild(fields);
|
||
card.appendChild(this.ce("div", { className: "detail-meta" }, this.ce("span", {
|
||
className: "text-xs text-muted",
|
||
textContent: `Created: ${new Date(this.entry.createdAt).toLocaleString()}`
|
||
}), this.ce("span", {
|
||
className: "text-xs text-muted",
|
||
textContent: `Updated: ${new Date(this.entry.updatedAt).toLocaleString()}`
|
||
})));
|
||
this.el.appendChild(card);
|
||
const editBtn = this.q("#edit-btn");
|
||
if (editBtn) this.on(editBtn, "click", () => this.onEdit(this.entry.id));
|
||
const restoreBtn = this.q("#restore-btn");
|
||
if (restoreBtn) this.on(restoreBtn, "click", () => this.onEdit(this.entry.id));
|
||
const trashBtn = this.q("#trash-btn");
|
||
if (trashBtn) this.on(trashBtn, "click", () => {
|
||
this.showDeleteConfirm = true;
|
||
this.#renderModal();
|
||
});
|
||
const permDeleteBtn = this.q("#perm-delete-btn");
|
||
if (permDeleteBtn) this.on(permDeleteBtn, "click", () => {
|
||
this.showPermanentDeleteConfirm = true;
|
||
this.#renderModal();
|
||
});
|
||
const togglePwd = this.q("#toggle-pwd");
|
||
if (togglePwd) this.on(togglePwd, "click", () => {
|
||
this.passwordVisible = !this.passwordVisible;
|
||
const display = this.q("#pwd-display");
|
||
if (display) display.textContent = this.passwordVisible ? this.decryptedPassword : "••••••••••••";
|
||
togglePwd.textContent = this.passwordVisible ? "🙈" : "👁";
|
||
});
|
||
this.qa("[data-copy]").forEach((btn) => {
|
||
this.on(btn, "click", () => {
|
||
const text = btn.dataset.copy;
|
||
this.#copyToClipboard(text, btn.title || "Text");
|
||
});
|
||
});
|
||
}
|
||
async #loadEntry() {
|
||
this.loading = true;
|
||
this.error = "";
|
||
try {
|
||
this.entry = await getEntryById(this.entryId);
|
||
if (this.entry && app$1.encryptionKey) this.decryptedPassword = await decrypt(this.entry.encryptedPassword, app$1.encryptionKey);
|
||
} catch (e) {
|
||
this.error = "Failed to load entry: " + e.message;
|
||
}
|
||
this.loading = false;
|
||
this.#renderContent();
|
||
}
|
||
#showToast(message) {
|
||
this.toast = message;
|
||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
||
this.toastTimer = setTimeout(() => {
|
||
this.toast = "";
|
||
}, 3e3);
|
||
}
|
||
async #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 {}
|
||
}, 15e3);
|
||
} 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;
|
||
}
|
||
};
|
||
//#endregion
|
||
//#region src/components/EntryForm.js
|
||
/**
|
||
* EntryForm — create/edit credential form.
|
||
*/
|
||
var EntryForm = class extends Component {
|
||
/** @param {{ entryId: string|null, onSave: Function, onCancel: Function }} props */
|
||
constructor(container, props = {}) {
|
||
super(container);
|
||
this.entryId = props.entryId || null;
|
||
this.onSave = props.onSave || (() => {});
|
||
this.onCancel = props.onCancel || (() => {});
|
||
this.title = "";
|
||
this.username = "";
|
||
this.password = "";
|
||
this.url = "";
|
||
this.notes = "";
|
||
this.groupId = "";
|
||
this.passwordVisible = false;
|
||
this.groups = [];
|
||
this.loading = true;
|
||
this.error = "";
|
||
this.saving = false;
|
||
this.isEdit = false;
|
||
this.formErrors = [];
|
||
}
|
||
mount() {
|
||
super.mount();
|
||
this.#loadForm();
|
||
return this;
|
||
}
|
||
render() {
|
||
this.el = this.ce("div", { className: "entry-form" });
|
||
this.#renderContent();
|
||
return this.el;
|
||
}
|
||
#renderContent() {
|
||
this.el.innerHTML = "";
|
||
if (this.loading) {
|
||
this.el.appendChild(this.ce("div", {
|
||
className: "loading",
|
||
textContent: "Loading..."
|
||
}));
|
||
return;
|
||
}
|
||
if (this.error && !this.isEdit) {
|
||
this.el.appendChild(this.ce("div", {
|
||
className: "error-banner",
|
||
textContent: this.error
|
||
}));
|
||
return;
|
||
}
|
||
const form = this.ce("form", {
|
||
className: "ef-form-card",
|
||
id: "entry-form"
|
||
});
|
||
if (this.formErrors.length > 0) {
|
||
const errDiv = this.ce("div", { className: "validation-errors" });
|
||
for (const err of this.formErrors) errDiv.appendChild(this.ce("div", {
|
||
className: "validation-error",
|
||
textContent: `⚠ ${err}`
|
||
}));
|
||
form.appendChild(errDiv);
|
||
}
|
||
form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
htmlFor: "title",
|
||
textContent: "Title *"
|
||
}), this.ce("input", {
|
||
id: "title",
|
||
type: "text",
|
||
placeholder: "e.g. GitHub, Gmail",
|
||
value: this.title
|
||
})));
|
||
form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
htmlFor: "username",
|
||
textContent: "Username / Email"
|
||
}), this.ce("input", {
|
||
id: "username",
|
||
type: "text",
|
||
placeholder: "username or email",
|
||
value: this.username
|
||
})));
|
||
const pwdGroup = this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
htmlFor: "password",
|
||
textContent: "Password *"
|
||
}), this.ce("div", { className: "password-input-group" }, this.ce("input", {
|
||
id: "password",
|
||
type: this.passwordVisible ? "text" : "password",
|
||
placeholder: "Password",
|
||
value: this.password
|
||
}), this.ce("button", {
|
||
type: "button",
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "toggle-pwd",
|
||
textContent: this.passwordVisible ? "🙈" : "👁",
|
||
title: "Toggle visibility"
|
||
}), this.ce("button", {
|
||
type: "button",
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "generate-pwd",
|
||
textContent: "🎲",
|
||
title: "Generate password"
|
||
})));
|
||
form.appendChild(pwdGroup);
|
||
form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
htmlFor: "url",
|
||
textContent: "URL"
|
||
}), this.ce("input", {
|
||
id: "url",
|
||
type: "url",
|
||
placeholder: "https://example.com",
|
||
value: this.url
|
||
})));
|
||
const select = this.ce("select", { id: "group" });
|
||
const defaultOpt = this.ce("option", { value: "" }, this.text("No group"));
|
||
select.appendChild(defaultOpt);
|
||
for (const group of this.groups) {
|
||
if (isTrashGroup(group.id)) continue;
|
||
const opt = this.ce("option", { value: group.id }, this.text(group.name));
|
||
if (group.id === this.groupId) opt.selected = true;
|
||
select.appendChild(opt);
|
||
}
|
||
form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
htmlFor: "group",
|
||
textContent: "Group"
|
||
}), select));
|
||
form.appendChild(this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
htmlFor: "notes",
|
||
textContent: "Notes"
|
||
}), this.ce("textarea", {
|
||
id: "notes",
|
||
placeholder: "Any additional notes..."
|
||
})));
|
||
form.appendChild(this.ce("div", { className: "ef-form-actions" }, this.ce("button", {
|
||
type: "submit",
|
||
className: "btn btn-primary",
|
||
disabled: this.saving,
|
||
textContent: this.saving ? "Saving..." : this.isEdit ? "💾 Update" : "➕ Create"
|
||
}), this.ce("button", {
|
||
type: "button",
|
||
className: "btn btn-ghost",
|
||
id: "cancel-btn",
|
||
textContent: "Cancel"
|
||
})));
|
||
this.el.appendChild(form);
|
||
const formEl = this.q("#entry-form");
|
||
if (formEl) this.on(formEl, "submit", this.#handleSubmit);
|
||
const cancelBtn = this.q("#cancel-btn");
|
||
if (cancelBtn) this.on(cancelBtn, "click", () => this.onCancel());
|
||
const togglePwd = this.q("#toggle-pwd");
|
||
if (togglePwd) this.on(togglePwd, "click", () => {
|
||
this.passwordVisible = !this.passwordVisible;
|
||
const pwdInput = this.q("#password");
|
||
if (pwdInput) pwdInput.type = this.passwordVisible ? "text" : "password";
|
||
togglePwd.textContent = this.passwordVisible ? "🙈" : "👁";
|
||
});
|
||
const generatePwd = this.q("#generate-pwd");
|
||
if (generatePwd) this.on(generatePwd, "click", () => {
|
||
this.password = generatePassword({ length: 16 });
|
||
const pwdInput = this.q("#password");
|
||
if (pwdInput) pwdInput.value = this.password;
|
||
});
|
||
const titleInput = this.q("#title");
|
||
if (titleInput) this.on(titleInput, "input", (e) => {
|
||
this.title = e.target.value;
|
||
});
|
||
const usernameInput = this.q("#username");
|
||
if (usernameInput) this.on(usernameInput, "input", (e) => {
|
||
this.username = e.target.value;
|
||
});
|
||
const pwdInput = this.q("#password");
|
||
if (pwdInput) this.on(pwdInput, "input", (e) => {
|
||
this.password = e.target.value;
|
||
});
|
||
const urlInput = this.q("#url");
|
||
if (urlInput) this.on(urlInput, "input", (e) => {
|
||
this.url = e.target.value;
|
||
});
|
||
const notesInput = this.q("#notes");
|
||
if (notesInput) this.on(notesInput, "input", (e) => {
|
||
this.notes = e.target.value;
|
||
});
|
||
const groupSelect = this.q("#group");
|
||
if (groupSelect) this.on(groupSelect, "change", (e) => {
|
||
this.groupId = e.target.value;
|
||
});
|
||
if (!this.isEdit && titleInput) autofocus(titleInput, true);
|
||
}
|
||
async #loadForm() {
|
||
this.loading = true;
|
||
try {
|
||
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$1.encryptionKey);
|
||
this.url = entry.url || "";
|
||
this.notes = entry.notes || "";
|
||
this.groupId = entry.groupId || "";
|
||
} else this.error = "Entry not found";
|
||
} else {
|
||
const active = search.activeGroupId;
|
||
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;
|
||
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$1.encryptionKey);
|
||
if (this.isEdit) await updateEntry(updateEntry$1(await getEntryById(this.entryId), {
|
||
title: this.title,
|
||
username: this.username,
|
||
encryptedPassword,
|
||
url: this.url,
|
||
notes: this.notes,
|
||
groupId: this.groupId
|
||
}));
|
||
else await addEntry(createEntry({
|
||
title: this.title,
|
||
username: this.username,
|
||
encryptedPassword,
|
||
url: this.url,
|
||
notes: this.notes,
|
||
groupId: this.groupId
|
||
}));
|
||
this.onSave();
|
||
} catch (e) {
|
||
this.error = "Failed to save: " + e.message;
|
||
}
|
||
this.saving = false;
|
||
this.#renderContent();
|
||
};
|
||
};
|
||
//#endregion
|
||
//#region src/components/ImportExport.js
|
||
/**
|
||
* ImportExport — export with group selection + import with source password.
|
||
*/
|
||
var ImportExport = class extends Component {
|
||
showExport = false;
|
||
showImport = false;
|
||
importMode = "merge";
|
||
importResult = null;
|
||
importError = "";
|
||
importing = false;
|
||
exporting = false;
|
||
sourcePassword = "";
|
||
parsedFileData = null;
|
||
allGroups = [];
|
||
allEntries = [];
|
||
selectedGroupIds = [];
|
||
get selectAll() {
|
||
return this.allGroups.length > 0 && this.allGroups.every((g) => this.selectedGroupIds.includes(g.id));
|
||
}
|
||
get exportEntryCount() {
|
||
return this.allEntries.filter((e) => this.selectedGroupIds.includes(e.groupId)).length;
|
||
}
|
||
mount() {
|
||
super.mount();
|
||
return this;
|
||
}
|
||
render() {
|
||
this.el = this.ce("div", { className: "import-export" }, this.ce("button", {
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "export-btn",
|
||
title: "Export",
|
||
textContent: "📤 Export"
|
||
}), this.ce("button", {
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "import-btn",
|
||
title: "Import",
|
||
textContent: "📥 Import"
|
||
}));
|
||
this.on(this.q("#export-btn"), "click", () => this.#openExportModal());
|
||
this.on(this.q("#import-btn"), "click", () => {
|
||
this.showImport = true;
|
||
this.#renderImportModal();
|
||
});
|
||
return this.el;
|
||
}
|
||
async #openExportModal() {
|
||
const groups = (await getGroups()).filter((g) => !isTrashGroup(g.id));
|
||
this.allGroups = [{
|
||
id: "",
|
||
name: "Ungrouped",
|
||
color: "#6b7280"
|
||
}, ...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." }));
|
||
const header = this.ce("div", { className: "group-select-header" }, this.ce("label", { className: "checkbox-label" }, this.ce("input", {
|
||
type: "checkbox",
|
||
id: "select-all-checkbox",
|
||
checked: this.selectAll
|
||
}), this.ce("span", { textContent: "Select all" })), this.ce("span", {
|
||
className: "entry-count",
|
||
id: "export-count",
|
||
textContent: `${this.exportEntryCount} entries`
|
||
}));
|
||
modal.appendChild(header);
|
||
const list = this.ce("div", { className: "group-select-list" });
|
||
for (const group of this.allGroups) {
|
||
const label = this.ce("label", { className: "checkbox-label group-checkbox" }, this.ce("input", {
|
||
type: "checkbox",
|
||
"data-group-id": group.id,
|
||
checked: this.selectedGroupIds.includes(group.id)
|
||
}), this.ce("span", {
|
||
className: "group-color-dot",
|
||
style: `background-color: ${group.color || "#6c63ff"}`
|
||
}), this.ce("span", {
|
||
className: "group-name",
|
||
textContent: group.name
|
||
}));
|
||
list.appendChild(label);
|
||
}
|
||
modal.appendChild(list);
|
||
const actions = this.ce("div", { className: "ie-modal-actions" }, this.ce("button", {
|
||
className: "btn btn-primary",
|
||
id: "do-export-btn",
|
||
disabled: this.exporting || this.selectedGroupIds.length === 0,
|
||
textContent: this.exporting ? "Exporting..." : "📤 Export JSON"
|
||
}), this.ce("button", {
|
||
className: "btn btn-ghost",
|
||
id: "cancel-export-btn",
|
||
textContent: "Cancel"
|
||
}));
|
||
modal.appendChild(actions);
|
||
overlay.appendChild(modal);
|
||
this.el.appendChild(overlay);
|
||
this.on(overlay, "click", () => {
|
||
this.showExport = false;
|
||
overlay.remove();
|
||
});
|
||
this.on(modal, "click", (e) => e.stopPropagation());
|
||
const selectAllCb = overlay.querySelector("#select-all-checkbox");
|
||
if (selectAllCb) this.on(selectAllCb, "change", () => this.#toggleSelectAll());
|
||
overlay.querySelectorAll(".group-select-list input[data-group-id]").forEach((cb) => {
|
||
this.on(cb, "change", () => {
|
||
const gid = cb.dataset.groupId;
|
||
this.#toggleGroup(gid);
|
||
});
|
||
});
|
||
const exportBtn = overlay.querySelector("#do-export-btn");
|
||
if (exportBtn) this.on(exportBtn, "click", () => this.#handleExport());
|
||
const cancelBtn = overlay.querySelector("#cancel-export-btn");
|
||
if (cancelBtn) this.on(cancelBtn, "click", () => {
|
||
this.showExport = false;
|
||
overlay.remove();
|
||
});
|
||
}
|
||
#toggleSelectAll() {
|
||
if (this.selectAll) this.selectedGroupIds = [];
|
||
else this.selectedGroupIds = this.allGroups.map((g) => g.id);
|
||
this.#renderExportModal();
|
||
}
|
||
#toggleGroup(groupId) {
|
||
if (this.selectedGroupIds.includes(groupId)) this.selectedGroupIds = this.selectedGroupIds.filter((id) => id !== groupId);
|
||
else this.selectedGroupIds = [...this.selectedGroupIds, groupId];
|
||
const overlay = this.q(".ie-modal-overlay");
|
||
if (overlay) {
|
||
const selectAllCb = overlay.querySelector("#select-all-checkbox");
|
||
if (selectAllCb) selectAllCb.checked = this.selectAll;
|
||
const countEl = overlay.querySelector("#export-count");
|
||
if (countEl) countEl.textContent = `${this.exportEntryCount} entries`;
|
||
const exportBtn = overlay.querySelector("#do-export-btn");
|
||
if (exportBtn) exportBtn.disabled = this.exporting || this.selectedGroupIds.length === 0;
|
||
}
|
||
}
|
||
async #handleExport() {
|
||
this.exporting = true;
|
||
try {
|
||
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-${(/* @__PURE__ */ 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
|
||
})));
|
||
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$1.encryptionKey);
|
||
this.sourcePassword = "";
|
||
this.parsedFileData = null;
|
||
search.refresh();
|
||
} catch (e) {
|
||
this.importError = "Import failed: " + e.message;
|
||
}
|
||
this.importing = false;
|
||
this.#renderImportModal();
|
||
}
|
||
};
|
||
//#endregion
|
||
//#region src/components/SettingsDialog.js
|
||
/**
|
||
* SettingsDialog — auto-lock and tab-switch settings.
|
||
*/
|
||
var SettingsDialog = class extends Component {
|
||
/** @param {{ onBack: Function }} props */
|
||
constructor(container, props = {}) {
|
||
super(container);
|
||
this.onBack = props.onBack || (() => {});
|
||
this.minutes = settings.autoLockMinutes;
|
||
this.lockOnTabSwitch = settings.lockOnTabSwitch;
|
||
this.saving = false;
|
||
}
|
||
mount() {
|
||
super.mount();
|
||
this.minutes = settings.autoLockMinutes;
|
||
this.lockOnTabSwitch = settings.lockOnTabSwitch;
|
||
return this;
|
||
}
|
||
render() {
|
||
this.el = this.ce("div", { className: "settings-panel" });
|
||
this.#renderContent();
|
||
return this.el;
|
||
}
|
||
#renderContent() {
|
||
this.el.innerHTML = "";
|
||
const form = this.ce("form", {
|
||
className: "sd-form-card",
|
||
id: "settings-form"
|
||
}, this.ce("h3", { textContent: "Settings" }), this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
htmlFor: "auto-lock-minutes",
|
||
textContent: "Auto-lock after"
|
||
}), this.#buildMinuteSelect(), this.ce("p", {
|
||
className: "text-muted text-xs mt-1",
|
||
id: "lock-hint"
|
||
})), this.ce("div", { className: "form-group" }, this.ce("label", {
|
||
className: "toggle-label",
|
||
htmlFor: "lock-tab-switch"
|
||
}, this.ce("input", {
|
||
id: "lock-tab-switch",
|
||
type: "checkbox",
|
||
checked: this.lockOnTabSwitch
|
||
}), this.ce("span", { className: "toggle-track" }, this.ce("span", { className: "toggle-thumb" })), this.ce("span", {
|
||
className: "toggle-text",
|
||
textContent: "Lock when tab loses focus"
|
||
})), this.ce("p", {
|
||
className: "text-muted text-xs mt-1",
|
||
id: "tab-hint"
|
||
})), this.ce("div", { className: "sd-form-actions" }, this.ce("button", {
|
||
type: "submit",
|
||
className: "btn btn-primary",
|
||
disabled: this.saving,
|
||
id: "save-settings-btn",
|
||
textContent: this.saving ? "Saving..." : "Save"
|
||
}), this.ce("button", {
|
||
type: "button",
|
||
className: "btn btn-ghost",
|
||
id: "cancel-settings-btn",
|
||
textContent: "Cancel"
|
||
})));
|
||
this.el.appendChild(form);
|
||
const lockHint = this.q("#lock-hint");
|
||
if (lockHint) lockHint.textContent = `Vault locks after ${this.minutes} ${this.minutes === 1 ? "minute" : "minutes"} of inactivity.`;
|
||
const tabHint = this.q("#tab-hint");
|
||
if (tabHint) tabHint.textContent = this.lockOnTabSwitch ? "The vault locks immediately when you switch to another tab." : "The vault stays unlocked even when you switch tabs.";
|
||
const formEl = this.q("#settings-form");
|
||
if (formEl) this.on(formEl, "submit", this.#handleSave);
|
||
const cancelBtn = this.q("#cancel-settings-btn");
|
||
if (cancelBtn) this.on(cancelBtn, "click", () => this.onBack());
|
||
const minutesSelect = this.q("#auto-lock-minutes");
|
||
if (minutesSelect) this.on(minutesSelect, "change", (e) => {
|
||
this.minutes = Number(e.target.value);
|
||
if (lockHint) lockHint.textContent = `Vault locks after ${this.minutes} ${this.minutes === 1 ? "minute" : "minutes"} of inactivity.`;
|
||
});
|
||
const tabCheckbox = this.q("#lock-tab-switch");
|
||
if (tabCheckbox) this.on(tabCheckbox, "change", (e) => {
|
||
this.lockOnTabSwitch = e.target.checked;
|
||
if (tabHint) tabHint.textContent = this.lockOnTabSwitch ? "The vault locks immediately when you switch to another tab." : "The vault stays unlocked even when you switch tabs.";
|
||
});
|
||
}
|
||
#buildMinuteSelect() {
|
||
const select = this.ce("select", { id: "auto-lock-minutes" });
|
||
for (const m of [
|
||
1,
|
||
5,
|
||
10,
|
||
15,
|
||
30,
|
||
60
|
||
]) {
|
||
const opt = this.ce("option", { value: m }, this.text(`${m} ${m === 1 ? "minute" : "minutes"}`));
|
||
if (m === this.minutes) opt.selected = true;
|
||
select.appendChild(opt);
|
||
}
|
||
return select;
|
||
}
|
||
#handleSave = async (e) => {
|
||
e.preventDefault();
|
||
this.saving = true;
|
||
try {
|
||
settings.autoLockMinutes = this.minutes;
|
||
settings.lockOnTabSwitch = this.lockOnTabSwitch;
|
||
await settings.save();
|
||
startAutoLock();
|
||
} catch (err) {
|
||
console.error("Failed to save settings:", err);
|
||
}
|
||
this.saving = false;
|
||
this.onBack();
|
||
};
|
||
};
|
||
//#endregion
|
||
//#region src/components/MainLayout.js
|
||
/**
|
||
* MainLayout — shell: sidebar + content area with view routing.
|
||
*/
|
||
var MainLayout = class extends Component {
|
||
sidebarOpen = false;
|
||
viewMode = "list";
|
||
selectedEntryId = null;
|
||
showEmptyTrashConfirm = false;
|
||
emptyingTrash = false;
|
||
_sidebar = null;
|
||
_importExport = null;
|
||
_contentComponent = null;
|
||
get isTrashView() {
|
||
return search.activeGroupId === "trash";
|
||
}
|
||
mount() {
|
||
super.mount();
|
||
this.subscribe(app$1, "isUnlocked", (unlocked) => {
|
||
if (!unlocked) this.emitLock();
|
||
});
|
||
return this;
|
||
}
|
||
render() {
|
||
this.el = this.ce("div", { className: "app-shell" });
|
||
this.el.appendChild(this.ce("div", { className: "mobile-header" }, this.ce("button", {
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "menu-btn",
|
||
textContent: "☰ Menu"
|
||
}), this.ce("span", {
|
||
className: "mobile-title",
|
||
textContent: "Password Vault"
|
||
}), this.ce("button", {
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "mobile-lock-btn",
|
||
title: "Lock",
|
||
textContent: "🔒"
|
||
})));
|
||
const aside = this.ce("aside", {
|
||
className: "sidebar",
|
||
id: "sidebar"
|
||
});
|
||
this.el.appendChild(aside);
|
||
const main = this.ce("main", { className: "main-content" }, this.ce("div", {
|
||
className: "top-bar",
|
||
id: "top-bar"
|
||
}), this.ce("div", {
|
||
className: "content-area",
|
||
id: "content-area"
|
||
}));
|
||
this.el.appendChild(main);
|
||
this.on(this.q("#menu-btn"), "click", () => {
|
||
this.sidebarOpen = !this.sidebarOpen;
|
||
this.#updateSidebar();
|
||
});
|
||
this.on(this.q("#mobile-lock-btn"), "click", () => app$1.lockVault());
|
||
this._sidebar = new Sidebar(this.q("#sidebar"));
|
||
this._sidebar.mount();
|
||
this.#renderTopBar();
|
||
this.#navigate();
|
||
return this.el;
|
||
}
|
||
#updateSidebar() {
|
||
const sidebar = this.q("#sidebar");
|
||
if (sidebar) sidebar.classList.toggle("open", this.sidebarOpen);
|
||
let overlay = this.q(".sidebar-overlay");
|
||
if (this.sidebarOpen && !overlay) {
|
||
overlay = this.ce("button", {
|
||
className: "sidebar-overlay",
|
||
"aria-label": "Close menu"
|
||
});
|
||
this.on(overlay, "click", () => {
|
||
this.sidebarOpen = false;
|
||
this.#updateSidebar();
|
||
});
|
||
this.el.appendChild(overlay);
|
||
} else if (!this.sidebarOpen && overlay) overlay.remove();
|
||
}
|
||
#renderTopBar() {
|
||
const topBar = this.q("#top-bar");
|
||
if (!topBar) return;
|
||
topBar.innerHTML = "";
|
||
if (this.viewMode !== "list") {
|
||
const backBtn = this.ce("button", {
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "back-btn",
|
||
textContent: "← Back"
|
||
});
|
||
this.on(backBtn, "click", () => this.#handleBack());
|
||
topBar.appendChild(backBtn);
|
||
}
|
||
const titleDiv = this.ce("div", { className: "top-bar-title" });
|
||
let titleText;
|
||
switch (this.viewMode) {
|
||
case "list":
|
||
titleText = this.isTrashView ? TRASH_GROUP_NAME : "All Entries";
|
||
break;
|
||
case "detail":
|
||
titleText = "Entry Details";
|
||
break;
|
||
case "form":
|
||
titleText = this.selectedEntryId ? "Edit Entry" : "New Entry";
|
||
break;
|
||
case "settings":
|
||
titleText = "Settings";
|
||
break;
|
||
default: titleText = "Password Vault";
|
||
}
|
||
titleDiv.appendChild(this.ce("h1", { textContent: titleText }));
|
||
topBar.appendChild(titleDiv);
|
||
const actions = this.ce("div", { className: "top-bar-actions" });
|
||
if (this.viewMode === "list" && this.isTrashView) actions.appendChild(this.ce("button", {
|
||
className: "btn btn-danger btn-sm",
|
||
id: "empty-trash-btn",
|
||
disabled: this.emptyingTrash,
|
||
textContent: this.emptyingTrash ? "Emptying..." : "🗑 Empty Trash"
|
||
}));
|
||
if (this.viewMode === "list" && !this.isTrashView) actions.appendChild(this.ce("button", {
|
||
className: "btn btn-primary btn-sm",
|
||
id: "new-entry-btn",
|
||
textContent: "+ New Entry"
|
||
}));
|
||
if (!this._importExport) {
|
||
this._importExport = new ImportExport(actions);
|
||
this._importExport.mount();
|
||
}
|
||
actions.appendChild(this.ce("button", {
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "settings-btn",
|
||
title: "Settings",
|
||
textContent: "⚙️"
|
||
}));
|
||
actions.appendChild(this.ce("button", {
|
||
className: "btn btn-ghost btn-sm",
|
||
id: "lock-btn",
|
||
title: "Lock vault",
|
||
textContent: "🔒"
|
||
}));
|
||
topBar.appendChild(actions);
|
||
const emptyTrashBtn = this.q("#empty-trash-btn");
|
||
if (emptyTrashBtn) this.on(emptyTrashBtn, "click", () => {
|
||
this.showEmptyTrashConfirm = true;
|
||
this.#renderEmptyTrashModal();
|
||
});
|
||
const newEntryBtn = this.q("#new-entry-btn");
|
||
if (newEntryBtn) this.on(newEntryBtn, "click", () => this.#goForm(null));
|
||
const settingsBtn = this.q("#settings-btn");
|
||
if (settingsBtn) this.on(settingsBtn, "click", () => this.#goSettings());
|
||
const lockBtn = this.q("#lock-btn");
|
||
if (lockBtn) this.on(lockBtn, "click", () => app$1.lockVault());
|
||
}
|
||
#navigate() {
|
||
if (this._contentComponent) {
|
||
this._contentComponent.destroy();
|
||
this._contentComponent = null;
|
||
}
|
||
const contentArea = this.q("#content-area");
|
||
if (!contentArea) return;
|
||
switch (this.viewMode) {
|
||
case "list":
|
||
this._contentComponent = new EntryList(contentArea, {
|
||
onSelect: (id) => this.#goDetail(id),
|
||
onAdd: () => this.#goForm(null)
|
||
});
|
||
break;
|
||
case "detail":
|
||
if (this.selectedEntryId) this._contentComponent = new EntryDetail(contentArea, {
|
||
entryId: this.selectedEntryId,
|
||
onEdit: (id) => this.#goForm(id),
|
||
onBack: () => this.#goList()
|
||
});
|
||
break;
|
||
case "form":
|
||
this._contentComponent = new EntryForm(contentArea, {
|
||
entryId: this.selectedEntryId,
|
||
onSave: () => this.#goList(),
|
||
onCancel: () => this.#handleBack()
|
||
});
|
||
break;
|
||
case "settings":
|
||
this._contentComponent = new SettingsDialog(contentArea, { onBack: () => this.#goList() });
|
||
break;
|
||
}
|
||
if (this._contentComponent) this._contentComponent.mount();
|
||
this.#renderTopBar();
|
||
}
|
||
#goList() {
|
||
this.viewMode = "list";
|
||
this.selectedEntryId = null;
|
||
this.sidebarOpen = false;
|
||
this.#updateSidebar();
|
||
this.#navigate();
|
||
}
|
||
#goDetail(entryId) {
|
||
this.selectedEntryId = entryId;
|
||
this.viewMode = "detail";
|
||
this.sidebarOpen = false;
|
||
this.#updateSidebar();
|
||
this.#navigate();
|
||
}
|
||
#goForm(entryId) {
|
||
this.selectedEntryId = entryId;
|
||
this.viewMode = "form";
|
||
this.sidebarOpen = false;
|
||
this.#updateSidebar();
|
||
this.#navigate();
|
||
}
|
||
#goSettings() {
|
||
this.viewMode = "settings";
|
||
this.sidebarOpen = false;
|
||
this.#updateSidebar();
|
||
this.#navigate();
|
||
}
|
||
#handleBack() {
|
||
this.#goList();
|
||
}
|
||
#renderEmptyTrashModal() {
|
||
const existing = this.q(".ml-modal-overlay");
|
||
if (existing) existing.remove();
|
||
const overlay = this.ce("div", {
|
||
className: "ml-modal-overlay",
|
||
role: "presentation"
|
||
});
|
||
const modal = this.ce("div", {
|
||
className: "ml-modal",
|
||
role: "dialog",
|
||
"aria-modal": "true",
|
||
"aria-label": "Empty trash confirmation",
|
||
tabindex: "-1"
|
||
});
|
||
modal.appendChild(this.ce("h3", { textContent: "Empty Trash" }));
|
||
modal.appendChild(this.ce("p", { textContent: "Permanently delete all entries from the trash? This cannot be undone." }));
|
||
const actions = this.ce("div", { className: "ml-modal-actions" }, this.ce("button", {
|
||
className: "btn btn-danger",
|
||
id: "confirm-empty-trash",
|
||
disabled: this.emptyingTrash,
|
||
textContent: this.emptyingTrash ? "Emptying..." : "Yes, empty trash"
|
||
}), this.ce("button", {
|
||
className: "btn btn-ghost",
|
||
id: "cancel-empty-trash",
|
||
textContent: "Cancel"
|
||
}));
|
||
modal.appendChild(actions);
|
||
overlay.appendChild(modal);
|
||
this.el.appendChild(overlay);
|
||
this.on(overlay, "click", () => {
|
||
this.showEmptyTrashConfirm = false;
|
||
overlay.remove();
|
||
});
|
||
this.on(modal, "click", (e) => e.stopPropagation());
|
||
const confirmBtn = overlay.querySelector("#confirm-empty-trash");
|
||
if (confirmBtn) this.on(confirmBtn, "click", () => this.#handleEmptyTrash());
|
||
const cancelBtn = overlay.querySelector("#cancel-empty-trash");
|
||
if (cancelBtn) this.on(cancelBtn, "click", () => {
|
||
this.showEmptyTrashConfirm = false;
|
||
overlay.remove();
|
||
});
|
||
}
|
||
async #handleEmptyTrash() {
|
||
this.emptyingTrash = true;
|
||
try {
|
||
await emptyTrash();
|
||
search.activeGroupId = "all";
|
||
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();
|
||
}
|
||
};
|
||
//#endregion
|
||
//#region src/App.js
|
||
/**
|
||
* Root app component — routes between LockScreen and MainLayout.
|
||
*/
|
||
var App = class extends Component {
|
||
_lockScreen = null;
|
||
_mainLayout = null;
|
||
mount() {
|
||
super.mount();
|
||
const head = document.head;
|
||
head.querySelectorAll("meta, link").forEach((el) => el.remove());
|
||
const charset = document.createElement("meta");
|
||
charset.setAttribute("charset", "UTF-8");
|
||
head.appendChild(charset);
|
||
const viewport = document.createElement("meta");
|
||
viewport.setAttribute("name", "viewport");
|
||
viewport.setAttribute("content", "width=device-width, initial-scale=1.0");
|
||
head.appendChild(viewport);
|
||
document.title = "Password Vault";
|
||
this.subscribe(app$1, "isUnlocked", (unlocked) => this.#swapView(unlocked));
|
||
this.#swapView(app$1.isUnlocked);
|
||
return this;
|
||
}
|
||
#swapView(unlocked) {
|
||
if (this._lockScreen) {
|
||
this._lockScreen.destroy();
|
||
this._lockScreen = null;
|
||
}
|
||
if (this._mainLayout) {
|
||
this._mainLayout.destroy();
|
||
this._mainLayout = null;
|
||
}
|
||
if (this.container) this.container.innerHTML = "";
|
||
if (unlocked) {
|
||
this._mainLayout = new MainLayout(this.container);
|
||
this._mainLayout.mount();
|
||
} else {
|
||
this._lockScreen = new LockScreen(this.container);
|
||
this._lockScreen.mount();
|
||
}
|
||
}
|
||
destroy() {
|
||
if (this._lockScreen) this._lockScreen.destroy();
|
||
if (this._mainLayout) this._mainLayout.destroy();
|
||
super.destroy();
|
||
}
|
||
};
|
||
new App(document.getElementById("app")).mount();
|
||
//#endregion</script>
|
||
<style rel="stylesheet" crossorigin>/* ===== CSS Reset & Base ===== */
|
||
*, *::before, *::after {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
:root {
|
||
--color-bg: #0f1117;
|
||
--color-surface: #1a1d27;
|
||
--color-surface-hover: #242836;
|
||
--color-border: #2e3345;
|
||
--color-text: #e4e6f0;
|
||
--color-text-muted: #8b8fa3;
|
||
--color-primary: #6c63ff;
|
||
--color-primary-hover: #5a52d9;
|
||
--color-danger: #e5484d;
|
||
--color-danger-hover: #c93a3f;
|
||
--color-success: #34d399;
|
||
--color-warning: #fbbf24;
|
||
--color-input-bg: #161822;
|
||
--color-sidebar: #13151d;
|
||
--radius-sm: 4px;
|
||
--radius-md: 8px;
|
||
--radius-lg: 12px;
|
||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||
--transition: 150ms ease;
|
||
}
|
||
|
||
html {
|
||
font-size: 16px;
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||
background-color: var(--color-bg);
|
||
color: var(--color-text);
|
||
line-height: 1.5;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
#app {
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* ===== Typography ===== */
|
||
h1 { font-size: 1.5rem; font-weight: 600; }
|
||
h2 { font-size: 1.25rem; font-weight: 600; }
|
||
h3 { font-size: 1.1rem; font-weight: 600; }
|
||
|
||
/* ===== Buttons ===== */
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background-color var(--transition), opacity var(--transition);
|
||
text-decoration: none;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-primary {
|
||
background-color: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-primary:hover:not(:disabled) {
|
||
background-color: var(--color-primary-hover);
|
||
}
|
||
|
||
.btn-danger {
|
||
background-color: var(--color-danger);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-danger:hover:not(:disabled) {
|
||
background-color: var(--color-danger-hover);
|
||
}
|
||
|
||
.btn-ghost {
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
|
||
.btn-ghost:hover:not(:disabled) {
|
||
background-color: var(--color-surface-hover);
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 4px 10px;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
/* ===== Inputs ===== */
|
||
input[type="text"],
|
||
input[type="password"],
|
||
input[type="url"],
|
||
input[type="email"],
|
||
input[type="number"],
|
||
textarea,
|
||
select {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
background-color: var(--color-input-bg);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-text);
|
||
font-size: 0.875rem;
|
||
font-family: inherit;
|
||
transition: border-color var(--transition);
|
||
outline: none;
|
||
}
|
||
|
||
input:focus,
|
||
textarea:focus,
|
||
select:focus {
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
input::placeholder,
|
||
textarea::placeholder {
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
textarea {
|
||
resize: vertical;
|
||
min-height: 80px;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-muted);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* ===== Utility ===== */
|
||
.text-muted { color: var(--color-text-muted); }
|
||
.text-sm { font-size: 0.8rem; }
|
||
.text-xs { font-size: 0.75rem; }
|
||
.mt-1 { margin-top: 4px; }
|
||
.mt-2 { margin-top: 8px; }
|
||
.mt-3 { margin-top: 12px; }
|
||
.mt-4 { margin-top: 16px; }
|
||
.mb-2 { margin-bottom: 8px; }
|
||
.mb-4 { margin-bottom: 16px; }
|
||
.flex { display: flex; }
|
||
.flex-col { flex-direction: column; }
|
||
.items-center { align-items: center; }
|
||
.justify-between { justify-content: space-between; }
|
||
.gap-1 { gap: 4px; }
|
||
.gap-2 { gap: 8px; }
|
||
.gap-3 { gap: 12px; }
|
||
.gap-4 { gap: 16px; }
|
||
.w-full { width: 100%; }
|
||
.truncate {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ============================================================
|
||
LockScreen
|
||
============================================================ */
|
||
.lock-screen {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100vh;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.lock-card {
|
||
width: 100%;
|
||
max-width: 400px;
|
||
padding: 2.5rem 2rem;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.lock-icon {
|
||
font-size: 3rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
.lock-card h1 { text-align: center; }
|
||
|
||
.subtitle {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.9rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.lock-form {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.lock-screen .error-banner {
|
||
width: 100%;
|
||
padding: 10px 14px;
|
||
background: rgba(229, 72, 77, 0.15);
|
||
border: 1px solid rgba(229, 72, 77, 0.4);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-danger);
|
||
font-size: 0.85rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.warning-banner {
|
||
width: 100%;
|
||
padding: 10px 14px;
|
||
background: rgba(255, 193, 7, 0.15);
|
||
border: 1px solid rgba(230, 168, 0, 0.5);
|
||
border-radius: var(--radius-md);
|
||
color: #b8860b;
|
||
font-size: 0.85rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.hint {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
text-align: center;
|
||
line-height: 1.4;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
/* ============================================================
|
||
MainLayout (shell)
|
||
============================================================ */
|
||
.app-shell {
|
||
display: flex;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.mobile-header {
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 16px;
|
||
background: var(--color-surface);
|
||
border-bottom: 1px solid var(--color-border);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
}
|
||
|
||
.mobile-title {
|
||
font-weight: 600;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 260px;
|
||
min-width: 260px;
|
||
background: var(--color-sidebar);
|
||
border-right: 1px solid var(--color-border);
|
||
height: 100vh;
|
||
position: sticky;
|
||
top: 0;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
|
||
.top-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 20px;
|
||
background: var(--color-surface);
|
||
border-bottom: 1px solid var(--color-border);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.top-bar-title {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.top-bar-title h1 {
|
||
font-size: 1.1rem;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.top-bar-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.content-area {
|
||
flex: 1;
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.sidebar-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 49;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
margin: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* MainLayout modals (empty-trash confirm) */
|
||
.ml-modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 200;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.ml-modal {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 24px;
|
||
max-width: 380px;
|
||
width: 100%;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.ml-modal h3 { margin-bottom: 16px; }
|
||
|
||
.ml-modal p {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.9rem;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.ml-modal-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* ============================================================
|
||
Sidebar
|
||
============================================================ */
|
||
.sidebar-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 16px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.sidebar-header h2 {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.search-box {
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.search-box input {
|
||
padding: 8px 10px;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.groups-nav {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.group-row {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.group-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 8px 12px;
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
font-size: 0.875rem;
|
||
cursor: pointer;
|
||
transition: background-color 150ms, color 150ms;
|
||
text-align: left;
|
||
}
|
||
|
||
.group-item:hover {
|
||
background: var(--color-surface-hover);
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.group-item.active {
|
||
background: rgba(108, 99, 255, 0.15);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.group-item.drag-over {
|
||
background: rgba(108, 99, 255, 0.2);
|
||
border: 2px dashed var(--color-primary);
|
||
outline: 2px solid rgba(108, 99, 255, 0.25);
|
||
outline-offset: -4px;
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.group-item.drag-over .drop-icon {
|
||
opacity: 1;
|
||
transform: scale(1.2);
|
||
}
|
||
|
||
.group-item.dropped {
|
||
animation: dropFlash 600ms ease;
|
||
}
|
||
|
||
@keyframes dropFlash {
|
||
0% { background: rgba(52, 211, 153, 0.35); }
|
||
100% { background: transparent; }
|
||
}
|
||
|
||
.drop-icon {
|
||
opacity: 0;
|
||
font-size: 0.85rem;
|
||
transition: opacity 150ms, transform 150ms;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.group-icon {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.group-color {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.group-name {
|
||
flex: 1;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.group-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.trash-section {
|
||
padding: 8px;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.group-action-btn {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
padding: 4px;
|
||
border-radius: var(--radius-sm);
|
||
transition: background-color 150ms;
|
||
}
|
||
|
||
.group-action-btn:hover {
|
||
background: var(--color-surface-hover);
|
||
}
|
||
|
||
.sidebar-footer {
|
||
padding: 12px 16px;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
/* Color picker */
|
||
.color-picker {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.color-swatch {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
border: 2px solid transparent;
|
||
cursor: pointer;
|
||
transition: transform 150ms, border-color 150ms;
|
||
}
|
||
|
||
.color-swatch:hover {
|
||
transform: scale(1.15);
|
||
}
|
||
|
||
.color-swatch.selected {
|
||
border-color: #fff;
|
||
transform: scale(1.15);
|
||
}
|
||
|
||
/* Sidebar modals (group form, delete confirm) */
|
||
.sb-modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 200;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.sb-modal {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 24px;
|
||
max-width: 380px;
|
||
width: 100%;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.sb-modal h3 { margin-bottom: 16px; }
|
||
|
||
.sb-modal p {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.9rem;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.sb-modal-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* ============================================================
|
||
EntryList
|
||
============================================================ */
|
||
.entry-list .loading,
|
||
.entry-list .empty-state {
|
||
text-align: center;
|
||
padding: 3rem 1rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.entry-list .error-banner {
|
||
padding: 12px 16px;
|
||
background: rgba(229, 72, 77, 0.15);
|
||
border: 1px solid rgba(229, 72, 77, 0.4);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-danger);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 3rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 1.1rem;
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.empty-hint {
|
||
font-size: 0.85rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.results-info {
|
||
padding: 8px 0;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.entries-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.entries-table th {
|
||
text-align: left;
|
||
padding: 8px 12px;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--color-text-muted);
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.entry-row {
|
||
cursor: grab;
|
||
transition: background-color 150ms, opacity 150ms;
|
||
}
|
||
|
||
.entry-row:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.entry-row:hover {
|
||
background: var(--color-surface-hover);
|
||
}
|
||
|
||
.entry-row.dragging {
|
||
opacity: 0.35;
|
||
}
|
||
|
||
.entry-row td {
|
||
padding: 10px 12px;
|
||
font-size: 0.875rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.drag-handle {
|
||
color: var(--color-text-muted);
|
||
opacity: 0.3;
|
||
margin-right: 6px;
|
||
font-size: 0.9rem;
|
||
user-select: none;
|
||
transition: opacity 150ms;
|
||
}
|
||
|
||
.entry-row:hover .drag-handle {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.restore-btn {
|
||
font-size: 0.85rem;
|
||
padding: 4px 6px;
|
||
}
|
||
|
||
.entry-title {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.entry-username {
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.entry-url {
|
||
color: var(--color-text-muted);
|
||
max-width: 200px;
|
||
}
|
||
|
||
.notes-icon {
|
||
color: var(--color-text-muted);
|
||
cursor: help;
|
||
font-size: 0.95rem;
|
||
opacity: 0.5;
|
||
transition: opacity 150ms;
|
||
}
|
||
|
||
.notes-icon:hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
.notes-tooltip {
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
|
||
.tooltip-popup {
|
||
visibility: hidden;
|
||
opacity: 0;
|
||
position: absolute;
|
||
bottom: calc(100% + 6px);
|
||
left: 0;
|
||
z-index: 100;
|
||
background: var(--color-surface, #1e1e2e);
|
||
color: var(--color-text, #cdd6f4);
|
||
border: 1px solid var(--color-border, #45475a);
|
||
border-radius: var(--radius-md, 8px);
|
||
padding: 8px 12px;
|
||
font-size: 0.8rem;
|
||
min-width: 150px;
|
||
max-width: 300px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
pointer-events: none;
|
||
transition: opacity 150ms, visibility 150ms;
|
||
}
|
||
|
||
.notes-tooltip:hover .tooltip-popup {
|
||
visibility: visible;
|
||
opacity: 1;
|
||
}
|
||
|
||
/* ============================================================
|
||
EntryForm
|
||
============================================================ */
|
||
.entry-form .loading {
|
||
text-align: center;
|
||
padding: 3rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.entry-form .error-banner {
|
||
padding: 12px 16px;
|
||
background: rgba(229, 72, 77, 0.15);
|
||
border: 1px solid rgba(229, 72, 77, 0.4);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-danger);
|
||
font-size: 0.85rem;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.ef-form-card {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 24px;
|
||
max-width: 500px;
|
||
}
|
||
|
||
.validation-errors {
|
||
margin-bottom: 16px;
|
||
padding: 12px;
|
||
background: rgba(251, 191, 36, 0.1);
|
||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
|
||
.validation-error {
|
||
font-size: 0.85rem;
|
||
color: var(--color-warning);
|
||
}
|
||
|
||
.password-input-group {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.password-input-group input {
|
||
flex: 1;
|
||
}
|
||
|
||
.ef-form-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
/* ============================================================
|
||
EntryDetail
|
||
============================================================ */
|
||
.entry-detail .loading,
|
||
.entry-detail .empty-state {
|
||
text-align: center;
|
||
padding: 3rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.entry-detail .error-banner {
|
||
padding: 12px 16px;
|
||
background: rgba(229, 72, 77, 0.15);
|
||
border: 1px solid rgba(229, 72, 77, 0.4);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-danger);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
padding: 10px 16px;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
font-size: 0.85rem;
|
||
color: var(--color-success);
|
||
box-shadow: var(--shadow);
|
||
z-index: 1000;
|
||
animation: slideIn 200ms ease;
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from { transform: translateY(20px); opacity: 0; }
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
|
||
.detail-card {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 24px;
|
||
max-width: 600px;
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-header h2 {
|
||
font-size: 1.25rem;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.detail-fields {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.field-label {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--color-text-muted);
|
||
margin-bottom: 4px;
|
||
display: block;
|
||
}
|
||
|
||
.field-value {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 0.95rem;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.field-value.notes {
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.field-value a {
|
||
color: var(--color-primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.field-value a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.copy-btn {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.detail-meta {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-top: 24px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
/* EntryDetail modals */
|
||
.ed-modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 200;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.ed-modal {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 24px;
|
||
max-width: 400px;
|
||
width: 100%;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.ed-modal h3 { margin-bottom: 12px; }
|
||
|
||
.ed-modal p {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.9rem;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.ed-modal-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* ============================================================
|
||
ImportExport
|
||
============================================================ */
|
||
.ie-modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 200;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.ie-modal {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 24px;
|
||
max-width: 420px;
|
||
width: 100%;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.ie-modal h3 { margin-bottom: 12px; }
|
||
|
||
.ie-modal p {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.9rem;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.ie-modal-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.ie-error-banner {
|
||
padding: 10px 14px;
|
||
background: rgba(229, 72, 77, 0.15);
|
||
border: 1px solid rgba(229, 72, 77, 0.4);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-danger);
|
||
font-size: 0.85rem;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.success-banner {
|
||
padding: 10px 14px;
|
||
background: rgba(52, 211, 153, 0.15);
|
||
border: 1px solid rgba(52, 211, 153, 0.4);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-success);
|
||
font-size: 0.85rem;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.import-mode {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.radio-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
transition: border-color 150ms, background-color 150ms;
|
||
}
|
||
|
||
.radio-label:hover {
|
||
border-color: var(--color-primary);
|
||
background: var(--color-surface-hover);
|
||
}
|
||
|
||
.radio-label input[type="radio"] {
|
||
accent-color: var(--color-primary);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.radio-label span {
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.file-label {
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-muted);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
input[type="file"] {
|
||
font-size: 0.85rem;
|
||
padding: 8px;
|
||
}
|
||
|
||
/* Group selection for export */
|
||
.group-select-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.group-select-header .entry-count {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.group-select-list {
|
||
max-height: 240px;
|
||
overflow-y: auto;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.group-select-list::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.group-select-list::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.group-select-list::-webkit-scrollbar-thumb {
|
||
background: var(--color-border);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.checkbox-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.checkbox-label input[type="checkbox"] {
|
||
accent-color: var(--color-primary);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.group-checkbox {
|
||
padding: 6px 12px;
|
||
border-radius: var(--radius-md);
|
||
transition: background-color 150ms;
|
||
}
|
||
|
||
.group-checkbox:hover {
|
||
background: var(--color-surface-hover);
|
||
}
|
||
|
||
.group-color-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ============================================================
|
||
SettingsDialog
|
||
============================================================ */
|
||
.settings-panel {
|
||
max-width: 500px;
|
||
}
|
||
|
||
.sd-form-card {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 24px;
|
||
}
|
||
|
||
.sd-form-card h3 {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.sd-form-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.toggle-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.toggle-label input[type="checkbox"] {
|
||
position: absolute;
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.toggle-track {
|
||
width: 40px;
|
||
height: 22px;
|
||
background: var(--color-border);
|
||
border-radius: 11px;
|
||
position: relative;
|
||
transition: background-color 150ms;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toggle-track .toggle-thumb {
|
||
position: absolute;
|
||
top: 3px;
|
||
left: 3px;
|
||
width: 16px;
|
||
height: 16px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
transition: transform 150ms;
|
||
}
|
||
|
||
.toggle-label input:checked + .toggle-track {
|
||
background: var(--color-primary);
|
||
}
|
||
|
||
.toggle-label input:checked + .toggle-track .toggle-thumb {
|
||
transform: translateX(18px);
|
||
}
|
||
|
||
.toggle-text {
|
||
font-size: 0.875rem;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
/* ============================================================
|
||
Responsive
|
||
============================================================ */
|
||
@media (max-width: 768px) {
|
||
.mobile-header {
|
||
display: flex;
|
||
}
|
||
|
||
.sidebar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
bottom: 0;
|
||
z-index: 50;
|
||
transform: translateX(-100%);
|
||
transition: transform 200ms ease;
|
||
}
|
||
|
||
.sidebar.open {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.sidebar-overlay {
|
||
display: block;
|
||
}
|
||
|
||
.content-area {
|
||
padding: 12px;
|
||
}
|
||
|
||
.top-bar {
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.entries-table th:nth-child(4),
|
||
.entry-row td:nth-child(4) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.entries-table th:nth-child(3),
|
||
.entry-row td:nth-child(3) {
|
||
display: none;
|
||
}
|
||
|
||
.header-actions {
|
||
display: none;
|
||
}
|
||
}
|
||
/*$vite$:1*/</style>
|
||
</head>
|
||
<body>
|
||
<div id="app"></div>
|
||
</body>
|
||
</html>
|