novela/containers/novela/templates/credentials.html
2026-04-15 21:39:20 +02:00

394 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela{% if develop_mode() %} Develop{% endif %} — Credentials</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--serif);
}
/* ── Main content ── */
.main {
margin-left: var(--sidebar);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem 5rem;
}
@media (max-width: 768px) {
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
width: 100%;
max-width: 620px;
margin-bottom: 1.5rem;
}
.card-title {
font-size: 0.7rem;
font-family: var(--mono);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 1.25rem;
}
label {
display: block;
font-size: 0.78rem;
font-family: var(--mono);
color: var(--text-dim);
margin-bottom: 0.4rem;
letter-spacing: 0.04em;
}
input[type="text"],
input[type="password"],
input[type="url"] {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--mono);
font-size: 0.85rem;
padding: 0.65rem 0.85rem;
outline: none;
transition: border-color 0.15s;
margin-bottom: 1rem;
}
input:focus { border-color: var(--accent); }
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.input-wrap {
position: relative;
margin-bottom: 1rem;
}
.input-wrap input { margin-bottom: 0; padding-right: 2.5rem; }
.toggle-pw {
position: absolute;
right: 0.65rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0;
width: auto;
color: var(--text-faint);
cursor: pointer;
display: flex;
align-items: center;
}
.toggle-pw:hover { color: var(--text-dim); }
button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.85rem;
background: var(--accent);
color: #0f0e0c;
border: none;
border-radius: var(--radius);
font-family: var(--mono);
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.05em;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
button:hover { background: var(--accent2); }
button:active { transform: scale(0.99); }
/* Credential list */
.cred-list { list-style: none; }
.cred-item {
border-bottom: 1px solid var(--border);
padding: 1rem 0;
}
.cred-item:first-child { padding-top: 0; }
.cred-item:last-child { border-bottom: none; padding-bottom: 0; }
.cred-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.cred-site {
font-family: var(--mono);
font-size: 0.85rem;
color: var(--text);
}
.cred-actions {
display: flex;
gap: 0.4rem;
}
.btn-sm {
width: auto;
padding: 0.3rem 0.7rem;
font-size: 0.72rem;
background: var(--surface2);
color: var(--text-dim);
border: 1px solid var(--border);
}
.btn-sm:hover { background: var(--border); color: var(--text); }
.btn-danger {
background: rgba(200, 90, 58, 0.12);
border-color: rgba(200, 90, 58, 0.3);
color: var(--error);
}
.btn-danger:hover { background: rgba(200, 90, 58, 0.25); }
.cred-detail {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-dim);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cred-detail span { display: flex; gap: 0.5rem; align-items: center; }
.cred-detail .label { color: var(--text-faint); min-width: 5rem; }
.pw-mask { letter-spacing: 0.1em; }
.toggle-visible {
font-size: 0.68rem;
color: var(--text-faint);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
background: none;
border: none;
padding: 0;
width: auto;
font-family: var(--mono);
}
.toggle-visible:hover { color: var(--text-dim); background: none; }
.empty-state {
font-family: var(--mono);
font-size: 0.8rem;
color: var(--text-faint);
text-align: center;
padding: 1rem 0;
}
.form-feedback {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--success);
text-align: center;
margin-top: 0.75rem;
min-height: 1.2em;
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<!-- Saved credentials -->
<div class="card">
<div class="card-title">Saved Credentials</div>
<ul class="cred-list" id="cred-list">
<li class="empty-state" id="empty-msg">No credentials saved yet.</li>
</ul>
</div>
<!-- Add / Edit -->
<div class="card">
<div class="card-title" id="form-title">Add Credentials</div>
<label for="f-site">Site (domain or key)</label>
<div style="font-family: var(--mono); font-size: 0.7rem; color: var(--text-faint); margin: -0.2rem 0 0.8rem;">Tip: use <code>dropbox</code> with the access token in Password.</div>
<input type="text" id="f-site" placeholder="e.g. example.com"/>
<div class="row">
<div>
<label for="f-username">Username</label>
<input type="text" id="f-username" autocomplete="off"/>
</div>
<div>
<label for="f-password">Password</label>
<div class="input-wrap">
<input type="password" id="f-password" autocomplete="off"/>
<button class="toggle-pw" type="button" onclick="toggleFormPw()" title="Show / hide">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
</div>
<button onclick="submitForm()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
</svg>
Save
</button>
<div class="form-feedback" id="form-feedback"></div>
</div>
</main>
<script src="/static/books.js"></script>
<script>
let allCredentials = {};
async function loadCredentials() {
const r = await fetch('/credentials');
allCredentials = await r.json();
renderList();
}
function renderList() {
const ul = document.getElementById('cred-list');
const empty = document.getElementById('empty-msg');
const sites = Object.keys(allCredentials);
if (sites.length === 0) {
ul.innerHTML = '';
ul.appendChild(empty);
return;
}
ul.innerHTML = '';
sites.forEach(site => {
const creds = allCredentials[site];
const li = document.createElement('li');
li.className = 'cred-item';
li.id = `site-${CSS.escape(site)}`;
li.innerHTML = `
<div class="cred-item-header">
<span class="cred-site">${site}</span>
<div class="cred-actions">
<button class="btn-sm" onclick="editSite('${site}')">Edit</button>
<button class="btn-sm btn-danger" onclick="deleteSite('${site}')">Delete</button>
</div>
</div>
<div class="cred-detail">
<span><span class="label">Username</span>${creds.username || '—'}</span>
<span>
<span class="label">Password</span>
<span class="pw-mask" id="pw-${CSS.escape(site)}">●●●●●●●●</span>
<button class="toggle-visible" onclick="togglePw('${site}')">show</button>
</span>
</div>`;
ul.appendChild(li);
});
}
function togglePw(site) {
const el = document.getElementById(`pw-${CSS.escape(site)}`);
const btn = el.nextElementSibling;
if (btn.textContent === 'show') {
el.textContent = allCredentials[site].password || '(empty)';
btn.textContent = 'hide';
} else {
el.textContent = '●●●●●●●●';
btn.textContent = 'show';
}
}
function editSite(site) {
document.getElementById('f-site').value = site;
document.getElementById('f-username').value = allCredentials[site].username || '';
document.getElementById('f-password').value = allCredentials[site].password || '';
document.getElementById('form-title').textContent = `Edit Credentials — ${site}`;
document.getElementById('f-site').focus();
document.getElementById('f-site').scrollIntoView({ behavior: 'smooth', block: 'center' });
}
async function deleteSite(site) {
if (!confirm(`Delete credentials for ${site}?`)) return;
await fetch(`/credentials/${encodeURIComponent(site)}`, { method: 'DELETE' });
delete allCredentials[site];
renderList();
}
async function submitForm() {
const site = document.getElementById('f-site').value.trim().replace(/^www\./, '');
const username = document.getElementById('f-username').value.trim();
const password = document.getElementById('f-password').value;
if (!site) { alert('Please enter a site domain.'); return; }
await fetch('/credentials', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ site, username, password })
});
allCredentials[site] = { username, password };
renderList();
document.getElementById('f-site').value = '';
document.getElementById('f-username').value = '';
document.getElementById('f-password').value = '';
document.getElementById('form-title').textContent = 'Add Credentials';
const fb = document.getElementById('form-feedback');
fb.textContent = `Saved credentials for ${site}`;
setTimeout(() => fb.textContent = '', 2500);
}
function toggleFormPw() {
const input = document.getElementById('f-password');
input.type = input.type === 'password' ? 'text' : 'password';
}
loadCredentials();
</script>
</body>
</html>