vigilar/vigilar/web/templates/visitors/profile.html
Aaron D. Lee 23d5bf062a feat(S3): visitors blueprint with profiles, visits, labeling, privacy controls
Add /visitors/ blueprint with REST API endpoints for listing profiles and
visits, labeling (with consent gate), household linking, ignore/unignore,
and cascading forget. Register blueprint in app.py. Add dashboard and profile
detail templates (Bootstrap 5 dark, tab navigation, fetch-based JS). All six
API tests pass (339 total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:01:33 -04:00

212 lines
9.5 KiB
HTML

{% extends "base.html" %}
{% block title %}Visitor Profile — Vigilar{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="/visitors/" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</a>
<h5 class="mb-0"><i class="bi bi-person-badge-fill me-2 text-info"></i>
<span id="profile-title">Loading…</span>
</h5>
</div>
<!-- Profile summary -->
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="card bg-dark border-secondary h-100">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold"><i class="bi bi-info-circle me-1"></i>Profile Details</span>
</div>
<div class="card-body small" id="profile-details">
<div class="text-muted">Loading…</div>
</div>
<div class="card-footer border-secondary py-2 d-flex flex-wrap gap-2" id="profile-actions">
</div>
</div>
</div>
<div class="col-md-8">
<div class="card bg-dark border-secondary h-100">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold"><i class="bi bi-clock-history me-1"></i>Visit History</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Camera</th>
<th>Arrived</th>
<th>Departed</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="visits-tbody">
<tr><td colspan="4" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Label modal -->
<div class="modal fade" id="label-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark border-secondary">
<div class="modal-header border-secondary">
<h6 class="modal-title"><i class="bi bi-tag-fill me-2"></i>Label Face Profile</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning small py-2">
<i class="bi bi-shield-exclamation me-1"></i>
Labeling stores biometric data. Only label faces with the subject's consent.
</div>
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
id="label-name-input" placeholder="Enter name">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="label-consent-check">
<label class="form-check-label small" for="label-consent-check">
I confirm consent has been obtained
</label>
</div>
</div>
<div class="modal-footer border-secondary">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="label-confirm-btn">Save Label</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
'use strict';
const profileId = {{ profile_id }};
const labelModal = new bootstrap.Modal(document.getElementById('label-modal'));
function fmtTs(ts) {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString();
}
function fmtDuration(s) {
if (!s) return '—';
if (s < 60) return s.toFixed(0) + 's';
return (s / 60).toFixed(1) + 'm';
}
function loadProfile() {
fetch('/visitors/api/profiles')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const profile = (data.profiles || []).find(p => p.id === profileId);
if (!profile) {
document.getElementById('profile-title').textContent = 'Profile not found';
return;
}
document.getElementById('profile-title').textContent =
profile.name || ('Visitor #' + profile.id);
const details = document.getElementById('profile-details');
details.innerHTML = `
<dl class="mb-0">
<dt class="text-muted">Name</dt>
<dd>${profile.name || '<em class="text-muted">Unlabeled</em>'}</dd>
<dt class="text-muted">Household</dt>
<dd>${profile.is_household
? `<span class="badge bg-primary">Yes</span>${profile.presence_member ? ' — ' + profile.presence_member : ''}`
: '—'}</dd>
<dt class="text-muted">Total Visits</dt>
<dd>${profile.visit_count}</dd>
<dt class="text-muted">First Seen</dt>
<dd>${fmtTs(profile.first_seen_at)}</dd>
<dt class="text-muted">Last Seen</dt>
<dd>${fmtTs(profile.last_seen_at)}</dd>
<dt class="text-muted">Status</dt>
<dd>${profile.ignored ? '<span class="badge bg-secondary">Ignored</span>' : '<span class="badge bg-success">Active</span>'}</dd>
</dl>`;
const actions = document.getElementById('profile-actions');
actions.innerHTML = `
<button class="btn btn-sm btn-outline-info" id="btn-label">
<i class="bi bi-tag me-1"></i>Label
</button>
${profile.ignored
? '<button class="btn btn-sm btn-outline-secondary" id="btn-unignore">Unignore</button>'
: '<button class="btn btn-sm btn-outline-warning" id="btn-ignore">Ignore</button>'}
<button class="btn btn-sm btn-outline-danger" id="btn-forget">
<i class="bi bi-trash me-1"></i>Forget
</button>`;
document.getElementById('btn-label').addEventListener('click', () => {
document.getElementById('label-name-input').value = profile.name || '';
document.getElementById('label-consent-check').checked = false;
labelModal.show();
});
const ignBtn = document.getElementById('btn-ignore') || document.getElementById('btn-unignore');
if (ignBtn) {
ignBtn.addEventListener('click', () => {
const endpoint = profile.ignored ? 'unignore' : 'ignore';
fetch(`/visitors/${profileId}/${endpoint}`, { method: 'POST' })
.then(() => loadProfile());
});
}
document.getElementById('btn-forget').addEventListener('click', () => {
if (confirm('Permanently delete this profile and all associated data?')) {
fetch(`/visitors/${profileId}/forget`, { method: 'DELETE' })
.then(() => { window.location.href = '/visitors/'; });
}
});
}).catch(() => {
document.getElementById('profile-title').textContent = 'Error loading profile';
});
}
function loadVisits() {
fetch(`/visitors/api/visits?profile_id=${profileId}&limit=100`)
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = data.visits || [];
const tbody = document.getElementById('visits-tbody');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">No visits recorded</td></tr>';
return;
}
tbody.innerHTML = rows.map(v => `<tr>
<td>${v.camera_id}</td>
<td class="text-nowrap">${fmtTs(v.arrived_at)}</td>
<td class="text-nowrap">${fmtTs(v.departed_at)}</td>
<td>${fmtDuration(v.duration_s)}</td>
</tr>`).join('');
}).catch(() => {});
}
document.getElementById('label-confirm-btn').addEventListener('click', () => {
const name = document.getElementById('label-name-input').value.trim();
const consent = document.getElementById('label-consent-check').checked;
fetch(`/visitors/${profileId}/label`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, consent }),
}).then(r => {
if (r.ok) { labelModal.hide(); loadProfile(); }
else r.json().then(d => alert(d.error || 'Failed to label profile'));
});
});
loadProfile();
loadVisits();
setInterval(() => { loadProfile(); loadVisits(); }, 30000);
})();
</script>
{% endblock %}