Initial commit
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user