Initial commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
uploads/
|
||||||
|
.git/
|
||||||
|
*.swp
|
||||||
|
.env
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
NC_URL=https://nextcloud.example.com
|
||||||
|
NC_USER=admin
|
||||||
|
NC_PASS=changeme
|
||||||
|
NC_FOLDER=APtool
|
||||||
|
SECRET_KEY=changeme-generate-a-random-string
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
uploads/
|
||||||
|
*.swp
|
||||||
|
.env
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
COPY templates/ templates/
|
||||||
|
COPY sites.conf .
|
||||||
|
|
||||||
|
RUN mkdir -p uploads
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "--access-logfile", "-", "app:app"]
|
||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
aptool:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- ./sites.conf:/app/sites.conf
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
flask
|
||||||
|
gunicorn
|
||||||
|
openpyxl
|
||||||
|
requests
|
||||||
25
sites.conf
Normal file
25
sites.conf
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# APtool Site Configuration
|
||||||
|
#
|
||||||
|
# Each line defines a site that technicians can log into.
|
||||||
|
# Format: site_number:pin[:nc_user:nc_pass]
|
||||||
|
#
|
||||||
|
# - site_number must be exactly 4 digits
|
||||||
|
# - pin can be any string (digits recommended for mobile entry)
|
||||||
|
# - nc_user and nc_pass are optional Nextcloud credentials for this site
|
||||||
|
# If omitted, the global NC_USER / NC_PASS defaults are used.
|
||||||
|
# - blank lines and lines starting with # are ignored
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# 5001:1234 (uses global Nextcloud credentials)
|
||||||
|
# 5002:5678:alice:AppPass-12345 (uses per-site Nextcloud user "alice")
|
||||||
|
# 9999:0000:bob:AppPass-67890 (uses per-site Nextcloud user "bob")
|
||||||
|
#
|
||||||
|
# To add a site: add a new line with site_number:pin[:nc_user:nc_pass]
|
||||||
|
# To remove a site: delete or comment out the line
|
||||||
|
# To change a PIN: edit the pin after the colon
|
||||||
|
#
|
||||||
|
# The app reloads this file on every login attempt, so changes
|
||||||
|
# take effect immediately — no restart needed.
|
||||||
|
|
||||||
|
1234:1234:1234:railFocus11
|
||||||
|
2725:2725:2725:makeBiscuits
|
||||||
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