4664 lines
143 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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