mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-01-03 01:15:57 +10:00
Add thumbnail previews to live progress header (#1753)
Show small thumbnails of recently completed ArchiveResult content in the
progress header. The thumbnail strip appears below the stats bar and
shows the last 20 successfully archived items with embeddable content
(screenshots, favicons, DOM snapshots, etc.).
Features:
- API returns recent_thumbnails with embed paths for succeeded results
- Thumbnails display with plugin-specific icons as fallback
- New thumbnails animate in with a pop effect
- Clicking a thumbnail navigates to the snapshot admin page
- Horizontal scrollable strip with custom scrollbar styling
<!-- IMPORTANT: Do not submit PRs with only formatting / PEP8 / line
length changes. -->
# Summary
<!--e.g. This PR fixes ABC or adds the ability to do XYZ...-->
# Related issues
<!-- e.g. #123 or Roadmap goal #
https://github.com/pirate/ArchiveBox/wiki/Roadmap -->
# Changes these areas
- [ ] Bugfixes
- [ ] Feature behavior
- [ ] Command line interface
- [ ] Configuration options
- [ ] Internal architecture
- [ ] Snapshot data layout on disk
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds a thumbnail strip to the live progress header. It shows previews of
the last 20 successful archived items for quick visual feedback and
one-click navigation.
- **New Features**
- API returns recent_thumbnails with embed paths for succeeded results.
- Horizontal, scrollable thumbnail strip under the header.
- Uses preview images when available; plugin icons as fallback.
- New thumbnails animate in with a pop effect.
- Clicking a thumbnail opens the snapshot admin page.
<sup>Written for commit 17029ba8b8.
Summary will update on new commits.</sup>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -565,6 +565,29 @@ def live_progress_view(request):
|
||||
archiveresults_succeeded = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.SUCCEEDED).count()
|
||||
archiveresults_failed = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.FAILED).count()
|
||||
|
||||
# Get recently completed ArchiveResults with thumbnails (last 20 succeeded results)
|
||||
recent_thumbnails = []
|
||||
recent_results = ArchiveResult.objects.filter(
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).select_related('snapshot').order_by('-end_ts')[:20]
|
||||
|
||||
for ar in recent_results:
|
||||
embed = ar.embed_path()
|
||||
if embed:
|
||||
# Only include results with embeddable image/media files
|
||||
ext = embed.lower().split('.')[-1] if '.' in embed else ''
|
||||
is_embeddable = ext in ('png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'pdf', 'html')
|
||||
if is_embeddable or ar.plugin in ('screenshot', 'favicon', 'dom'):
|
||||
recent_thumbnails.append({
|
||||
'id': str(ar.id),
|
||||
'plugin': ar.plugin,
|
||||
'snapshot_id': str(ar.snapshot_id),
|
||||
'snapshot_url': ar.snapshot.url[:60] if ar.snapshot else '',
|
||||
'embed_path': embed,
|
||||
'archive_path': f'/archive/{ar.snapshot.timestamp}/{embed}' if ar.snapshot else '',
|
||||
'end_ts': ar.end_ts.isoformat() if ar.end_ts else None,
|
||||
})
|
||||
|
||||
# Build hierarchical active crawls with nested snapshots and archive results
|
||||
from django.db.models import Prefetch
|
||||
|
||||
@@ -689,6 +712,7 @@ def live_progress_view(request):
|
||||
'archiveresults_succeeded': archiveresults_succeeded,
|
||||
'archiveresults_failed': archiveresults_failed,
|
||||
'active_crawls': active_crawls,
|
||||
'recent_thumbnails': recent_thumbnails,
|
||||
'server_time': timezone.now().isoformat(),
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -708,6 +732,7 @@ def live_progress_view(request):
|
||||
'archiveresults_succeeded': 0,
|
||||
'archiveresults_failed': 0,
|
||||
'active_crawls': [],
|
||||
'recent_thumbnails': [],
|
||||
'server_time': timezone.now().isoformat(),
|
||||
}, status=500)
|
||||
|
||||
|
||||
@@ -423,6 +423,102 @@
|
||||
color: #6e7681;
|
||||
}
|
||||
|
||||
/* Thumbnail Strip */
|
||||
#progress-monitor .thumbnail-strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border-top: 1px solid #21262d;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #30363d #0d1117;
|
||||
}
|
||||
#progress-monitor .thumbnail-strip::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
#progress-monitor .thumbnail-strip::-webkit-scrollbar-track {
|
||||
background: #0d1117;
|
||||
}
|
||||
#progress-monitor .thumbnail-strip::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#progress-monitor .thumbnail-strip::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
#progress-monitor .thumbnail-strip.empty {
|
||||
display: none;
|
||||
}
|
||||
#progress-monitor .thumbnail-item {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #30363d;
|
||||
background: #161b22;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
#progress-monitor .thumbnail-item:hover {
|
||||
transform: scale(1.1);
|
||||
border-color: #58a6ff;
|
||||
box-shadow: 0 0 12px rgba(88, 166, 255, 0.3);
|
||||
z-index: 10;
|
||||
}
|
||||
#progress-monitor .thumbnail-item.new {
|
||||
animation: thumbnail-pop 0.4s ease-out;
|
||||
}
|
||||
@keyframes thumbnail-pop {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
50% { transform: scale(1.15); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
#progress-monitor .thumbnail-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
#progress-monitor .thumbnail-item .thumbnail-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #8b949e;
|
||||
background: linear-gradient(135deg, #21262d 0%, #161b22 100%);
|
||||
}
|
||||
#progress-monitor .thumbnail-item .thumbnail-plugin {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 2px 4px;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.7);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#progress-monitor .thumbnail-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 4px;
|
||||
color: #8b949e;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div id="progress-monitor">
|
||||
@@ -456,6 +552,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="thumbnail-strip empty" id="thumbnail-strip">
|
||||
<span class="thumbnail-label">Recent:</span>
|
||||
</div>
|
||||
|
||||
<div class="tree-container" id="tree-container">
|
||||
<div class="idle-message" id="idle-message">No active crawls</div>
|
||||
<div id="crawl-tree"></div>
|
||||
@@ -469,9 +569,11 @@
|
||||
const treeContainer = document.getElementById('tree-container');
|
||||
const crawlTree = document.getElementById('crawl-tree');
|
||||
const idleMessage = document.getElementById('idle-message');
|
||||
const thumbnailStrip = document.getElementById('thumbnail-strip');
|
||||
|
||||
let pollInterval = null;
|
||||
let isCollapsed = localStorage.getItem('progress-monitor-collapsed') === 'true';
|
||||
let knownThumbnailIds = new Set();
|
||||
|
||||
// Baselines for resettable counters
|
||||
let succeededBaseline = parseInt(localStorage.getItem('progress-succeeded-baseline') || '0');
|
||||
@@ -501,6 +603,82 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginIcon(plugin) {
|
||||
const icons = {
|
||||
'screenshot': '📷',
|
||||
'favicon': '⭐',
|
||||
'dom': '📄',
|
||||
'pdf': '🗎',
|
||||
'title': '📝',
|
||||
'headers': '📋',
|
||||
'singlefile': '📦',
|
||||
'readability': '📖',
|
||||
'mercury': '⚜',
|
||||
'wget': '📥',
|
||||
'media': '🎥',
|
||||
};
|
||||
return icons[plugin] || '📄';
|
||||
}
|
||||
|
||||
function renderThumbnail(thumb, isNew) {
|
||||
const ext = (thumb.embed_path || '').toLowerCase().split('.').pop();
|
||||
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico'].includes(ext);
|
||||
|
||||
const item = document.createElement('a');
|
||||
item.className = 'thumbnail-item' + (isNew ? ' new' : '');
|
||||
item.href = `/admin/core/snapshot/${thumb.snapshot_id}/change/`;
|
||||
item.title = `${thumb.plugin}: ${thumb.snapshot_url}`;
|
||||
item.dataset.id = thumb.id;
|
||||
|
||||
if (isImage && thumb.archive_path) {
|
||||
item.innerHTML = `
|
||||
<img src="${thumb.archive_path}" alt="${thumb.plugin}" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'thumbnail-fallback\\'>${getPluginIcon(thumb.plugin)}</div><span class=\\'thumbnail-plugin\\'>${thumb.plugin}</span>'">
|
||||
<span class="thumbnail-plugin">${thumb.plugin}</span>
|
||||
`;
|
||||
} else {
|
||||
item.innerHTML = `
|
||||
<div class="thumbnail-fallback">${getPluginIcon(thumb.plugin)}</div>
|
||||
<span class="thumbnail-plugin">${thumb.plugin}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function updateThumbnails(thumbnails) {
|
||||
if (!thumbnails || thumbnails.length === 0) {
|
||||
thumbnailStrip.classList.add('empty');
|
||||
return;
|
||||
}
|
||||
|
||||
thumbnailStrip.classList.remove('empty');
|
||||
|
||||
// Find new thumbnails (ones we haven't seen before)
|
||||
const newThumbs = thumbnails.filter(t => !knownThumbnailIds.has(t.id));
|
||||
|
||||
// Add new thumbnails to the beginning (after the label)
|
||||
const label = thumbnailStrip.querySelector('.thumbnail-label');
|
||||
newThumbs.reverse().forEach(thumb => {
|
||||
const item = renderThumbnail(thumb, true);
|
||||
if (label.nextSibling) {
|
||||
thumbnailStrip.insertBefore(item, label.nextSibling);
|
||||
} else {
|
||||
thumbnailStrip.appendChild(item);
|
||||
}
|
||||
knownThumbnailIds.add(thumb.id);
|
||||
});
|
||||
|
||||
// Limit to 20 thumbnails (remove old ones)
|
||||
const items = thumbnailStrip.querySelectorAll('.thumbnail-item');
|
||||
if (items.length > 20) {
|
||||
for (let i = 20; i < items.length; i++) {
|
||||
const id = items[i].dataset.id;
|
||||
knownThumbnailIds.delete(id);
|
||||
items[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderExtractor(extractor) {
|
||||
const icon = extractor.status === 'started' ? '↻' :
|
||||
extractor.status === 'succeeded' ? '✓' :
|
||||
@@ -705,6 +883,9 @@
|
||||
idleMessage.innerHTML = `No active crawls (${data.crawls_pending || 0} pending, ${data.crawls_started || 0} started, <a href="${recentUrl}" style="color: #58a6ff;">${data.crawls_recent || 0} recent</a>)`;
|
||||
crawlTree.innerHTML = '';
|
||||
}
|
||||
|
||||
// Update thumbnail strip with recently completed results
|
||||
updateThumbnails(data.recent_thumbnails || []);
|
||||
}
|
||||
|
||||
function fetchProgress() {
|
||||
|
||||
Reference in New Issue
Block a user