Files
aptool/templates/index.html
2026-01-26 04:46:56 -06:00

682 lines
30 KiB
HTML

<!--
=============================================================================
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>