Files
LT_Janitorial_Quality_Control/app/templates/inspections/execute.html
T
2026-02-27 11:35:58 -05:00

599 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}{{ inspection.template.name }} — Inspection{% endblock %}
{% block extra_css %}
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'DM Sans', sans-serif; background: #eef0f4; }
.insp-wrap { max-width: 1000px; margin: 0 auto; padding: 0 1rem 4rem; }
.insp-header {
background: #1a1d23; color: #fff;
padding: 1.1rem 1.75rem;
border-radius: 12px 12px 0 0;
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
}
.insp-header h4 { margin:0; font-weight:600; font-size:1.05rem; }
.insp-header .sub{ font-size:.78rem; color:#94a3b8; margin-top:.2rem; }
.freq-badge {
font-size:.72rem; background:rgba(255,255,255,.15);
padding:.28rem .65rem; border-radius:20px; font-weight:500; white-space:nowrap;
}
.insp-body {
background:#fff; border:1px solid #e2e8f0; border-top:none;
border-radius: 0 0 12px 12px; padding:1.75rem; overflow-x:auto;
-webkit-overflow-scrolling: touch;
}
/* ── 12-col grid (matches form editor exactly) ── */
.form-grid {
display: grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: 52px; /* matches editor CELL_H — keeps proportions tight */
gap: 4px 8px;
width: max-content;
}
/* ── iPad / touch-device fluid grid override ── */
@media (max-width: 1194px) {
.form-grid {
--_gap: 8px;
--_cell: calc((min(calc(100vw - 2rem), 900px) - 11 * 8px) / 12);
grid-template-columns: repeat(12, var(--_cell));
grid-auto-rows: calc(var(--_cell) * 0.72);
width: 100%;
}
.insp-body { padding: 1rem; }
}
.fg-cell {
overflow:hidden; display:flex; flex-direction:column; padding:.18rem .45rem;
}
.fg-cell .field-lbl {
font-size:.74rem; font-weight:500; color:#64748b;
margin-bottom:.2rem; display:block;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex-shrink:0;
}
.required-mark { color:#dc2626; }
.help-text { font-size:.72rem; color:#64748b; margin-top:.18rem; flex-shrink:0; }
.field-error { font-size:.72rem; color:#dc2626; margin-top:.18rem; }
.fg-cell .form-control,
.fg-cell .form-select {
font-size:.76rem; padding:.2rem .4rem;
border-color:#e2e8f0; background:#f8fafc;
}
.fg-cell .form-control:focus,
.fg-cell .form-select:focus { border-color:#2563eb; background:#fff; box-shadow:none; }
.fg-cell textarea.form-control { resize:none; flex:1; min-height:0; }
.fg-cell .form-check-label { font-size:.76rem; color:#64748b; }
.fg-cell .form-check-input { margin-top:.18rem; }
.fg-cell .form-check { margin-bottom:.1rem; }
.upload-zone {
border:2px dashed #cbd5e1; border-radius:6px; flex:1; min-height:0;
display:flex; flex-direction:column; align-items:center; justify-content:center;
color:#64748b; background:#f8fafc; cursor:pointer; font-size:.74rem; gap:.2rem;
transition:border-color .18s, background .18s; position:relative;
}
.upload-zone:hover { border-color:#2563eb; background:#eff6ff; color:#2563eb; }
.upload-zone input[type=file] {
position:absolute; inset:0; opacity:0; cursor:pointer; width:100%; height:100%;
}
.upload-zone i { font-size:1.1rem; }
.upload-zone .file-name { font-size:.7rem; color:#2563eb; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.signature-pad-wrap {
flex:1; min-height:0; border-radius:6px; border:1px solid #e2e8f0;
background:#f8fafc; position:relative; overflow:hidden;
}
.signature-pad-wrap canvas { width:100%; height:100%; cursor:crosshair; display:block; }
.sig-clear {
position:absolute; top:4px; right:4px;
font-size:.65rem; padding:.15rem .4rem;
background:rgba(255,255,255,.85); border:1px solid #cbd5e1; border-radius:4px;
cursor:pointer; color:#64748b;
}
.sig-clear:hover { color:#dc2626; border-color:#dc2626; }
.rating-stars { display:flex; flex-direction:row; flex-wrap:nowrap; gap:.2rem; align-items:center; padding-top:.05rem; }
.rating-stars button {
border:none; background:none; font-size:1.2rem; color:#cbd5e1;
cursor:pointer; padding:0; line-height:1; transition:color .1s; flex-shrink:0;
}
.rating-stars button:hover,
.rating-stars button.on { color:#f59e0b; }
.section-divider {
height:100%; display:flex; align-items:center; padding:0;
border-top:2px solid #e2e8f0; padding-top:.4rem;
}
.section-divider strong { font-weight:700; color:#374151; font-size:.95rem; }
.tbl-field { width:100%; border-collapse:collapse; font-size:.8rem; }
.tbl-field th {
background:#f1f5f9; font-weight:600; color:#374151;
padding:.3rem .5rem; border:1px solid #e2e8f0; white-space:nowrap; font-size:.78rem;
}
.tbl-field td { border:1px solid #e2e8f0; padding:.15rem .25rem; }
.tbl-input { width:100%; border:none; outline:none; font-size:.8rem; padding:.1rem .2rem; background:transparent; }
.tbl-input:focus { background:#eff6ff; }
/* existing photo thumbnail */
.photo-thumb { max-height:60px; border-radius:4px; margin-bottom:.25rem; }
/* sticky footer bar */
.insp-footer {
position:sticky; bottom:0; background:#1a1d23;
border-radius:0 0 12px 12px;
padding:.75rem 1.75rem;
display:flex; align-items:center; justify-content:space-between; gap:1rem;
margin-top:1.5rem;
}
.insp-footer .meta { color:#94a3b8; font-size:.8rem; }
.insp-footer .meta strong { color:#e2e8f0; }
/* iPad: footer stacks vertically, full-width buttons */
@media (max-width: 1024px) {
.insp-footer {
flex-direction: column;
align-items: stretch;
padding: .75rem 1rem;
gap: .5rem;
}
.insp-footer .btn { width: 100%; justify-content: center; }
.insp-footer .meta { text-align: center; }
/* Header: stack on narrow screens */
.insp-header {
flex-direction: column;
align-items: flex-start;
gap: .75rem;
padding: 1rem;
}
.insp-header .d-flex.gap-2 { width: 100%; flex-wrap: wrap; }
.insp-header .btn { flex: 1; justify-content: center; }
.insp-wrap { padding: 0 .5rem 5rem; }
}
/* Touch: ensure 44px tap targets on footer buttons */
@media (pointer: coarse) {
.insp-footer .btn { min-height: 48px; font-size: 1rem; }
}
</style>
{% endblock %}
{% block content %}
<div class="insp-wrap mt-3">
<form method="post" enctype="multipart/form-data" id="inspectionForm" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# ── Header ── #}
<div class="insp-header">
<div>
<h4><i class="bi bi-clipboard-check"></i> {{ inspection.template.name }}</h4>
<div class="sub">
{{ inspection.facility.name }}
{% if inspection.area %} · {{ inspection.area.name }}{% endif %}
&nbsp;·&nbsp; Inspector: <strong style="color:#e2e8f0;">{{ inspection.inspector.username }}</strong>
</div>
</div>
<div class="d-flex gap-2 align-items-center">
<span class="freq-badge">{{ inspection.template.frequency|title }}</span>
<a href="{{ url_for('inspections.flag_issue', inspection_id=inspection.id) }}"
class="btn btn-sm btn-outline-danger btn-outline-light">
<i class="bi bi-exclamation-triangle"></i> Flag Issue
</a>
</div>
</div>
{# ── Form body ── #}
<div class="insp-body">
{% if form_fields %}
<div class="form-grid">
{% for field in form_fields %}
{% set fid = field.id %}
{% set saved = saved_responses.get(fid, '') %}
<div class="fg-cell"
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
grid-row: {{ field.row }} / span {{ field.rowSpan }};">
{# ── Section label (display only) ── #}
{% if field.type == 'section' %}
<div class="section-divider"><strong>{{ field.label }}</strong></div>
{# ── Static label ── #}
{% elif field.type == 'label' %}
{% set fs_map = {'small':'0.78rem','normal':'0.9rem','large':'1.05rem','x-large':'1.25rem'} %}
<div style="font-size:{{ fs_map.get(field.font_size or 'normal','0.9rem') }};
font-weight:{{ field.font_weight or 'normal' }};
color:#374151;line-height:1.5;white-space:pre-wrap;overflow:hidden;height:100%;">
{{ field.text_content or '' }}
</div>
{# ── Submit / Print / Email buttons (display in preview; no action needed here) ── #}
{% elif field.type in ('button_submit','button_print','button_email') %}
{# rendered by the sticky footer instead #}
{# ── Text ── #}
{% elif field.type == 'text' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="text" class="form-control" name="field_{{ fid }}"
value="{{ saved }}" placeholder="{{ field.placeholder or '' }}"
{% if field.required %}required{% endif %}>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Textarea ── #}
{% elif field.type == 'textarea' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<textarea class="form-control" name="field_{{ fid }}"
placeholder="{{ field.placeholder or '' }}"
{% if field.required %}required{% endif %}>{{ saved }}</textarea>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Number ── #}
{% elif field.type == 'number' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="number" class="form-control" name="field_{{ fid }}"
value="{{ saved }}" placeholder="{{ field.placeholder or '' }}"
{% if field.required %}required{% endif %}>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Date ── #}
{% elif field.type == 'date' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="date" class="form-control" name="field_{{ fid }}"
value="{{ saved }}" {% if field.required %}required{% endif %}>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Email ── #}
{% elif field.type == 'email' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="email" class="form-control" name="field_{{ fid }}"
value="{{ saved }}" placeholder="{{ field.placeholder or 'name@example.com' }}"
{% if field.required %}required{% endif %}>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Checkbox ── #}
{% elif field.type == 'checkbox' %}
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" name="field_{{ fid }}"
id="f_{{ fid }}" value="true" {{ 'checked' if saved == 'true' }}>
<label class="form-check-label" for="f_{{ fid }}">
{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}
</label>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Checkbox group ── #}
{% elif field.type == 'checkbox_group' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% for opt in field.options %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="field_{{ fid }}"
id="cg_{{ fid }}_{{ loop.index0 }}" value="{{ opt }}"
{{ 'checked' if opt in (saved if saved is iterable and saved is not string else []) }}>
<label class="form-check-label" for="cg_{{ fid }}_{{ loop.index0 }}">{{ opt }}</label>
</div>
{% endfor %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Radio ── #}
{% elif field.type == 'radio' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% for opt in field.options %}
<div class="form-check">
<input class="form-check-input" type="radio" name="field_{{ fid }}"
id="rg_{{ fid }}_{{ loop.index0 }}" value="{{ opt }}"
{{ 'checked' if saved == opt }}>
<label class="form-check-label" for="rg_{{ fid }}_{{ loop.index0 }}">{{ opt }}</label>
</div>
{% endfor %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Select / Dropdown ── #}
{% elif field.type == 'select' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<select class="form-select" name="field_{{ fid }}" {% if field.required %}required{% endif %}>
<option value="">— Select —</option>
{% for opt in field.options %}
<option value="{{ opt }}" {{ 'selected' if saved == opt }}>{{ opt }}</option>
{% endfor %}
</select>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Rating (stars) ── #}
{% elif field.type == 'rating' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="rating-stars" data-rating="{{ saved or 0 }}">
<input type="hidden" name="field_{{ fid }}" id="rating_input_{{ fid }}" value="{{ saved or 0 }}">
{% for i in range(1, 6) %}
<button type="button" data-val="{{ i }}" data-target="rating_input_{{ fid }}"
class="{{ 'on' if (saved|int) >= i }}"
onclick="setRating(this)"></button>
{% endfor %}
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Image / Photo upload ── #}
{% elif field.type == 'image' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% if saved %}
<img src="{{ url_for('static', filename=saved) }}" class="photo-thumb" alt="Uploaded photo">
{% endif %}
<div class="upload-zone">
<input type="file" name="field_{{ fid }}" accept="image/*"
onchange="showFileName(this)">
<i class="bi bi-cloud-upload"></i>
<span>Click or drag to upload</span>
<span class="file-name" id="fname_{{ fid }}">
{% if saved %}{{ saved.split('/')[-1] }}{% endif %}
</span>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Signature ── #}
{% elif field.type == 'signature' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="signature-pad-wrap">
<canvas id="sig_{{ fid }}" data-field="field_{{ fid }}"></canvas>
<button type="button" class="sig-clear" onclick="clearSig('sig_{{ fid }}')">Clear</button>
<input type="hidden" name="field_{{ fid }}" id="field_{{ fid }}" value="{{ saved }}">
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{# ── Table ── #}
{% elif field.type == 'table' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div style="overflow:auto;flex:1;min-height:0;">
<table class="tbl-field">
<thead><tr>
{% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %}
<th>{{ hdr }}</th>
{% endfor %}
</tr></thead>
<tbody>
{% set tbl_data = saved if saved is iterable and saved is not string else [] %}
{% for r in range(field.table_rows or 3) %}
<tr>
{% set row_data = tbl_data[r] if r < tbl_data|length else {} %}
{% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %}{% set c_idx = loop.index0 %}
<td>
<input type="text" class="tbl-input"
name="field_{{ fid }}_r{{ r }}_c{{ c_idx }}"
value="{{ row_data.get(hdr, '') }}">
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-layout-text-sidebar-reverse" style="font-size:2.5rem;opacity:.3;"></i>
<p class="mt-2 mb-0">This template has no form fields. Please add fields in the template editor.</p>
</div>
{% endif %}
</div>
{# ── Sticky footer ── #}
<div class="insp-footer">
<div class="meta">
Started: <strong>{{ inspection.inspection_date.strftime('%Y-%m-%d %H:%M') }}</strong>
&nbsp;·&nbsp; Template: <strong>{{ inspection.template.name }}</strong>
</div>
<div class="d-flex gap-2">
<button type="submit" name="action" value="draft" class="btn btn-sm btn-outline-light">
<i class="bi bi-floppy"></i> Save Draft
</button>
<button type="submit" name="action" value="submit" class="btn btn-success"
onclick="collectSignatures(); return confirmSubmit();">
<i class="bi bi-check-circle-fill"></i> Submit Inspection
</button>
</div>
</div>
</form>
</div>
{% endblock %}
{% block extra_js %}
<script>
// ── Rating stars ─────────────────────────────────────────────────────────────
function setRating(btn) {
const group = btn.closest('.rating-stars');
const val = parseInt(btn.dataset.val);
const input = document.getElementById(btn.dataset.target);
group.dataset.rating = val;
if (input) input.value = val;
group.querySelectorAll('button').forEach(b => {
b.classList.toggle('on', parseInt(b.dataset.val) <= val);
});
}
// ── File upload label ─────────────────────────────────────────────────────────
function showFileName(input) {
const wrap = input.closest('.upload-zone');
const label = wrap ? wrap.querySelector('.file-name') : null;
if (label && input.files.length) {
label.textContent = input.files[0].name;
}
}
// ── Signature pads ────────────────────────────────────────────────────────────
const sigPads = {};
document.querySelectorAll('[id^="sig_"]').forEach(canvas => {
const fid = canvas.id;
const input = document.getElementById(canvas.dataset.field);
const ctx = canvas.getContext('2d');
let drawing = false;
// size canvas to its container
const resize = () => {
const w = canvas.parentElement.clientWidth;
const h = canvas.parentElement.clientHeight || 80;
canvas.width = w;
canvas.height = h;
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1.8;
ctx.lineCap = 'round';
};
resize();
new ResizeObserver(resize).observe(canvas.parentElement);
// Restore saved signature
if (input && input.value && input.value.startsWith('data:')) {
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0);
img.src = input.value;
}
canvas.addEventListener('mousedown', e => { drawing = true; ctx.beginPath(); ctx.moveTo(...pos(e, canvas)); });
canvas.addEventListener('mousemove', e => { if (!drawing) return; ctx.lineTo(...pos(e, canvas)); ctx.stroke(); });
canvas.addEventListener('mouseup', () => { drawing = false; if (input) input.value = canvas.toDataURL(); });
canvas.addEventListener('mouseleave', () => { drawing = false; });
// Touch
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing = true; ctx.beginPath(); ctx.moveTo(...pos(e.touches[0], canvas)); });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!drawing) return; ctx.lineTo(...pos(e.touches[0], canvas)); ctx.stroke(); });
canvas.addEventListener('touchend', e => { drawing = false; if (input) input.value = canvas.toDataURL(); });
sigPads[fid] = { ctx, canvas, input };
});
function pos(e, canvas) {
const r = canvas.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function clearSig(id) {
const p = sigPads[id];
if (!p) return;
p.ctx.clearRect(0, 0, p.canvas.width, p.canvas.height);
if (p.input) p.input.value = '';
}
function collectSignatures() {
// Ensure all signature canvases flush their dataURL to hidden inputs
Object.values(sigPads).forEach(p => {
if (p.input && p.canvas.width > 0) {
p.input.value = p.canvas.toDataURL();
}
});
}
// ── Submit confirmation ───────────────────────────────────────────────────────
function confirmSubmit() {
return confirm('Submit this inspection? This action cannot be undone.');
}
// ── Client-side image compression ─────────────────────────────────────────────
// Compresses photos via Canvas before upload to prevent 413 errors.
// Target: ≤ 1MB per image at 85% JPEG quality, max 1920px on longest side.
const IMG_MAX_PX = 1920; // max dimension in pixels
const IMG_QUALITY = 0.85; // JPEG quality (01)
const IMG_MAX_BYTES = 1 * 1024 * 1024; // 1MB per compressed image
function compressImageFile(file) {
return new Promise((resolve) => {
// Only compress images that exceed the size threshold
if (!file.type.startsWith('image/') || file.size <= IMG_MAX_BYTES) {
return resolve(file);
}
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
// Calculate scaled dimensions
let { width, height } = img;
if (width > IMG_MAX_PX || height > IMG_MAX_PX) {
if (width >= height) {
height = Math.round(height * IMG_MAX_PX / width);
width = IMG_MAX_PX;
} else {
width = Math.round(width * IMG_MAX_PX / height);
height = IMG_MAX_PX;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
// Create a new File from the compressed blob, preserving the name
const compressed = new File([blob], file.name, { type: 'image/jpeg', lastModified: Date.now() });
resolve(compressed);
}, 'image/jpeg', IMG_QUALITY);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
// Intercept form submission — compress all image fields before sending
document.getElementById('inspectionForm').addEventListener('submit', async function(e) {
const fileInputs = this.querySelectorAll('input[type="file"]');
if (!fileInputs.length) return; // nothing to compress
// Only intercept if there are files to compress
const hasFiles = Array.from(fileInputs).some(inp => inp.files && inp.files.length > 0);
if (!hasFiles) return;
e.preventDefault();
const btn = this.querySelector('button[type="submit"]');
const origText = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Compressing photos…';
}
try {
// Build a FormData object with compressed images
const formData = new FormData(this);
for (const input of fileInputs) {
if (!input.files || !input.files.length) continue;
const compressed = await compressImageFile(input.files[0]);
formData.set(input.name, compressed, compressed.name);
}
// Submit via fetch with the compressed FormData
const response = await fetch(this.action || window.location.href, {
method: 'POST',
body: formData,
credentials: 'same-origin',
});
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.reload();
} else {
// Surface server error (e.g. still-too-large after compression)
if (btn) { btn.disabled = false; btn.innerHTML = origText; }
alert('Submission failed (HTTP ' + response.status + '). Please try again or remove some photos.');
}
} catch (err) {
if (btn) { btn.disabled = false; btn.innerHTML = origText; }
console.error('Photo compression error:', err);
alert('An error occurred while processing photos. Please try again.');
}
});
</script>
{% endblock %}