feat: frigate-notify v2 (modular app, CLIP recognition) + clip-service
Splits the single main.py into config/database/models/routes/vision/recognition/ ollama_client/cleanup modules, adds a web UI, and introduces clip-service for CLIP (ViT-L-14) embeddings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
// Frigate Alerts - Web UI
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let currentView = 'dashboard';
|
||||
let currentTagEventId = null;
|
||||
let refreshTimer = null;
|
||||
let knownObjectsCache = [];
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupNavigation();
|
||||
setupTagModal();
|
||||
loadView('dashboard');
|
||||
loadStats();
|
||||
});
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
function setupNavigation() {
|
||||
document.querySelectorAll('.sidebar-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const view = item.dataset.view;
|
||||
loadView(view);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveNav(view) {
|
||||
document.querySelectorAll('.sidebar-item').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.view === view);
|
||||
});
|
||||
}
|
||||
|
||||
// --- View Router ---
|
||||
|
||||
function loadView(view) {
|
||||
currentView = view;
|
||||
setActiveNav(view);
|
||||
clearAutoRefresh();
|
||||
|
||||
switch (view) {
|
||||
case 'dashboard':
|
||||
loadDashboard();
|
||||
startAutoRefresh();
|
||||
break;
|
||||
case 'review':
|
||||
loadReview();
|
||||
startAutoRefresh();
|
||||
break;
|
||||
case 'known':
|
||||
loadKnown();
|
||||
break;
|
||||
case 'settings':
|
||||
loadSettings();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto Refresh ---
|
||||
|
||||
function startAutoRefresh() {
|
||||
refreshTimer = setInterval(() => {
|
||||
if (currentView === 'dashboard') {
|
||||
loadDashboard();
|
||||
} else if (currentView === 'review') {
|
||||
loadReview();
|
||||
}
|
||||
loadStats();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function clearAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dashboard View ---
|
||||
|
||||
function loadDashboard() {
|
||||
const content = document.getElementById('main-content');
|
||||
content.innerHTML = '<div class="loading">Loading events</div>';
|
||||
|
||||
fetch('/api/events?limit=50')
|
||||
.then(r => r.json())
|
||||
.then(events => {
|
||||
if (!events.length) {
|
||||
content.innerHTML = `
|
||||
<h1>Dashboard</h1>
|
||||
<div class="empty-state">
|
||||
<p>No events recorded yet.</p>
|
||||
<p>Events will appear here when Frigate detects objects.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select id="filter-camera" onchange="window._applyFilters()">
|
||||
<option value="">All Cameras</option>
|
||||
</select>
|
||||
<select id="filter-label" onchange="window._applyFilters()">
|
||||
<option value="">All Labels</option>
|
||||
</select>
|
||||
<select id="filter-status" onchange="window._applyFilters()">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="known">Known</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
<option value="ambiguous">Ambiguous</option>
|
||||
<option value="unreviewed">Unreviewed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="events-list"></div>`;
|
||||
|
||||
populateFilterOptions(events);
|
||||
renderEvents(events);
|
||||
})
|
||||
.catch(err => {
|
||||
content.innerHTML = `<h1>Dashboard</h1><div class="empty-state"><p>Error loading events: ${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function populateFilterOptions(events) {
|
||||
const cameras = [...new Set(events.map(e => e.camera).filter(Boolean))].sort();
|
||||
const labels = [...new Set(events.map(e => e.label).filter(Boolean))].sort();
|
||||
|
||||
const cameraSelect = document.getElementById('filter-camera');
|
||||
const labelSelect = document.getElementById('filter-label');
|
||||
|
||||
if (cameraSelect) {
|
||||
cameras.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c;
|
||||
opt.textContent = c;
|
||||
cameraSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
if (labelSelect) {
|
||||
labels.forEach(l => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = l;
|
||||
opt.textContent = l;
|
||||
labelSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Expose filter function globally for inline onchange handlers
|
||||
window._applyFilters = function () {
|
||||
const camera = document.getElementById('filter-camera')?.value || '';
|
||||
const label = document.getElementById('filter-label')?.value || '';
|
||||
const status = document.getElementById('filter-status')?.value || '';
|
||||
|
||||
let url = '/api/events?limit=50';
|
||||
if (camera) url += `&camera=${encodeURIComponent(camera)}`;
|
||||
if (label) url += `&label=${encodeURIComponent(label)}`;
|
||||
if (status) url += `&status=${encodeURIComponent(status)}`;
|
||||
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(events => renderEvents(events))
|
||||
.catch(err => console.error('Filter error:', err));
|
||||
};
|
||||
|
||||
function renderEvents(events) {
|
||||
const container = document.getElementById('events-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!events.length) {
|
||||
container.innerHTML = '<div class="empty-state"><p>No events match the current filters.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = events.map(event => `
|
||||
<div class="event-card" data-event-id="${event.id}">
|
||||
<img src="/api/events/${event.id}/snapshot"
|
||||
onerror="this.style.display='none'"
|
||||
alt="${event.label || 'snapshot'}">
|
||||
<div class="event-info">
|
||||
<div class="event-header">
|
||||
<span>
|
||||
<span class="event-camera">${escapeHtml(event.camera || 'unknown')}</span>
|
||||
<span class="event-label"> — ${escapeHtml(event.label || 'unknown')}</span>
|
||||
<span class="event-score">(${(event.score * 100).toFixed(0)}%)</span>
|
||||
</span>
|
||||
<span class="badge badge-${event.match_status || 'unreviewed'}">${event.match_status || 'unreviewed'}</span>
|
||||
</div>
|
||||
<div class="event-time">${formatTime(event.created_at)}</div>
|
||||
${event.ai_description ? `<div class="event-description">${escapeHtml(event.ai_description)}</div>` : ''}
|
||||
${event.matched_object_name ? `<div class="event-label">Identified as: <strong>${escapeHtml(event.matched_object_name)}</strong></div>` : ''}
|
||||
<div class="event-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="window._tagEvent('${event.id}')">Tag</button>
|
||||
<button class="btn btn-sm" onclick="window._dismissEvent('${event.id}')">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// --- Review View ---
|
||||
|
||||
function loadReview() {
|
||||
const content = document.getElementById('main-content');
|
||||
content.innerHTML = '<div class="loading">Loading review queue</div>';
|
||||
|
||||
fetch('/api/events?limit=50&status=unreviewed')
|
||||
.then(r => r.json())
|
||||
.then(events => {
|
||||
if (!events.length) {
|
||||
content.innerHTML = `
|
||||
<h1>Review Queue</h1>
|
||||
<div class="empty-state">
|
||||
<p>No events to review.</p>
|
||||
<p>All events have been reviewed or dismissed.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Review Queue</h1>
|
||||
<span>${events.length} event${events.length !== 1 ? 's' : ''} to review</span>
|
||||
</div>
|
||||
<div id="events-list"></div>`;
|
||||
renderEvents(events);
|
||||
})
|
||||
.catch(err => {
|
||||
content.innerHTML = `<h1>Review Queue</h1><div class="empty-state"><p>Error loading events: ${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Known Objects View ---
|
||||
|
||||
function loadKnown() {
|
||||
const content = document.getElementById('main-content');
|
||||
content.innerHTML = '<div class="loading">Loading known objects</div>';
|
||||
|
||||
fetch('/api/known-objects')
|
||||
.then(r => r.json())
|
||||
.then(objects => {
|
||||
knownObjectsCache = objects;
|
||||
|
||||
if (!objects.length) {
|
||||
content.innerHTML = `
|
||||
<h1>Known Objects</h1>
|
||||
<div class="empty-state">
|
||||
<p>No known objects registered.</p>
|
||||
<p>Tag events from the Dashboard to add known objects.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Known Objects</h1>
|
||||
<span>${objects.length} object${objects.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="known-grid">
|
||||
${objects.map(obj => `
|
||||
<div class="known-card" data-object-id="${obj.id}">
|
||||
<h4>${escapeHtml(obj.name)}</h4>
|
||||
<span class="type-badge">${escapeHtml(obj.type)}</span>
|
||||
<div class="embedding-count">${obj.embedding_count || 0} embedding${(obj.embedding_count || 0) !== 1 ? 's' : ''}</div>
|
||||
${obj.samples && obj.samples.length ? `
|
||||
<div class="known-card-samples">
|
||||
${obj.samples.map(s => `<img src="${s}" onerror="this.style.display='none'" alt="sample">`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="known-card-actions">
|
||||
<button class="btn btn-danger btn-sm" onclick="window._deleteKnownObject('${obj.id}', '${escapeHtml(obj.name)}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
})
|
||||
.catch(err => {
|
||||
content.innerHTML = `<h1>Known Objects</h1><div class="empty-state"><p>Error loading objects: ${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Settings View ---
|
||||
|
||||
function loadSettings() {
|
||||
const content = document.getElementById('main-content');
|
||||
content.innerHTML = '<div class="loading">Loading settings</div>';
|
||||
|
||||
fetch('/api/settings')
|
||||
.then(r => r.json())
|
||||
.then(settings => {
|
||||
content.innerHTML = `
|
||||
<h1>Settings</h1>
|
||||
<div class="settings-form">
|
||||
<div class="settings-section">
|
||||
<h3>Vision Analysis</h3>
|
||||
<div class="form-group">
|
||||
<label>Vision Backend</label>
|
||||
<select id="setting-vision-backend">
|
||||
<option value="haiku" ${settings.vision_backend === 'haiku' ? 'selected' : ''}>Claude Haiku</option>
|
||||
<option value="ollama" ${settings.vision_backend === 'ollama' ? 'selected' : ''}>Ollama (Local)</option>
|
||||
<option value="both" ${settings.vision_backend === 'both' ? 'selected' : ''}>Both (Haiku + Ollama)</option>
|
||||
</select>
|
||||
<div class="help-text">Which AI backend to use for snapshot analysis</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Recognition</h3>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="setting-recognition-enabled" ${settings.recognition_enabled ? 'checked' : ''}>
|
||||
Enable object recognition
|
||||
</label>
|
||||
<div class="help-text">Match detected objects against known objects database</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Data Retention</h3>
|
||||
<div class="form-group">
|
||||
<label>Event Retention (days)</label>
|
||||
<input type="number" id="setting-retention-days" value="${settings.event_retention_days || 30}" min="1" max="365">
|
||||
<div class="help-text">How long to keep event records and snapshots</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" onclick="window._saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.catch(err => {
|
||||
content.innerHTML = `<h1>Settings</h1><div class="empty-state"><p>Error loading settings: ${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
window._saveSettings = function () {
|
||||
const settings = {
|
||||
vision_backend: document.getElementById('setting-vision-backend').value,
|
||||
recognition_enabled: document.getElementById('setting-recognition-enabled').checked,
|
||||
event_retention_days: parseInt(document.getElementById('setting-retention-days').value, 10)
|
||||
};
|
||||
|
||||
fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to save settings');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => showToast('Settings saved', 'success'))
|
||||
.catch(err => showToast('Error: ' + err.message, 'error'));
|
||||
};
|
||||
|
||||
// --- Stats ---
|
||||
|
||||
function loadStats() {
|
||||
fetch('/api/stats')
|
||||
.then(r => r.json())
|
||||
.then(stats => {
|
||||
const el = document.getElementById('stats-summary');
|
||||
if (!el) return;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="stat-item">
|
||||
<span>Total Events</span>
|
||||
<span class="stat-value">${stats.total_events || 0}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Unreviewed</span>
|
||||
<span class="stat-value">${stats.unreviewed || 0}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Known</span>
|
||||
<span class="stat-value">${stats.known_matches || 0}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Unknown</span>
|
||||
<span class="stat-value">${stats.unknown_events || 0}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Known Objects</span>
|
||||
<span class="stat-value">${stats.known_objects_count || 0}</span>
|
||||
</div>`;
|
||||
})
|
||||
.catch(() => {
|
||||
// Stats are non-critical, silently fail
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tag Modal ---
|
||||
|
||||
function setupTagModal() {
|
||||
document.getElementById('tag-cancel').addEventListener('click', closeTagModal);
|
||||
document.getElementById('tag-save').addEventListener('click', saveTag);
|
||||
|
||||
document.getElementById('tag-existing-object').addEventListener('change', (e) => {
|
||||
const newFields = document.getElementById('tag-new-fields');
|
||||
newFields.style.display = e.target.value ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// Close modal on backdrop click
|
||||
document.getElementById('tag-modal').addEventListener('click', (e) => {
|
||||
if (e.target === document.getElementById('tag-modal')) {
|
||||
closeTagModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeTagModal();
|
||||
});
|
||||
}
|
||||
|
||||
window._tagEvent = function (eventId) {
|
||||
currentTagEventId = eventId;
|
||||
|
||||
// Show snapshot in modal
|
||||
const snapshotContainer = document.getElementById('tag-modal-snapshot');
|
||||
snapshotContainer.innerHTML = `<img src="/api/events/${eventId}/snapshot" onerror="this.style.display='none'" alt="snapshot">`;
|
||||
|
||||
// Load known objects for dropdown
|
||||
fetch('/api/known-objects')
|
||||
.then(r => r.json())
|
||||
.then(objects => {
|
||||
knownObjectsCache = objects;
|
||||
const select = document.getElementById('tag-existing-object');
|
||||
select.innerHTML = '<option value="">-- Create new --</option>';
|
||||
objects.forEach(obj => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = obj.id;
|
||||
opt.textContent = `${obj.name} (${obj.type})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(() => { });
|
||||
|
||||
// Reset form
|
||||
document.getElementById('tag-name').value = '';
|
||||
document.getElementById('tag-type').value = 'person';
|
||||
document.getElementById('tag-new-fields').style.display = 'block';
|
||||
document.getElementById('tag-existing-object').value = '';
|
||||
|
||||
document.getElementById('tag-modal').classList.remove('hidden');
|
||||
};
|
||||
|
||||
function closeTagModal() {
|
||||
document.getElementById('tag-modal').classList.add('hidden');
|
||||
currentTagEventId = null;
|
||||
}
|
||||
|
||||
function saveTag() {
|
||||
if (!currentTagEventId) return;
|
||||
|
||||
const existingId = document.getElementById('tag-existing-object').value;
|
||||
let body;
|
||||
|
||||
if (existingId) {
|
||||
body = { known_object_id: existingId };
|
||||
} else {
|
||||
const name = document.getElementById('tag-name').value.trim();
|
||||
const type = document.getElementById('tag-type').value;
|
||||
if (!name) {
|
||||
showToast('Please enter a name', 'error');
|
||||
return;
|
||||
}
|
||||
body = { name: name, type: type };
|
||||
}
|
||||
|
||||
fetch(`/api/events/${currentTagEventId}/tag`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to tag event');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
showToast('Event tagged successfully', 'success');
|
||||
closeTagModal();
|
||||
loadView(currentView);
|
||||
loadStats();
|
||||
})
|
||||
.catch(err => showToast('Error: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
// --- Event Actions ---
|
||||
|
||||
window._dismissEvent = function (eventId) {
|
||||
fetch(`/api/events/${eventId}/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to dismiss event');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
showToast('Event dismissed', 'success');
|
||||
// Remove card from DOM for immediate feedback
|
||||
const card = document.querySelector(`.event-card[data-event-id="${eventId}"]`);
|
||||
if (card) {
|
||||
card.style.opacity = '0';
|
||||
card.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
}
|
||||
loadStats();
|
||||
})
|
||||
.catch(err => showToast('Error: ' + err.message, 'error'));
|
||||
};
|
||||
|
||||
// --- Known Object Actions ---
|
||||
|
||||
window._deleteKnownObject = function (objectId, name) {
|
||||
if (!confirm(`Delete known object "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
fetch(`/api/known-objects/${objectId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to delete object');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
showToast(`Deleted "${name}"`, 'success');
|
||||
loadKnown();
|
||||
loadStats();
|
||||
})
|
||||
.catch(err => showToast('Error: ' + err.message, 'error'));
|
||||
};
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Frigate Alerts</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Frigate Alerts</h2>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<a href="#" class="sidebar-item active" data-view="dashboard">Dashboard</a>
|
||||
<a href="#" class="sidebar-item" data-view="review">Review Queue</a>
|
||||
<a href="#" class="sidebar-item" data-view="known">Known Objects</a>
|
||||
<a href="#" class="sidebar-item" data-view="settings">Settings</a>
|
||||
</div>
|
||||
<div class="sidebar-section sidebar-stats">
|
||||
<div id="stats-summary"></div>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="content">
|
||||
<div id="main-content"></div>
|
||||
</main>
|
||||
|
||||
<div id="tag-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3>Tag as Known Object</h3>
|
||||
<div id="tag-modal-snapshot"></div>
|
||||
<div class="form-group">
|
||||
<label>Existing object:</label>
|
||||
<select id="tag-existing-object">
|
||||
<option value="">-- Create new --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="tag-new-fields">
|
||||
<div class="form-group">
|
||||
<label>Name:</label>
|
||||
<input type="text" id="tag-name" placeholder="e.g., Jeremy, Blue F-150, Max">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type:</label>
|
||||
<select id="tag-type">
|
||||
<option value="person">Person</option>
|
||||
<option value="vehicle">Vehicle</option>
|
||||
<option value="pet">Pet</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="tag-save" class="btn btn-primary">Save</button>
|
||||
<button id="tag-cancel" class="btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,534 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
width: 220px;
|
||||
background: #16213e;
|
||||
padding: 20px 0;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0 20px 20px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
color: #00b4d8;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
color: #a0a0a0;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: #0f3460;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: #0f3460;
|
||||
color: #00b4d8;
|
||||
border-left: 3px solid #00b4d8;
|
||||
}
|
||||
|
||||
.sidebar-stats {
|
||||
padding: 20px;
|
||||
font-size: 0.85em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
#content {
|
||||
margin-left: 220px;
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Page headers */
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters select,
|
||||
.filters input {
|
||||
padding: 6px 10px;
|
||||
background: #16213e;
|
||||
border: 1px solid #0f3460;
|
||||
color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filters select:focus,
|
||||
.filters input:focus {
|
||||
outline: none;
|
||||
border-color: #00b4d8;
|
||||
}
|
||||
|
||||
/* Event cards */
|
||||
.event-card {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
transition: border-color 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
border-color: #0f3460;
|
||||
}
|
||||
|
||||
.event-card img {
|
||||
width: 160px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-camera {
|
||||
font-weight: bold;
|
||||
color: #00b4d8;
|
||||
}
|
||||
|
||||
.event-label {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.event-score {
|
||||
color: #888;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
color: #ccc;
|
||||
margin: 8px 0;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge-known {
|
||||
background: #2d6a4f;
|
||||
color: #95d5b2;
|
||||
}
|
||||
|
||||
.badge-unknown {
|
||||
background: #9a4c1a;
|
||||
color: #f4a261;
|
||||
}
|
||||
|
||||
.badge-ambiguous {
|
||||
background: #7c6f09;
|
||||
color: #ffd166;
|
||||
}
|
||||
|
||||
.badge-unreviewed {
|
||||
background: #444;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
transition: all 0.2s;
|
||||
color: #ccc;
|
||||
background: #2a2a4a;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #3a3a5a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0f3460;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #00b4d8;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #7c2929;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
width: 450px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #00b4d8;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
#tag-modal-snapshot {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#tag-modal-snapshot img {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #0f3460;
|
||||
color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #00b4d8;
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.form-group .checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group .help-text {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Known objects grid */
|
||||
.known-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.known-card {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.known-card:hover {
|
||||
border-color: #0f3460;
|
||||
}
|
||||
|
||||
.known-card h4 {
|
||||
color: #00b4d8;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.known-card .type-badge {
|
||||
font-size: 0.8em;
|
||||
color: #a0a0a0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.known-card .embedding-count {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.known-card .known-card-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.known-card .known-card-samples {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.known-card .known-card-samples img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Settings */
|
||||
.settings-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.settings-form .form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
color: #00b4d8;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
/* Stats in sidebar */
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #00b4d8;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-top: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #0f3460;
|
||||
border-top-color: #00b4d8;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.9em;
|
||||
z-index: 2000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: #2d6a4f;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #7c2929;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
#sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
.sidebar-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.event-card img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.known-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user