Initial commit

This commit is contained in:
kamaji
2026-01-26 04:46:56 -06:00
commit 9f45ed3452
13 changed files with 3024 additions and 0 deletions

500
templates/admin.html Normal file
View 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;">&copy; 2026 Mack Allison, sdAnywhere LLC (with Claude Code)</div>
</body>
</html>

132
templates/admin_login.html Normal file
View 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;">&copy; 2026 Mack Allison, sdAnywhere LLC (with Claude Code)</div>
</body>
</html>

388
templates/entries.html Normal file
View 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;">&copy; 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()">&times;</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
View 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;">&copy; 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
View 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;">&copy; 2026 Mack Allison, sdAnywhere LLC (with Claude Code)</div>
</body>
</html>