feat: standalone: dashboard redesign
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cap-standalone",
|
||||
"version": "2.0.18",
|
||||
"version": "2.1.0",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "bun run --watch src/index.js",
|
||||
|
||||
@@ -1,825 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #161718;
|
||||
color: white;
|
||||
display: flex;
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
flex-direction: column;
|
||||
font-family: InterVariable, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
"Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding: 0.8em 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
nav .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background-color: transparent;
|
||||
padding: 0px;
|
||||
margin-right: auto;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
nav .logo img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
nav .logo p {
|
||||
margin: 0px;
|
||||
color: rgb(248, 248, 248);
|
||||
opacity: 0;
|
||||
margin-left: -5px;
|
||||
transition: margin-left 0.15s, opacity 0.15s;
|
||||
pointer-events: none;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
nav .logo:hover p {
|
||||
opacity: 1;
|
||||
margin-left: 0px;
|
||||
filter: none;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
nav .logo:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
nav .settings {
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
color: rgb(248, 248, 248);
|
||||
opacity: 0.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
nav .settings:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
nav .logout {
|
||||
font-family: inherit;
|
||||
margin-left: auto;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
color: rgb(248, 248, 248);
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
nav .logout:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.state-loading {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
}
|
||||
|
||||
.state-loading svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.state-homepage {
|
||||
max-width: 520px;
|
||||
flex-direction: column;
|
||||
margin: 2em auto;
|
||||
width: 100%;
|
||||
padding: 0px 0.5rem;
|
||||
}
|
||||
|
||||
.state-homepage h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top {
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 12px;
|
||||
background-color: #161616;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
transition: border 0.15s;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top input {
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top:has(input:focus) {
|
||||
border: 1px solid #3f3f3f;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top .create-key {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 4px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background-color: #056dff;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
padding: 0px 10px;
|
||||
font-weight: 450;
|
||||
font-size: 14px;
|
||||
transition: transform 0.15s, background-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top .create-key svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top .create-key:hover {
|
||||
background-color: #094cb2;
|
||||
}
|
||||
|
||||
.state-homepage .keys-top .create-key:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.state-homepage .keys-top .create-key:focus {
|
||||
box-shadow: 0px 0px 0px 2px rgba(0, 122, 255, 0.5);
|
||||
}
|
||||
|
||||
.keys-list.no-keys p {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
font-size: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #212121;
|
||||
border-radius: 10px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
color: white;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
animation: scaleIn 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1em;
|
||||
border-bottom: 1px solid #323232;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.modal-content header h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-content header .close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.modal .content {
|
||||
padding: 12px 16px;
|
||||
padding-top: 0px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modal-content input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.6em 0.7em;
|
||||
margin-top: 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
box-shadow: inset 0 0 0 1px color(display-p3 0.9608 0.9608 1 / 0.242);
|
||||
color: #eee;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
font-family: inherit;
|
||||
margin-bottom: 8px;
|
||||
text-overflow: ellipsis;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.modal-content input[type="text"]:focus {
|
||||
text-overflow: unset;
|
||||
box-shadow: 0px 0px 0px 2px rgba(255, 255, 255);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-content label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-content button.primary {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: #056dff;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
height: 36px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 16px;
|
||||
width: calc(100% - 32px);
|
||||
margin-top: 0px;
|
||||
transition: opacity 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.modal-content button.primary.secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-content button.primary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.modal-content button.primary:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.modal-content button.primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.key-created-header {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.key-created-header svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.key-created-header h3 {
|
||||
margin-top: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-content p.small-text {
|
||||
line-height: 1.4;
|
||||
font-size: 14px;
|
||||
max-width: 90%;
|
||||
opacity: 0.9;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.keys-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.keys-list .key {
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 8px;
|
||||
background-color: #161616;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
height: 68px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.key .key-text {
|
||||
padding-left: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.key .key-text h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.key .key-text p {
|
||||
margin-bottom: 0px;
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.key .key-text p b {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.key .key-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.key .key-actions button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 100px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.key:focus,
|
||||
.key:active,
|
||||
.key:has(button:focus) {
|
||||
box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.state-key-page {
|
||||
flex-direction: column;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 2em auto;
|
||||
padding: 0px 0.5rem;
|
||||
padding-bottom: 4em;
|
||||
}
|
||||
|
||||
.state-key-page .chart-container {
|
||||
width: 100%;
|
||||
height: 380px;
|
||||
|
||||
border: 1px solid #e3e4e810;
|
||||
background-color: #191A1C;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.state-key-page .topbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left h1 {
|
||||
font-size: 23px;
|
||||
margin: 0px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left button {
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 0.4em 0.6em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.state-key-page .topbar .left button:focus {
|
||||
box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.state-key-page .topbar .right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.state-key-page .time-duration {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
background-color: #161718;
|
||||
padding: 0px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.state-key-page .time-duration:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.state-key-page h2 {
|
||||
font-size: 18px;
|
||||
margin: 0px;
|
||||
font-weight: 600;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.danger-zone-buttons button {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
font-family: inherit;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 0.5em 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.danger-zone-buttons button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.danger-zone-buttons button:focus {
|
||||
box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.danger-zone-buttons button:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.config input {
|
||||
width: 100%;
|
||||
padding: 0.6em 0.7em;
|
||||
margin-top: 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
border: none;
|
||||
box-shadow: inset 0 0 0 1px color(display-p3 0.9608 0.9608 1 / 0.242);
|
||||
color: #eee;
|
||||
background-color: transparent;
|
||||
font-family: inherit;
|
||||
margin-bottom: 8px;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.config input:focus {
|
||||
text-overflow: unset;
|
||||
box-shadow: 0px 0px 0px 2px white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.config label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config .config-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.config button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
color: black;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
font-family: inherit;
|
||||
height: 36px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0px 16px;
|
||||
}
|
||||
|
||||
.config button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.config button:focus {
|
||||
box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
|
||||
}
|
||||
|
||||
.config button:disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.keypage-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
padding: 0px;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.63;
|
||||
margin-left: -6px;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.keypage-back:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.keypage-back:active {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.keypage-back svg {
|
||||
transition: margin-left 0.15s, margin-right 0.15s;
|
||||
}
|
||||
|
||||
.keypage-back:hover svg {
|
||||
margin-left: -2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.state-settings {
|
||||
flex-direction: column;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 2em auto;
|
||||
padding: 0px 0.5rem;
|
||||
padding-bottom: 4em;
|
||||
}
|
||||
|
||||
.state-settings {
|
||||
flex-direction: column;
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin: 2em auto;
|
||||
padding: 0px 0.5rem;
|
||||
padding-bottom: 4em;
|
||||
}
|
||||
|
||||
.state-settings h1 {
|
||||
margin-top: 0px;
|
||||
font-size: 27px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state-settings h2 {
|
||||
font-weight: 500;
|
||||
font-size: 22px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.sessions-list,
|
||||
.apikeys-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 13px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.sessions-list .session,
|
||||
.apikeys-list .apikey {
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 8px;
|
||||
background-color: #161616;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
height: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.apikeys-list p {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.about-info {
|
||||
line-height: 1.5;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.about-info b {
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.about-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
width: fit-content;
|
||||
padding: 0.4em 0.6em;
|
||||
transition: opacity 0.15s;
|
||||
border-radius: 6px;
|
||||
animation: abtbtn 1s ease-in-out;
|
||||
box-shadow: rgb(0 0 0 / 9%) 0px 8px 24px;
|
||||
}
|
||||
|
||||
.about-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.about-button:focus {
|
||||
box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.nano-github-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
opacity: 0.2;
|
||||
text-decoration: none;
|
||||
width: fit-content;
|
||||
transition: opacity 0.15s;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
margin-top: 2em;
|
||||
border-radius: 6px;
|
||||
animation: abtbtn 1s ease-in-out;
|
||||
}
|
||||
|
||||
.nano-github-button:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.nano-github-button:focus {
|
||||
box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.session .session-info,
|
||||
.apikey .apikey-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.session-token,
|
||||
.apikey-token {
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-expires,
|
||||
.apikey-created {
|
||||
opacity: 0.93;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.session .session-delete-button,
|
||||
.apikey .apikey-delete-button {
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 0.4em 0.6em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.session .session-delete-button:hover,
|
||||
.apikey .apikey-delete-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.session .session-delete-button:focus,
|
||||
.apikey .apikey-delete-button:focus {
|
||||
box-shadow: 0px 0px 0px 2px white;
|
||||
}
|
||||
|
||||
.apikeys-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.apikeys-title button {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #056dff;
|
||||
color: white;
|
||||
padding: 0.4em 0.6em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.apikeys-title button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.apikeys-title button:focus {
|
||||
box-shadow: 0px 0px 0px 2px rgba(0, 122, 255, 0.5);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: InterVariable;
|
||||
src: url("./assets/InterVariable.woff2");
|
||||
}
|
||||
+1075
-212
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
export default async (method, path, body) => {
|
||||
try {
|
||||
const { token, hash } = JSON.parse(localStorage.getItem("cap_auth"));
|
||||
|
||||
const requestInit = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${btoa(
|
||||
JSON.stringify({
|
||||
token,
|
||||
hash,
|
||||
})
|
||||
)}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
requestInit.headers["Content-Type"] = "application/json";
|
||||
requestInit.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const json = await (await fetch(`/server${path}`, requestInit)).json();
|
||||
|
||||
if (json?.error === "Unauthorized") {
|
||||
localStorage.removeItem("cap_auth");
|
||||
if (window?.CookieStore && CookieStore.prototype.delete) {
|
||||
window.cookieStore.delete("cap_authed");
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: fallback implemented
|
||||
document.cookie =
|
||||
"cap_authed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (e) {
|
||||
return {
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import sendApiRequest from "./api.js";
|
||||
|
||||
import { openSettings } from "./settings.js";
|
||||
import { createModal } from "./utils.js";
|
||||
|
||||
async function openCreateApiKey() {
|
||||
const modal = createModal(
|
||||
"Add a key",
|
||||
`<div class="content">
|
||||
<label for="key-name">Key name</label>
|
||||
<input type="text" name="key-name" class="key-name-input" max-length="200" required>
|
||||
</div>
|
||||
|
||||
<button class="create-key-button primary" disabled>Create API key</button>`
|
||||
);
|
||||
modal.querySelector(".key-name-input").focus();
|
||||
|
||||
modal.querySelector(".key-name-input").addEventListener("input", (event) => {
|
||||
modal.querySelector(".create-key-button").disabled =
|
||||
!event.target.value.trim();
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".key-name-input")
|
||||
.addEventListener("keydown", (event) => {
|
||||
if (event.target.value.trim() && event.key === "Enter") {
|
||||
modal.querySelector(".create-key-button").click();
|
||||
}
|
||||
});
|
||||
|
||||
modal.querySelector(".close-button").addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".create-key-button")
|
||||
.addEventListener("click", async () => {
|
||||
const name = modal.querySelector(".key-name-input").value.trim();
|
||||
|
||||
modal.querySelector(".create-key-button").disabled = true;
|
||||
|
||||
try {
|
||||
const { apiKey } = await sendApiRequest("POST", "/settings/apikeys", {
|
||||
name,
|
||||
});
|
||||
|
||||
document.body.removeChild(modal);
|
||||
|
||||
const successModal = createModal(
|
||||
"Key created",
|
||||
`<div class="content">
|
||||
<label for="api-key">API key</label>
|
||||
<input type="text" name="api-key" value="${apiKey}" readonly>
|
||||
|
||||
<p class="small-text">Make sure to copy your API key as it will not be shown again later.</p>
|
||||
</div>
|
||||
|
||||
<button class="close-small-button primary secondary">Close</button>`
|
||||
);
|
||||
|
||||
successModal
|
||||
.querySelector(".close-small-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(successModal);
|
||||
});
|
||||
|
||||
openSettings();
|
||||
} catch (error) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to create API key: ${error.message}</p></div>`
|
||||
);
|
||||
modal.querySelector(".create-key-button").disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".create-api-key")
|
||||
.addEventListener("click", openCreateApiKey);
|
||||
|
||||
export default openCreateApiKey;
|
||||
@@ -1,99 +0,0 @@
|
||||
import sendApiRequest from "./api.js";
|
||||
import initHomepage from "./homepage.js";
|
||||
import loadKeyPage from "./keyPage.js";
|
||||
import setState from "./state.js";
|
||||
import { createModal } from "./utils.js";
|
||||
|
||||
async function openCreateKey(namePrefill = "") {
|
||||
const modal = createModal(
|
||||
"Add a key",
|
||||
`<div class="content">
|
||||
<label for="key-name">Key name</label>
|
||||
<input type="text" name="key-name" class="key-name-input" placeholder="Blog comments" value="${namePrefill}" max-length="200" required>
|
||||
</div>
|
||||
|
||||
<button class="create-key-button primary">Create key</button>`
|
||||
);
|
||||
modal.querySelector(".key-name-input").focus();
|
||||
|
||||
if (!namePrefill)
|
||||
document.querySelector(".create-key-button").disabled = true;
|
||||
|
||||
modal.querySelector(".key-name-input").addEventListener("input", (event) => {
|
||||
modal.querySelector(".create-key-button").disabled =
|
||||
!event.target.value.trim();
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".key-name-input")
|
||||
.addEventListener("keydown", (event) => {
|
||||
if (event.target.value.trim() && event.key === "Enter") {
|
||||
modal.querySelector(".create-key-button").click();
|
||||
}
|
||||
});
|
||||
|
||||
modal.querySelector(".close-button").addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".create-key-button")
|
||||
.addEventListener("click", async () => {
|
||||
const name = modal.querySelector(".key-name-input").value.trim();
|
||||
|
||||
modal.querySelector(".create-key-button").disabled = true;
|
||||
|
||||
try {
|
||||
const { siteKey, secretKey } = await sendApiRequest("POST", "/keys", {
|
||||
name,
|
||||
});
|
||||
|
||||
document.body.removeChild(modal);
|
||||
|
||||
const successModal = createModal(
|
||||
"Key created",
|
||||
`<div class="content">
|
||||
<label for="site-key">Site key</label>
|
||||
<input type="text" name="site-key" value="${siteKey}" readonly>
|
||||
|
||||
<label for="secret-key">Secret key</label>
|
||||
<input type="text" name="secret-key" value="${secretKey}" readonly>
|
||||
|
||||
<p class="small-text">Make sure to copy your secret key as it will not be shown again later.</p>
|
||||
</div>
|
||||
|
||||
<button class="open-key-button primary" style="margin-bottom:8px">Open key</button>
|
||||
<button class="close-small-button primary secondary">Close</button>`
|
||||
);
|
||||
|
||||
successModal
|
||||
.querySelector(".open-key-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(successModal);
|
||||
loadKeyPage(siteKey, secretKey);
|
||||
});
|
||||
successModal
|
||||
.querySelector(".close-small-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(successModal);
|
||||
});
|
||||
|
||||
await initHomepage();
|
||||
setState("homepage");
|
||||
} catch (error) {
|
||||
setState("homepage");
|
||||
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to create key: ${error.message}</p></div>`
|
||||
);
|
||||
modal.querySelector(".create-key-button").disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector(".create-key").addEventListener("click", () => {
|
||||
openCreateKey(document.querySelector(".search-input").value?.trim());
|
||||
});
|
||||
|
||||
export default openCreateKey;
|
||||
@@ -0,0 +1,937 @@
|
||||
let keys = [];
|
||||
let selectedKey;
|
||||
let chart;
|
||||
|
||||
const keysList = document.getElementById("keysList");
|
||||
const searchInput = document.getElementById("searchInput");
|
||||
const welcomeScreen = document.getElementById("welcomeScreen");
|
||||
const keyDetail = document.getElementById("keyDetail");
|
||||
|
||||
const escapeHtml = (s) => {
|
||||
return s.replaceAll("<", "<").replaceAll(">", ">");
|
||||
};
|
||||
|
||||
const api = async (method, path, body) => {
|
||||
try {
|
||||
const auth = JSON.parse(localStorage.getItem("cap_auth"));
|
||||
if (!auth) throw new Error("Not authenticated");
|
||||
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${btoa(JSON.stringify({ token: auth.token, hash: auth.hash }))}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
return await await fetch(`/server${path}`, opts).json();
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const formatRelative = (date) => {
|
||||
const diff = new Date(date) - Date.now();
|
||||
const past = diff < 0;
|
||||
const d = Math.abs(diff);
|
||||
|
||||
const ms = {
|
||||
year: 365 * 24 * 60 * 60 * 1000,
|
||||
month: 30 * 24 * 60 * 60 * 1000,
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
minute: 60 * 1000,
|
||||
};
|
||||
|
||||
for (const [u, v] of Object.entries(ms)) {
|
||||
if (d >= v) {
|
||||
const val = Math.floor(d / v);
|
||||
return past
|
||||
? `${val} ${u}${val > 1 ? "s" : ""} ago`
|
||||
: `in ${val} ${u}${val > 1 ? "s" : ""}`;
|
||||
}
|
||||
}
|
||||
return past ? "just now" : "in a moment";
|
||||
};
|
||||
|
||||
const randKey = () => {
|
||||
const left =
|
||||
"admiring adoring affectionate agitated amazing angry awesome beautiful blissful bold boring brave busy charming clever compassionate competent condescending confident cool cranky crazy dazzling determined distracted dreamy eager ecstatic elastic elated elegant eloquent epic exciting fervent festive flamboyant focused friendly frosty funny gallant gifted goofy gracious great happy hardcore heuristic hopeful hungry infallible inspiring intelligent interesting jolly jovial keen kind laughing loving lucid magical modest musing mystifying naughty nervous nice nifty nostalgic objective optimistic peaceful pedantic pensive practical priceless quirky quizzical recursing relaxed reverent romantic sad serene sharp silly sleepy stoic strange stupefied suspicious sweet tender thirsty trusting unruffled upbeat vibrant vigilant vigorous wizardly wonderful xenodochial youthful zealous zen".split(
|
||||
" ",
|
||||
);
|
||||
const right =
|
||||
"agnesi albattani allen almeida antonelli archimedes ardinghelli aryabhata austin babbage banach banzai bardeen bartik bassi beaver bell benz bhabha bhaskara black blackburn blackwell bohr booth borg bose bouman boyd brahmagupta brattain brown buck burnell cannon carson cartwright carver cerf chandrasekhar chaplygin chatelet chatterjee chaum chebyshev clarke cohen colden cori cray curie curran darwin davinci dewdney dhawan diffie dijkstra dirac driscoll dubinsky easley edison einstein elbakyan elgamal elion ellis engelbart euclid euler faraday feistel fermat fermi feynman franklin gagarin galileo galois ganguly gates gauss germain goldberg goldstine goldwasser golick goodall gould greider grothendieck haibt hamilton haslett hawking heisenberg hellman hermann herschel hertz heyrovsky hodgkin hofstadter hoover hopper hugle hypatia ishizaka jackson jang jemison jennings jepsen johnson joliot jones kalam kapitsa kare keldysh keller kepler khayyam khorana kilby kirch knuth kowalevski lalande lamarr lamport leakey leavitt lederberg lehmann lewin lichterman liskov lovelace lumiere mahavira margulis matsumoto maxwell mayer mccarthy mcclintock mclaren mclean mcnulty meitner mendel mendeleev meninsky merkle mestorf mirzakhani montalcini moore morse moser murdock napier nash neumann newton nightingale nobel noether northcutt noyce panini pare pascal pasteur payne perlman pike poincare poitras proskuriakova ptolemy raman ramanujan rhodes ride ritchie robinson roentgen rosalind rubin saha sammet sanderson satoshi shamir shannon shaw shirley shockley shtern sinoussi snyder solomon spence stonebraker sutherland swanson swartz swirles taussig tesla tharp thompson torvalds tu turing varahamihira vaughan villani visvesvaraya volhard wescoff wilbur wiles williams williamson wilson wing wozniak wright wu yalow yonath zhukovsky".split(
|
||||
" ",
|
||||
);
|
||||
|
||||
return `${left[Math.floor(Math.random() * left.length)]}-${right[Math.floor(Math.random() * right.length)]}`;
|
||||
};
|
||||
|
||||
async function init() {
|
||||
if (!localStorage.getItem("cap_auth")) {
|
||||
document.cookie =
|
||||
"cap_authed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
return;
|
||||
}
|
||||
|
||||
await loadKeys();
|
||||
}
|
||||
|
||||
async function loadKeys() {
|
||||
keys = await api("GET", "/keys");
|
||||
if (keys.error?.includes?.("Unauthorized")) {
|
||||
localStorage.removeItem("cap_auth");
|
||||
document.cookie =
|
||||
"cap_authed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
if (keys.error) {
|
||||
keysList.innerHTML = `<div class="keys-empty"><p>Error loading keys</p></div>`;
|
||||
return;
|
||||
}
|
||||
renderKeysList();
|
||||
}
|
||||
|
||||
function renderKeysList(filter = "") {
|
||||
const filtered = keys.filter((k) =>
|
||||
k.name.toLowerCase().includes(filter.toLowerCase()),
|
||||
);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
keysList.innerHTML = `
|
||||
<div class="keys-empty">
|
||||
<p>${filter ? "No matching keys" : "No keys yet"}</p>
|
||||
${
|
||||
!filter
|
||||
? `<button class="create-btn" onclick="openCreateKeyModal()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Create your first key
|
||||
</button>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
keysList.innerHTML = filtered
|
||||
.map(
|
||||
(key) => `
|
||||
<div class="key-item ${selectedKey?.siteKey === key.siteKey ? "active" : ""}" data-key="${key.siteKey}">
|
||||
<div class="key-item-name">${escapeHtml(key.name)}</div>
|
||||
<div class="key-item-stats">
|
||||
<span>${key.solvesLast24h} solves/24h</span>
|
||||
${
|
||||
key.difference.direction
|
||||
? `
|
||||
<span class="trend ${key.difference.direction}">
|
||||
${
|
||||
key.difference.direction === "up"
|
||||
? '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.25 3.75H19.5a.75.75 0 01.75.75v11.25a.75.75 0 01-1.5 0V6.31L5.03 20.03a.75.75 0 01-1.06-1.06L17.69 5.25H8.25a.75.75 0 010-1.5z"/></svg>'
|
||||
: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3.97 3.97a.75.75 0 011.06 0l13.72 13.72V8.25a.75.75 0 011.5 0V19.5a.75.75 0 01-.75.75H8.25a.75.75 0 010-1.5h9.44L3.97 5.03a.75.75 0 010-1.06z"/></svg>'
|
||||
}
|
||||
${key.difference.value}%
|
||||
</span>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
keysList.querySelectorAll(".key-item").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
keysList.querySelectorAll(".key-item").forEach((e) => {
|
||||
e.classList.remove("active");
|
||||
});
|
||||
el.classList.add("active");
|
||||
selectKey(el.dataset.key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function selectKey(siteKey) {
|
||||
document.querySelector("#detailPanel").style.opacity = ".3";
|
||||
const data = await api("GET", `/keys/${siteKey}`);
|
||||
document.querySelector("#detailPanel").style.opacity = "1";
|
||||
if (data.error) {
|
||||
showModal(
|
||||
"Error",
|
||||
`<div class="modal-body"><p>Failed to load key: ${data.error}</p></div>`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedKey = data.key;
|
||||
selectedKey.stats = data.stats;
|
||||
selectedKey.chartData = data.chartData;
|
||||
|
||||
renderKeyDetail();
|
||||
keysList.querySelectorAll(".key-item").forEach((el) => {
|
||||
el.classList.remove("active");
|
||||
if (el.dataset.key === siteKey) {
|
||||
el.classList.add("active");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderKeyDetail() {
|
||||
welcomeScreen.style.display = "none";
|
||||
keyDetail.style.display = "block";
|
||||
|
||||
const key = selectedKey;
|
||||
const keyFromList = keys.find((k) => k.siteKey === key.siteKey);
|
||||
const change = keyFromList?.difference || { value: 0, direction: "" };
|
||||
|
||||
keyDetail.innerHTML = `
|
||||
<div class="key-header">
|
||||
<h1>${escapeHtml(key.name)}</h1>
|
||||
<button class="copy-btn" id="copyKeyBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
Copy site key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-label">Solves (24h)</div>
|
||||
<div class="stat-card-value">${key.stats.solvesLast24h.toLocaleString()}</div>
|
||||
${
|
||||
change.direction
|
||||
? `
|
||||
<div class="stat-card-change ${change.direction}">
|
||||
${
|
||||
change.direction === "up"
|
||||
? '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.25 3.75H19.5a.75.75 0 01.75.75v11.25a.75.75 0 01-1.5 0V6.31L5.03 20.03a.75.75 0 01-1.06-1.06L17.69 5.25H8.25a.75.75 0 010-1.5z"></path></svg>'
|
||||
: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3.97 3.97a.75.75 0 011.06 0l13.72 13.72V8.25a.75.75 0 011.5 0V19.5a.75.75 0 01-.75.75H8.25a.75.75 0 010-1.5h9.44L3.97 5.03a.75.75 0 010-1.06z"/></svg>'
|
||||
}
|
||||
${change.value}% vs yesterday
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-label">Difficulty</div>
|
||||
<div class="stat-card-value">${key.config.difficulty}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-label">Challenges</div>
|
||||
<div class="stat-card-value">${key.config.challengeCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<div class="chart-header">
|
||||
<h3>Activity</h3>
|
||||
<select class="time-select" id="timeSelect">
|
||||
<option value="today">Today</option>
|
||||
<option value="yesterday">Yesterday</option>
|
||||
<option value="last7days">Last 7 days</option>
|
||||
<option value="last28days">Last 28 days</option>
|
||||
<option value="last91days">Last 91 days</option>
|
||||
<option value="alltime">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="chart"></canvas>
|
||||
<div class="chart-loading" id="chartLoading">
|
||||
<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3>Configuration</h3>
|
||||
<div class="config-grid">
|
||||
<div class="config-field">
|
||||
<label for="cfgName">Name</label>
|
||||
<input type="text" id="cfgName" value="${escapeHtml(key.name)}">
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label for="cfgDifficulty">Difficulty</label>
|
||||
<input type="number" id="cfgDifficulty" value="${key.config.difficulty}" min="2">
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label for="cfgChallengeCount">Challenge count</label>
|
||||
<input type="number" id="cfgChallengeCount" value="${key.config.challengeCount}" min="1">
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label for="cfgSaltSize">Salt size</label>
|
||||
<input type="number" id="cfgSaltSize" value="${key.config.saltSize}" min="7">
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button class="save-btn" id="saveConfigBtn">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="danger-section">
|
||||
<h3>Danger zone</h3>
|
||||
<p>These actions are irreversible. Please be certain.</p>
|
||||
<div class="danger-actions">
|
||||
<button class="danger-btn" id="rotateSecretBtn">Rotate secret</button>
|
||||
<button class="danger-btn delete" id="deleteKeyBtn">Delete key</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("copyKeyBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key.siteKey);
|
||||
const btn = document.getElementById("copyKeyBtn");
|
||||
btn.innerHTML = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Copied!
|
||||
`;
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
Copy site key
|
||||
`;
|
||||
}, 2000);
|
||||
} catch {
|
||||
showModal(
|
||||
"Site Key",
|
||||
`
|
||||
<div class="modal-body">
|
||||
<div class="modal-field">
|
||||
<label>Site Key</label>
|
||||
<input type="text" value="${key.siteKey}" readonly onclick="this.select()">
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("timeSelect").addEventListener("change", (e) => {
|
||||
loadChartData(e.target.value);
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("saveConfigBtn")
|
||||
.addEventListener("click", saveConfig);
|
||||
document
|
||||
.getElementById("rotateSecretBtn")
|
||||
.addEventListener("click", rotateSecret);
|
||||
document.getElementById("deleteKeyBtn").addEventListener("click", deleteKey);
|
||||
|
||||
renderChart(key.chartData);
|
||||
}
|
||||
|
||||
async function loadChartData(duration) {
|
||||
document.getElementById("chartLoading").classList.add("visible");
|
||||
|
||||
const data = await api(
|
||||
"GET",
|
||||
`/keys/${selectedKey.siteKey}?chartDuration=${duration}`,
|
||||
);
|
||||
|
||||
document.getElementById("chartLoading").classList.remove("visible");
|
||||
|
||||
if (data.chartData) {
|
||||
renderChart(data.chartData);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(chartData) {
|
||||
const canvas = document.getElementById("chart");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (chart) chart.destroy();
|
||||
|
||||
const { data, duration } = chartData;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const labels = [];
|
||||
const values = [];
|
||||
|
||||
const dataMap = new Map(data.map((d) => [d.bucket, d.count]));
|
||||
|
||||
if (duration === "today") {
|
||||
const startTime = Math.floor(Date.now() / 1000 / 86400) * 86400;
|
||||
const buckets = Math.ceil((now - startTime) / 3600) + 1;
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
const t = startTime + i * 3600;
|
||||
labels.push(
|
||||
new Date(t * 1000).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
hour12: true,
|
||||
}),
|
||||
);
|
||||
values.push(dataMap.get(t) || 0);
|
||||
}
|
||||
} else if (duration === "yesterday") {
|
||||
const startTime = Math.floor(Date.now() / 1000 / 86400) * 86400 - 86400;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const t = startTime + i * 3600;
|
||||
labels.push(
|
||||
new Date(t * 1000).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
hour12: true,
|
||||
}),
|
||||
);
|
||||
values.push(dataMap.get(t) || 0);
|
||||
}
|
||||
} else {
|
||||
data.forEach((d) => {
|
||||
labels.push(
|
||||
new Date(d.bucket * 1000).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
);
|
||||
values.push(d.count || 0);
|
||||
});
|
||||
}
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: values,
|
||||
fill: true,
|
||||
backgroundColor: "rgba(137, 180, 250, 0.15)",
|
||||
borderColor: "#89b4fa",
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: "#89b4fa",
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 300 },
|
||||
interaction: { mode: "index", intersect: false },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: "#6c7086", maxTicksLimit: 8 },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: "rgba(108, 112, 134, 0.1)" },
|
||||
ticks: { color: "#6c7086" },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "#1e1e2e",
|
||||
titleColor: "#cdd6f4",
|
||||
bodyColor: "#cdd6f4",
|
||||
borderColor: "#313244",
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.raw} solutions`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const btn = document.getElementById("saveConfigBtn");
|
||||
btn.disabled = true;
|
||||
|
||||
const name = document.getElementById("cfgName").value.trim();
|
||||
const difficulty = parseInt(
|
||||
document.getElementById("cfgDifficulty").value,
|
||||
10,
|
||||
);
|
||||
const challengeCount = parseInt(
|
||||
document.getElementById("cfgChallengeCount").value,
|
||||
10,
|
||||
);
|
||||
const saltSize = parseInt(document.getElementById("cfgSaltSize").value, 10);
|
||||
|
||||
if (!name || difficulty < 2 || challengeCount < 1 || saltSize < 7) {
|
||||
showModal(
|
||||
"Validation Error",
|
||||
`<div class="modal-body"><p>Please check your input values. Difficulty must be ≥2, challenge count ≥1, and salt size ≥7.</p></div>`,
|
||||
);
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await api("PUT", `/keys/${selectedKey.siteKey}/config`, {
|
||||
name,
|
||||
difficulty,
|
||||
challengeCount,
|
||||
saltSize,
|
||||
});
|
||||
|
||||
btn.disabled = false;
|
||||
|
||||
if (res.success) {
|
||||
await loadKeys();
|
||||
selectedKey.name = name;
|
||||
selectedKey.config = { difficulty, challengeCount, saltSize };
|
||||
document.querySelector(".key-header h1").textContent = name;
|
||||
renderKeysList(searchInput.value);
|
||||
} else {
|
||||
showModal(
|
||||
"Error",
|
||||
`<div class="modal-body"><p>Failed to save configuration.</p></div>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function rotateSecret() {
|
||||
showConfirmModal(
|
||||
"Rotate Secret?",
|
||||
"This will generate a new secret key. Your existing integrations will stop working until updated.",
|
||||
"Rotate",
|
||||
async () => {
|
||||
const res = await api(
|
||||
"POST",
|
||||
`/keys/${selectedKey.siteKey}/rotate-secret`,
|
||||
);
|
||||
if (res.secretKey) {
|
||||
showModal(
|
||||
"Rotated secret key",
|
||||
`
|
||||
<div class="modal-body">
|
||||
<div class="modal-field">
|
||||
<label>New secret key</label>
|
||||
<input type="text" value="${res.secretKey}" readonly onclick="this.select()">
|
||||
<p class="hint">Make sure to copy this - it won't be shown again.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
} else {
|
||||
showModal(
|
||||
"Error",
|
||||
`<div class="modal-body"><p>Failed to rotate secret key.</p></div>`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function deleteKey() {
|
||||
showConfirmModal(
|
||||
"Delete Key?",
|
||||
"This will permanently delete this key and all associated data. This cannot be undone.",
|
||||
"Delete",
|
||||
async () => {
|
||||
const res = await api("DELETE", `/keys/${selectedKey.siteKey}`);
|
||||
if (res.success) {
|
||||
selectedKey = null;
|
||||
welcomeScreen.style.display = "flex";
|
||||
keyDetail.style.display = "none";
|
||||
await loadKeys();
|
||||
} else {
|
||||
showModal(
|
||||
"Error",
|
||||
`<div class="modal-body"><p>Failed to delete key.</p></div>`,
|
||||
);
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function createModal(title, content, isSettings = false) {
|
||||
closeModal();
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="modal ${isSettings ? "settings-modal" : ""}">
|
||||
<div class="modal-header">
|
||||
<h2>${title}</h2>
|
||||
<button class="modal-close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
${content}
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.querySelector(".modal-close").addEventListener("click", closeModal);
|
||||
overlay.addEventListener("mousedown", (e) => {
|
||||
if (e.target === overlay) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", escapeHandler);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function escapeHandler(e) {
|
||||
if (e.key === "Escape") closeModal();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const overlay = document.querySelector(".modal-overlay");
|
||||
if (overlay) {
|
||||
overlay.style.opacity = "0";
|
||||
overlay.querySelector(".modal").style.transform =
|
||||
"scale(0.95) translateY(10px)";
|
||||
overlay.querySelector(".modal").style.filter = "blur(2px)";
|
||||
document.removeEventListener("keydown", escapeHandler);
|
||||
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
function showModal(title, content) {
|
||||
createModal(title, content);
|
||||
}
|
||||
|
||||
function showConfirmModal(
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
onConfirm,
|
||||
isDanger = false,
|
||||
) {
|
||||
const modal = createModal(
|
||||
title,
|
||||
`
|
||||
<div class="modal-body">
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn secondary" id="cancelBtn">Cancel</button>
|
||||
<button class="modal-btn ${isDanger ? "danger" : "primary"}" id="confirmBtn">${confirmText}</button>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
|
||||
modal.querySelector("#cancelBtn").addEventListener("click", () => {
|
||||
closeModal();
|
||||
});
|
||||
modal.querySelector("#confirmBtn").addEventListener("click", async () => {
|
||||
modal.querySelector("#confirmBtn").disabled = true;
|
||||
await onConfirm();
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateKeyModal(prefill = "") {
|
||||
const placeholder = randKey();
|
||||
|
||||
const modal = createModal(
|
||||
"Create key",
|
||||
`
|
||||
<div class="modal-body">
|
||||
<div class="modal-field">
|
||||
<label for="newKeyName">Key name</label>
|
||||
<input type="text" id="newKeyName" placeholder="${placeholder}" value="${escapeHtml(prefill || placeholder)}" autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="modal-btn primary" id="createKeySubmit">Create</button>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
|
||||
const input = modal.querySelector("#newKeyName");
|
||||
const submitBtn = modal.querySelector("#createKeySubmit");
|
||||
|
||||
input.select();
|
||||
|
||||
input.focus();
|
||||
input.addEventListener("input", () => {
|
||||
submitBtn.disabled = !input.value.trim();
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && input.value.trim()) {
|
||||
submitBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
submitBtn.addEventListener("click", async () => {
|
||||
submitBtn.disabled = true;
|
||||
const res = await api("POST", "/keys", { name: input.value.trim() });
|
||||
|
||||
if (res.siteKey && res.secretKey) {
|
||||
closeModal();
|
||||
showModal(
|
||||
"Key created",
|
||||
`
|
||||
<div class="modal-body">
|
||||
<div class="modal-field">
|
||||
<label>Site key</label>
|
||||
<input type="text" value="${res.siteKey}" readonly onclick="this.select()">
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Secret key</label>
|
||||
<input type="text" value="${res.secretKey}" readonly onclick="this.select()">
|
||||
<p class="hint">Make sure to copy your secret key - it won't be shown again.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn secondary" onclick="closeModal()">Close</button>
|
||||
<button class="modal-btn primary" onclick="closeModal(); selectKey('${res.siteKey}')">Open key</button>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
await loadKeys();
|
||||
selectKey(res.siteKey);
|
||||
} else {
|
||||
showModal(
|
||||
"Error",
|
||||
`<div class="modal-body"><p>Failed to create key.</p></div>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
const modal = createModal(
|
||||
"Settings",
|
||||
`
|
||||
<div class="settings-tabs">
|
||||
<button class="settings-tab active" data-tab="sessions">Sessions</button>
|
||||
<button class="settings-tab" data-tab="apikeys">API keys</button>
|
||||
<button class="settings-tab" data-tab="about">About</button>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-section active" id="sessionsSection">
|
||||
<div id="sessionsList">Loading...</div>
|
||||
</div>
|
||||
<div class="settings-section" id="apikeysSection">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">
|
||||
<button class="create-btn" id="createApiKeyBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div id="apikeysList">Loading...</div>
|
||||
</div>
|
||||
<div class="settings-section" id="aboutSection">
|
||||
<img src="https://capjs.js.org/logo.png" alt="Cap logo" loading="lazy" class="about-logo" draggable="false">
|
||||
<p class="about-info" id="aboutInfo">Loading...</p>
|
||||
<a href="https://github.com/tiagozip/cap" target="_blank" class="github-link">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Star on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
true,
|
||||
);
|
||||
|
||||
modal.querySelectorAll(".settings-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
modal.querySelectorAll(".settings-tab").forEach((t) => {
|
||||
t.classList.remove("active");
|
||||
});
|
||||
modal.querySelectorAll(".settings-section").forEach((s) => {
|
||||
s.classList.remove("active");
|
||||
});
|
||||
tab.classList.add("active");
|
||||
modal.querySelector(`#${tab.dataset.tab}Section`).classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
const sessions = await api("GET", "/settings/sessions");
|
||||
const currentHash = JSON.parse(localStorage.getItem("cap_auth"))?.hash || "";
|
||||
|
||||
if (Array.isArray(sessions)) {
|
||||
document.getElementById("sessionsList").innerHTML = sessions
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="session-item">
|
||||
<div class="session-info">
|
||||
<div class="session-token">${s.token}</div>
|
||||
<div class="session-meta">
|
||||
${currentHash.endsWith(s.token) ? '<span class="current">Current</span> • ' : ""}
|
||||
expires ${formatRelative(s.expires)}
|
||||
</div>
|
||||
</div>
|
||||
<button class="session-action" data-token="${s.token}">Logout</button>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
document.querySelectorAll(".session-action").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const token = btn.dataset.token;
|
||||
|
||||
if (!currentHash.endsWith(token)) {
|
||||
btn.parentElement.remove();
|
||||
}
|
||||
|
||||
await api("POST", "/logout", { session: token });
|
||||
if (currentHash.endsWith(token)) {
|
||||
localStorage.removeItem("cap_auth");
|
||||
document.cookie =
|
||||
"cap_authed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const apikeys = await api("GET", "/settings/apikeys");
|
||||
if (Array.isArray(apikeys)) {
|
||||
if (apikeys.length === 0) {
|
||||
document.getElementById("apikeysList").innerHTML =
|
||||
'<div class="empty-list">No API keys yet</div>';
|
||||
} else {
|
||||
document.getElementById("apikeysList").innerHTML = apikeys
|
||||
.map(
|
||||
(k) => `
|
||||
<div class="apikey-item">
|
||||
<div class="apikey-info">
|
||||
<div class="apikey-name">${escapeHtml(k.name)}</div>
|
||||
<div class="apikey-meta">${k.id.slice(0, 12)}... • created ${formatRelative(k.created)}</div>
|
||||
</div>
|
||||
<button class="apikey-action" data-id="${k.id}">Delete</button>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
document.querySelectorAll(".apikey-action").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
showConfirmModal(
|
||||
"Delete API key?",
|
||||
"This will permanently delete this API key.",
|
||||
"Delete",
|
||||
async () => {
|
||||
await api("DELETE", `/settings/apikeys/${btn.dataset.id}`);
|
||||
},
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("createApiKeyBtn").addEventListener("click", () => {
|
||||
closeModal();
|
||||
openCreateApiKeyModal();
|
||||
});
|
||||
|
||||
const about = await api("GET", "/about");
|
||||
if (about.ver) {
|
||||
document.getElementById("aboutInfo").innerHTML = `
|
||||
Standalone <b>v${about.ver}</b><br>Bun <b>v${about.bun}</b>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateApiKeyModal() {
|
||||
const placeholder = randKey();
|
||||
|
||||
const modal = createModal(
|
||||
"Create API Key",
|
||||
`
|
||||
<div class="modal-body">
|
||||
<div class="modal-field">
|
||||
<label for="apiKeyName">Key name</label>
|
||||
<input type="text" id="apiKeyName" placeholder="${placeholder}" value="${placeholder}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn secondary" onclick="closeModal(); openSettings()">Cancel</button>
|
||||
<button class="modal-btn primary" id="createApiKeySubmit">Create</button>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
|
||||
const input = modal.querySelector("#apiKeyName");
|
||||
const submitBtn = modal.querySelector("#createApiKeySubmit");
|
||||
|
||||
input.select();
|
||||
|
||||
input.focus();
|
||||
input.addEventListener("input", () => {
|
||||
submitBtn.disabled = !input.value.trim();
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && input.value.trim()) {
|
||||
submitBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
submitBtn.addEventListener("click", async () => {
|
||||
submitBtn.disabled = true;
|
||||
const res = await api("POST", "/settings/apikeys", {
|
||||
name: input.value.trim(),
|
||||
});
|
||||
|
||||
if (res.apiKey) {
|
||||
closeModal();
|
||||
showModal(
|
||||
"API key created",
|
||||
`
|
||||
<div class="modal-body">
|
||||
<div class="modal-field">
|
||||
<label>API key</label>
|
||||
<input type="text" value="${res.apiKey}" readonly onclick="this.select()">
|
||||
<p class="hint">Make sure to copy your API key - it won't be shown again.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn primary" onclick="closeModal(); openSettings()">Done</button>
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
} else {
|
||||
showModal(
|
||||
"Error",
|
||||
`<div class="modal-body"><p>Failed to create API key.</p></div>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("createKeyBtn").addEventListener("click", () => {
|
||||
openCreateKeyModal(searchInput.value.trim());
|
||||
});
|
||||
|
||||
document.getElementById("searchInput").addEventListener("input", (e) => {
|
||||
renderKeysList(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("settingsBtn").addEventListener("click", openSettings);
|
||||
init();
|
||||
@@ -1,82 +0,0 @@
|
||||
import sendApiRequest from "./api.js";
|
||||
import loadKeyPage from "./keyPage.js";
|
||||
import setState from "./state.js";
|
||||
|
||||
const initHomepage = async () => {
|
||||
setState("loading");
|
||||
|
||||
let keys;
|
||||
|
||||
try {
|
||||
keys = await sendApiRequest("GET", "/keys");
|
||||
} catch {
|
||||
setState("homepage");
|
||||
|
||||
document.querySelector(".keys-list").classList.add("no-keys");
|
||||
document.querySelector(
|
||||
".keys-list"
|
||||
).innerHTML = `<p>An error occurred while fetching your keys. Please try again later.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
setState("homepage");
|
||||
|
||||
document.querySelector(".keys-list").classList.remove("no-keys");
|
||||
document.querySelector(".keys-list").innerHTML = "";
|
||||
|
||||
if (keys.length === 0) {
|
||||
document.querySelector(".keys-list").classList.add("no-keys");
|
||||
document.querySelector(
|
||||
".keys-list"
|
||||
).innerHTML = `<p>You don't have any keys</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
keys.forEach((key) => {
|
||||
const keyEl = document.createElement("div");
|
||||
keyEl.classList.add("key");
|
||||
|
||||
let differenceItem = "〰";
|
||||
if (key.difference.direction === "up") {
|
||||
differenceItem = `<span style='color:rgb(34 197 94)'><svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" width="12" height="12"><path fill-rule="evenodd" d="M8.25 3.75H19.5a.75.75 0 01.75.75v11.25a.75.75 0 01-1.5 0V6.31L5.03 20.03a.75.75 0 01-1.06-1.06L17.69 5.25H8.25a.75.75 0 010-1.5z" clip-rule="evenodd">
|
||||
</path></svg></span>`;
|
||||
} else if (key.difference.direction === "down") {
|
||||
differenceItem = `<span style='color:rgb(248 113 113)'><svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" width="12" height="12"><path fill-rule="evenodd" d="M3.97 3.97a.75.75 0 011.06 0l13.72 13.72V8.25a.75.75 0 011.5 0V19.5a.75.75 0 01-.75.75H8.25a.75.75 0 010-1.5h9.44L3.97 5.03a.75.75 0 010-1.06z" clip-rule="evenodd"></path></svg></span>`;
|
||||
}
|
||||
|
||||
keyEl.innerHTML = `
|
||||
<div class="key-text">
|
||||
<h2>${key.name}</h2>
|
||||
<p><b>${key.solvesLast24h}</b> solves in last 24h • ${differenceItem} ${key.difference.value}%</p>
|
||||
</div>
|
||||
<div class="key-actions">
|
||||
<button class="open-key" title="Open key"><svg alt="Right arrow" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M13 18l6 -6" /><path d="M13 6l6 6" /></svg></button>
|
||||
</div>
|
||||
`;
|
||||
document.querySelector(".keys-list").appendChild(keyEl);
|
||||
|
||||
keyEl.addEventListener("click", () => {
|
||||
keyEl.querySelector(".open-key").click();
|
||||
});
|
||||
|
||||
keyEl.querySelector(".open-key").addEventListener("click", () => {
|
||||
loadKeyPage(key.siteKey);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
document.querySelector(".keys-top input").addEventListener("input", (event) => {
|
||||
const searchTerm = event.target.value.toLowerCase();
|
||||
const keys = document.querySelectorAll(".keys-list .key");
|
||||
|
||||
keys.forEach((key) => {
|
||||
const keyName = key.querySelector("h2").innerText.toLowerCase();
|
||||
if (keyName.includes(searchTerm)) {
|
||||
key.style.display = "flex";
|
||||
} else {
|
||||
key.style.display = "none";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default initHomepage;
|
||||
@@ -1,51 +0,0 @@
|
||||
import sendApiRequest from "./api.js";
|
||||
import initHomepage from "./homepage.js";
|
||||
import { openSettings } from "./settings.js";
|
||||
import setState from "./state.js";
|
||||
import { createModal } from "./utils.js";
|
||||
|
||||
import "./createKey.js";
|
||||
import "./createApiKey.js";
|
||||
|
||||
initHomepage();
|
||||
|
||||
document.querySelectorAll(".logo, .keypage-back").forEach((el) =>
|
||||
el.addEventListener("click", () => {
|
||||
setState("homepage");
|
||||
initHomepage();
|
||||
})
|
||||
);
|
||||
|
||||
document.querySelector(".settings").addEventListener("click", openSettings);
|
||||
|
||||
document.querySelector(".logout").addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Logout?",
|
||||
`<button class="logout-confirm-button primary" style="margin-bottom:8px">Yes, log out</button>
|
||||
<button class="logout-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
modal
|
||||
.querySelector(".logout-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".logout-confirm-button").disabled = true;
|
||||
await sendApiRequest("POST", "/logout");
|
||||
|
||||
localStorage.removeItem("cap_auth");
|
||||
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: n/a
|
||||
document.cookie =
|
||||
"cap_authed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
location.reload();
|
||||
});
|
||||
|
||||
modal.querySelector(".logout-cancel-button").addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
|
||||
if (!localStorage.getItem("cap_auth")) {
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: n/a
|
||||
document.cookie =
|
||||
"cap_authed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
import makeApiRequest from "./api.js";
|
||||
import initHomepage from "./homepage.js";
|
||||
import setState from "./state.js";
|
||||
import { createModal } from "./utils.js";
|
||||
|
||||
let currentChart = null;
|
||||
let currentSiteKey = null;
|
||||
|
||||
export default async function loadKeyPage(siteKey) {
|
||||
setState("loading");
|
||||
currentSiteKey = siteKey;
|
||||
|
||||
try {
|
||||
const response = await makeApiRequest("GET", `/keys/${siteKey}`);
|
||||
|
||||
if (!response.success && response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
displayKeyData(response);
|
||||
await loadChartData();
|
||||
setupEventListeners();
|
||||
setState("keyPage");
|
||||
} catch (error) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to load key data: ${error.message}</p></div>`
|
||||
);
|
||||
setState("homepage");
|
||||
}
|
||||
}
|
||||
|
||||
function displayKeyData(data) {
|
||||
const { key } = data;
|
||||
|
||||
const titleElement = document.querySelector(".state-key-page .topbar h1");
|
||||
titleElement.innerText = key.name;
|
||||
|
||||
const configElement = document.querySelector(".state-key-page .config");
|
||||
configElement.querySelector("#keyName").value = key.name;
|
||||
configElement.querySelector("#difficulty").value = key.config.difficulty;
|
||||
configElement.querySelector("#challengeCount").value =
|
||||
key.config.challengeCount;
|
||||
configElement.querySelector("#saltSize").value = key.config.saltSize;
|
||||
|
||||
configElement.querySelector("button").onclick = async () => {
|
||||
const name = configElement.querySelector("#keyName").value.trim();
|
||||
const difficulty = parseInt(
|
||||
configElement.querySelector("#difficulty").value,
|
||||
10
|
||||
);
|
||||
const challengeCount = parseInt(
|
||||
configElement.querySelector("#challengeCount").value,
|
||||
10
|
||||
);
|
||||
const saltSize = parseInt(
|
||||
configElement.querySelector("#saltSize").value,
|
||||
10
|
||||
);
|
||||
|
||||
if (
|
||||
!name ||
|
||||
Number.isNaN(difficulty) ||
|
||||
Number.isNaN(challengeCount) ||
|
||||
Number.isNaN(saltSize)
|
||||
) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Please fill in all fields correctly.</p></div>`
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (difficulty <= 1 || challengeCount < 1 || saltSize <= 6) {
|
||||
createModal(
|
||||
"Validation failed",
|
||||
`<div class="content"><p>Difficulty must be greater than 1, challenge count must be greater than zero and salt size must be greater than 6.</p></div>`
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector(".state-key-page h1").textContent =
|
||||
configElement.querySelector("#keyName").value;
|
||||
configElement.querySelector("button").disabled = true;
|
||||
|
||||
const { success } = await makeApiRequest(
|
||||
"PUT",
|
||||
`/keys/${key.siteKey}/config`,
|
||||
{
|
||||
name,
|
||||
difficulty,
|
||||
challengeCount,
|
||||
saltSize,
|
||||
}
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to save key.</p></div>`
|
||||
);
|
||||
}
|
||||
|
||||
configElement.querySelector("button").disabled = false;
|
||||
};
|
||||
|
||||
document
|
||||
.querySelector(".danger-zone-buttons .delete-key")
|
||||
.addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Delete key?",
|
||||
`<div class="content"><p>Are you sure you want to delete this key? This action cannot be undone.</p></div>
|
||||
<button class="delete-confirm-button primary" style="margin-bottom:8px">Yes, delete key</button>
|
||||
<button class="delete-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal
|
||||
.querySelector(".delete-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".delete-confirm-button").disabled = true;
|
||||
const { success } = await makeApiRequest(
|
||||
"DELETE",
|
||||
`/keys/${key.siteKey}`
|
||||
);
|
||||
|
||||
document.body.removeChild(modal);
|
||||
|
||||
if (success) {
|
||||
initHomepage();
|
||||
} else {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to delete key</p></div>`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".delete-cancel-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector(".danger-zone-buttons .rotate-secret")
|
||||
.addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Rotate secret key?",
|
||||
`<div class="content"><p>Are you sure you want to rotate this key's secret? This won't delete the key itself.</p></div>
|
||||
<button class="rotate-confirm-button primary" style="margin-bottom:8px">Yes, rotate key</button>
|
||||
<button class="rotate-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
modal
|
||||
.querySelector(".rotate-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".rotate-confirm-button").disabled = true;
|
||||
const { secretKey } = await makeApiRequest(
|
||||
"POST",
|
||||
`/keys/${key.siteKey}/rotate-secret`
|
||||
);
|
||||
|
||||
document.body.removeChild(modal);
|
||||
|
||||
if (!secretKey) {
|
||||
createModal(
|
||||
"Error",
|
||||
`<div class="content"><p>Failed to rotate key secret.</p></div>`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const successModal = createModal(
|
||||
"Secret key rotated",
|
||||
`<div class="content">
|
||||
<label for="secret-key">New secret key</label>
|
||||
<input type="text" name="secret-key" value="${secretKey}" readonly>
|
||||
|
||||
<p class="small-text">Make sure to copy your secret key as it will not be shown again later.</p>
|
||||
</div>
|
||||
|
||||
<button class="close-small-button primary secondary">Close</button>`
|
||||
);
|
||||
|
||||
successModal
|
||||
.querySelector(".close-small-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(successModal);
|
||||
});
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".rotate-cancel-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
|
||||
const copyButton = document.querySelector(".state-key-page .topbar button");
|
||||
copyButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-copy"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 7m0 2.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667z" /><path d="M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1" /></svg> Copy site key`;
|
||||
|
||||
copyButton.onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key.siteKey);
|
||||
const originalText = copyButton.innerHTML;
|
||||
copyButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-check"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10" /></svg> Copied!`;
|
||||
setTimeout(() => {
|
||||
copyButton.innerHTML = originalText;
|
||||
}, 2000);
|
||||
} catch {
|
||||
createModal(
|
||||
"Site key",
|
||||
`<div class="content"><p class="small">We couldn't automatically copy your site key to your clipboard. Please copy it manually.</p><label for="site-key">Site key</label><input type="text" name="site-key" value="${key.siteKey}" readonly></div>`
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadChartData(duration = "today") {
|
||||
document.querySelector(".chart-container").style.opacity = "0.3";
|
||||
document.querySelector(".chart-container").style.pointerEvents = "none";
|
||||
|
||||
const response = await makeApiRequest(
|
||||
"GET",
|
||||
`/keys/${currentSiteKey}?chartDuration=${duration}`
|
||||
);
|
||||
|
||||
document.querySelector(".chart-container").style.opacity = "1";
|
||||
document.querySelector(".chart-container").style.pointerEvents = "all";
|
||||
|
||||
renderChart(response.chartData, duration);
|
||||
}
|
||||
|
||||
function formatDateLabel(timestamp, duration) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
|
||||
switch (duration) {
|
||||
case "today":
|
||||
case "yesterday":
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
case "last7days":
|
||||
case "last28days":
|
||||
case "last91days":
|
||||
case "alltime":
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
default:
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(chartData, duration) {
|
||||
const canvas = document.getElementById("chart");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (currentChart) {
|
||||
currentChart.destroy();
|
||||
}
|
||||
|
||||
const labels = [];
|
||||
const dataPoints = [];
|
||||
|
||||
const { bucketSize, data } = chartData;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
let startTime, endTime, buckets;
|
||||
|
||||
switch (duration) {
|
||||
case "today":
|
||||
startTime = Math.floor(Date.now() / 1000 / 86400) * 86400;
|
||||
endTime = startTime + 86400;
|
||||
buckets = Math.floor((endTime - startTime) / bucketSize);
|
||||
break;
|
||||
case "yesterday":
|
||||
startTime = Math.floor(Date.now() / 1000 / 86400) * 86400 - 86400;
|
||||
endTime = startTime + 86400;
|
||||
buckets = Math.floor((endTime - startTime) / bucketSize);
|
||||
break;
|
||||
case "last7days":
|
||||
startTime = now - 7 * 86400;
|
||||
endTime = now;
|
||||
buckets = 7;
|
||||
break;
|
||||
case "last28days":
|
||||
startTime = now - 28 * 86400;
|
||||
endTime = now;
|
||||
buckets = 28;
|
||||
break;
|
||||
case "last91days":
|
||||
startTime = now - 91 * 86400;
|
||||
endTime = now;
|
||||
buckets = 91;
|
||||
break;
|
||||
case "alltime":
|
||||
if (data.length > 0) {
|
||||
startTime = data[0].bucket;
|
||||
endTime = now;
|
||||
buckets = Math.ceil((endTime - startTime) / bucketSize);
|
||||
} else {
|
||||
startTime = now - 86400;
|
||||
endTime = now;
|
||||
buckets = 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
startTime = now - 86400;
|
||||
endTime = now;
|
||||
buckets = 24;
|
||||
}
|
||||
|
||||
const dataMap = new Map();
|
||||
data.forEach((item) => {
|
||||
dataMap.set(item.bucket, item.count);
|
||||
});
|
||||
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
const bucketTime = startTime + i * bucketSize;
|
||||
labels.push(formatDateLabel(bucketTime, duration));
|
||||
|
||||
const alignedBucket = Math.floor(bucketTime / bucketSize) * bucketSize;
|
||||
dataPoints.push(dataMap.get(alignedBucket) || 0);
|
||||
}
|
||||
currentChart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Solutions",
|
||||
data: dataPoints,
|
||||
fill: true,
|
||||
backgroundColor: "rgba(59, 130, 246, 0.5)",
|
||||
borderColor: "#3b82f6",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
pointBackgroundColor: "#3b82f6",
|
||||
pointBorderColor: "#3b82f6",
|
||||
pointHoverBorderWidth: 0,
|
||||
pointHoverRadius: 3,
|
||||
pointHoverBackgroundColor: "#3b82f6",
|
||||
tension: 0.4,
|
||||
animation: {
|
||||
duration: 0
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "#9ca3af",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(255, 255, 255, 0.05)",
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "#9ca3af",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
displayColors: false,
|
||||
backgroundColor: "#161718",
|
||||
titleColor: "#e5e7eb",
|
||||
bodyColor: "#e5e7eb",
|
||||
padding: 5,
|
||||
borderColor: "#1F2021",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
return `${context.raw} solutions`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (currentChart) {
|
||||
currentChart.update("resize");
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(canvas);
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
const timeDurationSelect = document.querySelector(
|
||||
".state-key-page .time-duration"
|
||||
);
|
||||
|
||||
timeDurationSelect.onchange = (e) => {
|
||||
const duration = e.target.value;
|
||||
loadChartData(duration);
|
||||
};
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import makeApiRequest from "./api.js";
|
||||
import setState from "./state.js";
|
||||
import { createModal } from "./utils.js";
|
||||
|
||||
export const openSettings = () => {
|
||||
setState("settings");
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const sessions = await makeApiRequest("GET", "/settings/sessions");
|
||||
|
||||
const sessionsList = document.querySelector(
|
||||
".state-settings .sessions-list"
|
||||
);
|
||||
sessionsList.innerHTML = "";
|
||||
function formatRelative(date) {
|
||||
const diff = new Date(date) - Date.now();
|
||||
const past = diff < 0;
|
||||
const d = Math.abs(diff);
|
||||
|
||||
const ms = {
|
||||
year: 365 * 24 * 60 * 60 * 1000,
|
||||
month: 30 * 24 * 60 * 60 * 1000,
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
minute: 60 * 1000,
|
||||
};
|
||||
|
||||
if (d >= ms.year) {
|
||||
const y = Math.floor(d / ms.year);
|
||||
const m = Math.floor((d - y * ms.year) / ms.month);
|
||||
return past
|
||||
? `${y} year${y > 1 ? "s" : ""}${
|
||||
m ? " " + m + " month" + (m > 1 ? "s" : "") : ""
|
||||
} ago`
|
||||
: `in ${y} year${y > 1 ? "s" : ""}${
|
||||
m ? " " + m + " month" + (m > 1 ? "s" : "") : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
for (const [u, v] of Object.entries(ms)) {
|
||||
if (d >= v) {
|
||||
const val = Math.floor(d / v);
|
||||
return past
|
||||
? `${val} ${u}${val > 1 ? "s" : ""} ago`
|
||||
: `in ${val} ${u}${val > 1 ? "s" : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
return past ? "just now" : "in a few seconds";
|
||||
}
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const sessionEl = document.createElement("div");
|
||||
sessionEl.classList.add("session");
|
||||
|
||||
sessionEl.innerHTML = `
|
||||
<div class="session-info">
|
||||
<span class="session-token">${session.token}...</span>
|
||||
<span class="session-expires">${
|
||||
JSON.parse(localStorage.getItem("cap_auth")).hash.endsWith(
|
||||
session.token
|
||||
)
|
||||
? "<b>current</b> • "
|
||||
: ""
|
||||
}expires ${formatRelative(
|
||||
session.expires
|
||||
)} • created ${formatRelative(session.created)}</span>
|
||||
</div>
|
||||
<button class="session-delete-button">Logout</button>
|
||||
`;
|
||||
|
||||
sessionsList.appendChild(sessionEl);
|
||||
|
||||
sessionEl
|
||||
.querySelector(".session-delete-button")
|
||||
.addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Logout session?",
|
||||
`<button class="logout-confirm-button primary" style="margin-bottom:8px;">Yes, log out</button>
|
||||
<button class="logout-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
modal
|
||||
.querySelector(".logout-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".logout-confirm-button").disabled = true;
|
||||
await makeApiRequest("POST", `/logout`, {
|
||||
session: session.token,
|
||||
});
|
||||
document.body.removeChild(modal);
|
||||
if (sessions.length === 1) {
|
||||
localStorage.removeItem("cap_auth");
|
||||
location.reload();
|
||||
}
|
||||
openSettings();
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".logout-cancel-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
const apikeys = await makeApiRequest("GET", "/settings/apikeys");
|
||||
|
||||
const apikeysList = document.querySelector(".state-settings .apikeys-list");
|
||||
apikeysList.innerHTML = "";
|
||||
|
||||
if (apikeys.length === 0) {
|
||||
apikeysList.innerHTML = `<p>You don't have any API keys</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
apikeys.forEach((key) => {
|
||||
const keyEl = document.createElement("div");
|
||||
keyEl.classList.add("apikey");
|
||||
|
||||
keyEl.innerHTML = `
|
||||
<div class="apikey-info">
|
||||
<span class="apikey-token">${key.name
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")}</span>
|
||||
<span class="apikey-created" title="${key.id}...">${key.id.slice(
|
||||
0,
|
||||
12
|
||||
)}... • created ${formatDate(key.created)}</span>
|
||||
</div>
|
||||
<button class="apikey-delete-button">Delete</button>
|
||||
`;
|
||||
|
||||
apikeysList.appendChild(keyEl);
|
||||
|
||||
keyEl
|
||||
.querySelector(".apikey-delete-button")
|
||||
.addEventListener("click", async () => {
|
||||
const modal = createModal(
|
||||
"Delete API key?",
|
||||
`<button class="delete-confirm-button primary" style="margin-bottom:8px;">Yes, delete</button>
|
||||
<button class="delete-cancel-button primary secondary">Cancel</button>`
|
||||
);
|
||||
|
||||
modal
|
||||
.querySelector(".delete-confirm-button")
|
||||
.addEventListener("click", async () => {
|
||||
modal.querySelector(".delete-confirm-button").disabled = true;
|
||||
await makeApiRequest("DELETE", `/settings/apikeys/${key.id}`);
|
||||
document.body.removeChild(modal);
|
||||
openSettings();
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".delete-cancel-button")
|
||||
.addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
const info = await makeApiRequest("GET", "/about");
|
||||
|
||||
document.querySelector(
|
||||
".about-info"
|
||||
).innerHTML = `You're using Cap Standalone <b>${info.ver}</b> on Bun <b>v${info.bun}</b>`;
|
||||
})();
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
const states = {
|
||||
loading: document.querySelector(".state-loading"),
|
||||
homepage: document.querySelector(".state-homepage"),
|
||||
keyPage: document.querySelector(".state-key-page"),
|
||||
settings: document.querySelector(".state-settings"),
|
||||
};
|
||||
|
||||
export default function setState(stateName) {
|
||||
for (const [name, element] of Object.entries(states)) {
|
||||
if (name === stateName) {
|
||||
element.style.display = "flex";
|
||||
} else {
|
||||
element.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
export const createModal = (title, content) => {
|
||||
if (document.querySelector(".modal"))
|
||||
document.querySelector(".modal").remove();
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.classList.add("modal");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<header>
|
||||
<h2>${title}</h2>
|
||||
<button class="close-button" title="Close"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
</header>
|
||||
|
||||
${content}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
modal.querySelector(".close-button").addEventListener("click", () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
|
||||
document.body.addEventListener("click", (event) => {
|
||||
if (event.target === modal) {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
});
|
||||
|
||||
return modal;
|
||||
};
|
||||
@@ -34,6 +34,21 @@
|
||||
margin-bottom: 28px;
|
||||
margin-top: 0;
|
||||
user-select: none;
|
||||
|
||||
|
||||
animation: title-in 0.6s both;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
@keyframes title-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
translate: 0 50px;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
img {
|
||||
width: 60px;
|
||||
@@ -46,6 +61,9 @@
|
||||
margin-bottom: 8px;
|
||||
color: #eee;
|
||||
user-select: none;
|
||||
will-change: transform, filter, opacity;
|
||||
animation: title-in 0.5s both;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.password-wrapper {
|
||||
@@ -55,6 +73,9 @@
|
||||
display: flex;
|
||||
border-radius: calc(0.8rem - 2px);
|
||||
margin-bottom: 16px;
|
||||
will-change: transform, filter, opacity;
|
||||
animation: title-in 0.5s both;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.showhide-password {
|
||||
@@ -122,6 +143,11 @@
|
||||
font-size: 14px;
|
||||
z-index: 12;
|
||||
transition: box-shadow 0.13s, opacity 0.2s;
|
||||
|
||||
|
||||
will-change: transform, filter, opacity;
|
||||
animation: title-in 0.5s both;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.submit:focus {
|
||||
@@ -141,6 +167,11 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 24px;
|
||||
|
||||
|
||||
will-change: transform, filter, opacity;
|
||||
animation: title-in 0.5s both;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
p.bottom .poweredby {
|
||||
margin-left: auto;
|
||||
|
||||
@@ -7,9 +7,9 @@ import { ratelimitGenerator } from "./ratelimit.js";
|
||||
const { ADMIN_KEY } = process.env;
|
||||
|
||||
if (!ADMIN_KEY) throw new Error("auth: Admin key missing. Please add one");
|
||||
if (ADMIN_KEY.length < 30)
|
||||
if (ADMIN_KEY.length < 12)
|
||||
throw new Error(
|
||||
"auth: Admin key too short. Please use one that's at least 30 characters"
|
||||
"auth: Admin key too short. Please use one that's at least 12 characters"
|
||||
);
|
||||
|
||||
export const auth = new Elysia({
|
||||
|
||||
Reference in New Issue
Block a user