pig.py/templates/live.html.j2

271 lines
11 KiB
Django/Jinja

{% extends "base.html.j2" %}
{% block title %}{{ t.live }} - neopig{% endblock %}
{% block extra_css %}
h1 { color: #ff6b6b; margin-bottom: 5px; }
.subtitle { color: #666; margin-bottom: 10px; }
.stats { background: #1a1a1a; padding: 15px; border-radius: 8px; margin-bottom: 20px; display: flex; gap: 30px; flex-wrap: wrap; }
.stat { display: flex; flex-direction: column; }
.stat-value { font-size: 24px; font-weight: bold; color: #ff6b6b; }
.stat-label { font-size: 12px; color: #888; }
.controls { margin-bottom: 20px; display: flex; gap: 10px; align-items: center; }
.controls button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
.controls button.primary { background: #ff6b6b; color: white; }
.controls button.secondary { background: #333; color: #ddd; }
.controls button:hover { opacity: 0.8; }
.status { color: #4ade80; font-size: 14px; }
.status.paused { color: #fbbf24; }
.live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; }
.live-card { background: #1a1a1a; border-radius: 8px; overflow: hidden; animation: fadeIn 0.5s ease-out; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.live-card.new { box-shadow: 0 0 20px rgba(255, 107, 107, 0.5); }
.live-card img, .live-card video { width: 100%; height: 180px; object-fit: contain; background: #222; }
.live-card-info { padding: 10px; }
.live-card-title { font-size: 12px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.live-card-source { font-size: 10px; color: #555; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.score-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; text-transform: uppercase; margin-left: 6px; vertical-align: middle; }
.score-screenshot { background: #666; color: #ccc; }
.score-og { background: #8b5cf6; color: white; }
.score-thumb { background: #3b82f6; color: white; }
.score-hd { background: #10b981; color: white; }
.live-card.upgraded { box-shadow: 0 0 20px rgba(16, 185, 129, 0.7); animation: upgradeGlow 1s ease-out; }
@keyframes upgradeGlow {
0% { box-shadow: 0 0 30px rgba(16, 185, 129, 1); }
100% { box-shadow: 0 0 20px rgba(16, 185, 129, 0.5); }
}
@media (max-width: 768px) { .live-grid { grid-template-columns: repeat(2, 1fr); } }
{% endblock %}
{% block content %}
<div class="container">
{% include 'partials/search_box.html.j2' %}
<h1>{{ t.live }}{% if domain %}: {{ domain }}{% endif %}</h1>
<p class="subtitle">{{ t.live_subtitle }}</p>
<div class="stats">
<div class="stat">
<span class="stat-value" id="total-count">0</span>
<span class="stat-label">{{ t.total_images }}</span>
</div>
<div class="stat">
<span class="stat-value" id="new-count">0</span>
<span class="stat-label">{{ t.new_session }}</span>
</div>
<div class="stat">
<span class="stat-value" id="rate">0</span>
<span class="stat-label">{{ t.per_minute }}</span>
</div>
</div>
<div class="controls">
<button class="primary" id="toggle-btn" onclick="toggleFeed()">{{ t.pause }}</button>
<button class="secondary" onclick="clearFeed()">{{ t.delete }}</button>
<span class="status" id="status">{{ t.watching }}</span>
</div>
<div class="live-grid" id="grid"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
const T = {{ t_json|safe }};
const DOMAIN_FILTER = {{ domain_json|safe }};
// Check for replay mode from URL
const urlParams = new URLSearchParams(window.location.search);
const REPLAY_JOB_ID = urlParams.get('replay');
let running = true;
let seenHashes = new Map();
let newCount = 0;
let startTime = Date.now();
let eventSource = null;
let replayMode = !!REPLAY_JOB_ID;
// Update UI for replay mode
if (replayMode) {
document.addEventListener('DOMContentLoaded', () => {
const h1 = document.querySelector('h1');
h1.textContent = '▶ Replay Job #' + REPLAY_JOB_ID;
h1.style.color = '#9b59b6';
const subtitle = document.querySelector('.subtitle');
subtitle.textContent = 'Replaying crawl at original discovery pace';
subtitle.style.color = '#9b59b6';
});
}
function connectSSE() {
if (eventSource) eventSource.close();
// Use replay endpoint if in replay mode
const sseUrl = replayMode
? '/api/crawl/jobs/' + REPLAY_JOB_ID + '/replay'
: '/api/live/stream';
eventSource = new EventSource(sseUrl);
eventSource.onmessage = function(event) {
if (!running) return;
try {
const item = JSON.parse(event.data);
// Handle replay end marker
if (item.type === 'replay_end') {
const status = document.getElementById('status');
status.textContent = '✓ Replay complete (' + item.total + ' items)';
status.style.color = '#10b981';
eventSource.close();
return;
}
addMediaCard(item, true);
} catch (e) {
console.error('SSE parse error:', e);
}
};
eventSource.onerror = function(e) {
if (replayMode) {
// Replay might have ended normally
console.log('Replay stream ended');
return;
}
console.log('SSE reconnecting...');
setTimeout(connectSSE, 3000);
};
}
function addMediaCard(item, isNew) {
if (seenHashes.has(item.md5_hash)) return;
seenHashes.set(item.md5_hash, {upgraded_at: item.upgraded_at, score: item.score || 5});
newCount++;
const grid = document.getElementById('grid');
const card = document.createElement('div');
card.className = 'live-card' + (isNew ? ' new' : '');
const isVideo = item.media_type === 'video';
const isCode = item.media_type === 'code';
const isStyle = item.media_type === 'style';
const isFont = item.media_type === 'font';
const mimeToExt = {
'text/javascript': '.js', 'application/javascript': '.js',
'text/css': '.css', 'application/json': '.json',
'font/woff': '.woff', 'font/woff2': '.woff2', 'font/ttf': '.ttf',
};
const getExt = (mime, alt) => mimeToExt[mime] || (alt ? '.' + alt : '');
const placeholder = (ext, icon, color) => `<svg viewBox="0 0 100 100" style="width:100%;height:100%;background:#1a1a1a;border-radius:4px;"><text x="50" y="40" text-anchor="middle" fill="${color}" font-size="24">${icon}</text><text x="50" y="65" text-anchor="middle" fill="#888" font-family="monospace" font-size="14" font-weight="bold">${ext}</text></svg>`;
let mediaEl;
if (isVideo) mediaEl = `<video src="/media/${item.md5_hash}" muted loop onmouseenter="this.play()" onmouseleave="this.pause()"></video>`;
else if (isCode) mediaEl = placeholder(getExt(item.mime_type, item.alt_text), '{ }', '#6af');
else if (isStyle) mediaEl = placeholder('.css', '#', '#f6a');
else if (isFont) mediaEl = placeholder(getExt(item.mime_type, ''), 'Aa', '#af6');
else mediaEl = `<img src="/media/${item.md5_hash}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22180%22><rect fill=%22%23333%22 width=%22200%22 height=%22180%22/><text x=%2250%%22 y=%2250%%22 fill=%22%23666%22 text-anchor=%22middle%22>Error</text></svg>'">`;
const getFilename = (uri) => {
if (!uri) return '';
try {
const path = new URL(uri).pathname;
const name = decodeURIComponent(path.split('/').pop() || '');
return name.length > 35 ? name.slice(0, 32) + '...' : name;
} catch { return ''; }
};
const filename = getFilename(item.media_uri);
const scoreBadge = getScoreBadge(item.score, item.media_type);
card.innerHTML = `<a href="/view/${item.md5_hash}">${mediaEl}</a>
<div class="live-card-info">
<div class="live-card-title">${filename || item.alt_text || item.title || item.md5_hash.slice(0,12)}</div>
<div class="live-card-source">${item.media_type} - ${formatSize(item.file_size)}${scoreBadge}</div>
</div>`;
grid.insertBefore(card, grid.firstChild);
updateStats(seenHashes.size);
if (isNew) setTimeout(() => card.classList.remove('new'), 2000);
}
function toggleFeed() {
running = !running;
const btn = document.getElementById('toggle-btn');
const status = document.getElementById('status');
if (running) {
btn.textContent = T.pause;
status.textContent = T.watching;
status.className = 'status';
connectSSE();
poll();
} else {
btn.textContent = T.resume;
status.textContent = T.paused;
status.className = 'status paused';
if (eventSource) eventSource.close();
}
}
function clearFeed() {
document.getElementById('grid').innerHTML = '';
seenHashes = new Map();
newCount = 0;
startTime = Date.now();
updateStats(0);
}
function updateStats(total) {
document.getElementById('total-count').textContent = total;
document.getElementById('new-count').textContent = newCount;
const minutes = (Date.now() - startTime) / 60000;
document.getElementById('rate').textContent = minutes > 0 ? Math.round(newCount / minutes) : 0;
}
async function poll() {
if (!running) return;
try {
const query = DOMAIN_FILTER || '';
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=50`);
const media = await res.json();
const statsRes = await fetch('/api/stats');
const stats = await statsRes.json();
const isFirstPoll = seenHashes.size === 0;
media.filter(m => !seenHashes.has(m.md5_hash)).reverse().forEach(item => addMediaCard(item, !isFirstPoll));
updateStats(stats.total_media || 0);
} catch (err) {
console.error('Poll error:', err);
}
setTimeout(poll, 10000);
}
function formatSize(bytes) {
if (!bytes) return '?';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function getScoreBadge(score, mediaType) {
let label, cssClass;
if (mediaType === 'screenshot' || score <= 1) { label = '📸'; cssClass = 'score-screenshot'; }
else if (score <= 3) { label = 'OG'; cssClass = 'score-og'; }
else if (!score || score <= 5) { label = 'THUMB'; cssClass = 'score-thumb'; }
else { label = 'HD'; cssClass = 'score-hd'; }
return `<span class="score-badge ${cssClass}">${label}</span>`;
}
// In replay mode: start with empty feed, only stream replay data
// In normal mode: poll for existing media and connect to live stream
if (!replayMode) {
poll();
}
connectSSE();
</script>
{% endblock %}