Initial commit
This commit is contained in:
500
templates/admin.html
Normal file
500
templates/admin.html
Normal file
@@ -0,0 +1,500 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>APtool Admin</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.header a {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header a:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Status banner */
|
||||
#status-banner {
|
||||
display: none;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
#status-banner.success {
|
||||
display: block;
|
||||
background: #064e3b;
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
#status-banner.error {
|
||||
display: block;
|
||||
background: #3b1111;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card .info {
|
||||
font-size: 0.9rem;
|
||||
color: #aaa;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card .info span {
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.nc-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nc-badge.exists {
|
||||
background: #064e3b;
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.nc-badge.missing {
|
||||
background: #3b1111;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.nc-badge.global {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-blue {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-blue:active { background: #1d4ed8; }
|
||||
|
||||
.btn-outline {
|
||||
background: #16213e;
|
||||
color: #2563eb;
|
||||
border: 2px solid #2563eb;
|
||||
}
|
||||
|
||||
.btn-outline:active { background: #1a2744; }
|
||||
|
||||
.btn-red {
|
||||
background: #16213e;
|
||||
color: #f87171;
|
||||
border: 2px solid #dc2626;
|
||||
}
|
||||
|
||||
.btn-red:active { background: #2a1111; }
|
||||
|
||||
.btn-green {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-green:active { background: #047857; }
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid #2a2a4a;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* Add form */
|
||||
.add-form h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-row input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 6px;
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.form-row input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
/* Edit modal overlay */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #16213e;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.modal input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 6px;
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.modal input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<div style="text-align:center;color:#999;font-size:0.8rem;margin-bottom:8px;">JCP Wifi Migration 2026</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>APtool</h1>
|
||||
<span class="badge">Admin</span>
|
||||
<a href="/admin/logout">Log out</a>
|
||||
</div>
|
||||
|
||||
<!-- Status banner (shown by JS after operations) -->
|
||||
<div id="status-banner"></div>
|
||||
|
||||
<!-- Add Site card -->
|
||||
<div class="card add-form">
|
||||
<h3>Add Site</h3>
|
||||
<div class="form-row">
|
||||
<input type="text" id="add-site" placeholder="Site (4 digits)" maxlength="4" inputmode="numeric">
|
||||
<input type="text" id="add-pin" placeholder="PIN" inputmode="numeric">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="add-nc-user" placeholder="NC User (optional)">
|
||||
<input type="text" id="add-nc-pass" placeholder="NC Pass (optional)">
|
||||
</div>
|
||||
<button class="btn btn-blue" onclick="addSite()">Add Site</button>
|
||||
</div>
|
||||
|
||||
<!-- Site cards -->
|
||||
{% for site in sites %}
|
||||
<div class="card site-card" data-id="{{ site.id }}">
|
||||
<h3>Site {{ site.id }}</h3>
|
||||
<div class="info">PIN: <span>{{ site.pin }}</span></div>
|
||||
<div class="info">
|
||||
NC User:
|
||||
{% if site.nc_user %}
|
||||
<span>{{ site.nc_user }}</span>
|
||||
{% if site.nc_exists == true %}
|
||||
<span class="nc-badge exists">Exists</span>
|
||||
{% elif site.nc_exists == false %}
|
||||
<span class="nc-badge missing">Not found</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="nc-badge global">Global</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
{% if site.nc_user and site.nc_exists == false %}
|
||||
<button class="btn btn-green" onclick="provisionUser('{{ site.id }}', this)">Create User</button>
|
||||
{% endif %}
|
||||
<button class="btn btn-outline" onclick="openEdit('{{ site.id }}', '{{ site.pin }}', '{{ site.nc_user or '' }}', '{{ site.nc_pass or '' }}')">Edit</button>
|
||||
<button class="btn btn-red" onclick="deleteSite('{{ site.id }}', this)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not sites %}
|
||||
<div class="card" style="text-align:center;color:#666;">
|
||||
No sites configured. Add one above.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="text-align:center;margin-top:24px;">
|
||||
<a href="/login" style="color:#666;font-size:0.8rem;text-decoration:none;">Tech Login</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal-overlay" id="edit-modal">
|
||||
<div class="modal">
|
||||
<h3>Edit Site <span id="edit-site-id"></span></h3>
|
||||
<label for="edit-pin">PIN</label>
|
||||
<input type="text" id="edit-pin" inputmode="numeric">
|
||||
<label for="edit-nc-user">NC User</label>
|
||||
<input type="text" id="edit-nc-user">
|
||||
<label for="edit-nc-pass">NC Pass</label>
|
||||
<input type="text" id="edit-nc-pass">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="closeEdit()">Cancel</button>
|
||||
<button class="btn btn-blue" onclick="saveEdit()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const banner = document.getElementById('status-banner');
|
||||
|
||||
function showBanner(msg, type) {
|
||||
banner.textContent = msg;
|
||||
banner.className = type; // 'success' or 'error'
|
||||
setTimeout(() => { banner.className = ''; banner.textContent = ''; }, 4000);
|
||||
}
|
||||
|
||||
// ---- Add Site ----
|
||||
async function addSite() {
|
||||
const site = document.getElementById('add-site').value.trim();
|
||||
const pin = document.getElementById('add-pin').value.trim();
|
||||
const nc_user = document.getElementById('add-nc-user').value.trim();
|
||||
const nc_pass = document.getElementById('add-nc-pass').value.trim();
|
||||
|
||||
if (!site || !pin) {
|
||||
showBanner('Site number and PIN are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/api/sites', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({site, pin, nc_user, nc_pass})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
showBanner(data.error || 'Failed to add site', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showBanner('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Delete Site ----
|
||||
async function deleteSite(id, btn) {
|
||||
if (!confirm('Delete site ' + id + '? Uploaded files will be preserved.')) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Deleting...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/api/sites/' + id, {method: 'DELETE'});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
const card = btn.closest('.site-card');
|
||||
if (card) card.remove();
|
||||
showBanner('Site ' + id + ' deleted', 'success');
|
||||
} else {
|
||||
showBanner(data.error || 'Failed to delete', 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Delete';
|
||||
}
|
||||
} catch (err) {
|
||||
showBanner('Network error', 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Delete';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Provision NC User ----
|
||||
async function provisionUser(id, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/api/sites/' + id + '/provision', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
showBanner(data.message || 'User created', 'success');
|
||||
window.location.reload();
|
||||
} else {
|
||||
showBanner(data.error || 'Failed to create user', 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create User';
|
||||
}
|
||||
} catch (err) {
|
||||
showBanner('Network error', 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create User';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Edit Modal ----
|
||||
let editingSiteId = null;
|
||||
|
||||
function openEdit(id, pin, nc_user, nc_pass) {
|
||||
editingSiteId = id;
|
||||
document.getElementById('edit-site-id').textContent = id;
|
||||
document.getElementById('edit-pin').value = pin;
|
||||
document.getElementById('edit-nc-user').value = nc_user;
|
||||
document.getElementById('edit-nc-pass').value = nc_pass;
|
||||
document.getElementById('edit-modal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
document.getElementById('edit-modal').classList.remove('active');
|
||||
editingSiteId = null;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingSiteId) return;
|
||||
|
||||
const pin = document.getElementById('edit-pin').value.trim();
|
||||
const nc_user = document.getElementById('edit-nc-user').value.trim();
|
||||
const nc_pass = document.getElementById('edit-nc-pass').value.trim();
|
||||
|
||||
if (!pin) {
|
||||
showBanner('PIN is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/api/sites/' + editingSiteId, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({pin, nc_user, nc_pass})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
showBanner(data.error || 'Failed to save', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showBanner('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on overlay click
|
||||
document.getElementById('edit-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeEdit();
|
||||
});
|
||||
</script>
|
||||
<div style="text-align:center;margin-top:32px;color:#555;font-size:0.7rem;">© 2026 Mack Allison, sdAnywhere LLC (with Claude Code)</div>
|
||||
</body>
|
||||
</html>
|
||||
132
templates/admin_login.html
Normal file
132
templates/admin_login.html
Normal file
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>APtool Admin - Login</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: #16213e;
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
border: 2px solid #2a2a4a;
|
||||
border-radius: 8px;
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #fca5a5;
|
||||
background: #3b1111;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<div style="color:#999;font-size:0.8rem;margin-bottom:8px;">JCP Wifi Migration 2026</div>
|
||||
<h1>APtool</h1>
|
||||
<span class="badge">Admin</span>
|
||||
<p class="subtitle">Enter admin credentials</p>
|
||||
|
||||
<form method="POST" action="/admin/login">
|
||||
<div class="field">
|
||||
<input type="text" name="username" placeholder="Username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="field">
|
||||
<input type="password" name="password" placeholder="Password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit">Log In</button>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:16px;">
|
||||
<a href="/login" style="color:#666;font-size:0.8rem;text-decoration:none;">Tech Login</a>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:24px;color:#555;font-size:0.7rem;">© 2026 Mack Allison, sdAnywhere LLC (with Claude Code)</div>
|
||||
</body>
|
||||
</html>
|
||||
388
templates/entries.html
Normal file
388
templates/entries.html
Normal file
@@ -0,0 +1,388 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>APtool - Submitted APs</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h1 { font-size: 1.5rem; text-align: center; margin: 0 0 20px; }
|
||||
.container { max-width: 700px; margin: 0 auto; }
|
||||
.badge {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.back-link {
|
||||
color: #93c5fd;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.count {
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px 16px;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.footer { text-align: center; margin-top: 32px; }
|
||||
.footer a { color: #666; font-size: 0.8rem; text-decoration: none; }
|
||||
|
||||
/* ── Card layout ─────────────────────────────────── */
|
||||
.card {
|
||||
background: #16213e;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #2a2a4a;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ap-num {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: #93c5fd;
|
||||
}
|
||||
.ap-location {
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 16px;
|
||||
font-size: 0.82rem;
|
||||
color: #bbb;
|
||||
}
|
||||
.card-meta dt { color: #777; font-weight: 600; margin: 0; }
|
||||
.card-meta dd { margin: 0 0 6px 0; }
|
||||
|
||||
/* ── Photo toggle ────────────────────────────────── */
|
||||
.photo-toggle {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
background: none;
|
||||
border: 1px solid #3b5998;
|
||||
color: #93c5fd;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.photo-toggle:hover { background: rgba(59, 89, 152, 0.3); }
|
||||
|
||||
/* ── Photo grid ──────────────────────────────────── */
|
||||
.photo-grid {
|
||||
display: none;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.photo-grid.open { display: grid; }
|
||||
@media (max-width: 400px) {
|
||||
.photo-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.photo-cell {
|
||||
text-align: center;
|
||||
}
|
||||
.photo-cell .label {
|
||||
font-size: 0.72rem;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.thumb-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
background: #0f1729;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid #2a2a4a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.thumb-wrap img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.thumb-wrap .no-photo {
|
||||
color: #555;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.photo-actions {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.btn-replace {
|
||||
background: none;
|
||||
border: 1px solid #3b5998;
|
||||
color: #93c5fd;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-replace:hover { background: rgba(59, 89, 152, 0.3); }
|
||||
.btn-upload {
|
||||
background: none;
|
||||
border: 1px solid #4a7a3a;
|
||||
color: #86efac;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-upload:hover { background: rgba(74, 122, 58, 0.3); }
|
||||
|
||||
/* ── Modal overlay ───────────────────────────────── */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.88);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
.modal-overlay img {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 30px rgba(0,0,0,0.6);
|
||||
}
|
||||
.modal-close {
|
||||
position: fixed;
|
||||
top: 14px;
|
||||
right: 18px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1.8rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Toast notifications ─────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
z-index: 2000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.success { background: #166534; color: #bbf7d0; }
|
||||
.toast.error { background: #7f1d1d; color: #fca5a5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div style="text-align:center;color:#999;font-size:0.8rem;margin-bottom:8px;">JCP Wifi Migration 2026</div>
|
||||
<h1>APtool</h1>
|
||||
<div style="text-align:center;margin-bottom:16px;">
|
||||
<span class="badge">Site {{ site }}</span>
|
||||
<a href="/" class="back-link">Back to form</a>
|
||||
</div>
|
||||
|
||||
<div class="count">{{ rows|length }} AP{{ 's' if rows|length != 1 else '' }} submitted</div>
|
||||
|
||||
{% if rows %}
|
||||
{% for row in rows %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="ap-num">AP {{ row.ap_number }}</div>
|
||||
<div class="ap-location">{{ row.ap_location }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="card-meta">
|
||||
<dt>Serial</dt>
|
||||
<dd>{{ row.serial_number }}</dd>
|
||||
<dt>MAC</dt>
|
||||
<dd>{{ row.mac_address }}</dd>
|
||||
<dt>Cable</dt>
|
||||
<dd>{{ row.cable_length }}</dd>
|
||||
</dl>
|
||||
|
||||
{% if row.photos %}
|
||||
<button class="photo-toggle" onclick="togglePhotos(this)">Show Photos</button>
|
||||
<div class="photo-grid" data-ap="{{ row.ap_number }}">
|
||||
{% for p in row.photos %}
|
||||
<div class="photo-cell">
|
||||
<div class="label">{{ p.label }}</div>
|
||||
<div class="thumb-wrap"
|
||||
onclick="{% if p.exists %}openModal('/photo/{{ row.ap_number }}/{{ p.suffix }}'){% endif %}"
|
||||
id="thumb-{{ row.ap_number }}-{{ p.suffix }}">
|
||||
{% if p.exists %}
|
||||
<img src="/photo/{{ row.ap_number }}/{{ p.suffix }}"
|
||||
loading="lazy"
|
||||
alt="{{ p.label }}">
|
||||
{% else %}
|
||||
<span class="no-photo">No photo</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="photo-actions">
|
||||
{% if p.exists %}
|
||||
<button class="btn-replace"
|
||||
onclick="pickFile('{{ row.ap_number }}','{{ p.suffix }}')">Replace</button>
|
||||
{% else %}
|
||||
<button class="btn-upload"
|
||||
onclick="pickFile('{{ row.ap_number }}','{{ p.suffix }}')">Upload</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty">No APs submitted yet for this site.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
<a href="/admin/login">Admin</a>
|
||||
<div style="color:#555;font-size:0.7rem;margin-top:12px;">© 2026 Mack Allison, sdAnywhere LLC (with Claude Code)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full-size photo modal -->
|
||||
<div class="modal-overlay" id="photoModal" onclick="closeModal()">
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
<img id="modalImg" src="" alt="Full size photo" onclick="event.stopPropagation()">
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- Hidden file input for uploads -->
|
||||
<input type="file" id="fileInput" accept="image/*" style="display:none">
|
||||
|
||||
<script>
|
||||
/* ── Photo grid toggle ──────────────────────────── */
|
||||
function togglePhotos(btn) {
|
||||
const grid = btn.nextElementSibling;
|
||||
const open = grid.classList.toggle('open');
|
||||
btn.textContent = open ? 'Hide Photos' : 'Show Photos';
|
||||
}
|
||||
|
||||
/* ── Full-size modal ────────────────────────────── */
|
||||
function openModal(src) {
|
||||
const modal = document.getElementById('photoModal');
|
||||
document.getElementById('modalImg').src = src + '?t=' + Date.now();
|
||||
modal.classList.add('open');
|
||||
}
|
||||
function closeModal() {
|
||||
document.getElementById('photoModal').classList.remove('open');
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
/* ── File picker + AJAX upload ──────────────────── */
|
||||
let pendingAp = null;
|
||||
let pendingSuffix = null;
|
||||
|
||||
function pickFile(ap, suffix) {
|
||||
pendingAp = ap;
|
||||
pendingSuffix = suffix;
|
||||
document.getElementById('fileInput').click();
|
||||
}
|
||||
|
||||
document.getElementById('fileInput').addEventListener('change', function() {
|
||||
if (!this.files.length || !pendingAp) return;
|
||||
const file = this.files[0];
|
||||
const fd = new FormData();
|
||||
fd.append('photo', file);
|
||||
|
||||
const url = '/photo/' + pendingAp + '/' + pendingSuffix + '/replace';
|
||||
fetch(url, { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
// Update thumbnail in-place
|
||||
const wrap = document.getElementById('thumb-' + pendingAp + '-' + pendingSuffix);
|
||||
if (wrap) {
|
||||
const cacheBuster = '?t=' + Date.now();
|
||||
const imgUrl = '/photo/' + pendingAp + '/' + pendingSuffix;
|
||||
wrap.innerHTML = '<img src="' + imgUrl + cacheBuster + '" loading="lazy" alt="Photo">';
|
||||
wrap.setAttribute('onclick', "openModal('" + imgUrl + "')");
|
||||
|
||||
// Switch "Upload" button to "Replace"
|
||||
const actions = wrap.nextElementSibling;
|
||||
if (actions) {
|
||||
const btn = actions.querySelector('button');
|
||||
if (btn && btn.classList.contains('btn-upload')) {
|
||||
btn.classList.remove('btn-upload');
|
||||
btn.classList.add('btn-replace');
|
||||
btn.textContent = 'Replace';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showToast(data.error || 'Upload failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('Network error', 'error'));
|
||||
|
||||
// Reset so the same file can be re-selected
|
||||
this.value = '';
|
||||
});
|
||||
|
||||
/* ── Toast notifications ────────────────────────── */
|
||||
function showToast(msg, type) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast ' + type + ' show';
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(() => t.classList.remove('show'), 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
681
templates/index.html
Normal file
681
templates/index.html
Normal file
@@ -0,0 +1,681 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
APtool — Main AP Data Collection Form (index.html)
|
||||
=============================================================================
|
||||
This is the primary page of the APtool application. Technicians use it to
|
||||
submit AP installation data (text fields + 6 photos) for the current site.
|
||||
|
||||
Page structure:
|
||||
1. Header — app title, site number badge, "Change site" link
|
||||
2. Text fields — AP Number, AP Location, Serial Number, Cable Length
|
||||
3. Photo section — 6 photo slots, each with "Take Photo" / "Upload Photo"
|
||||
4. Submit button — posts everything to /submit via AJAX (fetch API)
|
||||
5. Status bar — shows success (green) or error (red) messages
|
||||
|
||||
Key behaviors:
|
||||
- Photos use two hidden <input type="file"> elements per slot:
|
||||
* .cam-input — has capture="environment" to open the device camera
|
||||
* .gal-input — no capture attribute, opens the gallery/file picker
|
||||
- Selected files are stored in a JS object (selectedFiles) keyed by
|
||||
field name, so camera and gallery both feed into the same slot.
|
||||
- Form submission is handled via JavaScript fetch() to allow showing
|
||||
success/error messages without a full page reload.
|
||||
- On success, the form resets and all previews are cleared, ready for
|
||||
the next AP entry.
|
||||
|
||||
Template variables (passed from Flask):
|
||||
- site — the 4-digit site number from the session
|
||||
=============================================================================
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- Mobile-first: ensure proper scaling on phones/tablets -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>APtool - AP Data Collection</title>
|
||||
|
||||
<style>
|
||||
/* =================================================================
|
||||
Reset & Base Styles
|
||||
================================================================= */
|
||||
/* Use border-box sizing so padding doesn't expand element widths.
|
||||
Critical for mobile layouts where every pixel counts. */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* System font stack — uses the native font on each platform
|
||||
(San Francisco on iOS, Roboto on Android, Segoe UI on Windows) */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* App title — centered at top of page */
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Form Layout
|
||||
================================================================= */
|
||||
/* Center the form and cap its width for readability on larger screens */
|
||||
form {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Spacing between each text input group */
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Text Input Styles
|
||||
================================================================= */
|
||||
/* Bold labels above each input */
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Full-width text inputs with large touch-friendly padding (12px).
|
||||
Rounded corners (8px) for a modern mobile feel. */
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 8px;
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Blue focus ring — removes default outline and adds a visible
|
||||
blue border + glow so users know which field is active */
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Photo Section Styles
|
||||
================================================================= */
|
||||
/* Extra top margin to visually separate photos from text fields */
|
||||
.photo-section {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.photo-section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
/* Each photo slot is a white card with a border and rounded corners */
|
||||
.photo-field {
|
||||
margin-bottom: 14px;
|
||||
background: #16213e;
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.photo-field label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Photo Buttons — "Take Photo" and "Upload Photo"
|
||||
================================================================= */
|
||||
/* Flex row with equal-width buttons side by side */
|
||||
.photo-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Shared button styles — large touch targets (12px padding),
|
||||
rounded corners, bold text */
|
||||
.btn-camera, .btn-upload {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border: 2px solid;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* "Take Photo" — solid blue button (primary action) */
|
||||
.btn-camera {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Darker blue on tap/press for visual feedback */
|
||||
.btn-camera:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* "Upload Photo" — outlined blue button (secondary action) */
|
||||
.btn-upload {
|
||||
background: #16213e;
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* Light blue tint on tap/press */
|
||||
.btn-upload:active {
|
||||
background: #1a2744;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Photo Status & Preview
|
||||
================================================================= */
|
||||
/* Green text showing the selected filename — hidden until a file
|
||||
is picked via camera or gallery */
|
||||
.photo-status {
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: #059669;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Thumbnail preview of the selected photo — hidden until loaded */
|
||||
.photo-field .preview {
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
max-height: 150px;
|
||||
display: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* The actual <input type="file"> elements are hidden — users interact
|
||||
with the styled "Take Photo" / "Upload Photo" buttons instead */
|
||||
.photo-field input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Submit Button
|
||||
================================================================= */
|
||||
/* Full-width blue button at the bottom of the form.
|
||||
Large padding (16px) for easy tapping on mobile. */
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Darker on tap */
|
||||
button[type="submit"]:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Disabled state while uploading — lighter blue, no pointer cursor */
|
||||
button[type="submit"]:disabled {
|
||||
background: #93c5fd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Status Message Bar
|
||||
================================================================= */
|
||||
/* Hidden by default. Shown after form submission with either
|
||||
.success (green) or .error (red) class applied by JavaScript. */
|
||||
#status {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Green success banner */
|
||||
#status.success {
|
||||
display: block;
|
||||
background: #064e3b;
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
/* Red error banner */
|
||||
#status.error {
|
||||
display: block;
|
||||
background: #3b1111;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Toast notifications ─────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
z-index: 2000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.success { background: #166534; color: #bbf7d0; }
|
||||
.toast.error { background: #7f1d1d; color: #fca5a5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- ================================================================
|
||||
Header — App title, site badge, and change-site link
|
||||
================================================================ -->
|
||||
<div style="text-align:center;color:#999;font-size:0.8rem;margin-bottom:8px;">JCP Wifi Migration 2026</div>
|
||||
<h1>APtool</h1>
|
||||
<!-- Site badge: shows the current 4-digit site number from the session.
|
||||
"Change site" links to /logout which clears the session and
|
||||
redirects back to the login page. -->
|
||||
<div style="text-align:center;margin-bottom:16px;">
|
||||
<span style="background:#1e3a5f;color:#93c5fd;padding:6px 14px;border-radius:6px;font-weight:600;font-size:0.95rem;">
|
||||
Site {{ site }}
|
||||
</span>
|
||||
<a href="/logout" style="margin-left:12px;color:#999;font-size:0.85rem;">Change site</a>
|
||||
</div>
|
||||
<div style="text-align:center;margin-bottom:20px;">
|
||||
<a href="/entries" style="color:#93c5fd;font-size:0.9rem;text-decoration:none;border:1px solid #2a2a4a;padding:8px 16px;border-radius:6px;background:#16213e;">View Submitted APs</a>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
Main Form
|
||||
================================================================
|
||||
enctype="multipart/form-data" is required for file uploads.
|
||||
The form is NOT submitted natively — JavaScript intercepts the
|
||||
submit event and sends via fetch() for a smoother UX. -->
|
||||
<form id="apForm" enctype="multipart/form-data">
|
||||
|
||||
<!-- ============================================================
|
||||
Text Fields — AP metadata
|
||||
============================================================ -->
|
||||
|
||||
<!-- AP Number: exactly 3 digits, used in photo filenames (e.g. "001" → AP001.jpg) -->
|
||||
<div class="field">
|
||||
<label for="ap_number">AP Number</label>
|
||||
<input type="text" id="ap_number" name="ap_number" required
|
||||
placeholder="e.g. 001" pattern="\d{3}" maxlength="3"
|
||||
inputmode="numeric" title="Exactly 3 digits"
|
||||
spellcheck="false" autocorrect="off" autocapitalize="off">
|
||||
</div>
|
||||
|
||||
<!-- AP Location: freeform text describing where the AP is installed -->
|
||||
<div class="field">
|
||||
<label for="ap_location">AP Location</label>
|
||||
<input type="text" id="ap_location" name="ap_location" required placeholder="e.g. Building A, 2nd Floor"
|
||||
spellcheck="false" autocorrect="off">
|
||||
</div>
|
||||
|
||||
<!-- Serial Number: format xxxx-xxxx-xxxx (alphanumeric) -->
|
||||
<div class="field">
|
||||
<label for="serial_number">Serial Number</label>
|
||||
<input type="text" id="serial_number" name="serial_number" required
|
||||
placeholder="e.g. AB12-CD34-EF56" pattern="[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}"
|
||||
title="Format: xxxx-xxxx-xxxx"
|
||||
spellcheck="false" autocorrect="off" autocapitalize="characters">
|
||||
</div>
|
||||
|
||||
<!-- MAC Address: 6 hex pairs separated by colons or dashes -->
|
||||
<div class="field">
|
||||
<label for="mac_address">MAC Address</label>
|
||||
<input type="text" id="mac_address" name="mac_address" required
|
||||
placeholder="e.g. AA:BB:CC:DD:EE:FF"
|
||||
pattern="([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}"
|
||||
title="Format: AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF"
|
||||
spellcheck="false" autocorrect="off" autocapitalize="characters">
|
||||
</div>
|
||||
|
||||
<!-- Cable Length: the measured cable run length -->
|
||||
<div class="field">
|
||||
<label for="cable_length">Cable Length</label>
|
||||
<input type="text" id="cable_length" name="cable_length" required placeholder="e.g. 15m"
|
||||
spellcheck="false" autocorrect="off">
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Photo Section — 6 required photos per AP
|
||||
============================================================
|
||||
Each photo slot has:
|
||||
- data-name attribute: the form field name sent to the server
|
||||
- .cam-input: hidden file input WITH capture="environment"
|
||||
(opens device camera directly on mobile)
|
||||
- .gal-input: hidden file input WITHOUT capture
|
||||
(opens gallery / file picker)
|
||||
- Two visible buttons that trigger the hidden inputs
|
||||
- .photo-status: shows the selected filename
|
||||
- .preview: thumbnail of the selected image
|
||||
-->
|
||||
<div class="photo-section">
|
||||
<h2>Photos</h2>
|
||||
|
||||
<!-- Photo 1: Close-up of the installed AP → filename: APxxx.jpg -->
|
||||
<div class="photo-field" data-name="photo_ap">
|
||||
<label>AP Close-up (APxxx)</label>
|
||||
<input type="file" class="cam-input" accept="image/*" capture="environment">
|
||||
<input type="file" class="gal-input" accept="image/*">
|
||||
<div class="photo-buttons">
|
||||
<button type="button" class="btn-camera">Take Photo</button>
|
||||
<button type="button" class="btn-upload">Upload Photo</button>
|
||||
</div>
|
||||
<div class="photo-status"></div>
|
||||
<img class="preview" alt="Preview">
|
||||
</div>
|
||||
|
||||
<!-- Photo 2: Distant/wide view of the AP → filename: APxxxF.jpg -->
|
||||
<div class="photo-field" data-name="photo_far">
|
||||
<label>AP Distant View (APxxxF)</label>
|
||||
<input type="file" class="cam-input" accept="image/*" capture="environment">
|
||||
<input type="file" class="gal-input" accept="image/*">
|
||||
<div class="photo-buttons">
|
||||
<button type="button" class="btn-camera">Take Photo</button>
|
||||
<button type="button" class="btn-upload">Upload Photo</button>
|
||||
</div>
|
||||
<div class="photo-status"></div>
|
||||
<img class="preview" alt="Preview">
|
||||
</div>
|
||||
|
||||
<!-- Photo 3: Cable length measurement → filename: APxxx_length.jpg -->
|
||||
<div class="photo-field" data-name="photo_length">
|
||||
<label>Cable Length (APxxx_length)</label>
|
||||
<input type="file" class="cam-input" accept="image/*" capture="environment">
|
||||
<input type="file" class="gal-input" accept="image/*">
|
||||
<div class="photo-buttons">
|
||||
<button type="button" class="btn-camera">Take Photo</button>
|
||||
<button type="button" class="btn-upload">Upload Photo</button>
|
||||
</div>
|
||||
<div class="photo-status"></div>
|
||||
<img class="preview" alt="Preview">
|
||||
</div>
|
||||
|
||||
<!-- Photo 4: Continuity test result → filename: APxxx_cont.jpg -->
|
||||
<div class="photo-field" data-name="photo_cont">
|
||||
<label>Continuity Test (APxxx_cont)</label>
|
||||
<input type="file" class="cam-input" accept="image/*" capture="environment">
|
||||
<input type="file" class="gal-input" accept="image/*">
|
||||
<div class="photo-buttons">
|
||||
<button type="button" class="btn-camera">Take Photo</button>
|
||||
<button type="button" class="btn-upload">Upload Photo</button>
|
||||
</div>
|
||||
<div class="photo-status"></div>
|
||||
<img class="preview" alt="Preview">
|
||||
</div>
|
||||
|
||||
<!-- Photo 5: Speed test result → filename: APxxx_rate.jpg -->
|
||||
<div class="photo-field" data-name="photo_rate">
|
||||
<label>Speed Test (APxxx_rate)</label>
|
||||
<input type="file" class="cam-input" accept="image/*" capture="environment">
|
||||
<input type="file" class="gal-input" accept="image/*">
|
||||
<div class="photo-buttons">
|
||||
<button type="button" class="btn-camera">Take Photo</button>
|
||||
<button type="button" class="btn-upload">Upload Photo</button>
|
||||
</div>
|
||||
<div class="photo-status"></div>
|
||||
<img class="preview" alt="Preview">
|
||||
</div>
|
||||
|
||||
<!-- Photo 6: Box label / packaging → filename: APxxx_box.jpg -->
|
||||
<div class="photo-field" data-name="photo_box">
|
||||
<label>Box Label (APxxx_box)</label>
|
||||
<input type="file" class="cam-input" accept="image/*" capture="environment">
|
||||
<input type="file" class="gal-input" accept="image/*">
|
||||
<div class="photo-buttons">
|
||||
<button type="button" class="btn-camera">Take Photo</button>
|
||||
<button type="button" class="btn-upload">Upload Photo</button>
|
||||
</div>
|
||||
<div class="photo-status"></div>
|
||||
<img class="preview" alt="Preview">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit button — triggers JavaScript form handler below -->
|
||||
<button type="submit">Submit AP Data</button>
|
||||
</form>
|
||||
|
||||
<!-- ================================================================
|
||||
Status Message
|
||||
================================================================
|
||||
Hidden by default. After submission, JavaScript adds either the
|
||||
"success" or "error" class to show a colored banner with the
|
||||
server's response message. -->
|
||||
<div id="status"></div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<div style="text-align:center;margin-top:32px;">
|
||||
<a href="/admin/login" style="color:#666;font-size:0.8rem;text-decoration:none;">Admin</a>
|
||||
<div style="color:#555;font-size:0.7rem;margin-top:12px;">© 2026 Mack Allison, sdAnywhere LLC (with Claude Code)</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ==================================================================
|
||||
// Photo Selection Logic
|
||||
// ==================================================================
|
||||
// We store selected File objects in this dictionary, keyed by the
|
||||
// photo field name (e.g. "photo_ap", "photo_far", etc.).
|
||||
// This is needed because each photo slot has TWO hidden file inputs
|
||||
// (camera + gallery), and we need to track which file the user
|
||||
// most recently selected regardless of which input they used.
|
||||
const selectedFiles = {};
|
||||
|
||||
// Set up event listeners for each photo slot
|
||||
document.querySelectorAll('.photo-field').forEach(field => {
|
||||
// data-name attribute holds the form field name (e.g. "photo_ap")
|
||||
const name = field.dataset.name;
|
||||
|
||||
// The two hidden file inputs:
|
||||
// .cam-input — has capture="environment", opens device camera
|
||||
// .gal-input — no capture, opens file picker / gallery
|
||||
const camInput = field.querySelector('.cam-input');
|
||||
const galInput = field.querySelector('.gal-input');
|
||||
|
||||
// The two visible buttons that trigger the hidden inputs
|
||||
const btnCamera = field.querySelector('.btn-camera');
|
||||
const btnUpload = field.querySelector('.btn-upload');
|
||||
|
||||
// Preview elements
|
||||
const preview = field.querySelector('.preview');
|
||||
const statusEl = field.querySelector('.photo-status');
|
||||
|
||||
// Wire buttons to their respective hidden file inputs.
|
||||
// Clicking the button programmatically opens the file dialog.
|
||||
btnCamera.addEventListener('click', () => camInput.click());
|
||||
btnUpload.addEventListener('click', () => galInput.click());
|
||||
|
||||
// Handle file selection from either input (camera or gallery).
|
||||
// Stores the file, shows the filename, and renders a thumbnail.
|
||||
function onFileSelected(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
// Store the File object for later inclusion in FormData
|
||||
selectedFiles[name] = input.files[0];
|
||||
|
||||
// Show the filename in green text below the buttons
|
||||
statusEl.textContent = input.files[0].name;
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
// Generate and display a thumbnail preview using FileReader.
|
||||
// readAsDataURL converts the file to a base64 data URL that
|
||||
// can be set as the <img> src for instant preview.
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
preview.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for changes on both inputs — whichever the user picks
|
||||
// from last is the file that will be submitted for this slot
|
||||
camInput.addEventListener('change', () => onFileSelected(camInput));
|
||||
galInput.addEventListener('change', () => onFileSelected(galInput));
|
||||
});
|
||||
|
||||
// ==================================================================
|
||||
// Form Submission Handler
|
||||
// ==================================================================
|
||||
// Intercepts the native form submit, validates that all 6 photos
|
||||
// are selected, builds a FormData object manually (because the
|
||||
// file inputs are hidden and not named), and POSTs to /submit
|
||||
// via fetch(). Displays success or error feedback without a
|
||||
// page reload.
|
||||
document.getElementById('apForm').addEventListener('submit', async function (e) {
|
||||
// Prevent native form submission — we handle it via fetch()
|
||||
e.preventDefault();
|
||||
|
||||
const status = document.getElementById('status');
|
||||
const btn = this.querySelector('button[type="submit"]');
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Client-side validation: ensure all 6 photos are selected
|
||||
// ----------------------------------------------------------
|
||||
const photoNames = ['photo_ap', 'photo_far', 'photo_length', 'photo_cont', 'photo_rate', 'photo_box'];
|
||||
for (const name of photoNames) {
|
||||
if (!selectedFiles[name]) {
|
||||
status.textContent = 'Please capture or upload all 6 photos.';
|
||||
status.style.display = '';
|
||||
status.className = 'error';
|
||||
return; // Stop — don't submit
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Prepare UI for upload: hide previous status, disable button
|
||||
// ----------------------------------------------------------
|
||||
status.style.display = 'none';
|
||||
status.className = '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Uploading...';
|
||||
|
||||
try {
|
||||
// ----------------------------------------------------------
|
||||
// Build FormData manually
|
||||
// ----------------------------------------------------------
|
||||
// We can't rely on native FormData(form) because the photo
|
||||
// file inputs are hidden and unnamed. Instead we explicitly
|
||||
// append each text field and each stored File object.
|
||||
const formData = new FormData();
|
||||
|
||||
// Text fields — pulled directly from the DOM inputs
|
||||
formData.append('ap_number', document.getElementById('ap_number').value);
|
||||
formData.append('ap_location', document.getElementById('ap_location').value);
|
||||
formData.append('serial_number', document.getElementById('serial_number').value);
|
||||
formData.append('mac_address', document.getElementById('mac_address').value);
|
||||
formData.append('cable_length', document.getElementById('cable_length').value);
|
||||
|
||||
// Photo files — pulled from the selectedFiles dictionary
|
||||
for (const name of photoNames) {
|
||||
formData.append(name, selectedFiles[name]);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// POST to /submit
|
||||
// ----------------------------------------------------------
|
||||
// The server saves files locally, appends to Excel, syncs
|
||||
// to Nextcloud, and returns a JSON response.
|
||||
const resp = await fetch('/submit', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
// ----------------------------------------------------------
|
||||
// Success: show toast notification, reset form for next AP
|
||||
// ----------------------------------------------------------
|
||||
showToast(data.message, 'success');
|
||||
|
||||
// Reset all form inputs to blank
|
||||
this.reset();
|
||||
|
||||
// Hide all photo previews and filename labels
|
||||
document.querySelectorAll('.preview').forEach(p => p.style.display = 'none');
|
||||
document.querySelectorAll('.photo-status').forEach(s => s.style.display = 'none');
|
||||
|
||||
// Clear the stored File objects so validation works
|
||||
// correctly for the next submission
|
||||
for (const name of photoNames) {
|
||||
delete selectedFiles[name];
|
||||
}
|
||||
} else {
|
||||
// ----------------------------------------------------------
|
||||
// Server returned an error — show red banner
|
||||
// ----------------------------------------------------------
|
||||
status.textContent = data.error || 'Submission failed.';
|
||||
status.style.display = '';
|
||||
status.className = 'error';
|
||||
}
|
||||
} catch (err) {
|
||||
// ----------------------------------------------------------
|
||||
// Network error (server unreachable, timeout, etc.)
|
||||
// ----------------------------------------------------------
|
||||
status.textContent = 'Network error. Please try again.';
|
||||
status.style.display = '';
|
||||
status.className = 'error';
|
||||
} finally {
|
||||
// ----------------------------------------------------------
|
||||
// Re-enable the submit button regardless of outcome
|
||||
// ----------------------------------------------------------
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit AP Data';
|
||||
}
|
||||
});
|
||||
|
||||
// ==================================================================
|
||||
// Toast Notification Helper
|
||||
// ==================================================================
|
||||
// ==================================================================
|
||||
// Unsaved Data Warning
|
||||
// ==================================================================
|
||||
function hasFormData() {
|
||||
const fields = ['ap_number', 'ap_location', 'serial_number', 'mac_address', 'cable_length'];
|
||||
for (const id of fields) {
|
||||
if (document.getElementById(id).value.trim()) return true;
|
||||
}
|
||||
return Object.keys(selectedFiles).length > 0;
|
||||
}
|
||||
|
||||
document.querySelectorAll('a').forEach(function (link) {
|
||||
link.addEventListener('click', function (e) {
|
||||
if (hasFormData() && !confirm('You have unsaved data. Leave this page?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showToast(msg, type) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast ' + type + ' show';
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(() => t.classList.remove('show'), 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
209
templates/login.html
Normal file
209
templates/login.html
Normal file
@@ -0,0 +1,209 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
APtool — Login Page (login.html)
|
||||
=============================================================================
|
||||
This is the first page a technician sees. It collects two pieces of info:
|
||||
|
||||
1. Site Number (4 digits) — identifies which job site the tech is at.
|
||||
Stored in the session and used to organize all files into per-site
|
||||
folders (e.g. uploads/5001/, Nextcloud APtool/5001/).
|
||||
|
||||
2. PIN — the site-specific PIN code.
|
||||
Validated server-side against sites.conf (each site has its own PIN).
|
||||
|
||||
On successful login:
|
||||
- session["authenticated"] = True
|
||||
- session["site"] = "5001"
|
||||
- Redirects to / (the main data collection form)
|
||||
|
||||
On failure:
|
||||
- Re-renders this page with an error message (wrong PIN or bad site format)
|
||||
|
||||
Template variables (passed from Flask):
|
||||
- error — error message string, or None if no error
|
||||
|
||||
Design:
|
||||
- Centered card layout, vertically and horizontally centered on screen
|
||||
- Large, touch-friendly inputs with numeric keyboard hints (inputmode)
|
||||
- Site number input uses pattern="[0-9]{4}" for client-side validation
|
||||
- PIN input uses type="password" to hide the digits
|
||||
=============================================================================
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- Mobile-first: ensure proper scaling on phones/tablets -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>APtool - Login</title>
|
||||
|
||||
<style>
|
||||
/* =================================================================
|
||||
Reset & Base Styles
|
||||
================================================================= */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Full-viewport centered layout using flexbox.
|
||||
The body acts as the flex container to vertically and
|
||||
horizontally center the login card on all screen sizes. */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Login Card
|
||||
================================================================= */
|
||||
/* White card with shadow — max 320px wide so it looks good on
|
||||
both phones (full width with padding) and desktops (compact card) */
|
||||
.login-box {
|
||||
background: #16213e;
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* App title in the card */
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
/* "Enter site number and PIN" subtitle */
|
||||
.subtitle {
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
PIN Input
|
||||
================================================================= */
|
||||
/* Large centered text with letter spacing to mimic a PIN entry UI.
|
||||
type="password" hides the digits as they are typed.
|
||||
inputmode="numeric" brings up the number keyboard on mobile. */
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
letter-spacing: 0.5em;
|
||||
border: 2px solid #2a2a4a;
|
||||
border-radius: 8px;
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
-moz-appearance: textfield; /* Hide Firefox number spinner */
|
||||
}
|
||||
|
||||
/* Blue focus ring on the PIN input */
|
||||
input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
/* Hide the increment/decrement spinner arrows that some browsers
|
||||
show on inputs with inputmode="numeric" */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Unlock Button
|
||||
================================================================= */
|
||||
/* Full-width blue button — same style as the main form's submit.
|
||||
Large padding for easy tapping on mobile. */
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Darker blue on tap/press */
|
||||
button:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Error Message
|
||||
================================================================= */
|
||||
/* Red banner shown below the form when login fails.
|
||||
Only rendered if Flask passes a non-None error variable. */
|
||||
.error {
|
||||
color: #fca5a5;
|
||||
background: #3b1111;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- ================================================================
|
||||
Login Card
|
||||
================================================================ -->
|
||||
<div class="login-box">
|
||||
<div style="color:#999;font-size:0.8rem;margin-bottom:8px;">JCP Wifi Migration 2026</div>
|
||||
<h1>APtool</h1>
|
||||
<p class="subtitle">Enter site number and PIN</p>
|
||||
|
||||
<!-- Login form — POSTs to /login which validates and sets session -->
|
||||
<form method="POST" action="/login">
|
||||
|
||||
<!-- Site Number Input
|
||||
- type="text" with inputmode="numeric" to get the number
|
||||
keyboard on mobile while allowing leading zeros
|
||||
- autofocus opens the keyboard immediately on mobile
|
||||
- Validated server-side against sites.conf
|
||||
- Inline styles match the password input's look & feel -->
|
||||
<input type="text" name="site" inputmode="numeric"
|
||||
placeholder="Site Number" required autofocus
|
||||
autocomplete="off" style="width:100%;padding:14px;font-size:1.2rem;text-align:center;
|
||||
letter-spacing:0.3em;border:2px solid #2a2a4a;border-radius:8px;margin-bottom:12px;background:#0f3460;color:#e0e0e0;">
|
||||
|
||||
<!-- PIN Input
|
||||
- type="password" hides the entered digits
|
||||
- inputmode="numeric" brings up the number keyboard
|
||||
- Validated server-side against the site's PIN in sites.conf -->
|
||||
<input type="password" name="pin" inputmode="numeric" pattern="[0-9]*"
|
||||
placeholder="PIN" required autocomplete="off">
|
||||
|
||||
<button type="submit">Unlock</button>
|
||||
</form>
|
||||
|
||||
<!-- Error message — only shown if Flask passes error != None.
|
||||
Uses Jinja2 "if" template syntax. -->
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:16px;">
|
||||
<a href="/admin/login" style="color:#666;font-size:0.8rem;text-decoration:none;">Admin Login</a>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:24px;color:#555;font-size:0.7rem;">© 2026 Mack Allison, sdAnywhere LLC (with Claude Code)</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user