mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
impeccable pass
This commit is contained in:
Generated
+232
-232
@@ -10,21 +10,21 @@
|
||||
"dependencies": {
|
||||
"htmx-ext-json-enc": "^2.0.3",
|
||||
"htmx.org": "^2.0.8",
|
||||
"lucide": "^0.577.0"
|
||||
"lucide": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.2.1",
|
||||
"@tailwindcss/cli": "^4.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"daisyui": "^5.5.19",
|
||||
"esbuild": "^0.27.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"glob": "^13.0.6",
|
||||
"tailwindcss": "^4.2"
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -39,9 +39,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -56,9 +56,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -73,9 +73,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -90,9 +90,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -107,9 +107,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -124,9 +124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -141,9 +141,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -158,9 +158,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -175,9 +175,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -192,9 +192,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -209,9 +209,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -226,9 +226,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -243,9 +243,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -260,9 +260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -277,9 +277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -294,9 +294,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -311,9 +311,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -328,9 +328,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -345,9 +345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -362,9 +362,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -379,9 +379,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -396,9 +396,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -413,9 +413,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -430,9 +430,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -447,9 +447,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -823,68 +823,68 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/cli": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.1.tgz",
|
||||
"integrity": "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz",
|
||||
"integrity": "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@parcel/watcher": "^2.5.1",
|
||||
"@tailwindcss/node": "4.2.1",
|
||||
"@tailwindcss/oxide": "4.2.1",
|
||||
"@tailwindcss/node": "4.2.2",
|
||||
"@tailwindcss/oxide": "4.2.2",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"mri": "^1.2.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"tailwindcss": "4.2.1"
|
||||
"tailwindcss": "4.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"tailwindcss": "dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
||||
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.31.1",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.2.1"
|
||||
"tailwindcss": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
|
||||
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
|
||||
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.1",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.1",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
|
||||
"integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -899,9 +899,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
|
||||
"integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -916,9 +916,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
|
||||
"integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -933,9 +933,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
|
||||
"integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -950,9 +950,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
|
||||
"integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
|
||||
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -967,9 +967,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
|
||||
"integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -984,9 +984,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
|
||||
"integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1001,9 +1001,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
|
||||
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1018,9 +1018,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
|
||||
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1035,9 +1035,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
|
||||
"integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
|
||||
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -1065,9 +1065,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||
"integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1082,9 +1082,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
|
||||
"integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1168,9 +1168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||
"integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
|
||||
"version": "5.20.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1182,9 +1182,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1195,32 +1195,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
@@ -1296,9 +1296,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
@@ -1312,23 +1312,23 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.31.1",
|
||||
"lightningcss-darwin-arm64": "1.31.1",
|
||||
"lightningcss-darwin-x64": "1.31.1",
|
||||
"lightningcss-freebsd-x64": "1.31.1",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.31.1",
|
||||
"lightningcss-linux-arm64-gnu": "1.31.1",
|
||||
"lightningcss-linux-arm64-musl": "1.31.1",
|
||||
"lightningcss-linux-x64-gnu": "1.31.1",
|
||||
"lightningcss-linux-x64-musl": "1.31.1",
|
||||
"lightningcss-win32-arm64-msvc": "1.31.1",
|
||||
"lightningcss-win32-x64-msvc": "1.31.1"
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
|
||||
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1347,9 +1347,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
|
||||
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1368,9 +1368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
|
||||
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1389,9 +1389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
|
||||
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1410,9 +1410,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
|
||||
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1431,9 +1431,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
|
||||
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1452,9 +1452,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
|
||||
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1473,9 +1473,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
|
||||
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1494,9 +1494,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
|
||||
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1515,9 +1515,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
|
||||
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1536,9 +1536,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
|
||||
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1567,9 +1567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide": {
|
||||
"version": "0.577.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide/-/lucide-0.577.0.tgz",
|
||||
"integrity": "sha512-PpC/m5eOItp/WU/GlQPFBXDOhq6HibL73KzYP37OX3LM7VmzWQF8voEj8QRWUFvy9FIKfeDQkWYoyS1D/MdWFA==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide/-/lucide-1.8.0.tgz",
|
||||
"integrity": "sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
@@ -1687,16 +1687,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
||||
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+8
-8
@@ -4,9 +4,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"icons:build": "node scripts/generate-icons.js",
|
||||
"css:build": "BROWSERSLIST_IGNORE_OLD_DATA=1 npx tailwindcss -i ./pkg/appview/src/css/main.css -o ./pkg/appview/public/css/style.css --minify",
|
||||
"css:build:hold": "BROWSERSLIST_IGNORE_OLD_DATA=1 npx tailwindcss -i ./pkg/hold/admin/src/css/main.css -o ./pkg/hold/admin/public/css/style.css --minify",
|
||||
"css:watch": "BROWSERSLIST_IGNORE_OLD_DATA=1 npx tailwindcss -i ./pkg/appview/src/css/main.css -o ./pkg/appview/public/css/style.css --watch",
|
||||
"css:build": "npx tailwindcss -i ./pkg/appview/src/css/main.css -o ./pkg/appview/public/css/style.css --minify",
|
||||
"css:build:hold": "npx tailwindcss -i ./pkg/hold/admin/src/css/main.css -o ./pkg/hold/admin/public/css/style.css --minify",
|
||||
"css:watch": "npx tailwindcss -i ./pkg/appview/src/css/main.css -o ./pkg/appview/public/css/style.css --watch",
|
||||
"js:build": "esbuild pkg/appview/src/js/main.js --bundle --minify --format=esm --outfile=pkg/appview/public/js/bundle.min.js",
|
||||
"js:build:hold": "esbuild pkg/hold/admin/src/js/main.js --bundle --minify --format=esm --outfile=pkg/hold/admin/public/js/bundle.min.js",
|
||||
"js:watch": "esbuild pkg/appview/src/js/main.js --bundle --watch --format=esm --outfile=pkg/appview/public/js/bundle.min.js",
|
||||
@@ -16,16 +16,16 @@
|
||||
"watch": "npm run css:watch & npm run js:watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.2.1",
|
||||
"@tailwindcss/cli": "^4.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"daisyui": "^5.5.19",
|
||||
"esbuild": "^0.27.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"glob": "^13.0.6",
|
||||
"tailwindcss": "^4.2"
|
||||
"lucide": "^1.8.0",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"htmx-ext-json-enc": "^2.0.3",
|
||||
"htmx.org": "^2.0.8",
|
||||
"lucide": "^0.577.0"
|
||||
"htmx.org": "^2.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,19 +95,19 @@ func TestScanResult_WithVulnerabilities(t *testing.T) {
|
||||
if !strings.Contains(body, "vuln-box-critical") {
|
||||
t.Error("Expected body to contain vuln-box-critical for critical vulnerabilities")
|
||||
}
|
||||
if !strings.Contains(body, `data-tip="Critical">2<`) {
|
||||
if !strings.Contains(body, `aria-label="2 critical">2<`) {
|
||||
t.Error("Expected critical count of 2")
|
||||
}
|
||||
if !strings.Contains(body, "vuln-box-high") {
|
||||
t.Error("Expected body to contain vuln-box-high for high vulnerabilities")
|
||||
}
|
||||
if !strings.Contains(body, `data-tip="High">5<`) {
|
||||
if !strings.Contains(body, `aria-label="5 high">5<`) {
|
||||
t.Error("Expected high count of 5")
|
||||
}
|
||||
if !strings.Contains(body, `data-tip="Medium">10<`) {
|
||||
if !strings.Contains(body, `aria-label="10 medium">10<`) {
|
||||
t.Error("Expected medium count of 10")
|
||||
}
|
||||
if !strings.Contains(body, `data-tip="Low">3<`) {
|
||||
if !strings.Contains(body, `aria-label="3 low">3<`) {
|
||||
t.Error("Expected low count of 3")
|
||||
}
|
||||
// Should show vulnerability strip with tooltip
|
||||
@@ -274,7 +274,7 @@ func TestScanResult_OnlyCriticalShown(t *testing.T) {
|
||||
|
||||
body := rr.Body.String()
|
||||
|
||||
if !strings.Contains(body, `data-tip="Critical">3<`) {
|
||||
if !strings.Contains(body, `aria-label="3 critical">3<`) {
|
||||
t.Error("Expected critical count of 3")
|
||||
}
|
||||
// Zero-count badges should NOT appear
|
||||
@@ -353,7 +353,7 @@ func TestBatchScanResult_MultipleDigests(t *testing.T) {
|
||||
}
|
||||
|
||||
// abc123 should have vulnerability badges
|
||||
if !strings.Contains(body, `data-tip="Critical">2<`) {
|
||||
if !strings.Contains(body, `aria-label="2 critical">2<`) {
|
||||
t.Error("Expected critical count of 2 for abc123")
|
||||
}
|
||||
// def456 should have clean badge
|
||||
@@ -496,7 +496,7 @@ func TestBatchScanResult_SingleDigest(t *testing.T) {
|
||||
if !strings.Contains(body, `id="scan-badge-abc123"`) {
|
||||
t.Error("Expected OOB span for abc123")
|
||||
}
|
||||
if !strings.Contains(body, `data-tip="Critical">1<`) {
|
||||
if !strings.Contains(body, `aria-label="1 critical">1<`) {
|
||||
t.Error("Expected critical count of 1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,8 +223,8 @@ func TestVulnDetails_FullReport(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should contain "-" placeholder for unfixed vuln
|
||||
if !strings.Contains(body, `opacity-40`) {
|
||||
t.Error("Expected body to contain opacity-40 placeholder for unfixed vulnerability")
|
||||
if !strings.Contains(body, `text-base-content/40`) {
|
||||
t.Error("Expected body to contain dimmed placeholder for unfixed vulnerability")
|
||||
}
|
||||
|
||||
// Should contain a table
|
||||
@@ -256,7 +256,7 @@ func TestVulnDetails_NoVulnReportBlob(t *testing.T) {
|
||||
if !strings.Contains(body, "vuln-box-critical") {
|
||||
t.Error("Expected body to contain vuln-box-critical in summary")
|
||||
}
|
||||
if !strings.Contains(body, `data-tip="Critical">2<`) {
|
||||
if !strings.Contains(body, `aria-label="2 critical">2<`) {
|
||||
t.Error("Expected critical count of 2 in summary")
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
<symbol id="fingerprint" viewBox="0 0 24 24"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/></symbol>
|
||||
<symbol id="git-compare" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></symbol>
|
||||
<symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol>
|
||||
<symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol>
|
||||
<symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol>
|
||||
<symbol id="heading" viewBox="0 0 24 24"><path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></symbol>
|
||||
<symbol id="history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Vendored
+76
-2
File diff suppressed because one or more lines are too long
+228
-40
@@ -99,9 +99,9 @@
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(19.5% 0.036 257.7);
|
||||
--color-base-200: oklch(23.2% 0.041 253.9);
|
||||
--color-base-300: oklch(28% 0.049 252);
|
||||
--color-base-100: oklch(20% 0.05 250);
|
||||
--color-base-200: oklch(25% 0.05 250);
|
||||
--color-base-300: oklch(30% 0.05 250);
|
||||
--color-base-content: oklch(95.4% 0.022 211);
|
||||
--color-primary: oklch(60% 0.126 221.723);
|
||||
--color-primary-content: oklch(10% 0.126 221.723);
|
||||
@@ -134,12 +134,12 @@
|
||||
============================================ */
|
||||
@plugin "daisyui/theme" {
|
||||
name: "light";
|
||||
default: true
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "light";
|
||||
--color-base-100: oklch(99.4% 0.004 214.3);
|
||||
--color-base-200: oklch(97.3% 0.01 212.5);
|
||||
--color-base-300: oklch(93.7% 0.02 212.5);
|
||||
--color-base-100: oklch(98% 0.01 225);
|
||||
--color-base-200: oklch(95% 0.02 225);
|
||||
--color-base-300: oklch(92% 0.03 225);
|
||||
--color-base-content: oklch(21.1% 0.037 254.4);
|
||||
--color-primary: oklch(60% 0.126 221.723);
|
||||
--color-primary-content: oklch(10% 0.126 221.723);
|
||||
@@ -171,11 +171,23 @@
|
||||
ADDITIONAL CSS VARIABLES
|
||||
======================================== */
|
||||
:root {
|
||||
--shadow-card-hover:
|
||||
0 8px 25px oklch(67.1% 0.05 145 / 0.25), 0 4px 12px oklch(0% 0 0 / 0.1);
|
||||
/* Dedicated star/favorite color — semantically distinct from `warning`
|
||||
even if values currently coincide. Used by .text-star/.fill-star/etc. */
|
||||
--color-star: oklch(82% 0.189 84.429);
|
||||
|
||||
/* Vulnerability severity scale. Held constant across themes on purpose:
|
||||
CVE severity is a product-semantic signal that needs to read the same
|
||||
way regardless of surface. Content-pair colors come from the same hue
|
||||
family (lighter for dark surfaces, darker for light surfaces) to avoid
|
||||
gray-on-color contrast problems. */
|
||||
--color-severity-critical: oklch(45% 0.19 25);
|
||||
--color-severity-critical-content: oklch(97% 0.01 25);
|
||||
--color-severity-high: oklch(56% 0.19 50);
|
||||
--color-severity-high-content: oklch(97% 0.01 50);
|
||||
--color-severity-medium: oklch(72% 0.15 70);
|
||||
--color-severity-medium-content: oklch(25% 0.05 70);
|
||||
--color-severity-low: oklch(80% 0.1 85);
|
||||
--color-severity-low-content: oklch(25% 0.05 85);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
@@ -209,19 +221,21 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Card elevation. In dark mode, shadows disappear against a dark surface —
|
||||
depth is communicated primarily by the base-100 → base-200 surface ramp
|
||||
plus a white-alpha edge ring (see `.card-interactive` rule below). On
|
||||
hover we add a neutral-black amplified shadow (~4–5× the light-mode
|
||||
alpha). Tinted or glow shadows read as highlights, not elevation. */
|
||||
[data-theme="dark"] {
|
||||
--shadow-card-hover:
|
||||
0 8px 25px oklch(67.1% 0.05 145 / 0.2), 0 4px 12px oklch(0% 0 0 / 0.2);
|
||||
0 2px 4px -1px oklch(0% 0 0 / 0.55),
|
||||
0 12px 24px -6px oklch(0% 0 0 / 0.45);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--shadow-card-hover:
|
||||
0 8px 25px oklch(53.1% 0.1 144.8 / 0.25), 0 4px 12px oklch(0% 0 0 / 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--shadow-card-hover:
|
||||
0 8px 25px oklch(63.1% 0.07 144.7 / 0.2), 0 4px 12px oklch(0% 0 0 / 0.2);
|
||||
0 1px 2px oklch(0% 0 0 / 0.06),
|
||||
0 8px 24px -6px oklch(0% 0 0 / 0.12);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
@@ -348,6 +362,19 @@
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Skip-to-content link. Hidden until keyboard-focused, then anchors at
|
||||
the top-left so the user can press Enter to jump past the nav. */
|
||||
.skip-link {
|
||||
@apply absolute left-2 z-50 px-3 py-2 rounded-md;
|
||||
@apply bg-primary text-primary-content font-medium text-sm;
|
||||
top: -10rem;
|
||||
transition: top 0.15s ease-out;
|
||||
}
|
||||
.skip-link:focus,
|
||||
.skip-link:focus-visible {
|
||||
top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
@@ -360,6 +387,112 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TOUCH TARGET SIZING
|
||||
Small buttons and compact form controls meet the keyboard minimum on
|
||||
desktop but fall below the 44×44 recommended touch target on touch
|
||||
devices (WCAG 2.5.5). Grow them only on coarse-pointer devices so
|
||||
pointer-primary layouts stay dense.
|
||||
======================================== */
|
||||
@media (pointer: coarse) {
|
||||
/* Icon-only buttons grow both axes — daisyUI's circle/square variants
|
||||
are the marker for these. */
|
||||
:is(.btn-circle, .btn-square):is(.btn-xs, .btn-sm) {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
/* Text buttons only need vertical clearance — padding handles width. */
|
||||
.btn-xs, .btn-sm {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
/* Small checkbox/radio: expand the clickable region without distorting
|
||||
the control itself — negative margin on the parent <label> would be
|
||||
cleaner but requires markup changes; here we just grow the box. */
|
||||
:is(.checkbox, .radio):is(.checkbox-xs, .radio-xs, .checkbox-sm, .radio-sm) {
|
||||
min-width: 1.5rem;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
/* daisyUI menu items (used in dropdowns, settings sidebar) are small
|
||||
link-like elements. Ensure they meet the tap threshold. */
|
||||
.menu li > a,
|
||||
.menu li > button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
/* Custom tablist buttons (repo-tab, editor-tab) should match. Padding
|
||||
already puts them near the threshold on desktop; lock in the floor. */
|
||||
.repo-tab,
|
||||
.editor-tab {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.sailor-typeahead-selected .sailor-typeahead-clear {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
COMMAND BLOCK ON NARROW VIEWPORTS
|
||||
On desktop .cmd uses `truncate` for a tidy inline look. On mobile,
|
||||
truncation hides the very thing the user wants to copy ("atcr.io/…"),
|
||||
so switch to horizontal scrolling — the copy button still copies the
|
||||
full text, but users can also see what they're about to paste.
|
||||
======================================== */
|
||||
@media (max-width: 40rem) {
|
||||
.cmd code {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
text-overflow: clip;
|
||||
/* Touch-friendly scroll momentum */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
REDUCED MOTION
|
||||
Honor users who opt out of animation. Collapse all durations to a
|
||||
near-instant value rather than removing transitions entirely, so
|
||||
state changes still fire (transitionend listeners, etc.).
|
||||
======================================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
FORCED COLORS (Windows High Contrast)
|
||||
The OS strips box-shadow entirely, so any UI that communicates a
|
||||
"separate surface" purely through shadow becomes invisible. Paint
|
||||
explicit borders using system colors so edges survive. Hover/active
|
||||
states use the Highlight system color which the OS also guarantees
|
||||
is perceptually distinct.
|
||||
======================================== */
|
||||
@media (forced-colors: active) {
|
||||
.card-interactive {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
.card-interactive:hover {
|
||||
outline: 2px solid Highlight;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Severity badges lose their distinguishing fill in forced-colors;
|
||||
fall back to a visible border so the severity strip still reads
|
||||
as a series of distinct cells. */
|
||||
.vuln-strip > span {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CUSTOM COMPONENTS (Not in DaisyUI)
|
||||
======================================== */
|
||||
@@ -368,13 +501,16 @@
|
||||
COMMAND / CODE DISPLAY
|
||||
---------------------------------------- */
|
||||
.cmd {
|
||||
@apply flex items-center gap-2 relative w-fit max-w-full overflow-hidden;
|
||||
@apply bg-base-200 border border-base-300 rounded-md;
|
||||
@apply flex items-center gap-2 relative w-fit max-w-full;
|
||||
@apply bg-base-300 rounded-md;
|
||||
@apply px-3 py-2;
|
||||
}
|
||||
|
||||
/* `min-w-0` + `flex-1` let the code shrink below its intrinsic width so
|
||||
`truncate` can actually produce an ellipsis inside a flex container.
|
||||
Without them, long commands overflow silently. */
|
||||
.cmd code {
|
||||
@apply font-mono text-sm truncate;
|
||||
@apply font-mono text-sm truncate min-w-0 flex-1;
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
@@ -432,9 +568,18 @@
|
||||
/* ----------------------------------------
|
||||
CARD EXTENSIONS
|
||||
---------------------------------------- */
|
||||
/* Resting cards rely on surface lightness for elevation. In dark mode
|
||||
we also paint a 1px inner white-alpha ring so the edge reads against
|
||||
the slightly-lighter card surface — borders do more perceptual work
|
||||
than shadows in dark. Hover then adds a real (neutral-black) shadow. */
|
||||
.card-interactive {
|
||||
@apply cursor-pointer duration-500;
|
||||
transition-property: box-shadow, transform;
|
||||
position: relative;
|
||||
transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 250ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .card-interactive {
|
||||
box-shadow: inset 0 0 0 1px oklch(100% 0 0 / 0.06);
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
@@ -442,6 +587,32 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .card-interactive:hover {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklch(100% 0 0 / 0.1),
|
||||
var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
/* Stretched-link pattern: the whole card is one accessible link.
|
||||
The anchor fills the card via absolute positioning; real interactive
|
||||
descendants (owner/repo links, copy buttons, inline commands) raise
|
||||
themselves above it with z-index so they remain clickable. */
|
||||
.card-stretched-link {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-stretched-link:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-interactive :is(a:not(.card-stretched-link), button, .cmd, [role="button"]) {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
SAILOR TYPEAHEAD
|
||||
---------------------------------------- */
|
||||
@@ -455,6 +626,10 @@
|
||||
@apply rounded-lg shadow-lg;
|
||||
@apply max-h-80 overflow-y-auto z-50;
|
||||
margin-top: 0.25rem;
|
||||
/* Don't escape the viewport on narrow screens — the dropdown is
|
||||
anchored to its input, but if the input sits near a viewport
|
||||
edge the right: 0 anchor can still overflow. */
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.sailor-typeahead-header {
|
||||
@@ -470,7 +645,7 @@
|
||||
|
||||
.sailor-typeahead-item:hover,
|
||||
.sailor-typeahead-item.focused {
|
||||
@apply bg-base-200;
|
||||
@apply bg-base-300;
|
||||
}
|
||||
|
||||
.sailor-typeahead-item-compact {
|
||||
@@ -514,7 +689,7 @@
|
||||
@apply flex-shrink-0 w-8 h-8 rounded-full;
|
||||
@apply flex items-center justify-center;
|
||||
@apply text-xl leading-none text-base-content/60;
|
||||
@apply hover:bg-base-200 hover:text-base-content;
|
||||
@apply hover:bg-base-300 hover:text-base-content;
|
||||
@apply transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
@@ -557,19 +732,6 @@
|
||||
@apply block w-full;
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
OFFLINE MANIFEST FILTERING
|
||||
Hide offline manifests by default;
|
||||
show when "Show offline images" is checked
|
||||
---------------------------------------- */
|
||||
.manifests-list > [data-reachable="false"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.manifests-list.show-offline > [data-reachable="false"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
RESPONSIVE TABLE — STACK ON MOBILE
|
||||
Below md, each row becomes a labeled block.
|
||||
@@ -606,24 +768,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
MODAL — MOBILE GUTTER
|
||||
DaisyUI's .modal-box is ~512px wide by default with no mobile clamp;
|
||||
on viewports under that it presses against the screen edges. Force
|
||||
a 1rem gutter on each side so the dialog reads as a card. Only
|
||||
applies under the sm breakpoint so desktop max-w-* utilities win.
|
||||
---------------------------------------- */
|
||||
@media (max-width: 39.999rem) {
|
||||
.modal .modal-box {
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
VULNERABILITY SEVERITY BOX STRIP
|
||||
Docker Hub-style connected severity boxes
|
||||
---------------------------------------- */
|
||||
.vuln-strip {
|
||||
@apply inline-flex items-stretch text-xs font-semibold leading-none;
|
||||
/* Never spill out of a narrow column. The strip's natural width
|
||||
(~115px on mobile) almost always fits; this is a safety net for
|
||||
ultra-cramped table cells and side panels. */
|
||||
max-width: 100%;
|
||||
}
|
||||
.vuln-strip > span {
|
||||
@apply px-1 py-1 min-w-5 text-center cursor-pointer sm:px-2 sm:min-w-7;
|
||||
}
|
||||
.vuln-strip > span:first-child { @apply rounded-l-sm; }
|
||||
.vuln-strip > span:last-child { @apply rounded-r-sm; }
|
||||
.vuln-box-critical { background-color: oklch(45% 0.19 25); color: oklch(97% 0.01 25); }
|
||||
.vuln-box-high { background-color: oklch(56% 0.19 50); color: oklch(97% 0.01 50); }
|
||||
.vuln-box-medium { background-color: oklch(72% 0.15 70); color: oklch(25% 0.05 70); }
|
||||
.vuln-box-low { background-color: oklch(80% 0.1 85); color: oklch(25% 0.05 85); }
|
||||
.vuln-box-critical { background-color: var(--color-severity-critical); color: var(--color-severity-critical-content); }
|
||||
.vuln-box-high { background-color: var(--color-severity-high); color: var(--color-severity-high-content); }
|
||||
.vuln-box-medium { background-color: var(--color-severity-medium); color: var(--color-severity-medium-content); }
|
||||
.vuln-box-low { background-color: var(--color-severity-low); color: var(--color-severity-low-content); }
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
HTMX REQUEST INDICATOR
|
||||
Opt-in: elements with class `htmx-indicator` are hidden except during
|
||||
an in-flight request. Keeps request UX declarative — no JS required.
|
||||
======================================== */
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator { display: inline-block; }
|
||||
|
||||
/* ========================================
|
||||
TOAST CONTAINER
|
||||
======================================== */
|
||||
|
||||
+392
-115
@@ -32,9 +32,10 @@ function updateThemeUI(pref) {
|
||||
use.setAttribute('href', `/icons.svg#${iconMap[pref] || 'sun-moon'}`);
|
||||
});
|
||||
|
||||
// Update checkmarks in dropdown
|
||||
// Update checkmarks + ARIA state in dropdown
|
||||
document.querySelectorAll('.theme-option').forEach(option => {
|
||||
const isSelected = option.dataset.value === pref;
|
||||
option.setAttribute('aria-checked', isSelected ? 'true' : 'false');
|
||||
const check = option.querySelector('.theme-check');
|
||||
if (check) {
|
||||
check.style.visibility = isSelected ? 'visible' : 'hidden';
|
||||
@@ -58,24 +59,36 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ()
|
||||
});
|
||||
|
||||
// Expandable search
|
||||
function setSearchExpanded(wrapper, expanded) {
|
||||
if (!wrapper) return;
|
||||
const form = wrapper.querySelector('.nav-search-form');
|
||||
const toggleBtn = wrapper.querySelector('button[aria-controls="nav-search-form"]');
|
||||
wrapper.classList.toggle('expanded', expanded);
|
||||
if (form) {
|
||||
if (expanded) {
|
||||
form.removeAttribute('inert');
|
||||
} else {
|
||||
form.setAttribute('inert', '');
|
||||
}
|
||||
}
|
||||
if (toggleBtn) {
|
||||
toggleBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
const wrapper = document.querySelector('.nav-search-wrapper');
|
||||
const input = document.getElementById('nav-search-input');
|
||||
|
||||
if (!wrapper || !input) return;
|
||||
|
||||
wrapper.classList.toggle('expanded');
|
||||
|
||||
if (wrapper.classList.contains('expanded')) {
|
||||
input.focus();
|
||||
if (!wrapper) return;
|
||||
const willExpand = !wrapper.classList.contains('expanded');
|
||||
setSearchExpanded(wrapper, willExpand);
|
||||
if (willExpand) {
|
||||
const input = document.getElementById('nav-search-input');
|
||||
if (input) input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
const wrapper = document.querySelector('.nav-search-wrapper');
|
||||
if (wrapper) {
|
||||
wrapper.classList.remove('expanded');
|
||||
}
|
||||
setSearchExpanded(document.querySelector('.nav-search-wrapper'), false);
|
||||
}
|
||||
|
||||
// Close search on Escape key and click outside
|
||||
@@ -110,24 +123,65 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Copy to clipboard
|
||||
// text: the text to copy
|
||||
// btn: optional button element for feedback (uses event.target if not provided)
|
||||
// Copy to clipboard. Always called with an explicit btn from the delegated
|
||||
// dispatcher or direct callers — no implicit global `event` fallback.
|
||||
function copyToClipboard(text, btn) {
|
||||
if (!btn && typeof event !== 'undefined') {
|
||||
btn = event.target.closest('button');
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const onSuccess = () => {
|
||||
if (!btn) return;
|
||||
// Show success feedback
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#check"></use></svg> Copied!';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalHTML;
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
setTimeout(() => { btn.innerHTML = originalHTML; }, 2000);
|
||||
};
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(err => {
|
||||
console.error('Clipboard API failed, falling back:', err);
|
||||
if (!legacyCopy(text)) {
|
||||
showToast('Copy failed — check browser permissions', 'error');
|
||||
} else {
|
||||
onSuccess();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Insecure context (http://) or unsupported browser — fall back to the
|
||||
// legacy textarea + execCommand technique. Still works in every browser
|
||||
// released in the last decade.
|
||||
if (legacyCopy(text)) {
|
||||
onSuccess();
|
||||
} else {
|
||||
showToast('Copy failed — select the text and copy manually', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden-textarea fallback for clipboard copy. Returns true on success.
|
||||
function legacyCopy(text) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.setAttribute('aria-hidden', 'true');
|
||||
// Position off-screen but inside viewport so iOS Safari doesn't reject
|
||||
// the selection. Avoid display:none — the textarea must be focusable.
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.top = '0';
|
||||
ta.style.left = '0';
|
||||
ta.style.width = '1px';
|
||||
ta.style.height = '1px';
|
||||
ta.style.opacity = '0';
|
||||
ta.style.pointerEvents = 'none';
|
||||
document.body.appendChild(ta);
|
||||
let ok = false;
|
||||
try {
|
||||
ta.focus();
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, text.length);
|
||||
ok = document.execCommand && document.execCommand('copy');
|
||||
} catch (_) {
|
||||
ok = false;
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
return !!ok;
|
||||
}
|
||||
|
||||
// Serialize a <table> (thead + tbody) as CSV (RFC 4180 quoting)
|
||||
@@ -146,7 +200,7 @@ function tableToCSV(table) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Initialize copy buttons with data-cmd attribute and clickable cards with data-href
|
||||
// Initialize copy buttons with data-cmd attribute and CSV copy buttons.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('click', (e) => {
|
||||
// Handle copy-as-CSV buttons (for vulnerability / SBOM tables)
|
||||
@@ -164,13 +218,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
copyToClipboard(btn.getAttribute('data-cmd'), btn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle clickable cards (skip if clicking on interactive elements)
|
||||
if (e.target.closest('a, button, input, .cmd')) return;
|
||||
const card = e.target.closest('[data-href]');
|
||||
if (card) {
|
||||
window.location = card.getAttribute('data-href');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,46 +288,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Update timestamps after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', updateTimestamps);
|
||||
|
||||
// Update timestamps periodically
|
||||
setInterval(updateTimestamps, 60000); // Every minute
|
||||
|
||||
// Toggle offline manifests visibility
|
||||
function toggleOfflineManifests() {
|
||||
const checkbox = document.getElementById('show-offline-toggle');
|
||||
const manifestsList = document.querySelector('.manifests-list');
|
||||
|
||||
if (!checkbox || !manifestsList) return;
|
||||
|
||||
// Store preference in localStorage
|
||||
localStorage.setItem('showOfflineManifests', checkbox.checked);
|
||||
|
||||
// Toggle visibility of offline manifests
|
||||
if (checkbox.checked) {
|
||||
manifestsList.classList.add('show-offline');
|
||||
} else {
|
||||
manifestsList.classList.remove('show-offline');
|
||||
}
|
||||
// Update timestamps periodically — pause while the tab is hidden so we don't
|
||||
// walk the DOM every minute for an audience that isn't looking. On return,
|
||||
// refresh immediately so stamps reflect real time elapsed.
|
||||
let timestampInterval = null;
|
||||
function startTimestampUpdates() {
|
||||
if (timestampInterval === null) timestampInterval = setInterval(updateTimestamps, 60000);
|
||||
}
|
||||
|
||||
// Restore offline manifests toggle state on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const checkbox = document.getElementById('show-offline-toggle');
|
||||
if (!checkbox) return;
|
||||
|
||||
// Restore state from localStorage
|
||||
const showOffline = localStorage.getItem('showOfflineManifests') === 'true';
|
||||
checkbox.checked = showOffline;
|
||||
|
||||
// Apply initial state
|
||||
const manifestsList = document.querySelector('.manifests-list');
|
||||
if (manifestsList) {
|
||||
if (showOffline) {
|
||||
manifestsList.classList.add('show-offline');
|
||||
} else {
|
||||
manifestsList.classList.remove('show-offline');
|
||||
}
|
||||
function stopTimestampUpdates() {
|
||||
if (timestampInterval !== null) { clearInterval(timestampInterval); timestampInterval = null; }
|
||||
}
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
stopTimestampUpdates();
|
||||
} else {
|
||||
updateTimestamps();
|
||||
startTimestampUpdates();
|
||||
}
|
||||
});
|
||||
startTimestampUpdates();
|
||||
|
||||
// Delete manifest with confirmation for tagged manifests
|
||||
async function deleteManifest(repository, digest, sanitizedId) {
|
||||
@@ -303,11 +329,11 @@ async function deleteManifest(repository, digest, sanitizedId) {
|
||||
} else {
|
||||
// Other error
|
||||
const errorText = await response.text();
|
||||
alert(`Failed to delete manifest: ${errorText}`);
|
||||
showToast(`Failed to delete manifest: ${errorText || response.status}`, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting manifest:', err);
|
||||
alert(`Error deleting manifest: ${err.message}`);
|
||||
showToast(`Error deleting manifest: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,14 +354,12 @@ function showManifestDeleteModal(repository, digest, sanitizedId, tags) {
|
||||
// Set up confirm button click handler
|
||||
confirmBtn.onclick = () => confirmManifestDelete(repository, digest, sanitizedId);
|
||||
|
||||
// Show modal
|
||||
modal.style.display = 'flex';
|
||||
openDialog(modal);
|
||||
}
|
||||
|
||||
// Close the manifest delete confirmation modal
|
||||
function closeManifestDeleteModal() {
|
||||
const modal = document.getElementById('manifest-delete-modal');
|
||||
modal.style.display = 'none';
|
||||
closeDialog(document.getElementById('manifest-delete-modal'));
|
||||
}
|
||||
|
||||
// Confirm and execute manifest deletion with all tags
|
||||
@@ -357,21 +381,19 @@ async function confirmManifestDelete(repository, digest, sanitizedId) {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Successfully deleted
|
||||
closeManifestDeleteModal();
|
||||
removeManifestElement(sanitizedId);
|
||||
// Also remove any tag elements that were deleted
|
||||
location.reload(); // Reload to refresh the tags list
|
||||
location.reload();
|
||||
} else {
|
||||
// Error
|
||||
const errorText = await response.text();
|
||||
alert(`Failed to delete manifest: ${errorText}`);
|
||||
showToast(`Failed to delete manifest: ${errorText || response.status}`, 'error');
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = originalText;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting manifest:', err);
|
||||
alert(`Error deleting manifest: ${err.message}`);
|
||||
showToast(`Error deleting manifest: ${err.message}`, 'error');
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = originalText;
|
||||
}
|
||||
@@ -396,7 +418,7 @@ async function deleteUntaggedManifests(repository) {
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
document.getElementById('untagged-delete-modal').close();
|
||||
closeDialog(document.getElementById('untagged-delete-modal'));
|
||||
showToast(`Deleted ${data.deleted} untagged manifest(s)`, 'success');
|
||||
if (data.deleted > 0) {
|
||||
location.reload();
|
||||
@@ -404,13 +426,13 @@ async function deleteUntaggedManifests(repository) {
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = originalText;
|
||||
} else {
|
||||
alert(`Failed to delete untagged manifests: ${data.error || 'Unknown error'}`);
|
||||
showToast(`Failed to delete untagged manifests: ${data.error || 'Unknown error'}`, 'error');
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = originalText;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting untagged manifests:', err);
|
||||
alert(`Error: ${err.message}`);
|
||||
showToast(`Error: ${err.message}`, 'error');
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = originalText;
|
||||
}
|
||||
@@ -424,7 +446,9 @@ function removeManifestElement(sanitizedId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
// Close manifest-delete-modal on backdrop click. With native showModal()
|
||||
// the dialog itself receives clicks that land on its ::backdrop pseudo,
|
||||
// so e.target === modal correctly identifies a backdrop click.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modal = document.getElementById('manifest-delete-modal');
|
||||
if (modal) {
|
||||
@@ -436,24 +460,159 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Vulnerability details modal
|
||||
async function openVulnDetails(digest, holdEndpoint) {
|
||||
const modal = document.getElementById('vuln-detail-modal');
|
||||
const body = document.getElementById('vuln-modal-body');
|
||||
if (!modal || !body) return;
|
||||
// ----------------------------------------
|
||||
// Modal focus management
|
||||
// Native <dialog>.showModal() already traps Tab; we layer on:
|
||||
// 1. Restoring focus to the trigger when the dialog closes.
|
||||
// 2. Promoting HTMX-injected <dialog.modal-open> elements (which arrive
|
||||
// without showModal() ever being called) so the focus trap engages.
|
||||
// ----------------------------------------
|
||||
const _dialogOpeners = new WeakMap();
|
||||
|
||||
// Show modal with loading spinner
|
||||
body.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-lg"></span></div>';
|
||||
modal.showModal();
|
||||
function openDialog(dialog, opener) {
|
||||
if (!dialog) return;
|
||||
_dialogOpeners.set(dialog, opener || document.activeElement);
|
||||
if (typeof dialog.showModal !== 'function') return;
|
||||
// If the dialog is already in the non-modal open state (e.g. injected
|
||||
// with the [open] attribute), force it back into modal mode so Tab is
|
||||
// trapped. Setting open=false before showModal() avoids firing the
|
||||
// close event (which would steal focus restoration).
|
||||
if (dialog.open) dialog.open = false;
|
||||
try { dialog.showModal(); } catch (_) { /* detached from DOM */ }
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/vuln-details?digest=${encodeURIComponent(digest)}&holdEndpoint=${encodeURIComponent(holdEndpoint)}`);
|
||||
body.innerHTML = await resp.text();
|
||||
} catch {
|
||||
body.innerHTML = '<p class="text-error">Failed to load vulnerability details</p>';
|
||||
function closeDialog(dialog, { remove = false } = {}) {
|
||||
if (!dialog) return;
|
||||
const opener = _dialogOpeners.get(dialog);
|
||||
_dialogOpeners.delete(dialog);
|
||||
if (typeof dialog.close === 'function' && dialog.open) {
|
||||
try { dialog.close(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
if (remove) dialog.remove();
|
||||
restoreFocus(opener);
|
||||
}
|
||||
|
||||
function restoreFocus(opener) {
|
||||
if (opener && typeof opener.focus === 'function' && document.contains(opener)) {
|
||||
opener.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Native close events fire from Escape and <form method="dialog"> submits.
|
||||
// Capture phase because close doesn't bubble.
|
||||
document.addEventListener('close', e => {
|
||||
const t = e.target;
|
||||
if (!(t instanceof HTMLDialogElement)) return;
|
||||
const opener = _dialogOpeners.get(t);
|
||||
_dialogOpeners.delete(t);
|
||||
restoreFocus(opener);
|
||||
}, true);
|
||||
|
||||
// HTMX-injected dialogs were never passed through showModal(), so Tab can
|
||||
// escape them and the close event won't fire. Promote any settled <dialog>
|
||||
// that is currently visible — either via the .modal-open CSS class or via
|
||||
// the native [open] attribute. Skipping already-promoted ones keeps the
|
||||
// pass idempotent across nested swaps.
|
||||
document.body.addEventListener('htmx:afterSettle', () => {
|
||||
const sel = 'dialog.modal-open:not([data-modal-promoted]), dialog[open]:not([data-modal-promoted])';
|
||||
document.querySelectorAll(sel).forEach(d => {
|
||||
d.dataset.modalPromoted = '1';
|
||||
openDialog(d);
|
||||
});
|
||||
});
|
||||
|
||||
// Diff page: full-page navigate when the platform select changes.
|
||||
// Select carries data-diff-url with __VALUE__ as the platform placeholder.
|
||||
document.addEventListener('change', e => {
|
||||
const sel = e.target.closest('select[data-diff-url]');
|
||||
if (!sel) return;
|
||||
const template = sel.dataset.diffUrl;
|
||||
if (template) {
|
||||
window.location.href = template.replace('__VALUE__', encodeURIComponent(sel.value));
|
||||
}
|
||||
});
|
||||
|
||||
// Pull-command switcher: updates the displayed pull command in place and
|
||||
// persists the client preference (profile for signed-in users, localStorage
|
||||
// for anonymous). Server values flow through data-attributes on the
|
||||
// container element rather than inline string interpolation.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const container = document.getElementById('pull-cmd-container');
|
||||
if (!container) return;
|
||||
|
||||
const registryURL = container.dataset.registryUrl;
|
||||
const ownerHandle = container.dataset.ownerHandle;
|
||||
const repoName = container.dataset.repoName;
|
||||
const tag = container.dataset.tag || 'latest';
|
||||
const isLoggedIn = container.dataset.isLoggedIn === 'true';
|
||||
|
||||
function updatePullCommand(client) {
|
||||
const prefix = client === 'none' ? '' : client + ' pull ';
|
||||
const cmd = prefix + registryURL + '/' + ownerHandle + '/' + repoName + ':' + tag;
|
||||
const display = document.getElementById('pull-cmd-display');
|
||||
if (!display) return;
|
||||
const code = display.querySelector('code');
|
||||
if (code) code.textContent = cmd;
|
||||
const btn = display.querySelector('[data-cmd]');
|
||||
if (btn) btn.dataset.cmd = cmd;
|
||||
|
||||
if (isLoggedIn && window.htmx) {
|
||||
window.htmx.ajax('POST', '/api/profile/oci-client', { values: { oci_client: client }, swap: 'none' });
|
||||
} else if (!isLoggedIn) {
|
||||
localStorage.setItem('oci-client', client);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore preference for anonymous users.
|
||||
if (!isLoggedIn) {
|
||||
const saved = localStorage.getItem('oci-client');
|
||||
if (saved) {
|
||||
const sel = document.getElementById('oci-client-switcher');
|
||||
if (sel) {
|
||||
sel.value = saved;
|
||||
updatePullCommand(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sel = document.getElementById('oci-client-switcher');
|
||||
if (sel) sel.addEventListener('change', () => updatePullCommand(sel.value));
|
||||
});
|
||||
|
||||
// Install page: platform tab switcher. Each .platform-tab has data-platform
|
||||
// pointing at a sibling panel (#<platform>-content). No-op off the install page.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const tabs = document.querySelectorAll('.platform-tab[data-platform]');
|
||||
if (!tabs.length) return;
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach(t => {
|
||||
const active = t === tab;
|
||||
t.classList.toggle('btn-primary', active);
|
||||
t.classList.toggle('btn-ghost', !active);
|
||||
});
|
||||
document.querySelectorAll('.platform-content').forEach(p => p.classList.add('hidden'));
|
||||
const panel = document.getElementById(tab.dataset.platform + '-content');
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent double-submit on the login form. The OAuth redirect can take a few
|
||||
// seconds during which a second click would fire another request.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', () => {
|
||||
const submit = loginForm.querySelector('button[type="submit"]');
|
||||
if (!submit || submit.disabled) return;
|
||||
submit.disabled = true;
|
||||
submit.innerHTML = '<span class="loading loading-spinner loading-sm align-middle"></span> Navigating…';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save successful login handle from cookie (set by server after OAuth success)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const cookie = document.cookie.split('; ').find(c => c.startsWith('atcr_login_handle='));
|
||||
@@ -494,19 +653,30 @@ function initFeaturedCarousel() {
|
||||
let intervalId = null;
|
||||
const intervalMs = 5000;
|
||||
|
||||
// Read on demand — at most once per click or per 5s autoplay tick.
|
||||
function step() {
|
||||
const first = items[0];
|
||||
// Cache the per-step scroll distance; offsetWidth forces layout, so
|
||||
// measuring once per resize beats once per autoplay tick. rAF-coalesces
|
||||
// bursty resize events.
|
||||
let stepPx = 0;
|
||||
let resizeRaf = 0;
|
||||
function measureStep() {
|
||||
const gap = parseFloat(getComputedStyle(carousel).gap) || 24;
|
||||
return first.offsetWidth + gap;
|
||||
stepPx = items[0].offsetWidth + gap;
|
||||
}
|
||||
measureStep();
|
||||
window.addEventListener('resize', () => {
|
||||
cancelAnimationFrame(resizeRaf);
|
||||
resizeRaf = requestAnimationFrame(measureStep);
|
||||
});
|
||||
document.body.addEventListener('htmx:afterSettle', e => {
|
||||
if (e.target && e.target.contains && e.target.contains(carousel)) measureStep();
|
||||
});
|
||||
|
||||
function advance() {
|
||||
const max = carousel.scrollWidth - carousel.clientWidth;
|
||||
if (carousel.scrollLeft >= max - 10) {
|
||||
carousel.scrollTo({ left: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
carousel.scrollBy({ left: step(), behavior: 'smooth' });
|
||||
carousel.scrollBy({ left: stepPx, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,12 +684,13 @@ function initFeaturedCarousel() {
|
||||
if (carousel.scrollLeft <= 10) {
|
||||
carousel.scrollTo({ left: carousel.scrollWidth, behavior: 'smooth' });
|
||||
} else {
|
||||
carousel.scrollBy({ left: -step(), behavior: 'smooth' });
|
||||
carousel.scrollBy({ left: -stepPx, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function startInterval() {
|
||||
if (intervalId) return;
|
||||
if (document.visibilityState === 'hidden') return;
|
||||
if (carousel.scrollWidth <= carousel.clientWidth + 10) return;
|
||||
intervalId = setInterval(advance, intervalMs);
|
||||
}
|
||||
@@ -534,6 +705,13 @@ function initFeaturedCarousel() {
|
||||
carousel.addEventListener('mouseenter', stopInterval);
|
||||
carousel.addEventListener('mouseleave', startInterval);
|
||||
|
||||
// Pause autoplay while the tab is hidden — a scroll-snap animation on an
|
||||
// invisible carousel still eats compositor time on the other tab.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') stopInterval();
|
||||
else startInterval();
|
||||
});
|
||||
|
||||
startInterval();
|
||||
}
|
||||
|
||||
@@ -546,28 +724,79 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Toast notifications (auto-dismiss after 3s)
|
||||
// Toast notifications (auto-dismiss after 3s).
|
||||
// - Uses textContent, never innerHTML — error text sometimes relays server
|
||||
// response bodies that could contain markup.
|
||||
// - Container has aria-live for screen reader announcements; errors use
|
||||
// role="alert" (assertive) and success uses role="status" (polite).
|
||||
// - Dedupes identical toasts fired within 1.5s and caps the visible stack
|
||||
// so a burst of failures (bulk delete, retry storm) can't pile up.
|
||||
const TOAST_MAX = 4;
|
||||
const TOAST_DEDUPE_MS = 1500;
|
||||
|
||||
function showToast(message, type) {
|
||||
let container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'toast toast-end toast-bottom z-50';
|
||||
container.setAttribute('aria-live', 'polite');
|
||||
container.setAttribute('aria-atomic', 'false');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const alertClass = type === 'error' ? 'alert-error' : 'alert-success';
|
||||
// Dedupe: if an identical toast is already on screen and was added
|
||||
// within the dedupe window, reset its dismiss timer instead of adding
|
||||
// a duplicate. Keeps the stack readable during error storms.
|
||||
const key = (type || 'info') + '|' + message;
|
||||
const now = Date.now();
|
||||
const existing = container.querySelector(`[data-toast-key="${cssEscape(key)}"]`);
|
||||
if (existing && now - Number(existing.dataset.toastAt) < TOAST_DEDUPE_MS) {
|
||||
resetToastDismiss(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const isError = type === 'error';
|
||||
const alertClass = isError ? 'alert-error' : 'alert-success';
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert ${alertClass} shadow-lg transition-opacity duration-300`;
|
||||
toast.innerHTML = `<span>${message}</span>`;
|
||||
toast.style.willChange = 'opacity';
|
||||
toast.setAttribute('role', isError ? 'alert' : 'status');
|
||||
toast.dataset.toastKey = key;
|
||||
toast.dataset.toastAt = String(now);
|
||||
const span = document.createElement('span');
|
||||
span.textContent = message;
|
||||
toast.appendChild(span);
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
// Cap the visible stack — drop the oldest if we exceed the cap.
|
||||
while (container.children.length > TOAST_MAX) {
|
||||
container.firstElementChild.remove();
|
||||
}
|
||||
|
||||
scheduleToastDismiss(toast);
|
||||
}
|
||||
|
||||
function scheduleToastDismiss(toast) {
|
||||
toast._dismissTimer = setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
toast._removeTimer = setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function resetToastDismiss(toast) {
|
||||
clearTimeout(toast._dismissTimer);
|
||||
clearTimeout(toast._removeTimer);
|
||||
toast.style.opacity = '';
|
||||
toast.dataset.toastAt = String(Date.now());
|
||||
scheduleToastDismiss(toast);
|
||||
}
|
||||
|
||||
// Minimal CSS.escape polyfill for the dedupe attribute selector.
|
||||
function cssEscape(s) {
|
||||
return (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c);
|
||||
}
|
||||
|
||||
// Test webhook via fetch + toast
|
||||
async function testWebhook(id) {
|
||||
try {
|
||||
@@ -586,15 +815,63 @@ async function testWebhook(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions that are called from templates via onclick handlers
|
||||
// ----------------------------------------
|
||||
// Delegated action dispatcher
|
||||
// Templates use data-action="name" + data-* attributes for args instead
|
||||
// of inline onclick=/onchange=/oninput= handlers. Handlers resolve off
|
||||
// window.* (already registered below) so HTMX-swapped content keeps
|
||||
// working via natural event bubbling — no re-binding needed.
|
||||
// ----------------------------------------
|
||||
(function initActionDispatcher() {
|
||||
const click = {
|
||||
'switch-repo-tab': (t) => window.switchRepoTab && window.switchRepoTab(t.dataset.tab),
|
||||
'switch-editor-tab': (t) => window.switchEditorTab && window.switchEditorTab(t.dataset.tab),
|
||||
'insert-md': (t) => window.insertMd && window.insertMd(t.dataset.mdType),
|
||||
'toggle-editor': (t) => window.toggleOverviewEditor && window.toggleOverviewEditor(t.dataset.show === 'true'),
|
||||
'show-modal': (t) => openDialog(document.getElementById(t.dataset.modalId), t),
|
||||
'close-dialog': (t) => closeDialog(t.closest('dialog')),
|
||||
'remove-closest-dialog': (t) => closeDialog(t.closest('dialog'), { remove: true }),
|
||||
'close-manifest-delete-modal': () => window.closeManifestDeleteModal && window.closeManifestDeleteModal(),
|
||||
'save-overview': () => window.saveOverview && window.saveOverview(),
|
||||
'delete-manifest': (t) => window.deleteManifest && window.deleteManifest(t.dataset.repo, t.dataset.digest, t.dataset.manifestId || ''),
|
||||
'delete-untagged': (t) => window.deleteUntaggedManifests && window.deleteUntaggedManifests(t.dataset.repo),
|
||||
'copy': (t) => window.copyToClipboard && window.copyToClipboard(t.dataset.copy, t),
|
||||
'toggle-search': () => window.toggleSearch && window.toggleSearch(),
|
||||
'switch-settings-tab': (t) => window.switchSettingsTab && window.switchSettingsTab(t.dataset.tab),
|
||||
'test-webhook': (t) => window.testWebhook && window.testWebhook(t.dataset.webhookId),
|
||||
'diff-to': (t, e) => window.diffToTag && window.diffToTag(e, t),
|
||||
'modal-backdrop-close': (t, e) => { if (e.target === t) closeDialog(t, { remove: true }); },
|
||||
};
|
||||
|
||||
const change = {
|
||||
'sort-tags': (t) => window.sortTags && window.sortTags(t.value),
|
||||
'submit-form': (t) => t.form && t.form.requestSubmit(),
|
||||
};
|
||||
|
||||
const input = {
|
||||
'filter-tags': (t) => window.filterTags && window.filterTags(t.value),
|
||||
};
|
||||
|
||||
function dispatch(map, e) {
|
||||
const t = e.target.closest('[data-action]');
|
||||
if (!t) return;
|
||||
const fn = map[t.dataset.action];
|
||||
if (fn) fn(t, e);
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => dispatch(click, e));
|
||||
document.addEventListener('change', e => dispatch(change, e));
|
||||
document.addEventListener('input', e => dispatch(input, e));
|
||||
})();
|
||||
|
||||
// Expose the minimum surface the delegated dispatcher and other modules
|
||||
// need. Keep this list tight — anything added here is implicitly part of
|
||||
// the public API that templates can reach for.
|
||||
window.setTheme = setTheme;
|
||||
window.toggleSearch = toggleSearch;
|
||||
window.closeSearch = closeSearch;
|
||||
window.copyToClipboard = copyToClipboard;
|
||||
window.toggleOfflineManifests = toggleOfflineManifests;
|
||||
window.deleteManifest = deleteManifest;
|
||||
window.deleteUntaggedManifests = deleteUntaggedManifests;
|
||||
window.closeManifestDeleteModal = closeManifestDeleteModal;
|
||||
window.openVulnDetails = openVulnDetails;
|
||||
window.showToast = showToast;
|
||||
window.testWebhook = testWebhook;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Layer table enhancements: collapse runs of no-history layers into a
|
||||
// single summary row, and toggle empty-layer visibility via localStorage.
|
||||
// Shared by repository.html (Layers tab) and digest.html (side-by-side view).
|
||||
|
||||
const STORAGE_KEY = 'showEmptyLayers';
|
||||
|
||||
function sumBytesFromTable(rows, start, count) {
|
||||
let total = 0;
|
||||
for (let k = start; k < start + count; k++) {
|
||||
const sizeCell = rows[k].querySelector('td[data-bytes]');
|
||||
if (!sizeCell) continue;
|
||||
const bytes = Number(sizeCell.dataset.bytes);
|
||||
if (Number.isFinite(bytes)) total += bytes;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function humanize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
function collapseNoHistoryLayers(table) {
|
||||
const tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
let i = 0;
|
||||
while (i < rows.length) {
|
||||
if (rows[i].dataset.noCommand !== 'true') { i++; continue; }
|
||||
|
||||
const start = i;
|
||||
while (i < rows.length && rows[i].dataset.noCommand === 'true') {
|
||||
rows[i].classList.add('no-history-row', 'hidden');
|
||||
i++;
|
||||
}
|
||||
const count = i - start;
|
||||
if (count <= 1) { rows[start].classList.remove('hidden'); continue; }
|
||||
|
||||
const startIdx = rows[start].querySelector('td').textContent.trim();
|
||||
const endIdx = rows[i - 1].querySelector('td').textContent.trim();
|
||||
const sizeStr = humanize(sumBytesFromTable(rows, start, count));
|
||||
|
||||
const summary = document.createElement('tr');
|
||||
summary.className = 'no-history-summary cursor-pointer hover:bg-base-300';
|
||||
summary.innerHTML =
|
||||
'<td colspan="2" class="text-sm py-2">Layers ' + startIdx + '-' + endIdx +
|
||||
' contain no history <span class="text-xs ml-2">(' + count +
|
||||
' layers, click to expand)</span></td>' +
|
||||
'<td class="text-right text-sm whitespace-nowrap">' + sizeStr + '</td>';
|
||||
summary.addEventListener('click', () => {
|
||||
summary.remove();
|
||||
for (let j = start; j < start + count; j++) rows[j].classList.remove('hidden');
|
||||
});
|
||||
tbody.insertBefore(summary, rows[start]);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLayerVisibility(table) {
|
||||
const show = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
table.querySelectorAll('tr[data-empty="true"]').forEach(row => {
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function initLayersTables(root) {
|
||||
const scope = root || document;
|
||||
const tables = scope.querySelectorAll ?
|
||||
scope.querySelectorAll('.layers-table:not([data-layers-processed])') :
|
||||
[];
|
||||
tables.forEach(table => {
|
||||
table.setAttribute('data-layers-processed', '1');
|
||||
collapseNoHistoryLayers(table);
|
||||
applyLayerVisibility(table);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEmptyLayers(show) {
|
||||
localStorage.setItem(STORAGE_KEY, show);
|
||||
document.querySelectorAll('.show-empty-layers-cb').forEach(cb => { cb.checked = show; });
|
||||
document.querySelectorAll('.layers-table').forEach(applyLayerVisibility);
|
||||
}
|
||||
|
||||
// Seed checkbox state from localStorage on load, and delegate changes.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const show = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
document.querySelectorAll('.show-empty-layers-cb').forEach(cb => { cb.checked = show; });
|
||||
initLayersTables();
|
||||
});
|
||||
|
||||
document.addEventListener('change', e => {
|
||||
if (e.target.matches('[data-toggle-empty-layers]')) {
|
||||
toggleEmptyLayers(e.target.checked);
|
||||
}
|
||||
});
|
||||
|
||||
// Process tables that arrive via HTMX swap (layers tab lazy-loads).
|
||||
document.body.addEventListener('htmx:afterSettle', e => {
|
||||
if (e.target && e.target.querySelectorAll) initLayersTables(e.target);
|
||||
});
|
||||
@@ -13,3 +13,13 @@ import './sailor-typeahead.js';
|
||||
|
||||
// Import app functionality
|
||||
import './app.js';
|
||||
|
||||
// Repository page module (owner editor, tag sort/filter, tab controller).
|
||||
// Safe to include globally — all initializers no-op when their target DOM is absent.
|
||||
import './repository.js';
|
||||
|
||||
// Settings page module (tab controller + account deletion modal).
|
||||
import './settings.js';
|
||||
|
||||
// Layer table enhancements (shared by repository + digest views).
|
||||
import './layers.js';
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
// Repository page: owner markdown editor, tag sort/filter, tab controller.
|
||||
// Extracted from repository.html to keep the page shell light.
|
||||
|
||||
// ----------------------------------------
|
||||
// Owner markdown editor (no-op if not owner / not on page)
|
||||
// ----------------------------------------
|
||||
function initOverviewEditor() {
|
||||
const textarea = document.getElementById('md-editor');
|
||||
if (!textarea) return;
|
||||
|
||||
const ownerDID = textarea.dataset.ownerDid;
|
||||
const repoName = textarea.dataset.repository;
|
||||
|
||||
window.toggleOverviewEditor = function(show) {
|
||||
document.getElementById('overview-view').classList.toggle('hidden', show);
|
||||
document.getElementById('overview-edit').classList.toggle('hidden', !show);
|
||||
if (show) textarea.focus();
|
||||
};
|
||||
|
||||
window.switchEditorTab = function(tab) {
|
||||
document.querySelectorAll('.editor-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden');
|
||||
|
||||
document.querySelectorAll('.editor-tab').forEach(t => {
|
||||
const active = t.dataset.tab === tab;
|
||||
t.classList.toggle('border-primary', active);
|
||||
t.classList.toggle('text-primary', active);
|
||||
t.classList.toggle('border-transparent', !active);
|
||||
t.classList.toggle('text-base-content/60', !active);
|
||||
});
|
||||
|
||||
if (tab === 'preview') {
|
||||
const content = textarea.value;
|
||||
const previewEl = document.getElementById('preview-content');
|
||||
if (!content.trim()) {
|
||||
previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>';
|
||||
return;
|
||||
}
|
||||
previewEl.innerHTML = '<p class="text-base-content/60"><span class="loading loading-spinner loading-xs align-middle"></span> Rendering preview…</p>';
|
||||
const form = new FormData();
|
||||
form.append('markdown', content);
|
||||
fetch('/api/repo-page/preview', { method: 'POST', body: form })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.text();
|
||||
})
|
||||
.then(html => { previewEl.innerHTML = html; })
|
||||
.catch(() => {
|
||||
previewEl.innerHTML = '<p class="text-error">Preview failed. Check your connection and try again.</p>';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.insertMd = function(type) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = textarea.value.substring(start, end);
|
||||
const before = textarea.value.substring(0, start);
|
||||
const after = textarea.value.substring(end);
|
||||
let insert, cursorStart, cursorEnd;
|
||||
|
||||
switch (type) {
|
||||
case 'heading':
|
||||
insert = '## ' + (selected || 'Heading');
|
||||
cursorStart = start + 3;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'bold':
|
||||
insert = '**' + (selected || 'bold text') + '**';
|
||||
cursorStart = start + 2;
|
||||
cursorEnd = start + insert.length - 2;
|
||||
break;
|
||||
case 'italic':
|
||||
insert = '_' + (selected || 'italic text') + '_';
|
||||
cursorStart = start + 1;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'link':
|
||||
insert = '[' + (selected || 'link text') + '](url)';
|
||||
cursorStart = start + insert.length - 4;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'image':
|
||||
insert = '';
|
||||
cursorStart = start + insert.length - 4;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'ul':
|
||||
insert = '- ' + (selected || 'list item');
|
||||
cursorStart = start + 2;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'ol':
|
||||
insert = '1. ' + (selected || 'list item');
|
||||
cursorStart = start + 3;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'code':
|
||||
if (selected && selected.indexOf('\n') !== -1) {
|
||||
insert = '```\n' + selected + '\n```';
|
||||
cursorStart = start + 4;
|
||||
cursorEnd = start + 4 + selected.length;
|
||||
} else {
|
||||
insert = '`' + (selected || 'code') + '`';
|
||||
cursorStart = start + 1;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.value = before + insert + after;
|
||||
textarea.focus();
|
||||
textarea.selectionStart = cursorStart;
|
||||
textarea.selectionEnd = cursorEnd;
|
||||
};
|
||||
|
||||
window.saveOverview = function() {
|
||||
const btn = document.getElementById('save-overview-btn');
|
||||
btn.classList.add('btn-disabled');
|
||||
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...';
|
||||
|
||||
const form = new FormData();
|
||||
form.append('did', ownerDID);
|
||||
form.append('repository', repoName);
|
||||
form.append('description', textarea.value);
|
||||
|
||||
fetch('/api/repo-page', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'HX-Request': 'true' }
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) return r.text().then(t => { throw new Error(t); });
|
||||
return r.text();
|
||||
})
|
||||
.then(html => {
|
||||
document.getElementById('overview-rendered').innerHTML = html;
|
||||
window.toggleOverviewEditor(false);
|
||||
if (typeof window.showToast === 'function') window.showToast('Overview saved', 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
if (typeof window.showToast === 'function') window.showToast(err.message || 'Failed to save', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
btn.classList.remove('btn-disabled');
|
||||
btn.innerHTML = 'Save';
|
||||
});
|
||||
};
|
||||
|
||||
textarea.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
window.saveOverview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// Tag list sort & filter (called from repo-tags.html)
|
||||
// ----------------------------------------
|
||||
window.sortTags = function(method) {
|
||||
const container = document.getElementById('tags-list');
|
||||
if (!container) return;
|
||||
const entries = Array.from(container.querySelectorAll('.artifact-entry'));
|
||||
entries.sort((a, b) => {
|
||||
switch (method) {
|
||||
case 'oldest': return parseInt(a.dataset.created) - parseInt(b.dataset.created);
|
||||
case 'az': return a.dataset.tag.localeCompare(b.dataset.tag);
|
||||
case 'za': return b.dataset.tag.localeCompare(a.dataset.tag);
|
||||
default: return parseInt(b.dataset.created) - parseInt(a.dataset.created);
|
||||
}
|
||||
});
|
||||
entries.forEach(el => container.appendChild(el));
|
||||
};
|
||||
|
||||
// Debounced filter. Runs once per animation frame at most so holding down
|
||||
// a key or pasting doesn't thrash the whole artifact list (can be 100+
|
||||
// entries on active repos). Rendering still feels instant.
|
||||
let filterTagsHandle = 0;
|
||||
window.filterTags = function(query) {
|
||||
if (filterTagsHandle) cancelAnimationFrame(filterTagsHandle);
|
||||
filterTagsHandle = requestAnimationFrame(() => {
|
||||
filterTagsHandle = 0;
|
||||
const q = query.toLowerCase();
|
||||
document.querySelectorAll('#tags-list .artifact-entry').forEach(el => {
|
||||
el.style.display = (!q || el.dataset.tag.toLowerCase().includes(q)) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ----------------------------------------
|
||||
// Tag-scoped tab controller (reads config from #tag-content data attributes)
|
||||
// ----------------------------------------
|
||||
function initTabController() {
|
||||
if (!document.getElementById('tag-content')) return;
|
||||
|
||||
const validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts'];
|
||||
let loaded = {};
|
||||
|
||||
function lazyLoad(id, url) {
|
||||
if (loaded[id]) return;
|
||||
loaded[id] = true;
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
|
||||
// Abort if the request hangs. SBOM/vuln endpoints can stall when a
|
||||
// hold is overloaded; without a timeout the spinner spins forever.
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
fetch(url, { signal: controller.signal })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.text();
|
||||
})
|
||||
.then(html => {
|
||||
target.innerHTML = html;
|
||||
// innerHTML doesn't execute <script> tags — re-create them
|
||||
target.querySelectorAll('script').forEach(old => {
|
||||
const s = document.createElement('script');
|
||||
s.textContent = old.textContent;
|
||||
old.parentNode.replaceChild(s, old);
|
||||
});
|
||||
if (typeof window.htmx !== 'undefined') window.htmx.process(target);
|
||||
})
|
||||
.catch(err => {
|
||||
loaded[id] = false;
|
||||
const timedOut = err && err.name === 'AbortError';
|
||||
const msg = timedOut
|
||||
? 'This section took too long to load.'
|
||||
: 'Couldn\'t load this section.';
|
||||
target.innerHTML =
|
||||
'<div class="py-6 text-sm text-base-content/70">' +
|
||||
'<p>' + msg + '</p>' +
|
||||
'<button type="button" class="btn btn-sm btn-ghost mt-2" data-retry-section="' + id + '">Try again</button>' +
|
||||
'</div>';
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
}
|
||||
|
||||
// Retry button for failed section loads (event delegation)
|
||||
document.body.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-retry-section]');
|
||||
if (!btn) return;
|
||||
const id = btn.getAttribute('data-retry-section');
|
||||
// Re-derive URL from current active tab
|
||||
const map = { 'artifacts-content': tagsUrl, 'layers-content': () => contentUrl('layers'),
|
||||
'vulns-content': () => contentUrl('vulns'), 'sbom-content': () => contentUrl('sbom') };
|
||||
const fn = map[id];
|
||||
if (fn) { const u = fn(); if (u) lazyLoad(id, u); }
|
||||
});
|
||||
|
||||
function contentUrl(section) {
|
||||
const el = document.getElementById('tag-content');
|
||||
if (!el) return null;
|
||||
const digest = el.dataset.digest;
|
||||
if (!digest) return null;
|
||||
return '/api/digest-content/' + el.dataset.owner + '/' + el.dataset.repo +
|
||||
'?digest=' + encodeURIComponent(digest) + '§ion=' + section;
|
||||
}
|
||||
|
||||
function tagsUrl() {
|
||||
const el = document.getElementById('tag-content');
|
||||
if (!el) return null;
|
||||
return '/api/repo-tags/' + el.dataset.owner + '/' + el.dataset.repo;
|
||||
}
|
||||
|
||||
window.diffToTag = function(e, link) {
|
||||
e.preventDefault();
|
||||
const to = link.dataset.diffTo;
|
||||
const content = document.getElementById('tag-content');
|
||||
const selector = document.getElementById('tag-selector');
|
||||
if (!content || !selector || !to) return;
|
||||
const fromDigest = content.dataset.digest;
|
||||
const currentTag = selector.value;
|
||||
if (!fromDigest || to === currentTag) return;
|
||||
window.location.href = '/diff/' + content.dataset.owner + '/' + content.dataset.repo +
|
||||
'?from=' + encodeURIComponent(fromDigest) + '&to=' + encodeURIComponent(to);
|
||||
};
|
||||
|
||||
window.switchRepoTab = function(tabId) {
|
||||
window._activeRepoTab = tabId;
|
||||
const section = document.getElementById('tag-content');
|
||||
if (!section) return;
|
||||
|
||||
section.querySelectorAll('.repo-panel').forEach(p => p.classList.add('hidden'));
|
||||
const panel = document.getElementById('tab-' + tabId);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
|
||||
// Roving tabindex: only the active tab is in the document tab sequence.
|
||||
section.querySelectorAll('.repo-tab').forEach(tab => {
|
||||
const active = tab.dataset.tab === tabId;
|
||||
tab.classList.toggle('border-primary', active);
|
||||
tab.classList.toggle('text-primary', active);
|
||||
tab.classList.toggle('border-transparent', !active);
|
||||
tab.classList.toggle('text-base-content/60', !active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
tab.setAttribute('tabindex', active ? '0' : '-1');
|
||||
});
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.hash = tabId;
|
||||
history.replaceState(null, '', url.toString());
|
||||
|
||||
if (tabId === 'artifacts') { const u = tagsUrl(); if (u) lazyLoad('artifacts-content', u); }
|
||||
if (tabId === 'layers') { const u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); }
|
||||
if (tabId === 'vulns') { const u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); }
|
||||
if (tabId === 'sbom') { const u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); }
|
||||
};
|
||||
|
||||
function initTabs() {
|
||||
loaded = {};
|
||||
|
||||
const prefetch = [
|
||||
['artifacts-tab-btn', 'artifacts-content', tagsUrl],
|
||||
['layers-tab-btn', 'layers-content', () => contentUrl('layers')],
|
||||
['vulns-tab-btn', 'vulns-content', () => contentUrl('vulns')],
|
||||
['sbom-tab-btn', 'sbom-content', () => contentUrl('sbom')],
|
||||
];
|
||||
prefetch.forEach(([btnId, targetId, urlFn]) => {
|
||||
const btn = document.getElementById(btnId);
|
||||
if (btn) btn.addEventListener('mouseenter', () => {
|
||||
const u = urlFn();
|
||||
if (u) lazyLoad(targetId, u);
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
// WAI-ARIA tablist keyboard nav (manual activation — arrows move focus,
|
||||
// Enter/Space activates). Avoids triggering 4 lazy loads when cycling.
|
||||
const tablist = document.querySelector('[role="tablist"][aria-label="Repository sections"]');
|
||||
if (tablist && !tablist.dataset.keyboardBound) {
|
||||
tablist.dataset.keyboardBound = '1';
|
||||
tablist.addEventListener('keydown', evt => {
|
||||
const tabs = Array.from(tablist.querySelectorAll('.repo-tab'));
|
||||
const currentIdx = tabs.indexOf(document.activeElement);
|
||||
if (currentIdx === -1) return;
|
||||
let targetIdx = -1;
|
||||
switch (evt.key) {
|
||||
case 'ArrowRight': targetIdx = (currentIdx + 1) % tabs.length; break;
|
||||
case 'ArrowLeft': targetIdx = (currentIdx - 1 + tabs.length) % tabs.length; break;
|
||||
case 'Home': targetIdx = 0; break;
|
||||
case 'End': targetIdx = tabs.length - 1; break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
evt.preventDefault();
|
||||
window.switchRepoTab(tabs[currentIdx].dataset.tab);
|
||||
return;
|
||||
default: return;
|
||||
}
|
||||
evt.preventDefault();
|
||||
tabs[targetIdx].focus();
|
||||
});
|
||||
}
|
||||
|
||||
let initTab = window._activeRepoTab || window.location.hash.replace('#', '') || 'overview';
|
||||
if (validTabs.indexOf(initTab) === -1) initTab = 'overview';
|
||||
window.switchRepoTab(initTab);
|
||||
}
|
||||
|
||||
initTabs();
|
||||
|
||||
// Keyboard shortcuts: first letter of each tab name
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.tagName === 'SELECT' || e.target.isContentEditable) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const map = { o: 'overview', l: 'layers', v: 'vulns', s: 'sbom', a: 'artifacts' };
|
||||
const tab = map[e.key.toLowerCase()];
|
||||
if (tab && validTabs.indexOf(tab) !== -1) window.switchRepoTab(tab);
|
||||
});
|
||||
|
||||
// Re-run after HTMX swaps tag section content (tag dropdown change)
|
||||
document.body.addEventListener('htmx:afterSettle', evt => {
|
||||
if (evt.detail.target && evt.detail.target.id === 'tag-content') {
|
||||
initTabs();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initOverviewEditor();
|
||||
initTabController();
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
// Settings page: tab controller + account deletion modal.
|
||||
// Both initializers are no-ops when their targets aren't on the page.
|
||||
|
||||
// ----------------------------------------
|
||||
// Tab controller
|
||||
// Mobile horizontal tablist (.settings-tab-mobile) and desktop vertical
|
||||
// sidebar menu (.menu li[data-tab]) stay in sync via a single switch fn.
|
||||
// Uses roving tabindex + arrow-key nav per WAI-ARIA tabs pattern.
|
||||
// ----------------------------------------
|
||||
function initSettingsTabs() {
|
||||
const validTabs = ['user', 'billing', 'storage', 'devices', 'webhooks', 'advanced'];
|
||||
if (!document.querySelector('.settings-tab-mobile, .menu li[data-tab]')) return;
|
||||
|
||||
function switchSettingsTab(tabId) {
|
||||
document.querySelectorAll('.settings-panel').forEach(p => p.classList.add('hidden'));
|
||||
const panel = document.getElementById('tab-' + tabId);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
|
||||
document.querySelectorAll('.menu li[data-tab]').forEach(li => {
|
||||
const active = li.dataset.tab === tabId;
|
||||
li.classList.toggle('menu-active', active);
|
||||
const a = li.querySelector('a[role="tab"]');
|
||||
if (a) {
|
||||
a.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
a.setAttribute('tabindex', active ? '0' : '-1');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.settings-tab-mobile').forEach(btn => {
|
||||
const active = btn.dataset.tab === tabId;
|
||||
btn.classList.toggle('btn-ghost', !active);
|
||||
btn.classList.toggle('btn-secondary', active);
|
||||
btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
btn.setAttribute('tabindex', active ? '0' : '-1');
|
||||
});
|
||||
|
||||
history.replaceState(null, '', '#' + tabId);
|
||||
document.body.dispatchEvent(new CustomEvent('tab:' + tabId));
|
||||
}
|
||||
|
||||
// Exposed so HTMX hx-trigger="every 30s[isTabActive('devices')]" can poll.
|
||||
window.isTabActive = function(tabId) {
|
||||
const panel = document.getElementById('tab-' + tabId);
|
||||
return panel && !panel.classList.contains('hidden');
|
||||
};
|
||||
|
||||
// Exposed so inline <a href="#billing"> onclick can still hop tabs.
|
||||
window.switchSettingsTab = switchSettingsTab;
|
||||
|
||||
function handleTabKeydown(tabs, orientation) {
|
||||
const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
|
||||
const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
|
||||
return function(e) {
|
||||
const idx = tabs.indexOf(e.currentTarget);
|
||||
if (idx === -1) return;
|
||||
let target = null;
|
||||
if (e.key === prevKey) target = tabs[(idx - 1 + tabs.length) % tabs.length];
|
||||
else if (e.key === nextKey) target = tabs[(idx + 1) % tabs.length];
|
||||
else if (e.key === 'Home') target = tabs[0];
|
||||
else if (e.key === 'End') target = tabs[tabs.length - 1];
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
switchSettingsTab(target.dataset.tab || target.parentElement.dataset.tab);
|
||||
target.focus();
|
||||
};
|
||||
}
|
||||
|
||||
const mobileTabs = Array.from(document.querySelectorAll('.settings-tab-mobile'));
|
||||
const mobileKeydown = handleTabKeydown(mobileTabs, 'horizontal');
|
||||
mobileTabs.forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
switchSettingsTab(btn.dataset.tab);
|
||||
});
|
||||
btn.addEventListener('keydown', mobileKeydown);
|
||||
});
|
||||
|
||||
const sidebarTabs = Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]'));
|
||||
const sidebarKeydown = handleTabKeydown(sidebarTabs, 'vertical');
|
||||
sidebarTabs.forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
switchSettingsTab(link.parentElement.dataset.tab);
|
||||
});
|
||||
link.addEventListener('keydown', sidebarKeydown);
|
||||
});
|
||||
|
||||
let hash = window.location.hash.replace('#', '') || 'user';
|
||||
if (validTabs.indexOf(hash) === -1) hash = 'user';
|
||||
switchSettingsTab(hash);
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
let h = window.location.hash.replace('#', '') || 'user';
|
||||
if (validTabs.indexOf(h) !== -1) switchSettingsTab(h);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// Account deletion modal
|
||||
// Reads product name + handle from data-attributes on the trigger so the
|
||||
// module stays pure JS with no server-side string interpolation.
|
||||
// ----------------------------------------
|
||||
function initAccountDeletion() {
|
||||
const deleteBtn = document.getElementById('delete-account-btn');
|
||||
if (!deleteBtn) return;
|
||||
|
||||
const clientShortName = deleteBtn.dataset.clientShortName || 'this account';
|
||||
const profileHandle = deleteBtn.dataset.profileHandle || '';
|
||||
const expectedConfirmation = 'DELETE ' + profileHandle;
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
deleteBtn.addEventListener('click', showDeleteConfirmationModal);
|
||||
|
||||
function showDeleteConfirmationModal() {
|
||||
const deletePDSNow = document.getElementById('delete-pds-records').checked;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal modal-open';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-box bg-base-200 max-w-lg">
|
||||
<h2 class="text-xl font-bold flex items-center gap-2 text-error">
|
||||
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#alert-triangle"></use></svg>
|
||||
Delete ${escapeHtml(clientShortName)} Data
|
||||
</h2>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-success">
|
||||
<svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#check-circle"></use></svg>
|
||||
<span>Your ATProto account will <strong>NOT</strong> be affected.</span>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/80">
|
||||
This action <strong>cannot be undone</strong>. This will permanently delete:
|
||||
</p>
|
||||
|
||||
<ul class="list-disc list-inside text-sm space-y-1 text-base-content/70">
|
||||
<li>Your ${escapeHtml(clientShortName)} account and all settings</li>
|
||||
<li>All authorized devices</li>
|
||||
<li>Your data from all holds you're a member of</li>
|
||||
${deletePDSNow ? '<li>All io.atcr.* records from your ATProto PDS</li>' : ''}
|
||||
</ul>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="confirm-delete-input" class="text-sm">Type <strong class="font-mono">${escapeHtml(expectedConfirmation)}</strong> to confirm:</label>
|
||||
<input type="text" id="confirm-delete-input" class="input input-bordered w-full font-mono" placeholder="${escapeHtml(expectedConfirmation)}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="cancel-delete">Cancel</button>
|
||||
<button type="button" class="btn btn-error gap-2" id="confirm-delete" disabled>
|
||||
<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#trash-2"></use></svg>
|
||||
Delete My ${escapeHtml(clientShortName)} Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/50" id="modal-backdrop"></div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const confirmInput = document.getElementById('confirm-delete-input');
|
||||
const confirmBtn = document.getElementById('confirm-delete');
|
||||
const cancelBtn = document.getElementById('cancel-delete');
|
||||
|
||||
setTimeout(() => confirmInput.focus(), 100);
|
||||
|
||||
confirmInput.addEventListener('input', function() {
|
||||
confirmBtn.disabled = this.value !== expectedConfirmation;
|
||||
});
|
||||
|
||||
confirmInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && this.value === expectedConfirmation) {
|
||||
performAccountDeletion();
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => modal.remove());
|
||||
document.getElementById('modal-backdrop').addEventListener('click', () => modal.remove());
|
||||
|
||||
function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
confirmBtn.addEventListener('click', performAccountDeletion);
|
||||
|
||||
async function performAccountDeletion() {
|
||||
const deletePDS = document.getElementById('delete-pds-records').checked;
|
||||
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.innerHTML = '<svg class="icon size-4 animate-spin" aria-hidden="true"><use href="/icons.svg#loader-2"></use></svg> Deleting...';
|
||||
cancelBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
delete_pds_records: deletePDS,
|
||||
confirmation: expectedConfirmation
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
modal.querySelector('.modal-box').innerHTML = `
|
||||
<h2 class="text-xl font-bold flex items-center gap-2 text-success">
|
||||
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#check-circle"></use></svg>
|
||||
Account Deleted
|
||||
</h2>
|
||||
<div class="py-4 space-y-2">
|
||||
<p>Your account has been successfully deleted.</p>
|
||||
<p class="text-base-content/70">Redirecting to home page...</p>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => { window.location.href = '/?deleted=true'; }, 2000);
|
||||
} else {
|
||||
const errors = result.errors || ['An unknown error occurred'];
|
||||
modal.querySelector('.modal-box').innerHTML = `
|
||||
<h2 class="text-xl font-bold flex items-center gap-2 text-error">
|
||||
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#x-circle"></use></svg>
|
||||
Deletion Failed
|
||||
</h2>
|
||||
<div class="py-4 space-y-4">
|
||||
<p>There were errors during account deletion:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 text-error">
|
||||
${errors.map(e => '<li>' + escapeHtml(e) + '</li>').join('')}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" data-dismiss-modal>Close</button>
|
||||
</div>
|
||||
`;
|
||||
modal.querySelector('[data-dismiss-modal]').addEventListener('click', () => modal.remove());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete account error:', err);
|
||||
modal.querySelector('.modal-box').innerHTML = `
|
||||
<h2 class="text-xl font-bold flex items-center gap-2 text-error">
|
||||
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#x-circle"></use></svg>
|
||||
Error
|
||||
</h2>
|
||||
<div class="py-4">
|
||||
<p>Failed to delete account: ${escapeHtml(err.message)}</p>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" data-dismiss-modal>Close</button>
|
||||
</div>
|
||||
`;
|
||||
modal.querySelector('[data-dismiss-modal]').addEventListener('click', () => modal.remove());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initSettingsTabs();
|
||||
initAccountDeletion();
|
||||
});
|
||||
@@ -41,7 +41,7 @@
|
||||
<p class="text-lg">{{ or .EmptyMessage "No repositories found." }}</p>
|
||||
</div>
|
||||
{{ if .EmptySubtext }}
|
||||
<p class="text-base-content/40 text-sm">{{ .EmptySubtext }}</p>
|
||||
<p class="text-base-content/70 text-sm">{{ .EmptySubtext }}</p>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<p class="text-base-content/60">{{ or .EmptyMessage "No repositories found." }}</p>
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
- Display: string - short form shown in the UI (e.g. "alice.bsky.social/myapp:v1.2.3")
|
||||
- Copy: string - full command copied to clipboard (e.g. "docker pull atcr.io/alice.bsky.social/myapp:v1.2.3")
|
||||
*/}}
|
||||
<div class="cmd group !w-full !bg-base-100">
|
||||
<div class="cmd group !w-full">
|
||||
{{ icon "terminal" "size-4 shrink-0 text-base-content/60" }}
|
||||
<code class="flex-1 min-w-0">{{ .Display }}</code>
|
||||
<code>{{ .Display }}</code>
|
||||
<button class="btn btn-ghost btn-xs absolute right-1 top-1/2 -translate-y-1/2 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" data-cmd="{{ .Copy }}" aria-label="Copy pull command to clipboard">
|
||||
{{ icon "copy" "size-4" }}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{ define "footer" }}
|
||||
<footer class="footer footer-center bg-base-200 text-base-content p-6 pt-20 mt-auto relative">
|
||||
<img src="/static/wave-pattern.svg" alt="" class="absolute top-0 left-0 w-full h-16 pointer-events-none rotate-180" aria-hidden="true">
|
||||
<img src="/static/wave-pattern.svg" alt="" width="1440" height="64" loading="lazy" decoding="async" class="absolute top-0 left-0 w-full h-16 pointer-events-none rotate-180" aria-hidden="true">
|
||||
<nav class="flex flex-wrap justify-center items-center gap-x-2 gap-y-1 text-sm">
|
||||
<a href="/privacy" class="link link-hover">Privacy</a>
|
||||
<span class="text-base-content/30">·</span>
|
||||
@@ -12,8 +12,8 @@
|
||||
</a>
|
||||
<span class="text-base-content/30">·</span>
|
||||
<a href="https://tangled.org/evan.jarrett.net/at-container-registry" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1">
|
||||
<img src="/static/tangled-black.svg" alt="" class="size-3.5 icon-light">
|
||||
<img src="/static/tangled-white.svg" alt="" class="size-3.5 icon-dark">
|
||||
<img src="/static/tangled-black.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-light">
|
||||
<img src="/static/tangled-white.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-dark">
|
||||
Source
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
<link rel="preconnect" href="https://imgs.blue" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://imgs.blue">
|
||||
|
||||
<!-- Preload critical assets -->
|
||||
<!-- Preload critical assets. Onest is the display face used on h1/hero
|
||||
headings and is the LCP candidate on landing/discovery pages. -->
|
||||
<link rel="preload" href="/icons.svg" as="image" type="image/svg+xml">
|
||||
<link rel="preload" href="/fonts/onest-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/figtree-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/commit-mono-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="mockup-code bg-base-300 text-base-content text-left w-full max-w-lg text-base mt-8">
|
||||
<pre data-prefix="$"><code>docker login {{ .RegistryURL }}</code></pre>
|
||||
<pre data-prefix="$"><code>docker push {{ .RegistryURL }}/you/app</code></pre>
|
||||
<pre data-prefix="#" class="text-base-content/65"><code>same docker, decentralized</code></pre>
|
||||
<pre data-prefix="#" class="text-base-content/70"><code>same docker, decentralized</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-4 mt-8">
|
||||
@@ -23,21 +23,21 @@
|
||||
|
||||
<!-- Benefit Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 w-full max-w-4xl">
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center">
|
||||
<div class="card bg-base-200 shadow-sm p-6 text-center">
|
||||
<div class="text-primary mb-4 flex justify-center">
|
||||
{{ icon "ship" "size-8" }}
|
||||
</div>
|
||||
<h2 class="font-semibold text-lg">Works with Docker</h2>
|
||||
<p class="text-base-content/70 mt-2">Use docker push & pull. No new tools to learn.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center">
|
||||
<div class="card bg-base-200 shadow-sm p-6 text-center">
|
||||
<div class="text-primary mb-4 flex justify-center">
|
||||
{{ icon "anchor" "size-8" }}
|
||||
</div>
|
||||
<h2 class="font-semibold text-lg">Your Data</h2>
|
||||
<p class="text-base-content/70 mt-2">Join shared holds or captain your own storage.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center">
|
||||
<div class="card bg-base-200 shadow-sm p-6 text-center">
|
||||
<div class="text-primary mb-4 flex justify-center">
|
||||
{{ icon "compass" "size-8" }}
|
||||
</div>
|
||||
@@ -46,6 +46,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<img src="/static/wave-pattern.svg" width="1440" height="128" alt="" class="absolute bottom-0 left-0 w-full h-32 pointer-events-none" aria-hidden="true">
|
||||
<img src="/static/wave-pattern.svg" width="1440" height="128" alt="" loading="lazy" decoding="async" class="absolute bottom-0 left-0 w-full h-32 pointer-events-none" aria-hidden="true">
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
{{ define "manifest-modal" }}
|
||||
<dialog class="modal modal-open" onclick="if(event.target===this)this.remove()">
|
||||
<div class="modal-box">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="this.closest('dialog').remove()" aria-label="Close manifest details">✕</button>
|
||||
<dialog class="modal modal-open" data-action="modal-backdrop-close">
|
||||
<div class="modal-box bg-base-200">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" data-action="remove-closest-dialog" aria-label="Close manifest details">✕</button>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-4">Manifest Details</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<strong class="text-base-content/60 min-w-[150px]">Digest:</strong>
|
||||
<code class="font-mono text-sm">{{ .Digest }}</code>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<strong class="text-base-content/60 min-w-[150px]">Media Type:</strong>
|
||||
<span>{{ .MediaType }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<strong class="text-base-content/60 min-w-[150px]">Hold Endpoint:</strong>
|
||||
<span>{{ .HoldEndpoint }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<strong class="text-base-content/60 min-w-[150px]">Created:</strong>
|
||||
<dl class="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-3 text-sm">
|
||||
<dt class="text-base-content/70 font-medium">Digest</dt>
|
||||
<dd class="font-mono break-all">{{ .Digest }}</dd>
|
||||
|
||||
<dt class="text-base-content/70 font-medium">Media Type</dt>
|
||||
<dd class="break-all">{{ .MediaType }}</dd>
|
||||
|
||||
<dt class="text-base-content/70 font-medium">Hold Endpoint</dt>
|
||||
<dd class="break-all">{{ .HoldEndpoint }}</dd>
|
||||
|
||||
<dt class="text-base-content/70 font-medium">Created</dt>
|
||||
<dd>
|
||||
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
{{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button onclick="this.closest('dialog').remove()">close</button>
|
||||
<button data-action="remove-closest-dialog">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{ define "nav-brand" }}
|
||||
<a href="/" class="flex items-center gap-2 text-2xl font-display font-bold text-secondary no-underline tracking-tight">
|
||||
<img src="/favicon-96x96.png" class="h-12 w-auto" alt="{{ .ClientName }} logo">
|
||||
<img src="/favicon-96x96.png" width="48" height="48" fetchpriority="high" class="h-12 w-auto" alt="{{ .ClientName }} logo">
|
||||
{{ .ClientName }}
|
||||
</a>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{{ define "nav-search" }}
|
||||
<div class="nav-search-wrapper">
|
||||
<button onclick="toggleSearch()" class="btn btn-ghost btn-circle" aria-label="Search">
|
||||
<button type="button" data-action="toggle-search" class="btn btn-ghost btn-circle" aria-label="Search" aria-expanded="false" aria-controls="nav-search-form">
|
||||
{{ icon "search" "size-5" }}
|
||||
</button>
|
||||
<form action="/search" method="get" class="nav-search-form" role="search">
|
||||
<form id="nav-search-form" action="/search" method="get" class="nav-search-form" role="search" inert>
|
||||
<label for="nav-search-input" class="sr-only">Search images</label>
|
||||
<input type="text" id="nav-search-input" name="q" placeholder="Search images..." value="{{ .Query }}" class="input input-sm input-bordered" aria-label="Search images" />
|
||||
<button type="submit" class="sr-only">Search</button>
|
||||
<input type="text" id="nav-search-input" name="q" placeholder="Search images..." value="{{ .Query }}" class="input input-sm input-bordered" />
|
||||
</form>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
{{ define "nav-theme-toggle" }}
|
||||
<details class="dropdown dropdown-end">
|
||||
<summary data-theme-toggle class="btn btn-ghost btn-circle list-none" aria-label="Theme settings">
|
||||
<summary data-theme-toggle class="btn btn-ghost btn-circle list-none" aria-label="Theme settings" aria-haspopup="menu">
|
||||
<svg class="icon size-5" data-theme-icon aria-hidden="true"><use href="/icons.svg#sun"></use></svg>
|
||||
</summary>
|
||||
<ul data-theme-menu class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
|
||||
<li>
|
||||
<button type="button" class="theme-option" data-value="system">
|
||||
<ul data-theme-menu role="menu" class="dropdown-content menu bg-base-200 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
|
||||
<li role="none">
|
||||
<button type="button" role="menuitemradio" aria-checked="false" class="theme-option" data-value="system">
|
||||
{{ icon "sun-moon" "size-4" }}
|
||||
<span>System</span>
|
||||
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="theme-option" data-value="light">
|
||||
<li role="none">
|
||||
<button type="button" role="menuitemradio" aria-checked="false" class="theme-option" data-value="light">
|
||||
{{ icon "sun" "size-4" }}
|
||||
<span>Light</span>
|
||||
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="theme-option" data-value="dark">
|
||||
<li role="none">
|
||||
<button type="button" role="menuitemradio" aria-checked="false" class="theme-option" data-value="dark">
|
||||
{{ icon "moon" "size-4" }}
|
||||
<span>Dark</span>
|
||||
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="avatar{{ if not .User.Avatar }} avatar-placeholder{{ end }}">
|
||||
{{ if .User.Avatar }}
|
||||
<div class="w-7 rounded-full">
|
||||
<img src="{{ resizeImage .User.Avatar 96 }}" alt="{{ .User.Handle }}" loading="lazy" />
|
||||
<img src="{{ resizeImage .User.Avatar 96 }}" alt="{{ .User.Handle }}" width="28" height="28" />
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="bg-secondary text-secondary-content w-7 rounded-full">
|
||||
@@ -16,7 +16,7 @@
|
||||
<span class="hidden sm:inline">@{{ .User.Handle }}</span>
|
||||
{{ icon "chevron-down" "size-3.5" }}
|
||||
</summary>
|
||||
<ul class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-52 p-2 shadow-lg">
|
||||
<ul class="dropdown-content menu bg-base-200 text-base-content rounded-box z-50 w-52 p-2 shadow-lg">
|
||||
<li><a href="/u/{{ .User.Handle }}">Your Repositories</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li class="border-t border-base-300 mt-2 pt-2">
|
||||
@@ -26,6 +26,6 @@
|
||||
<form id="logout-form" action="/auth/logout" method="POST" hidden></form>
|
||||
</details>
|
||||
{{ else }}
|
||||
<button type="button" onclick="window.location='/auth/oauth/login?return_to=/'" class="btn btn-secondary btn-sm">Login</button>
|
||||
<a href="/auth/oauth/login?return_to=/" class="btn btn-secondary btn-sm">Login</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{{ define "skip-link" }}
|
||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||
{{ end }}
|
||||
|
||||
{{ define "nav" }}
|
||||
<nav class="navbar bg-base-300 text-secondary px-4">
|
||||
{{ template "skip-link" }}
|
||||
<nav class="navbar bg-base-200 text-secondary px-4">
|
||||
<div class="navbar-start">
|
||||
{{ template "nav-brand" . }}
|
||||
</div>
|
||||
@@ -12,7 +17,8 @@
|
||||
{{ end }}
|
||||
|
||||
{{ define "nav-simple" }}
|
||||
<nav class="navbar bg-base-300 text-secondary px-4">
|
||||
{{ template "skip-link" }}
|
||||
<nav class="navbar bg-base-200 text-secondary px-4">
|
||||
<div class="navbar-start">
|
||||
{{ template "nav-brand" . }}
|
||||
</div>
|
||||
|
||||
@@ -18,11 +18,15 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="space-y-2" id="pull-cmd-container">
|
||||
<p class="text-sm font-medium text-base-content/70">Pull this image</p>
|
||||
<div class="space-y-2" id="pull-cmd-container"
|
||||
data-registry-url="{{ .RegistryURL }}"
|
||||
data-owner-handle="{{ .OwnerHandle }}"
|
||||
data-repo-name="{{ .RepoName }}"
|
||||
data-tag="{{ if .Tag }}{{ .Tag }}{{ else }}latest{{ end }}"
|
||||
data-is-logged-in="{{ .IsLoggedIn }}">
|
||||
<label for="oci-client-switcher" class="text-sm font-medium text-base-content/70">Pull this image</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="oci-client-switcher" class="select select-xs select-bordered w-auto"
|
||||
onchange="updatePullCommand(this.value)">
|
||||
<select id="oci-client-switcher" class="select select-xs select-bordered w-auto">
|
||||
<option value="docker"{{ if or (eq .OciClient "") (eq .OciClient "docker") }} selected{{ end }}>docker</option>
|
||||
<option value="podman"{{ if eq .OciClient "podman" }} selected{{ end }}>podman</option>
|
||||
<option value="nerdctl"{{ if eq .OciClient "nerdctl" }} selected{{ end }}>nerdctl</option>
|
||||
@@ -39,44 +43,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var registryURL = {{ .RegistryURL }};
|
||||
var ownerHandle = {{ .OwnerHandle }};
|
||||
var repoName = {{ .RepoName }};
|
||||
var tag = {{ if .Tag }}{{ .Tag }}{{ else }}"latest"{{ end }};
|
||||
var isLoggedIn = {{ .IsLoggedIn }};
|
||||
|
||||
// Restore from localStorage for anonymous users
|
||||
if (!isLoggedIn) {
|
||||
var saved = localStorage.getItem('oci-client');
|
||||
if (saved) {
|
||||
var sel = document.getElementById('oci-client-switcher');
|
||||
if (sel) {
|
||||
sel.value = saved;
|
||||
updatePullCommand(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.updatePullCommand = function(client) {
|
||||
var prefix = client === 'none' ? '' : client + ' pull ';
|
||||
var cmd = prefix + registryURL + '/' + ownerHandle + '/' + repoName + ':' + tag;
|
||||
var container = document.getElementById('pull-cmd-display');
|
||||
if (!container) return;
|
||||
var code = container.querySelector('code');
|
||||
if (code) code.textContent = cmd;
|
||||
var btn = container.querySelector('[data-cmd]');
|
||||
if (btn) btn.dataset.cmd = cmd;
|
||||
|
||||
// Persist preference
|
||||
if (isLoggedIn) {
|
||||
htmx.ajax('POST', '/api/profile/oci-client', {values: {oci_client: client}, swap: 'none'});
|
||||
} else {
|
||||
localStorage.setItem('oci-client', client);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/}}
|
||||
<div id="repo-avatar" class="relative shrink-0">
|
||||
{{ if .IconURL }}
|
||||
<img src="{{ resizeImage .IconURL 96 }}" alt="{{ .RepositoryName }}" loading="lazy" class="w-20 rounded-lg object-cover">
|
||||
<img src="{{ resizeImage .IconURL 160 }}" alt="{{ .RepositoryName }}" width="80" height="80" fetchpriority="high" class="w-20 rounded-lg object-cover">
|
||||
{{ else }}
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="bg-neutral text-neutral-content w-20 rounded-lg shadow-sm uppercase">
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
- LastUpdated: time.Time (optional) - Last push time
|
||||
- RegistryURL: string - Registry URL for docker commands (e.g., "atcr.io")
|
||||
*/}}
|
||||
<div class="card card-interactive bg-base-200 border-2 border-base-300 p-4 flex flex-col justify-between min-h-60 w-full" data-href="/r/{{ .OwnerHandle }}/{{ .Repository }}">
|
||||
<article class="card bg-base-200 card-interactive p-4 flex flex-col justify-between min-h-60 w-full">
|
||||
<a href="/r/{{ .OwnerHandle }}/{{ .Repository }}" class="card-stretched-link" aria-label="{{ .OwnerHandle }}/{{ .Repository }} repository"></a>
|
||||
<div class="flex gap-4 items-start">
|
||||
{{ if .IconURL }}
|
||||
<img src="{{ resizeImage .IconURL 96 }}" alt="{{ .Repository }}" loading="lazy" width="48" height="48" class="w-12 rounded-lg object-cover shrink-0">
|
||||
<img src="{{ resizeImage .IconURL 96 }}" alt="" loading="lazy" width="48" height="48" class="w-12 rounded-lg object-cover shrink-0">
|
||||
{{ else if .OwnerAvatarURL }}
|
||||
<img src="{{ resizeImage .OwnerAvatarURL 96 }}" alt="{{ .OwnerHandle }}" loading="lazy" width="48" height="48" class="w-12 rounded-lg object-cover shrink-0">
|
||||
<img src="{{ resizeImage .OwnerAvatarURL 96 }}" alt="" loading="lazy" width="48" height="48" class="w-12 rounded-lg object-cover shrink-0">
|
||||
{{ else }}
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="bg-neutral text-neutral-content w-12 rounded-lg shadow-sm uppercase">
|
||||
@@ -32,9 +33,9 @@
|
||||
{{ end }}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-sm flex items-baseline gap-1 min-w-0">
|
||||
<a href="/u/{{ .OwnerHandle }}" class="link link-primary truncate min-w-0" onclick="event.stopPropagation()">{{ .OwnerHandle }}</a>
|
||||
<a href="/u/{{ .OwnerHandle }}" class="link link-primary truncate min-w-0">{{ .OwnerHandle }}</a>
|
||||
<span class="text-base-content/60 shrink-0">/</span>
|
||||
<a href="/r/{{ .OwnerHandle }}/{{ .Repository }}" class="link text-base-content hover:underline truncate min-w-0" onclick="event.stopPropagation()">{{ .Repository }}</a>
|
||||
<span class="text-base-content truncate min-w-0">{{ .Repository }}</span>
|
||||
</div>
|
||||
{{ if .Tag }}
|
||||
<span class="block text-base-content/60 text-sm truncate">Tag: {{ .Tag }}</span>
|
||||
@@ -42,7 +43,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ if .Description }}
|
||||
<p class="text-base-content/60 text-sm line-clamp-3 m-0 my-4">{{ .Description }}</p>
|
||||
<p class="text-base-content/60 text-sm line-clamp-3 break-words m-0 my-4">{{ .Description }}</p>
|
||||
{{ end }}
|
||||
<div class="flex-1 flex flex-col justify-end py-2 min-w-0">
|
||||
{{ if eq .ArtifactType "helm-chart" }}
|
||||
@@ -79,5 +80,5 @@
|
||||
<span class="text-base-content/60 text-sm flex items-center gap-1">{{ icon "history" "size-4" }}{{ timeAgoShort .LastUpdated }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{{ end }}
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav-simple" . }}
|
||||
<main class="hero min-h-[60vh]">
|
||||
<main id="main-content" class="hero min-h-[60vh]">
|
||||
<div class="hero-content text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
{{ icon "anchor" "size-16 text-neutral mb-4" }}
|
||||
<div class="font-bold text-primary" style="font-size: 150px; line-height: 1;">404</div>
|
||||
<div class="font-display font-bold text-primary leading-none tabular-nums" style="font-size: clamp(5rem, 18vw, 10rem);">404</div>
|
||||
<h1 class="text-2xl font-semibold mt-4">Lost at Sea</h1>
|
||||
<p class="text-base-content/60 mt-2 max-w-md">The page you're looking for has drifted into uncharted waters.</p>
|
||||
<a href="/" class="btn btn-primary mt-6">Return to Port</a>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6">
|
||||
<div class="card bg-base-200 shadow-sm border border-base-300 p-6">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<h1 class="text-xl font-bold">
|
||||
<span class="font-mono">{{ .FromTag }}</span>
|
||||
@@ -74,22 +74,16 @@
|
||||
</div>
|
||||
|
||||
{{ if .IsMultiArch }}
|
||||
<div class="flex items-center gap-3 pt-4 border-t border-base-200">
|
||||
<div class="flex items-center gap-3 pt-4 border-t border-base-300">
|
||||
<label for="diff-arch-select" class="text-sm font-medium whitespace-nowrap">{{ icon "cpu" "size-4" }} Platform</label>
|
||||
<select id="diff-arch-select" class="select select-sm select-bordered"
|
||||
onchange="switchDiffPlatform(this.value)">
|
||||
data-diff-url="/diff/{{ .Owner.Handle }}/{{ .Repository }}?from={{ .FromDigest }}&to={{ .ToDigest }}&platform=__VALUE__">
|
||||
{{ range .CommonPlatforms }}
|
||||
{{ $platKey := printf "%s/%s" .OS .Architecture }}{{ if .Variant }}{{ $platKey = printf "%s/%s/%s" .OS .Architecture .Variant }}{{ end }}
|
||||
<option value="{{ $platKey }}"{{ if eq $platKey $.SelectedPlatform }} selected{{ end }}>{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
<script>
|
||||
function switchDiffPlatform(platform) {
|
||||
var url = '/diff/{{ .Owner.Handle }}/{{ .Repository }}?from={{ .FromDigest }}&to={{ .ToDigest }}&platform=' + encodeURIComponent(platform);
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<div class="text-sm breadcrumbs min-w-0">
|
||||
<ul>
|
||||
<li><a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a></li>
|
||||
<li><a href="/r/{{ .Owner.Handle }}/{{ .Repository }}" class="link link-primary">{{ .Repository }}</a></li>
|
||||
<li><code class="font-mono text-xs">{{ truncateDigest (trimPrefix "sha256:" .Manifest.Digest) 16 }}</code></li>
|
||||
<li class="min-w-0"><a href="/u/{{ .Owner.Handle }}" class="link link-primary truncate">{{ .Owner.Handle }}</a></li>
|
||||
<li class="min-w-0"><a href="/r/{{ .Owner.Handle }}/{{ .Repository }}" class="link link-primary truncate">{{ .Repository }}</a></li>
|
||||
<li class="min-w-0"><code class="font-mono text-xs truncate">{{ truncateDigest (trimPrefix "sha256:" .Manifest.Digest) 16 }}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Digest Header -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-3">
|
||||
<div class="card bg-base-200 shadow-sm border border-base-300 p-6 space-y-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="space-y-2 min-w-0">
|
||||
<!-- Title: tags or truncated digest -->
|
||||
@@ -42,7 +42,7 @@
|
||||
<!-- Digest (small) -->
|
||||
<div class="flex items-center gap-2 text-base-content/70">
|
||||
<code class="font-mono text-xs" title="{{ .Manifest.Digest }}">{{ truncateDigest (trimPrefix "sha256:" .Manifest.Digest) 16 }}</code>
|
||||
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy digest">{{ icon "copy" "size-3" }}</button>
|
||||
<button class="btn btn-ghost btn-xs" data-action="copy" data-copy="{{ .Manifest.Digest }}" aria-label="Copy digest">{{ icon "copy" "size-3" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
@@ -52,30 +52,23 @@
|
||||
|
||||
{{ if .Manifest.IsManifestList }}
|
||||
<!-- Architecture Dropdown -->
|
||||
<div class="flex items-center gap-3 pt-2 border-t border-base-200">
|
||||
<div class="flex items-center gap-3 pt-2 border-t border-base-300">
|
||||
<label for="arch-select" class="text-sm font-medium whitespace-nowrap">{{ icon "cpu" "size-4" }} Architecture</label>
|
||||
<select id="arch-select" class="select select-sm select-bordered"
|
||||
onchange="loadArchContent(this.value)">
|
||||
name="digest"
|
||||
hx-get="/api/digest-content/{{ .Owner.Handle }}/{{ .Repository }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#digest-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#arch-loading">
|
||||
{{ range .Manifest.Platforms }}
|
||||
<option value="{{ .Digest }}"{{ if eq .Digest $.SelectedPlatform }} selected{{ end }}>{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}{{ if .CompressedSize }} ({{ humanizeBytes .CompressedSize }}){{ end }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<span id="arch-loading" class="hidden">
|
||||
<span id="arch-loading" class="htmx-indicator" aria-hidden="true">
|
||||
{{ icon "loader" "size-4 animate-spin" }}
|
||||
</span>
|
||||
</div>
|
||||
<script>
|
||||
function loadArchContent(digest) {
|
||||
var target = document.getElementById('digest-content');
|
||||
var loading = document.getElementById('arch-loading');
|
||||
loading.classList.remove('hidden');
|
||||
htmx.ajax('GET', '/api/digest-content/{{ .Owner.Handle }}/{{ .Repository }}?digest=' + encodeURIComponent(digest), {target: target, swap: 'innerHTML'}).then(function() {
|
||||
loading.classList.add('hidden');
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
<head>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
{{ if not .User }}
|
||||
<!-- Preload hero images -->
|
||||
{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
@@ -15,7 +12,7 @@
|
||||
{{ template "hero" . }}
|
||||
{{ end }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8">
|
||||
<div class="space-y-12">
|
||||
<!-- Featured Repositories Section -->
|
||||
{{ if .FeaturedRepos }}
|
||||
@@ -31,7 +28,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="featured-carousel" class="carousel w-full gap-6 scroll-smooth">
|
||||
<div id="featured-carousel" class="carousel w-full gap-3 sm:gap-6 scroll-smooth">
|
||||
{{ range $i, $repo := .FeaturedRepos }}
|
||||
<div id="featured-{{ $i }}" class="carousel-item overflow-hidden min-w-0 w-full md:w-[calc(50%-0.75rem)] lg:w-[calc(33.333%-1rem)] shrink-0">
|
||||
{{ template "repo-card" $repo }}
|
||||
@@ -51,9 +48,6 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal container for HTMX -->
|
||||
<div id="modal"></div>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 class="text-3xl font-display font-bold tracking-tight mb-2">Install {{ .ClientShortName }} Credential Helper</h1>
|
||||
<p class="text-base-content/70 mb-8">The {{ .ClientShortName }} credential helper enables Docker to authenticate with {{ .ClientShortName }} registries using your ATProto identity.</p>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<h2 class="text-xl font-semibold mb-4">Quick Install</h2>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button class="btn btn-sm btn-primary platform-tab" onclick="showPlatform('linux')">Linux / macOS</button>
|
||||
<button class="btn btn-sm btn-ghost platform-tab" onclick="showPlatform('windows')">Windows</button>
|
||||
<button type="button" class="btn btn-sm btn-primary platform-tab" data-platform="linux">Linux / macOS</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost platform-tab" data-platform="windows">Windows</button>
|
||||
</div>
|
||||
|
||||
<div class="platform-content" id="linux-content">
|
||||
@@ -53,7 +53,7 @@
|
||||
<h2 class="text-xl font-semibold mb-4">Configure Docker</h2>
|
||||
<p class="mb-4">After installation, configure Docker to use the credential helper:</p>
|
||||
|
||||
<h3 class="text-lg font-medium mb-3">Create or edit <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">~/.docker/config.json</code></h3>
|
||||
<h3 class="text-lg font-medium mb-3">Create or edit <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">~/.docker/config.json</code></h3>
|
||||
<div class="mockup-code bg-base-300 text-base-content mb-4">
|
||||
<pre><code>{</code></pre>
|
||||
<pre><code> "credHelpers": {</code></pre>
|
||||
@@ -92,11 +92,11 @@
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold mb-4">Alternative: Use docker login</h2>
|
||||
<p class="mb-4">You can also use <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">docker login</code> with your ATProto app password:</p>
|
||||
<p class="mb-4">You can also use <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">docker login</code> with your ATProto app password:</p>
|
||||
|
||||
<ol class="list-decimal list-inside space-y-2 ml-4 mb-6">
|
||||
<li>Generate an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">bsky.app/settings/app-passwords</a></li>
|
||||
<li>Run: <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">docker login {{ .RegistryURL }}</code></li>
|
||||
<li>Run: <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">docker login {{ .RegistryURL }}</code></li>
|
||||
<li>Enter your handle as username</li>
|
||||
<li>Enter your app password</li>
|
||||
</ol>
|
||||
@@ -127,7 +127,7 @@
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold mb-4">Security</h2>
|
||||
<ul class="list-disc list-inside space-y-2 ml-4">
|
||||
<li>Credentials are stored in <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">~/.atcr/device.json</code> with secure permissions (0600)</li>
|
||||
<li>Credentials are stored in <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">~/.atcr/device.json</code> with secure permissions (0600)</li>
|
||||
<li>Device secrets are issued per-device and can be revoked anytime</li>
|
||||
<li>No passwords are stored locally</li>
|
||||
<li>Uses ATProto OAuth with device authorization flow</li>
|
||||
@@ -136,25 +136,6 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function showPlatform(platform) {
|
||||
// Update tabs
|
||||
document.querySelectorAll('.platform-tab').forEach(tab => {
|
||||
tab.classList.remove('btn-primary');
|
||||
tab.classList.add('btn-ghost');
|
||||
});
|
||||
event.target.classList.remove('btn-ghost');
|
||||
event.target.classList.add('btn-primary');
|
||||
|
||||
// Update content
|
||||
document.querySelectorAll('.platform-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
document.getElementById(platform + '-content').classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="modal"></div>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<!-- Hero Section -->
|
||||
<section class="text-center mb-16">
|
||||
<h1 class="text-4xl md:text-5xl font-display font-bold tracking-tight mb-4">Docker meets the decentralized web</h1>
|
||||
@@ -31,7 +31,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="collapse collapse-arrow bg-base-100 border border-base-300">
|
||||
<div class="collapse collapse-arrow bg-base-200 border border-base-300">
|
||||
<input type="checkbox" aria-label="Toggle technical details" />
|
||||
<div class="collapse-title font-medium">
|
||||
Technical Details
|
||||
@@ -51,7 +51,7 @@
|
||||
<h2 class="text-2xl font-bold mb-6">Why Decentralized?</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="card bg-base-100 border border-base-300 p-6">
|
||||
<div class="card bg-base-200 border border-base-300 p-6">
|
||||
<div class="text-primary mb-3">
|
||||
{{ icon "database" "size-8" }}
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-300 p-6">
|
||||
<div class="card bg-base-200 border border-base-300 p-6">
|
||||
<div class="text-primary mb-3">
|
||||
{{ icon "fingerprint" "size-8" }}
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-300 p-6">
|
||||
<div class="card bg-base-200 border border-base-300 p-6">
|
||||
<div class="text-primary mb-3">
|
||||
{{ icon "hard-drive" "size-8" }}
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-300 p-6">
|
||||
<div class="card bg-base-200 border border-base-300 p-6">
|
||||
<div class="text-primary mb-3">
|
||||
{{ icon "eye" "size-8" }}
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="bg-base-200 rounded-lg p-6">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-6 text-center">
|
||||
<div class="flex-1">
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300">
|
||||
<div class="bg-base-300 rounded-lg p-4 border border-base-300">
|
||||
{{ icon "container" "size-8 text-primary mx-auto mb-2" }}
|
||||
<p class="font-semibold">Docker Client</p>
|
||||
<p class="text-sm text-base-content/60">Push & Pull</p>
|
||||
@@ -119,7 +119,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-primary">
|
||||
<div class="bg-base-300 rounded-lg p-4 border border-primary">
|
||||
{{ icon "server" "size-8 text-primary mx-auto mb-2" }}
|
||||
<p class="font-semibold">AppView</p>
|
||||
<p class="text-sm text-base-content/60">{{ .SiteURL }}</p>
|
||||
@@ -136,12 +136,12 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col gap-4">
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300">
|
||||
<div class="bg-base-300 rounded-lg p-4 border border-base-300">
|
||||
{{ icon "user" "size-8 text-primary mx-auto mb-2" }}
|
||||
<p class="font-semibold">Your PDS</p>
|
||||
<p class="text-sm text-base-content/60">Manifests</p>
|
||||
</div>
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300">
|
||||
<div class="bg-base-300 rounded-lg p-4 border border-base-300">
|
||||
{{ icon "database" "size-8 text-primary mx-auto mb-2" }}
|
||||
<p class="font-semibold">Hold Service</p>
|
||||
<p class="text-sm text-base-content/60">Blobs</p>
|
||||
@@ -165,14 +165,14 @@
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a href="/install" class="btn btn-primary btn-lg">Get Started</a>
|
||||
<a href="https://tangled.org/evan.jarrett.net/at-container-registry" target="_blank" rel="noopener" class="btn btn-ghost btn-lg">
|
||||
{{ icon "github" "size-5 mr-2" }}
|
||||
<img src="/static/tangled-black.svg" alt="" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-light">
|
||||
<img src="/static/tangled-white.svg" alt="" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-dark">
|
||||
View Source
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="modal"></div>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<body>
|
||||
{{ template "nav-simple" . }}
|
||||
|
||||
<main class="min-h-[calc(100vh-4rem)] flex items-start justify-center px-4 pt-16 sm:pt-24">
|
||||
<main id="main-content" class="min-h-[calc(100vh-4rem)] flex items-start justify-center px-4 pt-16 sm:pt-24">
|
||||
<div class="w-full max-w-2xl">
|
||||
<h1 class="text-3xl sm:text-4xl font-display font-semibold tracking-tight text-center mb-8">Sign in to {{ .ClientName }}</h1>
|
||||
|
||||
{{ if .Error }}
|
||||
<div class="alert alert-error mb-6">
|
||||
<div id="login-error" role="alert" class="alert alert-error mb-6">
|
||||
{{ icon "circle-x" "size-5" }}
|
||||
<span>
|
||||
{{ if eq .Error "handle_required" }}
|
||||
@@ -42,7 +42,8 @@
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
autofocus />
|
||||
autofocus
|
||||
{{ if .Error }}aria-invalid="true" aria-describedby="login-error"{{ end }} />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-full mt-6 order-3">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 class="text-3xl font-display font-bold tracking-tight mb-2">Privacy Policy - {{ .CompanyName }} ({{ .SiteURL }})</h1>
|
||||
<p class="text-base-content/60 mb-8"><em>Last updated: January 2025</em></p>
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<h2 class="text-xl font-semibold text-primary">Data We Collect and Store</h2>
|
||||
|
||||
<h3 class="text-lg font-medium mt-4">Data Stored on Your PDS (Controlled by You)</h3>
|
||||
<p>When you use {{ .CompanyName }}, records are written to your Personal Data Server (PDS) under the <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> namespace. This data is stored on infrastructure you or your PDS hosting provider controls. We do not control this data, and its retention and deletion is governed by your PDS provider's policies.</p>
|
||||
<p>When you use {{ .CompanyName }}, records are written to your Personal Data Server (PDS) under the <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> namespace. This data is stored on infrastructure you or your PDS hosting provider controls. We do not control this data, and its retention and deletion is governed by your PDS provider's policies.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">Data Stored on Our Infrastructure</h3>
|
||||
|
||||
<p><strong>Layer Records:</strong> Our hold services (e.g., <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">hold01.{{ .SiteURL }}</code>) maintain records in their embedded PDS that reference container image layers you publish. These records are public and link your AT Protocol identity (DID) to content-addressed SHA identifiers.</p>
|
||||
<p><strong>Layer Records:</strong> Our hold services (e.g., <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">hold01.{{ .SiteURL }}</code>) maintain records in their embedded PDS that reference container image layers you publish. These records are public and link your AT Protocol identity (DID) to content-addressed SHA identifiers.</p>
|
||||
|
||||
<p><strong>OCI Blobs:</strong> Container image layers are stored in our object storage (S3). These blobs are content-addressed and deduplicated—meaning identical layers uploaded by different users are stored only once.</p>
|
||||
|
||||
@@ -58,13 +58,13 @@
|
||||
</ul>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">{{ .ClientShortName }}-Hosted Hold Services</h3>
|
||||
<p>Storage backends we operate (e.g., <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">hold01.{{ .SiteURL }}</code>). Each hold has an embedded PDS and stores:</p>
|
||||
<p>Storage backends we operate (e.g., <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">hold01.{{ .SiteURL }}</code>). Each hold has an embedded PDS and stores:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li>OCI blobs (container image layers) in object storage</li>
|
||||
<li>Layer records in the hold's embedded PDS linking your DID to blob references</li>
|
||||
<li>Crew membership records for access control</li>
|
||||
</ul>
|
||||
<p class="mt-2">Hold services on <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">*.{{ .SiteURL }}</code> domains are operated by us and covered by this policy.</p>
|
||||
<p class="mt-2">Hold services on <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">*.{{ .SiteURL }}</code> domains are operated by us and covered by this policy.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">User-Deployed Hold Services (BYOS)</h3>
|
||||
<p>You may use "Bring Your Own Storage" by deploying your own hold service. Data on user-deployed holds is governed by that operator's privacy policy, not ours. We can request deletion on your behalf but cannot guarantee it for services we do not control.</p>
|
||||
@@ -133,7 +133,7 @@
|
||||
</ul>
|
||||
|
||||
<p class="mt-4"><strong>Optional: Delete AT Protocol Records</strong></p>
|
||||
<p>When deleting your account, you may optionally authorize us to delete <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> records from your PDS. This requires an active OAuth session and is optional because:</p>
|
||||
<p>When deleting your account, you may optionally authorize us to delete <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> records from your PDS. This requires an active OAuth session and is optional because:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li>Your PDS is controlled by you or your hosting provider, not us</li>
|
||||
<li>You may delete these records yourself at any time</li>
|
||||
@@ -186,19 +186,19 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td data-label="Category">Identifiers</td>
|
||||
<td data-label="Examples">DID, handle, IP address, device name</td>
|
||||
<td data-label="Collected">Yes</td>
|
||||
<td data-label="Category" aria-label="Category: Identifiers">Identifiers</td>
|
||||
<td data-label="Examples" aria-label="Examples: DID, handle, IP address, device name">DID, handle, IP address, device name</td>
|
||||
<td data-label="Collected" aria-label="Collected: Yes">Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Category">Internet activity</td>
|
||||
<td data-label="Examples">Access logs, usage data, actions performed</td>
|
||||
<td data-label="Collected">Yes</td>
|
||||
<td data-label="Category" aria-label="Category: Internet activity">Internet activity</td>
|
||||
<td data-label="Examples" aria-label="Examples: Access logs, usage data, actions performed">Access logs, usage data, actions performed</td>
|
||||
<td data-label="Collected" aria-label="Collected: Yes">Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Category">Geolocation</td>
|
||||
<td data-label="Examples">Approximate location via IP</td>
|
||||
<td data-label="Collected">Yes</td>
|
||||
<td data-label="Category" aria-label="Category: Geolocation">Geolocation</td>
|
||||
<td data-label="Examples" aria-label="Examples: Approximate location via IP">Approximate location via IP</td>
|
||||
<td data-label="Collected" aria-label="Collected: Yes">Yes</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -219,39 +219,39 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td data-label="Data Type">OAuth tokens</td>
|
||||
<td data-label="Service">AppView</td>
|
||||
<td data-label="Retention">Until revoked or logout</td>
|
||||
<td data-label="Data Type" aria-label="Data Type: OAuth tokens">OAuth tokens</td>
|
||||
<td data-label="Service" aria-label="Service: AppView">AppView</td>
|
||||
<td data-label="Retention" aria-label="Retention: Until revoked or logout">Until revoked or logout</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Data Type">Web UI session tokens</td>
|
||||
<td data-label="Service">AppView</td>
|
||||
<td data-label="Retention">Until logout or expiration</td>
|
||||
<td data-label="Data Type" aria-label="Data Type: Web UI session tokens">Web UI session tokens</td>
|
||||
<td data-label="Service" aria-label="Service: AppView">AppView</td>
|
||||
<td data-label="Retention" aria-label="Retention: Until logout or expiration">Until logout or expiration</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Data Type">Device tokens (credential helper)</td>
|
||||
<td data-label="Service">AppView</td>
|
||||
<td data-label="Retention">Until revoked by user</td>
|
||||
<td data-label="Data Type" aria-label="Data Type: Device tokens (credential helper)">Device tokens (credential helper)</td>
|
||||
<td data-label="Service" aria-label="Service: AppView">AppView</td>
|
||||
<td data-label="Retention" aria-label="Retention: Until revoked by user">Until revoked by user</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Data Type">Cached PDS data</td>
|
||||
<td data-label="Service">AppView</td>
|
||||
<td data-label="Retention">Refreshed periodically; deleted on account deletion</td>
|
||||
<td data-label="Data Type" aria-label="Data Type: Cached PDS data">Cached PDS data</td>
|
||||
<td data-label="Service" aria-label="Service: AppView">AppView</td>
|
||||
<td data-label="Retention" aria-label="Retention: Refreshed periodically; deleted on account deletion">Refreshed periodically; deleted on account deletion</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Data Type">Server logs</td>
|
||||
<td data-label="Service">AppView</td>
|
||||
<td data-label="Retention">Currently ephemeral; this policy will be updated if log retention is implemented</td>
|
||||
<td data-label="Data Type" aria-label="Data Type: Server logs">Server logs</td>
|
||||
<td data-label="Service" aria-label="Service: AppView">AppView</td>
|
||||
<td data-label="Retention" aria-label="Retention: Currently ephemeral; this policy will be updated if log retention is implemented">Currently ephemeral; this policy will be updated if log retention is implemented</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Data Type">Layer records</td>
|
||||
<td data-label="Service">Hold PDS</td>
|
||||
<td data-label="Retention">Until you request deletion</td>
|
||||
<td data-label="Data Type" aria-label="Data Type: Layer records">Layer records</td>
|
||||
<td data-label="Service" aria-label="Service: Hold PDS">Hold PDS</td>
|
||||
<td data-label="Retention" aria-label="Retention: Until you request deletion">Until you request deletion</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-label="Data Type">OCI blobs</td>
|
||||
<td data-label="Service">Hold Storage</td>
|
||||
<td data-label="Retention">Until no longer referenced (pruned within 30 days)</td>
|
||||
<td data-label="Data Type" aria-label="Data Type: OCI blobs">OCI blobs</td>
|
||||
<td data-label="Service" aria-label="Service: Hold Storage">Hold Storage</td>
|
||||
<td data-label="Retention" aria-label="Retention: Until no longer referenced (pruned within 30 days)">Until no longer referenced (pruned within 30 days)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -276,7 +276,7 @@
|
||||
<p>{{ .CompanyName }} supports "Bring Your Own Storage" where users can deploy their own hold services to store container image blobs. This section explains how BYOS affects your privacy rights.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-4">{{ .CompanyName }}-Hosted Holds</h3>
|
||||
<p>Hold services on <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">*.{{ .SiteURL }}</code> domains (e.g., <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">hold01.{{ .SiteURL }}</code>) are operated by us and fully covered by this privacy policy. We can fulfill all data access, export, and deletion requests for these services.</p>
|
||||
<p>Hold services on <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">*.{{ .SiteURL }}</code> domains (e.g., <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">hold01.{{ .SiteURL }}</code>) are operated by us and fully covered by this privacy policy. We can fulfill all data access, export, and deletion requests for these services.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">User-Deployed Holds</h3>
|
||||
<p>If you use a hold service not operated by us:</p>
|
||||
@@ -303,7 +303,7 @@
|
||||
<p>Most data management can be done directly through your account settings at {{ .SiteURL }}:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li><strong>Export your data:</strong> Use the "Export Data" button in settings to download a copy of all personal data we store about you.</li>
|
||||
<li><strong>Delete your data:</strong> Use the "Delete Account" button in settings. This will remove your layer records, cached data, and authentication tokens. You may also choose to have us delete <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> records from your PDS (requires active OAuth session).</li>
|
||||
<li><strong>Delete your data:</strong> Use the "Delete Account" button in settings. This will remove your layer records, cached data, and authentication tokens. You may also choose to have us delete <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> records from your PDS (requires active OAuth session).</li>
|
||||
<li><strong>Revoke device tokens:</strong> Manage and revoke credential helper devices in settings.</li>
|
||||
<li><strong>Update your data:</strong> Corrections happen through normal use of the service.</li>
|
||||
</ul>
|
||||
@@ -334,7 +334,6 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="modal"></div>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Static Header: Identity + Metadata (does not change with tag) -->
|
||||
<div class="card bg-base-100 shadow-sm p-6 space-y-4 w-full">
|
||||
<div class="card bg-base-200 shadow-sm p-6 space-y-4 w-full">
|
||||
<div class="flex gap-4 items-start">
|
||||
{{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }}
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -37,7 +37,7 @@
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if .Stats.LastPush }}
|
||||
<span class="text-sm text-base-content/50" title="Last pushed {{ (derefTime .Stats.LastPush).Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
<span class="text-sm text-base-content/70" title="Last pushed {{ (derefTime .Stats.LastPush).Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
Updated {{ timeAgoShort (derefTime .Stats.LastPush) }}
|
||||
</span>
|
||||
{{ end }}
|
||||
@@ -101,7 +101,7 @@
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-10 w-56 p-2 shadow max-h-60 overflow-y-auto">
|
||||
{{ range .AllTags }}
|
||||
<li><a href="#" data-diff-to="{{ . }}" onclick="diffToTag(event, this)">{{ . }}</a></li>
|
||||
<li><a href="#" data-action="diff-to" data-diff-to="{{ . }}">{{ . }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@
|
||||
hx-get="/api/attestation-details?digest={{ .SelectedTag.Info.Digest | urlquery }}&did={{ .Owner.DID | urlquery }}&repo={{ .Repository.Name | urlquery }}"
|
||||
hx-target="#attestation-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('attestation-detail-modal').showModal()">
|
||||
data-action="show-modal" data-modal-id="attestation-detail-modal">
|
||||
{{ icon "shield-check" "size-3" }} Attested
|
||||
</button>
|
||||
{{ end }}
|
||||
@@ -138,16 +138,16 @@
|
||||
|
||||
<!-- Inline Editor (hidden, owner only) — outside HTMX-swapped section so edits survive tag changes -->
|
||||
{{ if .IsOwner }}
|
||||
<div id="overview-edit" class="card bg-base-100 shadow-sm p-6 hidden">
|
||||
<div id="overview-edit" class="card bg-base-200 shadow-sm p-6 hidden">
|
||||
<!-- Write/Preview tabs -->
|
||||
<div class="border-b border-base-300 mb-4">
|
||||
<nav class="flex gap-0" role="tablist">
|
||||
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
|
||||
data-tab="write" onclick="switchEditorTab('write')">
|
||||
data-tab="write" data-action="switch-editor-tab">
|
||||
Write
|
||||
</button>
|
||||
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60"
|
||||
data-tab="preview" onclick="switchEditorTab('preview')">
|
||||
data-tab="preview" data-action="switch-editor-tab">
|
||||
Preview
|
||||
</button>
|
||||
</nav>
|
||||
@@ -157,36 +157,39 @@
|
||||
<div id="editor-write" class="editor-panel">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('heading')" title="Heading">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="heading" title="Heading">
|
||||
{{ icon "heading" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('bold')" title="Bold">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="bold" title="Bold">
|
||||
{{ icon "bold" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('italic')" title="Italic">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="italic" title="Italic">
|
||||
{{ icon "italic" "size-4" }}
|
||||
</button>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('link')" title="Link">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="link" title="Link">
|
||||
{{ icon "link" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('image')" title="Image">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="image" title="Image">
|
||||
{{ icon "image" "size-4" }}
|
||||
</button>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('ul')" title="Bulleted list">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ul" title="Bulleted list">
|
||||
{{ icon "list" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('ol')" title="Numbered list">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ol" title="Numbered list">
|
||||
{{ icon "list-ordered" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" onclick="insertMd('code')" title="Code">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="code" title="Code">
|
||||
{{ icon "code" "size-4" }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="md-editor"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm leading-relaxed"
|
||||
rows="20"
|
||||
maxlength="102400"
|
||||
data-owner-did="{{ .Owner.DID }}"
|
||||
data-repository="{{ .Repository.Name }}"
|
||||
placeholder="Write your repository description in Markdown...">{{ .RawDescription }}</textarea>
|
||||
</div>
|
||||
|
||||
@@ -199,44 +202,41 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button class="btn btn-sm btn-ghost" onclick="toggleOverviewEditor(false)">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary" id="save-overview-btn" onclick="saveOverview()">Save</button>
|
||||
<button class="btn btn-sm btn-ghost" data-action="toggle-editor" data-show="false">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary" id="save-overview-btn" data-action="save-overview">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal container for HTMX -->
|
||||
<div id="modal"></div>
|
||||
|
||||
<!-- Manifest Delete Confirmation Modal -->
|
||||
<dialog id="manifest-delete-modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Confirm Deletion</h3>
|
||||
<dialog id="manifest-delete-modal" class="modal" aria-modal="true" aria-labelledby="manifest-delete-title">
|
||||
<div class="modal-box bg-base-200">
|
||||
<h3 id="manifest-delete-title" class="text-lg font-bold">Confirm Deletion</h3>
|
||||
<p id="manifest-delete-message" class="py-2">This manifest has associated tags that will also be deleted:</p>
|
||||
<ul id="manifest-delete-tags" class="list-disc list-inside text-sm space-y-1"></ul>
|
||||
<p class="font-bold py-2 text-error">This action cannot be undone.</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick="closeManifestDeleteModal()">Cancel</button>
|
||||
<button class="btn" data-action="close-manifest-delete-modal">Cancel</button>
|
||||
<button class="btn btn-error" id="confirm-manifest-delete-btn">Delete All</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button onclick="closeManifestDeleteModal()">close</button>
|
||||
<button data-action="close-manifest-delete-modal">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Untagged Manifests Delete Confirmation Modal -->
|
||||
<dialog id="untagged-delete-modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Delete Untagged Manifests</h3>
|
||||
<dialog id="untagged-delete-modal" class="modal" aria-modal="true" aria-labelledby="untagged-delete-title">
|
||||
<div class="modal-box bg-base-200">
|
||||
<h3 id="untagged-delete-title" class="text-lg font-bold">Delete Untagged Manifests</h3>
|
||||
<p class="py-2">This will delete <strong>all</strong> untagged manifests in this repository, including those not currently visible.</p>
|
||||
<p class="font-bold py-2 text-error">This action cannot be undone.</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog"><button class="btn">Cancel</button></form>
|
||||
<button class="btn btn-error" id="confirm-untagged-delete-btn"
|
||||
onclick="deleteUntaggedManifests('{{ .Repository.Name }}')">
|
||||
data-action="delete-untagged" data-repo="{{ .Repository.Name }}">
|
||||
Delete All Untagged
|
||||
</button>
|
||||
</div>
|
||||
@@ -245,9 +245,9 @@
|
||||
</dialog>
|
||||
|
||||
<!-- Attestation Details Modal -->
|
||||
<dialog id="attestation-detail-modal" class="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold">Attestation Details</h3>
|
||||
<dialog id="attestation-detail-modal" class="modal" aria-modal="true" aria-labelledby="attestation-detail-title">
|
||||
<div class="modal-box bg-base-200 max-w-2xl">
|
||||
<h3 id="attestation-detail-title" class="text-lg font-bold">Attestation Details</h3>
|
||||
<div id="attestation-modal-body" class="py-4">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
@@ -258,310 +258,6 @@
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
{{ if .IsOwner }}
|
||||
<script>
|
||||
(function() {
|
||||
var textarea = document.getElementById('md-editor');
|
||||
if (!textarea) return;
|
||||
|
||||
var ownerDID = {{ .Owner.DID }};
|
||||
var repoName = {{ .Repository.Name }};
|
||||
|
||||
window.toggleOverviewEditor = function(show) {
|
||||
document.getElementById('overview-view').classList.toggle('hidden', show);
|
||||
document.getElementById('overview-edit').classList.toggle('hidden', !show);
|
||||
if (show) textarea.focus();
|
||||
};
|
||||
|
||||
window.switchEditorTab = function(tab) {
|
||||
document.querySelectorAll('.editor-panel').forEach(function(p) { p.classList.add('hidden'); });
|
||||
document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden');
|
||||
|
||||
document.querySelectorAll('.editor-tab').forEach(function(t) {
|
||||
var active = t.dataset.tab === tab;
|
||||
t.classList.toggle('border-primary', active);
|
||||
t.classList.toggle('text-primary', active);
|
||||
t.classList.toggle('border-transparent', !active);
|
||||
t.classList.toggle('text-base-content/60', !active);
|
||||
});
|
||||
|
||||
if (tab === 'preview') {
|
||||
var content = textarea.value;
|
||||
var previewEl = document.getElementById('preview-content');
|
||||
if (!content.trim()) {
|
||||
previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>';
|
||||
return;
|
||||
}
|
||||
var form = new FormData();
|
||||
form.append('markdown', content);
|
||||
fetch('/api/repo-page/preview', { method: 'POST', body: form })
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(html) { previewEl.innerHTML = html; });
|
||||
}
|
||||
};
|
||||
|
||||
window.insertMd = function(type) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var selected = textarea.value.substring(start, end);
|
||||
var before = textarea.value.substring(0, start);
|
||||
var after = textarea.value.substring(end);
|
||||
var insert, cursorStart, cursorEnd;
|
||||
|
||||
switch (type) {
|
||||
case 'heading':
|
||||
insert = '## ' + (selected || 'Heading');
|
||||
cursorStart = start + 3;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'bold':
|
||||
insert = '**' + (selected || 'bold text') + '**';
|
||||
cursorStart = start + 2;
|
||||
cursorEnd = start + insert.length - 2;
|
||||
break;
|
||||
case 'italic':
|
||||
insert = '_' + (selected || 'italic text') + '_';
|
||||
cursorStart = start + 1;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'link':
|
||||
insert = '[' + (selected || 'link text') + '](url)';
|
||||
cursorStart = start + insert.length - 4;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'image':
|
||||
insert = '';
|
||||
cursorStart = start + insert.length - 4;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'ul':
|
||||
insert = '- ' + (selected || 'list item');
|
||||
cursorStart = start + 2;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'ol':
|
||||
insert = '1. ' + (selected || 'list item');
|
||||
cursorStart = start + 3;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'code':
|
||||
if (selected && selected.indexOf('\n') !== -1) {
|
||||
insert = '```\n' + selected + '\n```';
|
||||
cursorStart = start + 4;
|
||||
cursorEnd = start + 4 + selected.length;
|
||||
} else {
|
||||
insert = '`' + (selected || 'code') + '`';
|
||||
cursorStart = start + 1;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.value = before + insert + after;
|
||||
textarea.focus();
|
||||
textarea.selectionStart = cursorStart;
|
||||
textarea.selectionEnd = cursorEnd;
|
||||
};
|
||||
|
||||
window.saveOverview = function() {
|
||||
var btn = document.getElementById('save-overview-btn');
|
||||
btn.classList.add('btn-disabled');
|
||||
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...';
|
||||
|
||||
var form = new FormData();
|
||||
form.append('did', ownerDID);
|
||||
form.append('repository', repoName);
|
||||
form.append('description', textarea.value);
|
||||
|
||||
fetch('/api/repo-page', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'HX-Request': 'true' }
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
|
||||
return r.text();
|
||||
})
|
||||
.then(function(html) {
|
||||
document.getElementById('overview-rendered').innerHTML = html;
|
||||
toggleOverviewEditor(false);
|
||||
if (typeof showToast === 'function') showToast('Overview saved', 'success');
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (typeof showToast === 'function') showToast(err.message || 'Failed to save', 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
btn.classList.remove('btn-disabled');
|
||||
btn.innerHTML = 'Save';
|
||||
});
|
||||
};
|
||||
|
||||
// Ctrl+S / Cmd+S to save
|
||||
textarea.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
saveOverview();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
<script>
|
||||
// Global helpers (tags sort/filter)
|
||||
window.sortTags = function(method) {
|
||||
var container = document.getElementById('tags-list');
|
||||
if (!container) return;
|
||||
var entries = Array.from(container.querySelectorAll('.artifact-entry'));
|
||||
entries.sort(function(a, b) {
|
||||
switch (method) {
|
||||
case 'oldest': return parseInt(a.dataset.created) - parseInt(b.dataset.created);
|
||||
case 'az': return a.dataset.tag.localeCompare(b.dataset.tag);
|
||||
case 'za': return b.dataset.tag.localeCompare(a.dataset.tag);
|
||||
default: return parseInt(b.dataset.created) - parseInt(a.dataset.created);
|
||||
}
|
||||
});
|
||||
entries.forEach(function(el) { container.appendChild(el); });
|
||||
};
|
||||
|
||||
window.filterTags = function(query) {
|
||||
var q = query.toLowerCase();
|
||||
document.querySelectorAll('#tags-list .artifact-entry').forEach(function(el) {
|
||||
el.style.display = (!q || el.dataset.tag.toLowerCase().includes(q)) ? '' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
// Tab controller — reads config from #tag-content data attributes.
|
||||
// Re-runs on initial load and after every HTMX swap of #tag-content.
|
||||
(function() {
|
||||
var validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts'];
|
||||
var loaded = {};
|
||||
|
||||
function lazyLoad(id, url) {
|
||||
if (loaded[id]) return;
|
||||
loaded[id] = true;
|
||||
var target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
fetch(url)
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(html) {
|
||||
target.innerHTML = html;
|
||||
// innerHTML doesn't execute <script> tags — re-create them
|
||||
target.querySelectorAll('script').forEach(function(old) {
|
||||
var s = document.createElement('script');
|
||||
s.textContent = old.textContent;
|
||||
old.parentNode.replaceChild(s, old);
|
||||
});
|
||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||
});
|
||||
}
|
||||
|
||||
function contentUrl(section) {
|
||||
var el = document.getElementById('tag-content');
|
||||
if (!el) return null;
|
||||
var owner = el.dataset.owner;
|
||||
var repo = el.dataset.repo;
|
||||
var digest = el.dataset.digest;
|
||||
if (!digest) return null;
|
||||
return '/api/digest-content/' + owner + '/' + repo + '?digest=' + encodeURIComponent(digest) + '§ion=' + section;
|
||||
}
|
||||
|
||||
function tagsUrl() {
|
||||
var el = document.getElementById('tag-content');
|
||||
if (!el) return null;
|
||||
return '/api/repo-tags/' + el.dataset.owner + '/' + el.dataset.repo;
|
||||
}
|
||||
|
||||
window.diffToTag = function(e, link) {
|
||||
e.preventDefault();
|
||||
var to = link.dataset.diffTo;
|
||||
var content = document.getElementById('tag-content');
|
||||
var selector = document.getElementById('tag-selector');
|
||||
if (!content || !selector || !to) return;
|
||||
var fromDigest = content.dataset.digest;
|
||||
var currentTag = selector.value;
|
||||
if (!fromDigest || to === currentTag) return;
|
||||
window.location.href = '/diff/' + content.dataset.owner + '/' + content.dataset.repo +
|
||||
'?from=' + encodeURIComponent(fromDigest) + '&to=' + encodeURIComponent(to);
|
||||
};
|
||||
|
||||
window.switchRepoTab = function(tabId) {
|
||||
window._activeRepoTab = tabId;
|
||||
var section = document.getElementById('tag-content');
|
||||
if (!section) return;
|
||||
|
||||
section.querySelectorAll('.repo-panel').forEach(function(p) {
|
||||
p.classList.add('hidden');
|
||||
});
|
||||
var panel = document.getElementById('tab-' + tabId);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
|
||||
section.querySelectorAll('.repo-tab').forEach(function(tab) {
|
||||
if (tab.dataset.tab === tabId) {
|
||||
tab.classList.add('border-primary', 'text-primary');
|
||||
tab.classList.remove('border-transparent', 'text-base-content/60');
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
tab.classList.remove('border-primary', 'text-primary');
|
||||
tab.classList.add('border-transparent', 'text-base-content/60');
|
||||
tab.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
var url = new URL(window.location);
|
||||
url.hash = tabId;
|
||||
history.replaceState(null, '', url.toString());
|
||||
|
||||
if (tabId === 'artifacts') { var u = tagsUrl(); if (u) lazyLoad('artifacts-content', u); }
|
||||
if (tabId === 'layers') { var u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); }
|
||||
if (tabId === 'vulns') { var u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); }
|
||||
if (tabId === 'sbom') { var u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); }
|
||||
};
|
||||
|
||||
function initTabs() {
|
||||
// Reset lazy-load tracking (new tag = new content)
|
||||
loaded = {};
|
||||
|
||||
// Prefetch on hover
|
||||
var tagsBtn = document.getElementById('artifacts-tab-btn');
|
||||
if (tagsBtn) tagsBtn.addEventListener('mouseenter', function() { var u = tagsUrl(); if (u) lazyLoad('artifacts-content', u); }, { once: true });
|
||||
var layersBtn = document.getElementById('layers-tab-btn');
|
||||
if (layersBtn) layersBtn.addEventListener('mouseenter', function() { var u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); }, { once: true });
|
||||
var vulnsBtn = document.getElementById('vulns-tab-btn');
|
||||
if (vulnsBtn) vulnsBtn.addEventListener('mouseenter', function() { var u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); }, { once: true });
|
||||
var sbomBtn = document.getElementById('sbom-tab-btn');
|
||||
if (sbomBtn) sbomBtn.addEventListener('mouseenter', function() { var u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); }, { once: true });
|
||||
|
||||
// Pick tab: persisted > hash > default
|
||||
var initTab = window._activeRepoTab || window.location.hash.replace('#', '') || 'overview';
|
||||
if (validTabs.indexOf(initTab) === -1) initTab = 'overview';
|
||||
switchRepoTab(initTab);
|
||||
}
|
||||
|
||||
// Run on initial page load
|
||||
initTabs();
|
||||
|
||||
// Keyboard shortcuts: first letter of each tab name
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.tagName === 'SELECT' || e.target.isContentEditable) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
var map = { o: 'overview', l: 'layers', v: 'vulns', s: 'sbom', a: 'artifacts' };
|
||||
var tab = map[e.key.toLowerCase()];
|
||||
if (tab && validTabs.indexOf(tab) !== -1) switchRepoTab(tab);
|
||||
});
|
||||
|
||||
// Re-run after HTMX swaps tag section content (tag dropdown change)
|
||||
document.body.addEventListener('htmx:afterSettle', function(evt) {
|
||||
if (evt.detail.target && evt.detail.target.id === 'tag-content') {
|
||||
initTabs();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8">
|
||||
{{ if .SearchQuery }}
|
||||
<h1 class="text-2xl font-bold mb-6">Search Results for "{{ .SearchQuery }}"</h1>
|
||||
{{ else }}
|
||||
@@ -27,9 +27,6 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal container for HTMX -->
|
||||
<div id="modal"></div>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-display font-bold tracking-tight mb-6">Settings</h1>
|
||||
|
||||
<!-- Mobile identity info (below lg) -->
|
||||
<div class="lg:hidden mb-4 space-y-1 text-xs text-base-content/50">
|
||||
<div class="lg:hidden mb-4 space-y-1 text-xs text-base-content/70">
|
||||
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
|
||||
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
<li data-tab="webhooks" role="none"><a href="#webhooks" role="tab" id="tab-desktop-webhooks" aria-controls="tab-webhooks" aria-selected="false" tabindex="-1">{{ icon "webhook" "size-4" }} Webhooks</a></li>
|
||||
<li data-tab="advanced" role="none"><a href="#advanced" role="tab" id="tab-desktop-advanced" aria-controls="tab-advanced" aria-selected="false" tabindex="-1">{{ icon "shield-check" "size-4" }} Advanced</a></li>
|
||||
</ul>
|
||||
<div class="mt-4 px-2 space-y-1 text-xs text-base-content/50">
|
||||
<div class="mt-4 px-2 space-y-1 text-xs text-base-content/70">
|
||||
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
|
||||
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
<!-- USER TAB -->
|
||||
<div id="tab-user" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-user" tabindex="0">
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-6">
|
||||
<section class="card bg-base-200 shadow-sm p-6 space-y-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Preferences</h2>
|
||||
<p class="text-base-content/70 mt-1">Customize your experience across the site.</p>
|
||||
@@ -71,11 +71,14 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<label for="oci-client-select" class="text-sm font-medium">Preferred client</label>
|
||||
<p id="oci-client-hint" class="text-xs text-base-content/60">Sets the pull command shown on repository pages. Choose <em>Image reference only</em> to copy just the image ref (useful for Kubernetes manifests or docker-compose).</p>
|
||||
<p id="oci-client-hint" class="text-xs text-base-content/70">Sets the pull command shown on repository pages. Choose <em>Image reference only</em> to copy without a command prefix.</p>
|
||||
</div>
|
||||
{{ $oci := .Profile.OciClient }}
|
||||
<select id="oci-client-select" aria-describedby="oci-client-hint" class="select select-sm select-bordered min-w-40"
|
||||
onchange="htmx.ajax('POST', '/api/profile/oci-client', {values: {oci_client: this.value}, swap: 'none'})">
|
||||
name="oci_client"
|
||||
hx-post="/api/profile/oci-client"
|
||||
hx-trigger="change"
|
||||
hx-swap="none">
|
||||
<option value="docker"{{ if or (eq $oci "") (eq $oci "docker") }} selected{{ end }}>Docker</option>
|
||||
<option value="podman"{{ if eq $oci "podman" }} selected{{ end }}>Podman</option>
|
||||
<option value="buildah"{{ if eq $oci "buildah" }} selected{{ end }}>Buildah</option>
|
||||
@@ -104,9 +107,9 @@
|
||||
{{ else }}
|
||||
<div>
|
||||
<span class="font-medium text-base-content/50">AI Image Advisor</span>
|
||||
<p class="text-xs text-base-content/50">Analyze your container images for optimization suggestions using AI.</p>
|
||||
<p class="text-xs text-base-content/70">Analyze your container images for optimization suggestions using AI.</p>
|
||||
<p class="text-xs text-primary mt-1">
|
||||
<a href="/settings#billing" onclick="switchSettingsTab('billing')">Upgrade your plan</a> to enable this feature.
|
||||
<a href="/settings#billing">Upgrade your plan</a> to enable this feature.
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -125,7 +128,7 @@
|
||||
{{ if .ActiveHold }}
|
||||
{{ template "hold_card" .ActiveHold }}
|
||||
{{ else }}
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60">
|
||||
<div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60">
|
||||
No active hold selected. Choose one above.
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -137,7 +140,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60">
|
||||
<div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60">
|
||||
No holds configured. Push an image to get started.
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -169,7 +172,7 @@
|
||||
|
||||
<!-- DEVICES TAB -->
|
||||
<div id="tab-devices" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-devices" tabindex="0">
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-6">
|
||||
<section class="card bg-base-200 shadow-sm p-6 space-y-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Authorized Devices</h2>
|
||||
<p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">docker-credential-atcr</code> credential helper.</p>
|
||||
@@ -229,7 +232,7 @@
|
||||
|
||||
<!-- WEBHOOKS TAB -->
|
||||
<div id="tab-webhooks" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-webhooks" tabindex="0">
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Webhooks</h2>
|
||||
<p class="text-base-content/70 mt-1">Get notified when images are pushed or vulnerability scans complete.</p>
|
||||
@@ -243,7 +246,7 @@
|
||||
<!-- ADVANCED TAB -->
|
||||
<div id="tab-advanced" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-advanced" tabindex="0">
|
||||
<!-- Data Privacy Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Data Privacy</h2>
|
||||
<p class="text-base-content/70">Download a copy of all data we store about you.</p>
|
||||
|
||||
@@ -290,7 +293,9 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="button" id="delete-account-btn" class="btn btn-error btn-lg gap-2">
|
||||
<button type="button" id="delete-account-btn" class="btn btn-error btn-lg gap-2"
|
||||
data-client-short-name="{{ .ClientShortName }}"
|
||||
data-profile-handle="{{ .Profile.Handle }}">
|
||||
{{ icon "trash-2" "size-5" }}
|
||||
Delete My {{ .ClientShortName }} Data
|
||||
</button>
|
||||
@@ -302,291 +307,6 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// OCI client dropdown
|
||||
// Tab switching
|
||||
(function() {
|
||||
var validTabs = ['user', 'billing', 'storage', 'devices', 'webhooks', 'advanced'];
|
||||
|
||||
function switchSettingsTab(tabId) {
|
||||
// Hide all panels
|
||||
document.querySelectorAll('.settings-panel').forEach(function(p) {
|
||||
p.classList.add('hidden');
|
||||
});
|
||||
// Show selected panel
|
||||
var panel = document.getElementById('tab-' + tabId);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
|
||||
// Sidebar: toggle menu-active + aria-selected + tabindex
|
||||
document.querySelectorAll('.menu li[data-tab]').forEach(function(li) {
|
||||
var active = li.dataset.tab === tabId;
|
||||
li.classList.toggle('menu-active', active);
|
||||
var a = li.querySelector('a[role="tab"]');
|
||||
if (a) {
|
||||
a.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
a.setAttribute('tabindex', active ? '0' : '-1');
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile: toggle btn styles + aria-selected + tabindex
|
||||
document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) {
|
||||
var active = btn.dataset.tab === tabId;
|
||||
btn.classList.toggle('btn-ghost', !active);
|
||||
btn.classList.toggle('btn-secondary', active);
|
||||
btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
btn.setAttribute('tabindex', active ? '0' : '-1');
|
||||
});
|
||||
|
||||
// Update URL hash without adding history entry
|
||||
history.replaceState(null, '', '#' + tabId);
|
||||
// Dispatch custom event for HTMX lazy loading
|
||||
document.body.dispatchEvent(new CustomEvent('tab:' + tabId));
|
||||
}
|
||||
|
||||
// Helper for HTMX conditional polling
|
||||
window.isTabActive = function(tabId) {
|
||||
var panel = document.getElementById('tab-' + tabId);
|
||||
return panel && !panel.classList.contains('hidden');
|
||||
};
|
||||
|
||||
// Roving tab navigation (arrow keys, Home, End) per WAI-ARIA tabs pattern
|
||||
function handleTabKeydown(tabs, orientation) {
|
||||
return function(e) {
|
||||
var prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
|
||||
var nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
|
||||
var idx = tabs.indexOf(e.currentTarget);
|
||||
if (idx === -1) return;
|
||||
var target = null;
|
||||
if (e.key === prevKey) target = tabs[(idx - 1 + tabs.length) % tabs.length];
|
||||
else if (e.key === nextKey) target = tabs[(idx + 1) % tabs.length];
|
||||
else if (e.key === 'Home') target = tabs[0];
|
||||
else if (e.key === 'End') target = tabs[tabs.length - 1];
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
switchSettingsTab(target.dataset.tab || target.parentElement.dataset.tab);
|
||||
target.focus();
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Read initial tab from hash
|
||||
var hash = window.location.hash.replace('#', '') || 'user';
|
||||
if (validTabs.indexOf(hash) === -1) hash = 'user';
|
||||
|
||||
// Mobile tab click + keyboard handlers
|
||||
var mobileTabs = Array.prototype.slice.call(document.querySelectorAll('.settings-tab-mobile'));
|
||||
var mobileKeydown = handleTabKeydown(mobileTabs, 'horizontal');
|
||||
mobileTabs.forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
switchSettingsTab(this.dataset.tab);
|
||||
});
|
||||
btn.addEventListener('keydown', mobileKeydown);
|
||||
});
|
||||
|
||||
// Sidebar tab click + keyboard handlers
|
||||
var sidebarTabs = Array.prototype.slice.call(document.querySelectorAll('.menu li[data-tab] a[role="tab"]'));
|
||||
var sidebarKeydown = handleTabKeydown(sidebarTabs, 'vertical');
|
||||
sidebarTabs.forEach(function(link) {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
switchSettingsTab(this.parentElement.dataset.tab);
|
||||
});
|
||||
link.addEventListener('keydown', sidebarKeydown);
|
||||
});
|
||||
|
||||
// Activate initial tab
|
||||
switchSettingsTab(hash);
|
||||
});
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('hashchange', function() {
|
||||
var hash = window.location.hash.replace('#', '') || 'user';
|
||||
if (validTabs.indexOf(hash) !== -1) {
|
||||
switchSettingsTab(hash);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// Account Deletion JavaScript
|
||||
(function() {
|
||||
const deleteBtn = document.getElementById('delete-account-btn');
|
||||
if (!deleteBtn) return;
|
||||
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
showDeleteConfirmationModal();
|
||||
});
|
||||
|
||||
function showDeleteConfirmationModal() {
|
||||
// Create modal using DaisyUI structure
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal modal-open';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-box max-w-lg">
|
||||
<h2 class="text-xl font-bold flex items-center gap-2 text-error">
|
||||
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#alert-triangle"></use></svg>
|
||||
Delete {{ .ClientShortName }} Data
|
||||
</h2>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-success">
|
||||
<svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#check-circle"></use></svg>
|
||||
<span>Your ATProto account will <strong>NOT</strong> be affected.</span>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/80">
|
||||
This action <strong>cannot be undone</strong>. This will permanently delete:
|
||||
</p>
|
||||
|
||||
<ul class="list-disc list-inside text-sm space-y-1 text-base-content/70">
|
||||
<li>Your {{ .ClientShortName }} account and all settings</li>
|
||||
<li>All authorized devices</li>
|
||||
<li>Your data from all holds you're a member of</li>
|
||||
${document.getElementById('delete-pds-records').checked ?
|
||||
'<li>All io.atcr.* records from your ATProto PDS</li>' : ''}
|
||||
</ul>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="confirm-delete-input" class="text-sm">Type <strong class="font-mono">DELETE {{ .Profile.Handle }}</strong> to confirm:</label>
|
||||
<input type="text" id="confirm-delete-input" class="input input-bordered w-full font-mono" placeholder="DELETE {{ .Profile.Handle }}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="cancel-delete">Cancel</button>
|
||||
<button type="button" class="btn btn-error gap-2" id="confirm-delete" disabled>
|
||||
<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#trash-2"></use></svg>
|
||||
Delete My {{ .ClientShortName }} Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/50" id="modal-backdrop"></div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Focus the input
|
||||
const confirmInput = document.getElementById('confirm-delete-input');
|
||||
const confirmBtn = document.getElementById('confirm-delete');
|
||||
const cancelBtn = document.getElementById('cancel-delete');
|
||||
|
||||
setTimeout(() => confirmInput.focus(), 100);
|
||||
|
||||
// Expected confirmation string
|
||||
const expectedConfirmation = 'DELETE {{ .Profile.Handle }}';
|
||||
|
||||
// Enable button only when full confirmation is typed
|
||||
confirmInput.addEventListener('input', function() {
|
||||
confirmBtn.disabled = this.value !== expectedConfirmation;
|
||||
});
|
||||
|
||||
// Handle enter key
|
||||
confirmInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && this.value === expectedConfirmation) {
|
||||
performAccountDeletion();
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
cancelBtn.addEventListener('click', function() {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
// Click outside to close (on backdrop)
|
||||
document.getElementById('modal-backdrop').addEventListener('click', function() {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
// Escape key to close
|
||||
document.addEventListener('keydown', function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm delete
|
||||
confirmBtn.addEventListener('click', performAccountDeletion);
|
||||
|
||||
async function performAccountDeletion() {
|
||||
const deletePDS = document.getElementById('delete-pds-records').checked;
|
||||
|
||||
// Show loading state
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.innerHTML = '<svg class="icon size-4 animate-spin" aria-hidden="true"><use href="/icons.svg#loader-2"></use></svg> Deleting...';
|
||||
cancelBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
delete_pds_records: deletePDS,
|
||||
confirmation: expectedConfirmation
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// Show success and redirect
|
||||
modal.querySelector('.modal-box').innerHTML = `
|
||||
<h2 class="text-xl font-bold flex items-center gap-2 text-success">
|
||||
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#check-circle"></use></svg>
|
||||
Account Deleted
|
||||
</h2>
|
||||
<div class="py-4 space-y-2">
|
||||
<p>Your account has been successfully deleted.</p>
|
||||
<p class="text-base-content/70">Redirecting to home page...</p>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => {
|
||||
window.location.href = '/?deleted=true';
|
||||
}, 2000);
|
||||
} else {
|
||||
// Show error
|
||||
const errors = result.errors || ['An unknown error occurred'];
|
||||
modal.querySelector('.modal-box').innerHTML = `
|
||||
<h2 class="text-xl font-bold flex items-center gap-2 text-error">
|
||||
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#x-circle"></use></svg>
|
||||
Deletion Failed
|
||||
</h2>
|
||||
<div class="py-4 space-y-4">
|
||||
<p>There were errors during account deletion:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 text-error">
|
||||
${errors.map(e => '<li>' + escapeHtml(e) + '</li>').join('')}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Close</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete account error:', err);
|
||||
modal.querySelector('.modal-box').innerHTML = `
|
||||
<h2 class="text-xl font-bold flex items-center gap-2 text-error">
|
||||
<svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#x-circle"></use></svg>
|
||||
Error
|
||||
</h2>
|
||||
<div class="py-4">
|
||||
<p>Failed to delete account: ${escapeHtml(err.message)}</p>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Close</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 class="text-3xl font-display font-bold tracking-tight mb-2">Terms of Service - {{ .CompanyName }} ({{ .SiteURL }})</h1>
|
||||
<p class="text-base-content/60 mb-8"><em>Last updated: January 2025</em></p>
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<p>The Service operates on the AT Protocol, a distributed network. Data written to your PDS is controlled by you or your PDS hosting provider, not by us.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">7.2 Records on Your PDS</h3>
|
||||
<p>When you use the Service, records are written to your PDS under the <code class="bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> namespace. We can create, update, and delete these records only while you have granted us OAuth access. Revoking access does not automatically delete existing records.</p>
|
||||
<p>When you use the Service, records are written to your PDS under the <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">io.atcr.*</code> namespace. We can create, update, and delete these records only while you have granted us OAuth access. Revoking access does not automatically delete existing records.</p>
|
||||
|
||||
<h3 class="text-lg font-medium mt-6">7.3 No Control Over Your PDS</h3>
|
||||
<p>We do not control your PDS. If your PDS is offline or your PDS provider terminates your account, we cannot restore your AT Protocol records.</p>
|
||||
@@ -198,7 +198,6 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="modal"></div>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main id="main-content" class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<!-- User Profile Header -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
{{ if .ViewedUser.Avatar }}
|
||||
<div class="avatar">
|
||||
<div class="w-20 rounded-full shadow">
|
||||
<img src="{{ .ViewedUser.Avatar }}" alt="{{ .ViewedUser.Handle }}" loading="lazy" />
|
||||
<img src="{{ resizeImage .ViewedUser.Avatar 160 }}" alt="{{ .ViewedUser.Handle }}" width="80" height="80" fetchpriority="high" />
|
||||
</div>
|
||||
</div>
|
||||
{{ else if .HasProfile }}
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="bg-base-300 text-base-content/60 w-20 rounded-full shadow">
|
||||
<div class="bg-neutral text-neutral-content/60 w-20 rounded-full shadow">
|
||||
<span class="text-3xl">?</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,9 +54,6 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal container for HTMX -->
|
||||
<div id="modal"></div>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
<code class="font-mono text-xs text-base-content/60 truncate max-w-48" title="{{ .Digest }}">{{ .Digest }}</code>
|
||||
</div>
|
||||
{{ if .NeedsLogin }}
|
||||
<p class="text-sm text-base-content/50"><a href="{{ $.LoginURL }}" class="link link-primary">Log in</a> to view attestation content</p>
|
||||
<p class="text-sm text-base-content/70"><a href="{{ $.LoginURL }}" class="link link-primary">Log in</a> to view attestation content</p>
|
||||
{{ else if .FetchError }}
|
||||
<p class="text-sm text-base-content/50">{{ .FetchError }}</p>
|
||||
<p class="text-sm text-base-content/70">{{ .FetchError }}</p>
|
||||
{{ else if .RawJSON }}
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm text-base-content/70 hover:text-base-content">View content</summary>
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</details>
|
||||
{{ else if .Size }}
|
||||
<p class="text-sm text-base-content/50">Binary content ({{ .Size }} bytes) — cannot display inline</p>
|
||||
<p class="text-sm text-base-content/70">Binary content ({{ .Size }} bytes) — cannot display inline</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!-- Layers + Vulnerabilities Diff -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Layer Diff (Left) -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="card bg-base-200 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<h2 class="text-lg font-semibold">Layers</h2>
|
||||
{{ if .LayerDiff }}
|
||||
<div class="overflow-x-auto">
|
||||
@@ -29,7 +29,7 @@
|
||||
{{ if not .Layer.EmptyLayer }}{{ humanizeBytes .Layer.Size }}{{ end }}
|
||||
{{ if and (eq .Status "rebuilt") .PrevLayer }}
|
||||
{{ if ne .Layer.Size .PrevLayer.Size }}
|
||||
<span class="text-xs text-base-content/50">({{ humanizeByteDelta (sub64 .Layer.Size .PrevLayer.Size) }})</span>
|
||||
<span class="text-xs text-base-content/70">({{ humanizeByteDelta (sub64 .Layer.Size .PrevLayer.Size) }})</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</td>
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Vulnerability Diff (Right) -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="card bg-base-200 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<h2 class="text-lg font-semibold">Vulnerabilities</h2>
|
||||
|
||||
{{ if not .HasVulnData }}
|
||||
@@ -54,7 +54,7 @@
|
||||
<!-- Fixed Vulns -->
|
||||
{{ if .FixedVulns }}
|
||||
<div class="collapse collapse-arrow bg-success/5 border border-success/20 rounded-lg">
|
||||
<input type="checkbox" checked />
|
||||
<input type="checkbox" checked aria-label="Toggle fixed vulnerabilities ({{ len .FixedVulns }})" />
|
||||
<div class="collapse-title font-medium text-sm flex items-center gap-2">
|
||||
{{ icon "shield-check" "size-4 text-success" }}
|
||||
Fixed ({{ len .FixedVulns }})
|
||||
@@ -94,7 +94,7 @@
|
||||
<!-- New Vulns -->
|
||||
{{ if .NewVulns }}
|
||||
<div class="collapse collapse-arrow bg-error/5 border border-error/20 rounded-lg">
|
||||
<input type="checkbox" checked />
|
||||
<input type="checkbox" checked aria-label="Toggle new vulnerabilities ({{ len .NewVulns }})" />
|
||||
<div class="collapse-title font-medium text-sm flex items-center gap-2">
|
||||
{{ icon "alert-triangle" "size-4 text-error" }}
|
||||
New ({{ len .NewVulns }})
|
||||
@@ -136,7 +136,7 @@
|
||||
<!-- Unchanged Vulns -->
|
||||
{{ if .UnchangedVulns }}
|
||||
<div class="collapse collapse-arrow bg-base-200/50 border border-base-300 rounded-lg">
|
||||
<input type="checkbox" />
|
||||
<input type="checkbox" aria-label="Toggle unchanged vulnerabilities ({{ len .UnchangedVulns }})" />
|
||||
<div class="collapse-title font-medium text-sm text-base-content/60">
|
||||
Unchanged ({{ len .UnchangedVulns }})
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<!-- Layers + Vulnerabilities Side by Side -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Layers (Left) -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="card bg-base-200 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Layers ({{ len .Layers }})</h2>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" class="checkbox checkbox-xs show-empty-layers-cb" onchange="toggleEmptyLayers(this.checked)">
|
||||
<input type="checkbox" class="checkbox checkbox-xs show-empty-layers-cb" data-toggle-empty-layers>
|
||||
<span>Show empty layers</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -29,99 +29,19 @@
|
||||
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="text-right text-sm whitespace-nowrap">{{ humanizeBytes .Size }}</td>
|
||||
<td class="text-right text-sm whitespace-nowrap" data-bytes="{{ .Size }}">{{ humanizeBytes .Size }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = showEmpty; });
|
||||
|
||||
window.toggleEmptyLayers = function(show) {
|
||||
localStorage.setItem('showEmptyLayers', show);
|
||||
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = show; });
|
||||
applyLayerVisibility();
|
||||
};
|
||||
|
||||
function collapseNoHistoryLayers() {
|
||||
document.querySelectorAll('.layers-table').forEach(function(table) {
|
||||
var tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
var rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
var i = 0;
|
||||
while (i < rows.length) {
|
||||
if (rows[i].dataset.noCommand === 'true') {
|
||||
var start = i;
|
||||
while (i < rows.length && rows[i].dataset.noCommand === 'true') {
|
||||
rows[i].classList.add('no-history-row', 'hidden');
|
||||
i++;
|
||||
}
|
||||
var count = i - start;
|
||||
if (count > 1) {
|
||||
var totalBytes = 0;
|
||||
for (var k = start; k < start + count; k++) {
|
||||
var sizeCell = rows[k].querySelector('td:last-child');
|
||||
if (sizeCell) {
|
||||
var txt = sizeCell.textContent.trim();
|
||||
var match = txt.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
|
||||
if (match) {
|
||||
var val = parseFloat(match[1]);
|
||||
var unit = match[2].toUpperCase();
|
||||
var multipliers = {'B':1,'KB':1024,'MB':1048576,'GB':1073741824,'TB':1099511627776};
|
||||
totalBytes += val * (multipliers[unit] || 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
var sizeStr = '';
|
||||
if (totalBytes < 1024) sizeStr = totalBytes + ' B';
|
||||
else if (totalBytes < 1048576) sizeStr = (totalBytes/1024).toFixed(1) + ' KB';
|
||||
else if (totalBytes < 1073741824) sizeStr = (totalBytes/1048576).toFixed(1) + ' MB';
|
||||
else sizeStr = (totalBytes/1073741824).toFixed(1) + ' GB';
|
||||
|
||||
var startIdx = rows[start].querySelector('td').textContent.trim();
|
||||
var endIdx = rows[i - 1].querySelector('td').textContent.trim();
|
||||
var summary = document.createElement('tr');
|
||||
summary.className = 'no-history-summary cursor-pointer hover:bg-base-200';
|
||||
summary.innerHTML = '<td colspan="2" class="text-sm py-2">Layers ' + startIdx + '-' + endIdx + ' contain no history <span class="text-xs ml-2">(' + count + ' layers, click to expand)</span></td><td class="text-right text-sm whitespace-nowrap">' + sizeStr + '</td>';
|
||||
summary.onclick = function() {
|
||||
summary.remove();
|
||||
for (var j = start; j < start + count; j++) {
|
||||
rows[j].classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
tbody.insertBefore(summary, rows[start]);
|
||||
} else {
|
||||
rows[start].classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyLayerVisibility() {
|
||||
var show = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
document.querySelectorAll('.layers-table tr[data-empty="true"]').forEach(function(row) {
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
collapseNoHistoryLayers();
|
||||
applyLayerVisibility();
|
||||
})();
|
||||
</script>
|
||||
{{ else }}
|
||||
<p class="text-base-content">No layer information available</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Vulnerabilities + SBOM (Right) -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="card bg-base-200 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div role="tablist" class="tabs tabs-bordered">
|
||||
<input type="radio" name="scan-tabs" role="tab" class="tab" aria-label="Vulnerabilities" checked="checked" />
|
||||
<div role="tabpanel" class="tab-content pt-4">
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
{{ define "hold_card" }}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card bg-base-200 shadow-sm">
|
||||
<!-- Header -->
|
||||
<div class="p-4 flex flex-wrap items-center gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-warning" title="Active hold">★</span>
|
||||
<h3 class="font-semibold text-lg truncate">{{ .DisplayName }}</h3>
|
||||
<span class="badge badge-sm badge-warning">Active</span>
|
||||
<span class="badge badge-sm badge-soft badge-primary">Active</span>
|
||||
{{ if eq .Membership "owner" }}<span class="badge badge-sm badge-primary">Owner</span>
|
||||
{{ else }}<span class="badge badge-sm badge-secondary">Crew</span>{{ end }}
|
||||
{{ if eq .Status "online" }}<span class="badge badge-sm badge-success gap-1">● Online</span>
|
||||
{{ else if eq .Status "offline" }}<span class="badge badge-sm badge-error gap-1">● Offline</span>
|
||||
{{ if eq .Status "online" }}<span class="badge badge-sm badge-success gap-1.5"><span class="inline-block size-1.5 rounded-full bg-current" aria-hidden="true"></span>Online</span>
|
||||
{{ else if eq .Status "offline" }}<span class="badge badge-sm badge-error gap-1.5"><span class="inline-block size-1.5 rounded-full bg-current" aria-hidden="true"></span>Offline</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<code class="text-xs text-base-content/50 break-all">{{ .DID }}</code>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<code class="text-xs text-base-content/70 break-all">{{ .DID }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +22,7 @@
|
||||
hx-get="/api/storage?hold_did={{ .DID | urlquery }}"
|
||||
hx-trigger="load, tab:storage from:body once"
|
||||
hx-swap="innerHTML">
|
||||
<p class="flex items-center gap-2 text-sm text-base-content/50">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p>
|
||||
<p class="flex items-center gap-2 text-sm text-base-content/70">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{{ define "hold_selector" }}
|
||||
<div class="card bg-base-100 shadow-sm p-4">
|
||||
<div class="card bg-base-200 shadow-sm p-4">
|
||||
<form hx-post="/api/profile/default-hold" hx-swap="none" class="flex items-center gap-3 flex-wrap">
|
||||
<label class="text-sm font-medium whitespace-nowrap" for="hold-select">Active Hold:</label>
|
||||
<select id="hold-select" name="hold_did" class="select select-bordered select-sm flex-1 min-w-0"
|
||||
onchange="this.form.requestSubmit()">
|
||||
data-action="submit-form">
|
||||
{{ if not .ActiveHold }}
|
||||
<option value="" selected>-- Select a hold --</option>
|
||||
{{ end }}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<span>{{ .Error }}</span>
|
||||
</div>
|
||||
{{ else if .Suggestions }}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300">
|
||||
<div class="card bg-base-200 shadow-sm border border-base-300">
|
||||
<div class="card-body p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||
{{ icon "sparkle" "size-4" }}
|
||||
@@ -66,10 +66,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs opacity-50">Generated by Claude Haiku. Suggestions are advisory only.</p>
|
||||
<p class="text-xs text-base-content/70">Generated by Claude Haiku. Suggestions are advisory only.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-sm opacity-60">No suggestions generated.</p>
|
||||
<p class="text-sm text-base-content/60">No suggestions generated.</p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{{ define "layers-section" }}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="space-y-4 min-w-0 pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Layers ({{ len .Layers }})</h2>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" class="checkbox checkbox-xs show-empty-layers-cb" onchange="toggleEmptyLayers(this.checked)">
|
||||
<input type="checkbox" class="checkbox checkbox-xs show-empty-layers-cb" data-toggle-empty-layers>
|
||||
<span>Show empty layers</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -26,94 +26,17 @@
|
||||
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="text-right text-sm whitespace-nowrap">{{ humanizeBytes .Size }}</td>
|
||||
<td class="text-right text-sm whitespace-nowrap" data-bytes="{{ .Size }}">{{ humanizeBytes .Size }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = showEmpty; });
|
||||
|
||||
window.toggleEmptyLayers = function(show) {
|
||||
localStorage.setItem('showEmptyLayers', show);
|
||||
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = show; });
|
||||
applyLayerVisibility();
|
||||
};
|
||||
|
||||
function collapseNoHistoryLayers() {
|
||||
document.querySelectorAll('.layers-table').forEach(function(table) {
|
||||
var tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
var rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
var i = 0;
|
||||
while (i < rows.length) {
|
||||
if (rows[i].dataset.noCommand === 'true') {
|
||||
var start = i;
|
||||
while (i < rows.length && rows[i].dataset.noCommand === 'true') {
|
||||
rows[i].classList.add('no-history-row', 'hidden');
|
||||
i++;
|
||||
}
|
||||
var count = i - start;
|
||||
if (count > 1) {
|
||||
var totalBytes = 0;
|
||||
for (var k = start; k < start + count; k++) {
|
||||
var sizeCell = rows[k].querySelector('td:last-child');
|
||||
if (sizeCell) {
|
||||
var txt = sizeCell.textContent.trim();
|
||||
var match = txt.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
|
||||
if (match) {
|
||||
var val = parseFloat(match[1]);
|
||||
var unit = match[2].toUpperCase();
|
||||
var multipliers = {'B':1,'KB':1024,'MB':1048576,'GB':1073741824,'TB':1099511627776};
|
||||
totalBytes += val * (multipliers[unit] || 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
var sizeStr = '';
|
||||
if (totalBytes < 1024) sizeStr = totalBytes + ' B';
|
||||
else if (totalBytes < 1048576) sizeStr = (totalBytes/1024).toFixed(1) + ' KB';
|
||||
else if (totalBytes < 1073741824) sizeStr = (totalBytes/1048576).toFixed(1) + ' MB';
|
||||
else sizeStr = (totalBytes/1073741824).toFixed(1) + ' GB';
|
||||
|
||||
var startIdx = rows[start].querySelector('td').textContent.trim();
|
||||
var endIdx = rows[i - 1].querySelector('td').textContent.trim();
|
||||
var summary = document.createElement('tr');
|
||||
summary.className = 'no-history-summary cursor-pointer hover:bg-base-200';
|
||||
summary.innerHTML = '<td colspan="2" class="text-sm py-2">Layers ' + startIdx + '-' + endIdx + ' contain no history <span class="text-xs ml-2">(' + count + ' layers, click to expand)</span></td><td class="text-right text-sm whitespace-nowrap">' + sizeStr + '</td>';
|
||||
summary.onclick = function() {
|
||||
summary.remove();
|
||||
for (var j = start; j < start + count; j++) {
|
||||
rows[j].classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
tbody.insertBefore(summary, rows[start]);
|
||||
} else {
|
||||
rows[start].classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyLayerVisibility() {
|
||||
var show = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
document.querySelectorAll('.layers-table tr[data-empty="true"]').forEach(function(row) {
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
collapseNoHistoryLayers();
|
||||
applyLayerVisibility();
|
||||
})();
|
||||
</script>
|
||||
{{ else }}
|
||||
<p class="text-base-content">No layer information available</p>
|
||||
<div class="py-8 text-sm text-base-content/70 max-w-prose">
|
||||
<p class="font-medium text-base-content">No layer history available</p>
|
||||
<p class="mt-1">This image was built without history metadata — common for squashed images or tarballs imported with <code class="font-mono text-xs bg-base-300 px-1 py-0.5 rounded">docker load</code>.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{ define "other_holds_table" }}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card bg-base-200 shadow-sm">
|
||||
<div class="p-4 pb-2">
|
||||
<h3 class="text-sm font-semibold text-base-content/70">Other Holds</h3>
|
||||
</div>
|
||||
@@ -25,13 +25,13 @@
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{ if eq .Status "online" }}
|
||||
<span class="text-success" aria-hidden="true">●</span>
|
||||
<span class="inline-block size-2 rounded-full bg-success" aria-hidden="true"></span>
|
||||
<span class="sr-only">Online</span>
|
||||
{{ else if eq .Status "offline" }}
|
||||
<span class="text-error" aria-hidden="true">●</span>
|
||||
<span class="inline-block size-2 rounded-full bg-error" aria-hidden="true"></span>
|
||||
<span class="sr-only">Offline</span>
|
||||
{{ else }}
|
||||
<span class="text-base-content/50" aria-hidden="true">●</span>
|
||||
<span class="inline-block size-2 rounded-full bg-base-content/40" aria-hidden="true"></span>
|
||||
<span class="sr-only">Unknown</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
|
||||
@@ -11,44 +11,44 @@
|
||||
{{ range .NonDefaultHolds }}
|
||||
<span class="badge badge-outline badge-sm" title="{{ . }}">{{ displayHoldDID . }}</span>
|
||||
{{ end }}
|
||||
<span class="text-base-content/50">(different from your default hold)</span>
|
||||
<span class="text-base-content/70">(different from your default hold)</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<!-- Size & Layers -->
|
||||
<div class="card bg-base-100 border border-base-300 p-4">
|
||||
<div class="card bg-base-200 border border-base-300 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Image Size</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Layers</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Image Size</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Layers</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-lg font-bold">{{ humanizeBytes .SelectedTag.CompressedSize }}</span>
|
||||
<span class="text-lg font-bold">{{ .SelectedTag.LayerCount }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50 mt-1">
|
||||
<div class="text-xs text-base-content/70 mt-1">
|
||||
Pushed {{ timeAgoShort .SelectedTag.Info.CreatedAt }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vulnerabilities (first platform only for summary) -->
|
||||
<div class="card bg-base-100 border border-base-300 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Vulnerabilities</div>
|
||||
<div class="card bg-base-200 border border-base-300 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-base-content/70 mb-2">Vulnerabilities</div>
|
||||
<div id="vuln-summary-card">
|
||||
{{ $firstPlatform := index .SelectedTag.Info.Platforms 0 }}
|
||||
<span id="scan-badge-{{ trimPrefix "sha256:" $firstPlatform.Digest }}"></span>
|
||||
<span id="vuln-loading-text" class="text-sm text-base-content/40">Loading...</span>
|
||||
<span id="vuln-loading-text" class="text-sm text-base-content/70">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pulls -->
|
||||
<div class="card bg-base-100 border border-base-300 p-4">
|
||||
<div class="card bg-base-200 border border-base-300 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Pulls</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Pulls</span>
|
||||
</div>
|
||||
<div class="text-lg font-bold">{{ .Stats.PullCount }} <span class="text-sm font-normal text-base-content/50">total</span></div>
|
||||
<div class="text-xs text-base-content/50 mt-1">
|
||||
<div class="text-lg font-bold">{{ .Stats.PullCount }} <span class="text-sm font-normal text-base-content/70">total</span></div>
|
||||
<div class="text-xs text-base-content/70 mt-1">
|
||||
{{ if .Stats.LastPull }}Last pull {{ timeAgoShort (derefTime .Stats.LastPull) }}{{ else }}No pulls yet{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@
|
||||
aria-selected="false"
|
||||
aria-controls="tab-overview"
|
||||
id="overview-tab-btn"
|
||||
onclick="switchRepoTab('overview')">
|
||||
data-action="switch-repo-tab">
|
||||
Overview
|
||||
</button>
|
||||
{{ if .SelectedTag }}
|
||||
@@ -90,7 +90,7 @@
|
||||
aria-selected="false"
|
||||
aria-controls="tab-layers"
|
||||
id="layers-tab-btn"
|
||||
onclick="switchRepoTab('layers')">
|
||||
data-action="switch-repo-tab">
|
||||
Layers
|
||||
</button>
|
||||
<button class="repo-tab shrink-0 whitespace-nowrap px-4 sm:px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
|
||||
@@ -99,7 +99,7 @@
|
||||
aria-selected="false"
|
||||
aria-controls="tab-vulns"
|
||||
id="vulns-tab-btn"
|
||||
onclick="switchRepoTab('vulns')">
|
||||
data-action="switch-repo-tab">
|
||||
Vulnerabilities
|
||||
</button>
|
||||
<button class="repo-tab shrink-0 whitespace-nowrap px-4 sm:px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
|
||||
@@ -108,7 +108,7 @@
|
||||
aria-selected="false"
|
||||
aria-controls="tab-sbom"
|
||||
id="sbom-tab-btn"
|
||||
onclick="switchRepoTab('sbom')">
|
||||
data-action="switch-repo-tab">
|
||||
SBOM
|
||||
</button>
|
||||
{{ end }}
|
||||
@@ -118,7 +118,7 @@
|
||||
aria-selected="false"
|
||||
aria-controls="tab-artifacts"
|
||||
id="artifacts-tab-btn"
|
||||
onclick="switchRepoTab('artifacts')">
|
||||
data-action="switch-repo-tab">
|
||||
Artifacts
|
||||
</button>
|
||||
</nav>
|
||||
@@ -126,7 +126,7 @@
|
||||
|
||||
<!-- Tab Panels -->
|
||||
<div id="tab-overview" class="repo-panel" role="tabpanel" aria-labelledby="overview-tab-btn" tabindex="0">
|
||||
<div id="overview-view" class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
|
||||
<div id="overview-view" class="space-y-4 min-w-0 pt-6">
|
||||
{{ if and .AIAdvisorEnabled .User .IsOwner .SelectedTag }}
|
||||
<div id="ai-advisor-section">
|
||||
<button id="ai-advisor-btn" class="btn btn-sm btn-outline gap-1"
|
||||
@@ -147,7 +147,7 @@
|
||||
|
||||
{{ if and .IsOwner .ReadmeHTML }}
|
||||
<div class="flex justify-end">
|
||||
<button class="btn btn-sm btn-ghost gap-1" onclick="toggleOverviewEditor(true)">
|
||||
<button class="btn btn-sm btn-ghost gap-1" data-action="toggle-editor" data-show="true">
|
||||
{{ icon "pencil" "size-4" }}
|
||||
Edit
|
||||
</button>
|
||||
@@ -160,18 +160,18 @@
|
||||
{{ else }}
|
||||
{{ if .IsOwner }}
|
||||
<div class="text-center py-12">
|
||||
{{ icon "file-text" "size-12 text-base-content opacity-40 mx-auto" }}
|
||||
{{ icon "file-text" "size-12 text-base-content/40 mx-auto" }}
|
||||
<p class="text-base-content/60 mt-4">No README provided</p>
|
||||
<p class="text-base-content/40 text-sm mt-1">Add a README to help users understand this image.</p>
|
||||
<button class="btn btn-primary btn-sm mt-4" onclick="toggleOverviewEditor(true)">
|
||||
<p class="text-base-content/70 text-sm mt-1">Add a README to help users understand this image.</p>
|
||||
<button class="btn btn-primary btn-sm mt-4" data-action="toggle-editor" data-show="true">
|
||||
{{ icon "pencil" "size-4" }} Add README
|
||||
</button>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="text-center py-12">
|
||||
{{ icon "file-text" "size-12 text-base-content opacity-40 mx-auto" }}
|
||||
{{ icon "file-text" "size-12 text-base-content/40 mx-auto" }}
|
||||
<p class="text-base-content/60 mt-4">No README provided</p>
|
||||
<p class="text-base-content/40 text-sm mt-1">Image metadata is shown above.</p>
|
||||
<p class="text-base-content/70 text-sm mt-1">Image metadata is shown above.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
hx-get="/api/attestation-details?digest={{ .Entry.Digest | urlquery }}&did={{ .OwnerDID | urlquery }}&repo={{ .RepoName | urlquery }}"
|
||||
hx-target="#attestation-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('attestation-detail-modal').showModal()">
|
||||
data-action="show-modal" data-modal-id="attestation-detail-modal">
|
||||
{{ icon "shield-check" "size-3" }} Attested
|
||||
</button>
|
||||
{{ end }}
|
||||
@@ -38,7 +38,7 @@
|
||||
</button>
|
||||
{{ else }}
|
||||
<button class="btn btn-ghost btn-sm text-error"
|
||||
onclick="deleteManifest('{{ .RepoName }}', '{{ .Entry.Digest }}', '')"
|
||||
data-action="delete-manifest" data-repo="{{ .RepoName }}" data-digest="{{ .Entry.Digest }}"
|
||||
aria-label="Delete manifest">
|
||||
{{ icon "trash-2" "size-4" }}
|
||||
</button>
|
||||
@@ -86,7 +86,7 @@
|
||||
<td>
|
||||
<div class="flex items-center gap-1">
|
||||
<a href="/d/{{ $.OwnerHandle }}/{{ $.RepoName }}/{{ $.Entry.Digest }}?platform={{ .Digest }}" class="font-mono text-xs link link-primary" title="{{ .Digest }}">{{ truncateDigest (trimPrefix "sha256:" .Digest) 16 }}</a>
|
||||
<button class="btn btn-ghost btn-xs hidden sm:inline-flex" onclick="copyToClipboard('{{ .Digest }}')" aria-label="Copy digest">{{ icon "copy" "size-3" }}</button>
|
||||
<button class="btn btn-ghost btn-sm sm:btn-xs" data-action="copy" data-copy="{{ .Digest }}" aria-label="Copy digest">{{ icon "copy" "size-3" }}</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -110,7 +110,7 @@
|
||||
|
||||
{{ define "load-more-button" }}
|
||||
{{ if .HasMore }}
|
||||
<div id="load-more-container" class="p-6 text-center border-t border-base-200">
|
||||
<div id="load-more-container" class="p-6 text-center border-t border-base-300">
|
||||
<button class="btn btn-outline"
|
||||
hx-get="/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}?offset={{ .NextOffset }}"
|
||||
hx-target="#tags-list"
|
||||
@@ -138,7 +138,7 @@
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium whitespace-nowrap">Sort by</span>
|
||||
<select id="tag-sort" class="select select-sm select-bordered min-w-28" onchange="sortTags(this.value)">
|
||||
<select id="tag-sort" class="select select-sm select-bordered min-w-28" data-action="sort-tags">
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="az">A-Z</option>
|
||||
@@ -146,12 +146,12 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1 max-w-xs">
|
||||
<input type="text" id="tag-filter" class="input input-sm input-bordered w-full" placeholder="Filter artifacts..." oninput="filterTags(this.value)">
|
||||
<input type="text" id="tag-filter" class="input input-sm input-bordered w-full" placeholder="Filter artifacts..." data-action="filter-tags">
|
||||
</div>
|
||||
{{ if $.IsOwner }}
|
||||
<div class="flex-none ml-auto">
|
||||
<button class="btn btn-ghost btn-sm text-error"
|
||||
onclick="document.getElementById('untagged-delete-modal').showModal()"
|
||||
data-action="show-modal" data-modal-id="untagged-delete-modal"
|
||||
aria-label="Delete all untagged manifests">
|
||||
{{ icon "trash-2" "size-4" }} Delete untagged
|
||||
</button>
|
||||
@@ -160,8 +160,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Manifest Entries -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300">
|
||||
<div class="divide-y divide-base-200" id="tags-list">
|
||||
<div class="pt-6">
|
||||
<div class="divide-y divide-base-300" id="tags-list">
|
||||
{{ range .Entries }}
|
||||
{{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "OciClient" $.OciClient "IsOwner" $.IsOwner "ViewerDefaultHold" $.ViewerDefaultHold) }}
|
||||
{{ end }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{ define "sbom-details" }}
|
||||
{{ if .Error }}
|
||||
<p>{{ .Error }}</p>
|
||||
{{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
{{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
{{ else }}
|
||||
<div class="space-y-4" data-csv-section data-csv-filename="sbom.csv">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@@ -12,7 +12,7 @@
|
||||
{{ icon "download" "size-3.5" }}
|
||||
{{ icon "chevron-down" "size-3" }}
|
||||
</summary>
|
||||
<ul class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300">
|
||||
<ul class="dropdown-content menu bg-base-200 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300">
|
||||
<li><button type="button" data-copy-csv>{{ icon "copy" "size-4" }} Copy as CSV</button></li>
|
||||
<li><a href="/api/scan-download?type=sbom&digest={{ .Digest | urlquery }}&holdEndpoint={{ .HoldEndpoint | urlquery }}" download>{{ icon "download" "size-4" }} SPDX JSON</a></li>
|
||||
</ul>
|
||||
@@ -20,7 +20,7 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
{{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
|
||||
{{ if .Packages }}
|
||||
<div class="overflow-y-auto max-h-[32rem]">
|
||||
@@ -40,7 +40,7 @@
|
||||
<td class="font-mono text-xs">{{ .Version }}</td>
|
||||
<td class="text-xs">
|
||||
{{ if eq .License "-" }}
|
||||
<span class="opacity-40">-</span>
|
||||
<span class="text-base-content/40">-</span>
|
||||
{{ else }}
|
||||
{{ .License }}
|
||||
{{ end }}
|
||||
@@ -49,7 +49,7 @@
|
||||
{{ if .Type }}
|
||||
<span class="badge badge-xs badge-ghost">{{ .Type }}</span>
|
||||
{{ else }}
|
||||
<span class="opacity-40">-</span>
|
||||
<span class="text-base-content/40">-</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{ define "sbom-section" }}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="space-y-4 min-w-0 pt-6">
|
||||
{{ if .SbomData }}
|
||||
{{ template "sbom-details" .SbomData }}
|
||||
{{ else }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{ define "subscription_plans" }}
|
||||
{{ if not .HideBilling }}
|
||||
{{ if .Tiers }}
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
|
||||
<h3 class="text-xl font-semibold">Available Plans</h3>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4">
|
||||
{{ range .Tiers }}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
{{ else if eq .Total 0 }}
|
||||
<span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span>
|
||||
{{ else }}
|
||||
<span class="vuln-strip" title="Vulnerabilities: {{ .Critical }} critical, {{ .High }} high, {{ .Medium }} medium, {{ .Low }} low (scanned {{ .ScannedAt }})">
|
||||
<span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Critical }}</span>
|
||||
<span class="tooltip vuln-box-high" data-tip="High">{{ .High }}</span>
|
||||
<span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Medium }}</span>
|
||||
<span class="tooltip vuln-box-low" data-tip="Low">{{ .Low }}</span>
|
||||
<span class="vuln-strip" role="group" aria-label="Vulnerabilities: {{ .Critical }} critical, {{ .High }} high, {{ .Medium }} medium, {{ .Low }} low (scanned {{ .ScannedAt }})" title="Vulnerabilities: {{ .Critical }} critical, {{ .High }} high, {{ .Medium }} medium, {{ .Low }} low (scanned {{ .ScannedAt }})">
|
||||
<span class="tooltip vuln-box-critical" data-tip="Critical" aria-label="{{ .Critical }} critical">{{ .Critical }}</span>
|
||||
<span class="tooltip vuln-box-high" data-tip="High" aria-label="{{ .High }} high">{{ .High }}</span>
|
||||
<span class="tooltip vuln-box-medium" data-tip="Medium" aria-label="{{ .Medium }} medium">{{ .Medium }}</span>
|
||||
<span class="tooltip vuln-box-low" data-tip="Low" aria-label="{{ .Low }} low">{{ .Low }}</span>
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
{{ if .Summary.Total }}
|
||||
<!-- Summary available but no detailed report -->
|
||||
<div class="space-y-4">
|
||||
<span class="vuln-strip">
|
||||
<span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Summary.Critical }}</span>
|
||||
<span class="tooltip vuln-box-high" data-tip="High">{{ .Summary.High }}</span>
|
||||
<span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Summary.Medium }}</span>
|
||||
<span class="tooltip vuln-box-low" data-tip="Low">{{ .Summary.Low }}</span>
|
||||
<span class="vuln-strip" role="group" aria-label="Vulnerability summary by severity">
|
||||
<span class="tooltip vuln-box-critical" data-tip="Critical" aria-label="{{ .Summary.Critical }} critical">{{ .Summary.Critical }}</span>
|
||||
<span class="tooltip vuln-box-high" data-tip="High" aria-label="{{ .Summary.High }} high">{{ .Summary.High }}</span>
|
||||
<span class="tooltip vuln-box-medium" data-tip="Medium" aria-label="{{ .Summary.Medium }} medium">{{ .Summary.Medium }}</span>
|
||||
<span class="tooltip vuln-box-low" data-tip="Low" aria-label="{{ .Summary.Low }} low">{{ .Summary.Low }}</span>
|
||||
</span>
|
||||
<p class="text-sm">{{ .Error }}</p>
|
||||
{{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
{{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<p>{{ .Error }}</p>
|
||||
@@ -20,11 +20,11 @@
|
||||
<!-- Summary badges -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities</span>
|
||||
<span class="vuln-strip">
|
||||
<span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Summary.Critical }}</span>
|
||||
<span class="tooltip vuln-box-high" data-tip="High">{{ .Summary.High }}</span>
|
||||
<span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Summary.Medium }}</span>
|
||||
<span class="tooltip vuln-box-low" data-tip="Low">{{ .Summary.Low }}</span>
|
||||
<span class="vuln-strip" role="group" aria-label="Vulnerability summary by severity">
|
||||
<span class="tooltip vuln-box-critical" data-tip="Critical" aria-label="{{ .Summary.Critical }} critical">{{ .Summary.Critical }}</span>
|
||||
<span class="tooltip vuln-box-high" data-tip="High" aria-label="{{ .Summary.High }} high">{{ .Summary.High }}</span>
|
||||
<span class="tooltip vuln-box-medium" data-tip="Medium" aria-label="{{ .Summary.Medium }} medium">{{ .Summary.Medium }}</span>
|
||||
<span class="tooltip vuln-box-low" data-tip="Low" aria-label="{{ .Summary.Low }} low">{{ .Summary.Low }}</span>
|
||||
</span>
|
||||
{{ if .Matches }}
|
||||
<details class="dropdown dropdown-end ml-auto">
|
||||
@@ -32,7 +32,7 @@
|
||||
{{ icon "download" "size-3.5" }}
|
||||
{{ icon "chevron-down" "size-3" }}
|
||||
</summary>
|
||||
<ul class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300">
|
||||
<ul class="dropdown-content menu bg-base-200 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300">
|
||||
<li><button type="button" data-copy-csv>{{ icon "copy" "size-4" }} Copy as CSV</button></li>
|
||||
<li><a href="/api/scan-download?type=vuln&digest={{ .Digest | urlquery }}&holdEndpoint={{ .HoldEndpoint | urlquery }}" download>{{ icon "download" "size-4" }} Grype Report</a></li>
|
||||
</ul>
|
||||
@@ -40,7 +40,7 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if .ScannedAt }}<p class="text-xs opacity-60">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
{{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
|
||||
{{ if .Matches }}
|
||||
<!-- CVE table -->
|
||||
@@ -79,14 +79,14 @@
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
{{ .Package }}{{ if .Type }} <span class="opacity-60">({{ .Type }})</span>{{ end }}
|
||||
{{ .Package }}{{ if .Type }} <span class="text-base-content/60">({{ .Type }})</span>{{ end }}
|
||||
</td>
|
||||
<td class="font-mono text-xs">{{ .Version }}</td>
|
||||
<td class="font-mono text-xs">
|
||||
{{ if .FixedIn }}
|
||||
<span class="text-success">{{ .FixedIn }}</span>
|
||||
{{ else }}
|
||||
<span class="opacity-40">-</span>
|
||||
<span class="text-base-content/40">-</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
{{ define "vulns-section" }}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="space-y-4 min-w-0 pt-6">
|
||||
{{ if .VulnData }}
|
||||
{{ template "vuln-details" .VulnData }}
|
||||
{{ else }}
|
||||
<p class="text-base-content">No vulnerability scan data available</p>
|
||||
<div class="py-8 text-sm text-base-content/70 max-w-prose">
|
||||
<p class="font-medium text-base-content">No vulnerability scan available yet</p>
|
||||
<p class="mt-1">Scans run automatically shortly after a push. Check back in a few minutes, or push a new tag to trigger a scan.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -12,16 +12,17 @@
|
||||
<span class="label-text">Webhook URL</span>
|
||||
</label>
|
||||
<input type="url" id="webhook-url" name="url" placeholder="https://example.com/webhook"
|
||||
class="input input-bordered w-full" required>
|
||||
class="input input-bordered w-full" required aria-required="true">
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="webhook-secret">
|
||||
<span class="label-text">Signing Secret <span class="text-base-content/50">(optional)</span></span>
|
||||
<span class="label-text">Signing Secret <span class="text-base-content/70">(optional)</span></span>
|
||||
</label>
|
||||
<input type="password" id="webhook-secret" name="secret" placeholder="HMAC-SHA256 signing secret"
|
||||
class="input input-bordered w-full" autocomplete="new-password">
|
||||
<p class="text-xs text-base-content/60 mt-1">If set, payloads include an <code class="cmd">X-Webhook-Signature-256</code> header</p>
|
||||
class="input input-bordered w-full" autocomplete="new-password"
|
||||
aria-describedby="webhook-secret-hint">
|
||||
<p id="webhook-secret-hint" class="text-xs text-base-content/60 mt-1">If set, payloads include an <code class="cmd">X-Webhook-Signature-256</code> header</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
@@ -58,7 +59,7 @@
|
||||
{{ if .Webhooks }}
|
||||
<div class="space-y-3">
|
||||
{{ range .Webhooks }}
|
||||
<div class="card bg-base-100 border border-base-300 p-4">
|
||||
<div class="card bg-base-200 border border-base-300 p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<code class="text-sm break-all">{{ .URL }}</code>
|
||||
@@ -82,7 +83,7 @@
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button class="btn btn-xs btn-ghost"
|
||||
onclick="testWebhook('{{ .ID }}')"
|
||||
data-action="test-webhook" data-webhook-id="{{ .ID }}"
|
||||
title="Send test payload">
|
||||
Test
|
||||
</button>
|
||||
@@ -100,7 +101,7 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-base-content/50 text-sm text-center py-4">No webhooks configured yet.</p>
|
||||
<p class="text-base-content/70 text-sm text-center py-4">No webhooks configured yet.</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
<symbol id="fingerprint" viewBox="0 0 24 24"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/></symbol>
|
||||
<symbol id="git-compare" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></symbol>
|
||||
<symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol>
|
||||
<symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol>
|
||||
<symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol>
|
||||
<symbol id="heading" viewBox="0 0 24 24"><path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></symbol>
|
||||
<symbol id="history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -38,21 +38,21 @@
|
||||
|
||||
<!-- Benefit Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 w-full max-w-4xl">
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center">
|
||||
<div class="card bg-base-200 shadow-sm p-6 text-center">
|
||||
<div class="text-primary mb-4 flex justify-center">
|
||||
{{ icon "ship" "size-8" }}
|
||||
</div>
|
||||
<h2 class="font-semibold text-lg">Works with Docker</h2>
|
||||
<p class="text-base-content/70 mt-2">Use docker push & pull. No new tools to learn.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center">
|
||||
<div class="card bg-base-200 shadow-sm p-6 text-center">
|
||||
<div class="text-primary mb-4 flex justify-center">
|
||||
{{ icon "anchor" "size-8" }}
|
||||
</div>
|
||||
<h2 class="font-semibold text-lg">Your Data</h2>
|
||||
<p class="text-base-content/70 mt-2">Join shared holds or captain your own storage.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center">
|
||||
<div class="card bg-base-200 shadow-sm p-6 text-center">
|
||||
<div class="text-primary mb-4 flex justify-center">
|
||||
{{ icon "compass" "size-8" }}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
/* Seamark color overrides (injected after atcr.io's compiled stylesheet) */
|
||||
[data-theme="dark"] {
|
||||
--color-primary: oklch(72% 0.17 225);
|
||||
--color-primary-content: oklch(15% 0.05 240);
|
||||
--color-secondary: oklch(90% 0.182 98.111);
|
||||
--color-secondary-content: oklch(30% 0.182 98.111);
|
||||
--color-accent: oklch(66.6% 0.121 28);
|
||||
--color-accent-content: oklch(28% 0.121 28);
|
||||
--color-accent: oklch(70% 0.14 345);
|
||||
--color-accent-content: oklch(98% 0.008 345);
|
||||
--color-base-100: oklch(20% 0.05 250);
|
||||
--color-base-200: oklch(25% 0.05 250);
|
||||
--color-base-300: oklch(30% 0.05 250);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--color-primary: oklch(48% 0.17 250);
|
||||
--color-primary-content: oklch(98% 0.01 250);
|
||||
--color-secondary: oklch(76% 0.095 76.1);
|
||||
--color-secondary-content: oklch(27.1% 0.026 76.4);
|
||||
--color-accent: oklch(66.6% 0.121 28);
|
||||
--color-accent-content: oklch(28% 0.121 28);
|
||||
--color-accent: oklch(56% 0.17 345);
|
||||
--color-accent-content: oklch(98% 0.008 345);
|
||||
--color-base-100: oklch(98% 0.01 225);
|
||||
--color-base-200: oklch(95% 0.02 225);
|
||||
--color-base-300: oklch(92% 0.03 225);
|
||||
}
|
||||
|
||||
@keyframes rock {
|
||||
|
||||
Reference in New Issue
Block a user