Add pets dashboard template with Bootstrap 5 dark theme

This commit is contained in:
Aaron D. Lee 2026-04-03 13:30:07 -04:00
parent 94c5184f46
commit 32955bc7e4

View File

@ -1,19 +1,513 @@
{% extends "base.html" %}
{% block title %}Pets — Vigilar{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><i class="bi bi-heart-fill me-2 text-danger"></i>Pet &amp; Wildlife Monitor</h4>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#registerPetModal">
<i class="bi bi-plus-lg me-1"></i>Register Pet
</button>
<h5 class="mb-0"><i class="bi bi-heart-fill me-2 text-danger"></i>Pet &amp; Wildlife Monitor</h5>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" id="btn-train" title="Train model">
<i class="bi bi-cpu me-1"></i>Train Model
</button>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#registerPetModal">
<i class="bi bi-plus-lg me-1"></i>Register Pet
</button>
</div>
</div>
<div class="alert alert-secondary">
<i class="bi bi-tools me-2"></i>
Full pet dashboard UI coming in the next task. API endpoints are available at
<code>/pets/api/status</code>, <code>/pets/api/sightings</code>,
<code>/pets/api/wildlife</code>, and <code>/pets/api/unlabeled</code>.
<!-- Per-pet status cards -->
<div class="row g-3 mb-3" id="pet-cards">
{% for pet in pets %}
<div class="col-sm-6 col-lg-4 col-xl-3">
<div class="card bg-dark border-secondary h-100 pet-card" data-pet-id="{{ pet.id }}">
<div class="card-body d-flex flex-column gap-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fs-4 me-2">{{ '🐈\u200d⬛' if pet.species == 'cat' else '🐕' if pet.species == 'dog' else '🐾' }}</span>
<span class="fw-semibold">{{ pet.name }}</span>
{% if pet.breed %}
<small class="text-muted ms-1">{{ pet.breed }}</small>
{% endif %}
</div>
<span class="badge pet-status-badge bg-secondary" id="status-{{ pet.id }}">
<i class="bi bi-circle-fill me-1"></i>
</span>
</div>
<div class="small text-muted mt-1">
<div><i class="bi bi-camera me-1"></i><span class="pet-location" id="loc-{{ pet.id }}"></span></div>
<div><i class="bi bi-clock me-1"></i><span class="pet-last-seen" id="seen-{{ pet.id }}"></span></div>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="card bg-dark border-secondary">
<div class="card-body text-center text-muted py-4">
<i class="bi bi-heart me-2"></i>No pets registered yet.
<a href="#" class="ms-2" data-bs-toggle="modal" data-bs-target="#registerPetModal">Register your first pet</a>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Wildlife summary bar -->
<div class="card bg-dark border-secondary mb-3">
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-2">
<span class="small fw-semibold"><i class="bi bi-binoculars me-2"></i>Wildlife Today</span>
<small class="text-muted" id="wildlife-updated"></small>
</div>
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-3" id="wildlife-summary">
<span class="text-muted small">Loading…</span>
</div>
</div>
</div>
<!-- Activity timeline -->
<div class="card bg-dark border-secondary mb-3">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold"><i class="bi bi-bar-chart-line me-2"></i>24-Hour Activity</span>
</div>
<div class="card-body p-2" id="activity-timeline">
{% for pet in pets %}
<div class="mb-2 activity-row" data-pet-id="{{ pet.id }}">
<div class="d-flex align-items-center mb-1">
<span class="small text-muted" style="min-width:8rem">
{{ '🐈\u200d⬛' if pet.species == 'cat' else '🐕' if pet.species == 'dog' else '🐾' }}
{{ pet.name }}
</span>
<div class="flex-grow-1 position-relative" style="height:20px;background:rgba(255,255,255,.05);border-radius:3px;">
<canvas class="activity-canvas w-100 h-100" data-pet-id="{{ pet.id }}" style="display:block;"></canvas>
</div>
</div>
</div>
{% else %}
<div class="text-muted small text-center py-2">No pets registered</div>
{% endfor %}
<!-- Hour labels -->
{% if pets %}
<div class="d-flex justify-content-between small text-muted px-1" style="margin-left:8rem;">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
</div>
{% endif %}
</div>
</div>
<!-- Unlabeled detection queue -->
<div class="card bg-dark border-secondary mb-3">
<div class="card-header border-secondary d-flex justify-content-between align-items-center py-2">
<span class="small fw-semibold"><i class="bi bi-question-circle me-2"></i>Unlabeled Detections</span>
<span class="badge bg-secondary" id="unlabeled-count">0</span>
</div>
<div class="card-body">
<div class="row g-2" id="unlabeled-grid">
<div class="col-12 text-muted small text-center py-2">Loading…</div>
</div>
</div>
</div>
<!-- Highlight reel -->
<div class="card bg-dark border-secondary mb-3">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold"><i class="bi bi-stars me-2"></i>Today's Highlights</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>Pet</th>
<th>Camera</th>
<th>Time</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody id="highlights-list">
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Register Pet Modal -->
<div class="modal fade" id="registerPetModal" tabindex="-1" aria-labelledby="registerPetLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content bg-dark border-secondary">
<div class="modal-header border-secondary">
<h6 class="modal-title" id="registerPetLabel">
<i class="bi bi-plus-circle me-2"></i>Register Pet
</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="register-pet-form">
<div class="mb-3">
<label class="form-label small">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
id="pet-name" name="name" required placeholder="e.g. Mochi">
</div>
<div class="mb-3">
<label class="form-label small">Species <span class="text-danger">*</span></label>
<select class="form-select form-select-sm bg-dark text-light border-secondary"
id="pet-species" name="species" required>
<option value="">Select…</option>
<option value="cat">Cat</option>
<option value="dog">Dog</option>
<option value="other">Other</option>
</select>
</div>
<div class="mb-3">
<label class="form-label small">Breed <span class="text-muted">(optional)</span></label>
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
id="pet-breed" name="breed" placeholder="e.g. Tabby">
</div>
<div class="mb-3">
<label class="form-label small">Color / Description <span class="text-muted">(optional)</span></label>
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
id="pet-color" name="color_description" placeholder="e.g. orange with white socks">
</div>
<div class="mb-3">
<label class="form-label small">Upload Photos <span class="text-muted">(optional)</span></label>
<input type="file" class="form-control form-control-sm bg-dark text-light border-secondary"
id="pet-photos" name="photos" accept="image/*" multiple>
<div class="form-text text-muted small">Upload clear photos to improve detection accuracy.</div>
</div>
<div id="register-error" class="alert alert-danger py-2 small d-none"></div>
</form>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="btn-register-submit">
<i class="bi bi-check-lg me-1"></i>Register
</button>
</div>
</div>
</div>
</div>
<!-- Label crop modal -->
<div class="modal fade" id="labelModal" tabindex="-1" aria-labelledby="labelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content bg-dark border-secondary">
<div class="modal-header border-secondary">
<h6 class="modal-title" id="labelModalLabel">Label Detection</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<img id="label-crop-img" src="" alt="Detection crop"
class="img-fluid rounded mb-3" style="max-height:200px;">
<div class="d-grid gap-2" id="label-pet-buttons">
<!-- Populated by JS -->
</div>
<button class="btn btn-sm btn-outline-secondary mt-2 w-100" id="btn-label-unknown">
Not a registered pet
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
'use strict';
const PET_SPECIES_ICON = { cat: '🐈\u200d⬛', dog: '🐕' };
const THREAT_COLORS = { low: 'success', medium: 'warning', high: 'danger' };
// ---- pet status -------------------------------------------------------
function relativeTime(isoStr) {
if (!isoStr) return '—';
const diff = Math.floor((Date.now() - new Date(isoStr)) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return new Date(isoStr).toLocaleDateString();
}
function statusBadgeClass(status) {
if (!status) return 'bg-secondary';
const s = status.toLowerCase();
if (s === 'safe' || s === 'interior') return 'bg-success';
if (s === 'check' || s === 'transition') return 'bg-warning text-dark';
if (s === 'alert' || s === 'exterior') return 'bg-danger';
return 'bg-secondary';
}
function loadPetStatus() {
fetch('/pets/api/status')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => {
(data.pets || []).forEach(pet => {
const badge = document.getElementById('status-' + pet.id);
const loc = document.getElementById('loc-' + pet.id);
const seen = document.getElementById('seen-' + pet.id);
if (badge) {
badge.className = 'badge pet-status-badge ' + statusBadgeClass(pet.status);
badge.innerHTML = `<i class="bi bi-circle-fill me-1"></i>${pet.status || '—'}`;
}
if (loc) loc.textContent = pet.last_camera || '—';
if (seen) seen.textContent = relativeTime(pet.last_seen);
});
})
.catch(() => {});
}
// ---- wildlife summary -------------------------------------------------
function loadWildlife() {
fetch('/pets/api/wildlife')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => {
const bar = document.getElementById('wildlife-summary');
const upd = document.getElementById('wildlife-updated');
const counts = {};
(data.sightings || []).forEach(s => {
const key = s.species || 'unknown';
if (!counts[key]) counts[key] = { total: 0, tier: s.threat_tier || 'low' };
counts[key].total++;
});
if (Object.keys(counts).length === 0) {
bar.innerHTML = '<span class="text-muted small">No wildlife detected today</span>';
} else {
bar.innerHTML = Object.entries(counts).map(([sp, v]) =>
`<span class="badge bg-${THREAT_COLORS[v.tier] || 'secondary'}">
${sp} <span class="fw-bold">${v.total}</span>
</span>`
).join('');
}
upd.textContent = 'Updated ' + new Date().toLocaleTimeString();
})
.catch(() => {
document.getElementById('wildlife-summary').innerHTML =
'<span class="text-muted small">Unavailable</span>';
});
}
// ---- activity timeline ------------------------------------------------
function loadActivityTimeline() {
fetch('/pets/api/sightings')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => {
const sightings = data.sightings || [];
document.querySelectorAll('.activity-canvas').forEach(canvas => {
const petId = canvas.dataset.petId;
const petSightings = sightings.filter(s => String(s.pet_id) === String(petId));
drawActivityBar(canvas, petSightings);
});
})
.catch(() => {});
}
function drawActivityBar(canvas, sightings) {
const ctx = canvas.getContext('2d');
const W = canvas.offsetWidth || 400;
const H = canvas.offsetHeight || 20;
canvas.width = W;
canvas.height = H;
ctx.clearRect(0, 0, W, H);
const now = new Date();
const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const dayMs = 86400000;
sightings.forEach(s => {
const t = new Date(s.ts).getTime();
const x = Math.floor(((t - dayStart) / dayMs) * W);
const w = Math.max(2, Math.floor(((s.duration_s || 60) * 1000 / dayMs) * W));
ctx.fillStyle = s.location === 'exterior' ? '#dc3545' :
s.location === 'transition' ? '#ffc107' : '#198754';
ctx.fillRect(x, 1, w, H - 2);
});
}
// ---- unlabeled crops --------------------------------------------------
let pendingLabelCropId = null;
function loadUnlabeled() {
fetch('/pets/api/unlabeled')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => {
const crops = data.crops || [];
const grid = document.getElementById('unlabeled-grid');
const badge = document.getElementById('unlabeled-count');
badge.textContent = crops.length;
if (crops.length === 0) {
grid.innerHTML = '<div class="col-12 text-muted small text-center py-2">No unlabeled detections</div>';
return;
}
grid.innerHTML = crops.map(crop =>
`<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card bg-dark border-secondary h-100 crop-thumb" data-crop-id="${crop.id}"
style="cursor:pointer;" data-img="${crop.thumb_url || ''}">
<img src="${crop.thumb_url || ''}" class="card-img-top" alt="Detection crop"
style="object-fit:cover;height:80px;"
onerror="this.src='/static/img/no-preview.svg'">
<div class="card-body p-1 small text-muted text-center">
${crop.camera_id || '?'}<br>
<span class="x-small">${relativeTime(crop.ts)}</span>
</div>
</div>
</div>`
).join('');
grid.querySelectorAll('.crop-thumb').forEach(el => {
el.addEventListener('click', () => openLabelModal(el.dataset.cropId, el.dataset.img));
});
})
.catch(() => {
document.getElementById('unlabeled-grid').innerHTML =
'<div class="col-12 text-muted small text-center py-2">Unavailable</div>';
});
}
function openLabelModal(cropId, imgUrl) {
pendingLabelCropId = cropId;
document.getElementById('label-crop-img').src = imgUrl;
const btnContainer = document.getElementById('label-pet-buttons');
const petCards = document.querySelectorAll('.pet-card');
btnContainer.innerHTML = '';
petCards.forEach(card => {
const petId = card.dataset.petId;
const name = card.querySelector('.fw-semibold')?.textContent?.trim() || petId;
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline-primary w-100';
btn.textContent = name;
btn.addEventListener('click', () => submitLabel(petId));
btnContainer.appendChild(btn);
});
if (petCards.length === 0) {
btnContainer.innerHTML = '<p class="text-muted small">No pets registered.</p>';
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('labelModal')).show();
}
function submitLabel(petId) {
if (!pendingLabelCropId) return;
fetch('/pets/api/label', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ crop_id: pendingLabelCropId, pet_id: petId }),
}).finally(() => {
bootstrap.Modal.getOrCreateInstance(document.getElementById('labelModal')).hide();
loadUnlabeled();
});
}
document.getElementById('btn-label-unknown')?.addEventListener('click', () => {
submitLabel(null);
});
// ---- highlights -------------------------------------------------------
function loadHighlights() {
fetch('/pets/api/highlights')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => {
const list = document.getElementById('highlights-list');
const rows = data.highlights || [];
if (rows.length === 0) {
list.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No highlights yet today</td></tr>';
return;
}
list.innerHTML = rows.map(h =>
`<tr>
<td>${h.pet_name || '—'}</td>
<td>${h.camera_id || '—'}</td>
<td>${relativeTime(h.ts)}</td>
<td>${h.description || '—'}</td>
<td>
${h.recording_id
? `<a href="/recordings/${h.recording_id}/download"
class="btn btn-sm btn-outline-light" title="Download">
<i class="bi bi-play-circle"></i>
</a>`
: ''}
</td>
</tr>`
).join('');
})
.catch(() => {
document.getElementById('highlights-list').innerHTML =
'<tr><td colspan="5" class="text-center text-muted py-3">Unavailable</td></tr>';
});
}
// ---- register pet form ------------------------------------------------
document.getElementById('btn-register-submit').addEventListener('click', () => {
const form = document.getElementById('register-pet-form');
const errBox = document.getElementById('register-error');
errBox.classList.add('d-none');
const name = document.getElementById('pet-name').value.trim();
const species = document.getElementById('pet-species').value;
if (!name || !species) {
errBox.textContent = 'Name and species are required.';
errBox.classList.remove('d-none');
return;
}
const body = { name, species };
const breed = document.getElementById('pet-breed').value.trim();
const color = document.getElementById('pet-color').value.trim();
if (breed) body.breed = breed;
if (color) body.color_description = color;
fetch('/pets/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'Error')))
.then(() => {
bootstrap.Modal.getOrCreateInstance(document.getElementById('registerPetModal')).hide();
form.reset();
window.location.reload();
})
.catch(err => {
errBox.textContent = typeof err === 'string' ? err : 'Registration failed.';
errBox.classList.remove('d-none');
});
});
// ---- train model button -----------------------------------------------
document.getElementById('btn-train').addEventListener('click', () => {
const btn = document.getElementById('btn-train');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Training…';
fetch('/pets/train', { method: 'POST' })
.then(r => r.ok ? r.json() : Promise.reject())
.then(() => {
btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Queued';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cpu me-1"></i>Train Model';
}, 3000);
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cpu me-1"></i>Train Model';
});
});
// ---- initial load + polling ------------------------------------------
function refreshAll() {
loadPetStatus();
loadWildlife();
loadActivityTimeline();
loadUnlabeled();
loadHighlights();
}
refreshAll();
setInterval(refreshAll, 30000);
})();
</script>
{% endblock %}