From 51634305ff4acaccb808074cc76df599c47e38fa Mon Sep 17 00:00:00 2001 From: Diego Cirilo Date: Tue, 2 Dec 2025 02:28:08 -0300 Subject: [PATCH 1/2] refactor --- presente/views.py | 28 +- static/css/qrcode_page.css | 352 +------------------- static/js/qrcode.js | 127 +++---- templates/presente/includes/qr_content.html | 34 +- templates/presente/public_activity.html | 75 +++-- 5 files changed, 148 insertions(+), 468 deletions(-) diff --git a/presente/views.py b/presente/views.py index 92f5355..80b3a9e 100644 --- a/presente/views.py +++ b/presente/views.py @@ -15,6 +15,10 @@ CoreDeleteView, CoreFilterView, ) +import qrcode +import qrcode.image.svg +from io import BytesIO +import base64 from .models import Activity, Attendance from .tables import ActivityTable, AttendanceTable, ActivityAttendanceTable from .forms import ActivityForm, AttendancePrintConfigForm @@ -166,8 +170,9 @@ class ActivityQRCodeView(TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) encoded_id = kwargs.get("encoded_id") + now = timezone.now() - context["server_time"] = timezone.now().isoformat() + context["server_time"] = now.isoformat() context["encoded_id"] = encoded_id activity_id = decode_activity_id(encoded_id) @@ -175,6 +180,11 @@ def get_context_data(self, **kwargs): activity = get_object_or_404(Activity, id=activity_id) context["activity"] = activity + # Pre-calculate countdown for not_started activities + if activity.status == "not_started": + seconds_until_start = int((activity.start_time - now).total_seconds()) + context["seconds_until_start"] = max(0, seconds_until_start) + if activity.status == "active": checkin_token = generate_checkin_token(activity.id, activity.qr_timeout) checkin_path = reverse( @@ -182,9 +192,25 @@ def get_context_data(self, **kwargs): ) checkin_url = self.request.build_absolute_uri(checkin_path) + # Generate QR code server-side + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_H, + box_size=10, + border=1, + ) + qr.add_data(checkin_url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buffer = BytesIO() + img.save(buffer, format="PNG") + qr_data_url = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}" + context.update( { "checkin_url": checkin_url, + "qr_data_url": qr_data_url, "timeout": activity.qr_timeout, } ) diff --git a/static/css/qrcode_page.css b/static/css/qrcode_page.css index 3a934a0..dc8a647 100644 --- a/static/css/qrcode_page.css +++ b/static/css/qrcode_page.css @@ -1,374 +1,58 @@ -/* Public Activity Page - Fullscreen QR Code Display */ -.public-activity-page body { - overflow: hidden; -} -.fullscreen-container { - min-height: 100vh; - display: flex; - flex-direction: column; +/* Gradient backgrounds */ +.qr-gradient { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - padding: 1.5rem; -} -.activity-card { - background: white; - border-radius: 1.5rem; - box-shadow: 0 20px 60px rgba(0,0,0,0.3); - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - max-width: 1200px; - margin: 0 auto; - width: 100%; } -/* Header with branding */ -.activity-header { +.qr-header-gradient { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 1.5rem 2rem; - text-align: center; -} -.activity-header h1 { - margin: 0; - font-size: 2.5rem; - font-weight: 700; -} - -/* Body layout */ -.activity-body { - flex: 1; - display: flex; - flex-direction: column; - padding: 2rem; - justify-content: space-between; - gap: 1rem; -} - -/* Time info blocks */ -.time-info { - display: flex; - justify-content: center; - gap: 0.75rem; - flex-wrap: wrap; -} -.time-block { - text-align: center; - padding: 0.75rem 1rem; - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); - border-radius: 0.75rem; - min-width: 160px; - flex: 1; - max-width: 220px; - box-shadow: 0 2px 8px rgba(0,0,0,0.08); -} -.time-label { - font-size: 0.75rem; - color: #666; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 0.25rem; - font-weight: 600; -} -.time-label i { - margin-right: 0.25rem; -} -.time-value { - font-size: 1.1rem; - font-weight: 700; - color: #333; } -/* QR Section */ -.qr-section { - text-align: center; - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} -.qr-title { - font-size: 1.5rem; - font-weight: 600; - color: #333; - margin-bottom: 1rem; -} -#qr-code-container { - margin-bottom: 0.75rem; -} -.qr-wrapper { - display: flex; - flex-direction: column; - align-items: center; -} +/* QR Code animations */ .qr-code-link { - display: block; - padding: 1.5rem; - background: white; - border-radius: 1.2rem; - box-shadow: 0 10px 40px rgba(0,0,0,0.15); - transition: transform 0.2s, box-shadow 0.2s, opacity 0.3s ease-in-out; - cursor: pointer; + transition: transform 0.2s, opacity 0.3s ease-in-out; text-decoration: none; - opacity: 1; + cursor: pointer; + max-width: 400px; } + .qr-code-link:hover { transform: translateY(-2px); - box-shadow: 0 12px 50px rgba(0,0,0,0.2); -} -.qr-code-link.qr-fade-in { - animation: fadeIn 0.3s ease-in; } + .qr-code-link.qr-fade-out { opacity: 0.3; } -@keyframes fadeIn { - from { - opacity: 0.3; - } - to { - opacity: 1; - } -} -/* HTMX transition animations */ +/* HTMX transitions */ #qr-code-container.htmx-swapping { opacity: 0; transition: opacity 0.3s ease-out; } + #qr-code-container.htmx-settling { opacity: 1; transition: opacity 0.3s ease-in; } -#qrcode img { - display: block; - border-radius: 0.5rem; - max-width: 100%; - height: auto; -} -/* Datetime badge - compact utility display */ -.qr-datetime-badge { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.75rem; - background: rgba(102, 126, 234, 0.08); - border-radius: 0.5rem; - font-size: 0.8rem; - color: #555; - font-weight: 500; - font-variant-numeric: tabular-nums; -} -.qr-datetime-badge i { - color: #667eea; - font-size: 0.75rem; -} -.qr-datetime-separator { - color: #999; - margin: 0 0.15rem; -} -/* Status container */ -.qr-status-container { - display: flex; - flex-direction: column; - align-items: center; -} -.qr-countdown { - margin-top: 1rem; - font-size: 0.95rem; - color: #666; - font-weight: 500; -} +/* QR countdown spinner */ .qr-countdown i { animation: spin 2s linear infinite; } + @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } -/* Footer branding */ -.page-footer { - margin-top: auto; - padding-top: 1rem; - border-top: 1px solid #e9ecef; - text-align: center; -} -.footer-content { - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; -} -.footer-logo { - height: 24px; -} -.footer-text { - font-size: 1rem; - color: #333; - font-weight: 700; -} -.footer-separator { - color: #999; - font-size: 1rem; - margin: 0 0.25rem; -} -.footer-copyright { - font-size: 0.85rem; - color: #666; -} - -/* Responsive adjustments for public activity page */ -@media (max-width: 768px) { - .fullscreen-container { - padding: 1rem; - } - .activity-header { - padding: 1rem 1.5rem; - } - .activity-header h1 { - font-size: 1.8rem; - } - .activity-body { - padding: 1.5rem; - gap: 1rem; - } - .qr-title { - font-size: 1.2rem; - margin-bottom: 0.75rem; - } - .time-block { - min-width: 140px; - padding: 0.6rem 0.85rem; - } - .time-label { - font-size: 0.7rem; - } - .time-value { - font-size: 1rem; - } +/* Responsive QR size based on viewport height */ +@media (min-height: 900px) { .qr-code-link { - padding: 1rem; - } - .qr-datetime-badge { - font-size: 0.75rem; - padding: 0.35rem 0.65rem; + max-width: 600px !important; } } -@media (max-width: 576px) { - .activity-header h1 { - font-size: 1.4rem; - } - .qr-title { - font-size: 1.1rem; - margin-bottom: 0.5rem; - } - .time-info { - gap: 0.5rem; - } - .time-block { - min-width: 110px; - padding: 0.5rem 0.75rem; - } - .time-label { - font-size: 0.65rem; - letter-spacing: 0.25px; - } - .time-value { - font-size: 0.95rem; - } +@media (min-height: 1080px) { .qr-code-link { - padding: 0.75rem; - } - .qr-datetime-badge { - font-size: 0.72rem; - padding: 0.3rem 0.6rem; - gap: 0.35rem; - } - .qr-countdown { - font-size: 0.85rem; - } - .footer-content { - gap: 0.35rem; - } - .footer-logo { - height: 20px; - } - .footer-text { - font-size: 0.85rem; - } - .footer-separator { - font-size: 0.85rem; - } - .footer-copyright { - font-size: 0.7rem; - } -} - -@media (max-height: 700px) { - .fullscreen-container { - padding: 0.75rem; - } - .activity-header { - padding: 0.75rem 1rem; - } - .activity-header h1 { - font-size: 1.5rem; - } - .activity-body { - padding: 1rem; - gap: 0.75rem; - } - .qr-title { - font-size: 1.1rem; - margin-bottom: 0.5rem; - } - .time-info { - gap: 0.5rem; - } - .time-block { - padding: 0.5rem 0.75rem; - min-width: 130px; - } - .time-label { - font-size: 0.65rem; - } - .time-value { - font-size: 0.95rem; - } - .qr-code-link { - padding: 0.75rem; - } - .qr-datetime-badge { - font-size: 0.7rem; - padding: 0.3rem 0.55rem; - gap: 0.3rem; - } - .qr-datetime-badge i { - font-size: 0.7rem; - } - .qr-countdown { - font-size: 0.85rem; - } - .page-footer { - padding-top: 0.5rem; - } - .footer-content { - gap: 0.35rem; - } - .footer-logo { - height: 18px; - } - .footer-text { - font-size: 0.8rem; - } - .footer-separator { - font-size: 0.8rem; - } - .footer-copyright { - font-size: 0.65rem; + max-width: 700px !important; } } diff --git a/static/js/qrcode.js b/static/js/qrcode.js index 5167bc0..1c3ba9a 100644 --- a/static/js/qrcode.js +++ b/static/js/qrcode.js @@ -3,6 +3,12 @@ class QRCodeManager { const serverTimeMs = new Date(serverTime).getTime(); const clientTimeMs = new Date().getTime(); this.timeOffset = serverTimeMs - clientTimeMs; + this.intervals = []; + } + + clearIntervals() { + this.intervals.forEach(interval => clearInterval(interval)); + this.intervals = []; } getServerTime() { @@ -14,43 +20,46 @@ class QRCodeManager { const currentDateEl = document.getElementById('current-date'); if (!currentTimeEl) return; + // Create formatters once for efficiency + const timeFormatter = new Intl.DateTimeFormat('pt-BR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + + const dateFormatter = new Intl.DateTimeFormat('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + const update = () => { const now = this.getServerTime(); - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); - const seconds = String(now.getSeconds()).padStart(2, '0'); - currentTimeEl.textContent = hours + ':' + minutes + ':' + seconds; - + currentTimeEl.textContent = timeFormatter.format(now); if (currentDateEl) { - const day = String(now.getDate()).padStart(2, '0'); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const year = now.getFullYear(); - currentDateEl.textContent = day + '/' + month + '/' + year; + currentDateEl.textContent = dateFormatter.format(now); } }; update(); - setInterval(update, 1000); + this.intervals.push(setInterval(update, 1000)); } - initActivityStartCountdown(startTime) { - const startTimeMs = new Date(startTime).getTime(); + initActivityStartCountdown(secondsLeft) { const countdownEl = document.getElementById('start-countdown'); if (!countdownEl) return; const updateCountdown = () => { - const now = this.getServerTime().getTime(); - const distance = startTimeMs - now; - - if (distance < 0) { + if (secondsLeft <= 0) { countdownEl.textContent = 'iniciando...'; return; } - const days = Math.floor(distance / (1000 * 60 * 60 * 24)); - const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((distance % (1000 * 60)) / 1000); + const days = Math.floor(secondsLeft / 86400); + const hours = Math.floor((secondsLeft % 86400) / 3600); + const minutes = Math.floor((secondsLeft % 3600) / 60); + const seconds = secondsLeft % 60; let timeString = ''; if (days > 0) { @@ -64,89 +73,41 @@ class QRCodeManager { } countdownEl.textContent = timeString; + secondsLeft--; }; updateCountdown(); - const interval = setInterval(updateCountdown, 1000); - - const observer = new MutationObserver(function(mutations) { - if (!document.contains(countdownEl)) { - clearInterval(interval); - observer.disconnect(); - } - }); - observer.observe(document.body, { childList: true, subtree: true }); + this.intervals.push(setInterval(updateCountdown, 1000)); } - initQRCode(checkinUrl, timeout) { - let timeLeft = timeout; - - const getQRSize = () => { - const screenWidth = window.innerWidth; - const screenHeight = window.innerHeight; - - if (screenWidth < 576) { - return Math.min(300, screenWidth - 60); - } else if (screenWidth < 768) { - return Math.min(360, screenWidth - 100); - } else if (screenHeight < 800) { - return 340; - } else { - return 420; - } - }; - - const initQR = () => { - const qrcodeDiv = document.getElementById('qrcode'); - if (qrcodeDiv && !qrcodeDiv.hasChildNodes() && typeof QRCode !== 'undefined') { - const qrSize = getQRSize(); - - new QRCode(qrcodeDiv, { - text: checkinUrl, - width: qrSize, - height: qrSize, - colorDark: '#000000', - colorLight: '#ffffff', - correctLevel: QRCode.CorrectLevel.H - }); - - this.startQRCountdown(timeLeft); - } else if (typeof QRCode === 'undefined') { - setTimeout(initQR, 50); - } - }; - - initQR(); - - let resizeTimer; - window.addEventListener('resize', () => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - const qrcodeDiv = document.getElementById('qrcode'); - if (qrcodeDiv) { - qrcodeDiv.innerHTML = ''; - initQR(); - } - }, 250); - }); + initQRCountdown(timeout) { + this.startQRCountdown(timeout); } startQRCountdown(timeLeft) { const countdownText = document.getElementById('countdown-text'); - const qrcodeDiv = document.getElementById('qrcode'); + const qrLink = document.querySelector('.qr-code-link'); if (!countdownText) return; + countdownText.textContent = timeLeft; + const interval = setInterval(() => { timeLeft--; countdownText.textContent = timeLeft; - if (timeLeft === 3 && qrcodeDiv) { - qrcodeDiv.classList.add('qr-fade-out'); + if (timeLeft === 3 && qrLink) { + qrLink.classList.add('qr-fade-out'); } if (timeLeft <= 0) { clearInterval(interval); } }, 1000); + + this.intervals.push(interval); } } + +if (!window.qrManager) { + window.qrManager = null; +} diff --git a/templates/presente/includes/qr_content.html b/templates/presente/includes/qr_content.html index 92dbe81..ff1cf21 100644 --- a/templates/presente/includes/qr_content.html +++ b/templates/presente/includes/qr_content.html @@ -1,20 +1,18 @@ {% load static %} {% if activity.status == 'not_started' %} -
Aguarde o Início
-

O QR Code será exibido automaticamente quando o registro de presença estiver disponível.

+

O QR Code será exibido automaticamente quando o registro de presença estiver disponível.

-
-
- - Inicia em: calculando... -
+
+ + Inicia em: calculando...
@@ -26,11 +24,13 @@
Atividade Encerrada
{% elif activity.status == 'active' %} -
Escaneie ou clique para registrar presença
+
Escaneie ou clique para registrar presença
-
- -
+
+ + QR Code + +
Renovando em {{ timeout }}s
@@ -51,14 +51,18 @@
Erro
diff --git a/templates/presente/public_activity.html b/templates/presente/public_activity.html index 6e2945f..61e6de3 100644 --- a/templates/presente/public_activity.html +++ b/templates/presente/public_activity.html @@ -11,30 +11,37 @@ {% endblock %} {% block app_wrapper %} -
-
-
-

{{ activity.title }}

+
+
+
+

{{ activity.title }}

-
- - - + +
+ + + + +
-
-
-
-
Início
-
{{ activity.start_time|date:"d/m/Y H:i" }}
+
+
+
+
+ Início +
+
{{ activity.start_time|date:"d/m/Y H:i" }}
-
-
Término
-
{{ activity.end_time|date:"d/m/Y H:i" }}
+
+
+ Término +
+
{{ activity.end_time|date:"d/m/Y H:i" }}
-
+
{{ activity.title }} Carregando...
-