271 lines
11 KiB
Django/Jinja
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 %}
|