This commit is contained in:
Kylian Schmidt
2025-07-07 15:26:01 +02:00
parent 9041abb72f
commit b5b2c676b3
3 changed files with 406 additions and 85 deletions
+1
View File
@@ -0,0 +1 @@
__pycache__
+82 -2
View File
@@ -143,18 +143,88 @@ def build_gallery(source_dir: Path, web_dir: Path,
else:
title = f"Gallery: {relative_path}"
# Calculate statistics for current directory
current_stats = calculate_directory_stats(web_dir)
stats = {
"file_count": len(items),
"folder_count": len(subdir_names),
"total_size": format_file_size(current_stats["total_size"]),
"total_size_bytes": current_stats["total_size"]
}
with output_html.open("w") as f:
f.write(template.render(
title=title,
items=items,
subdirs=subdir_names,
relpath=str(relative_path),
ui=CONFIG.ui
ui=CONFIG.ui,
stats=stats
))
print(f"Generated {output_html}")
def calculate_directory_stats(directory: Path) -> dict:
"""
Calculate statistics for a directory.
Args:
directory: Path to the directory to analyze
Returns:
Dictionary containing file count, folder count, and total size
"""
stats = {
"file_count": 0,
"folder_count": 0,
"total_size": 0,
"pdf_size": 0,
"png_size": 0
}
if not directory.exists():
return stats
for item in directory.rglob("*"):
if item.is_file():
stats["file_count"] += 1
size = item.stat().st_size
stats["total_size"] += size
if item.suffix.lower() == '.pdf':
stats["pdf_size"] += size
elif item.suffix.lower() == '.png':
stats["png_size"] += size
elif item.is_dir():
stats["folder_count"] += 1
return stats
def format_file_size(size_bytes: int) -> str:
"""
Format file size in human readable format.
Args:
size_bytes: Size in bytes
Returns:
Formatted size string
"""
if size_bytes == 0:
return "0 B"
size_names = ["B", "KB", "MB", "GB", "TB"]
size = float(size_bytes)
i = 0
while size >= 1024 and i < len(size_names) - 1:
size /= 1024
i += 1
return f"{size:.1f} {size_names[i]}"
def main() -> None:
"""
Main entry point for gallery generation.
@@ -206,6 +276,15 @@ def main() -> None:
"png_href": png_name
}]
# Calculate statistics for single file
current_stats = calculate_directory_stats(source_web_dir)
stats = {
"file_count": 1,
"folder_count": 0,
"total_size": format_file_size(current_stats["total_size"]),
"total_size_bytes": current_stats["total_size"]
}
output_html = source_web_dir / "index.html"
with output_html.open("w") as f:
f.write(template.render(
@@ -213,7 +292,8 @@ def main() -> None:
items=items,
subdirs=[],
relpath=source.name,
ui=CONFIG.ui
ui=CONFIG.ui,
stats=stats
))
print(f"Generated {output_html}")
+323 -83
View File
@@ -278,6 +278,18 @@
text-decoration: none;
}
.grid-item.highlighted {
border-color: var(--button-bg);
box-shadow: 0 0 15px rgba(0, 120, 212, 0.3);
transform: translateY(-2px);
animation: highlightPulse 2s ease-in-out;
}
@keyframes highlightPulse {
0%, 100% { transform: translateY(-2px) scale(1); }
50% { transform: translateY(-2px) scale(1.02); }
}
.plot-name {
margin-top: 0.8rem;
word-wrap: break-word;
@@ -509,6 +521,47 @@
font-weight: 500;
}
/* ========================================
GALLERY STATISTICS
======================================== */
.gallery-stats {
position: fixed;
bottom: 20px;
left: 20px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.8rem 1rem;
font-size: 0.85rem;
color: var(--breadcrumb-color);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 500;
opacity: 0.8;
transition: opacity 0.2s ease;
max-width: 200px;
}
.gallery-stats:hover {
opacity: 1;
}
.stats-item {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0.2rem 0;
white-space: nowrap;
}
.stats-label {
margin-right: 0.8rem;
}
.stats-value {
font-weight: 600;
color: var(--text-color);
}
/* ========================================
SUBDIRECTORIES LIST
======================================== */
@@ -583,9 +636,6 @@
<!-- Navigation Buttons -->
<div class="navigation">
<button class="nav-btn" onclick="goToContent()" id="contentBtn">
🎯 Go to Content
</button>
<button class="nav-btn" onclick="window.history.back()">
← Back
</button>
@@ -673,6 +723,26 @@
</div>
</div>
<!-- Gallery Statistics -->
<div class="gallery-stats" id="galleryStats">
<div class="stats-item">
<span class="stats-label">📊 Files:</span>
<span class="stats-value" id="fileCount">0</span>
</div>
<div class="stats-item">
<span class="stats-label">📁 Folders:</span>
<span class="stats-value" id="folderCount">0</span>
</div>
<div class="stats-item">
<span class="stats-label">💾 Size:</span>
<span class="stats-value" id="totalSize">0 KB</span>
</div>
<div class="stats-item">
<span class="stats-label">🕒 Updated:</span>
<span class="stats-value" id="lastUpdated">Now</span>
</div>
</div>
<script>
// ========================================
// GALLERY APPLICATION CLASS
@@ -696,7 +766,8 @@
this.initKeyboardShortcuts();
this.updateRecentPlotsDisplay();
this.trackPlotClicks();
this.hideContentButtonIfNeeded();
this.handleThumbnailHighlight();
this.updateGalleryStats();
}
/**
@@ -782,7 +853,7 @@
}
/**
* Recursively build tree structure for folders
* Recursively build tree structure for folders with collapsed empty directories
*/
async buildTreeRecursive(path, depth, currentPath, maxDepth = 5) {
if (depth > maxDepth) {
@@ -790,9 +861,6 @@
return `<div class="tree-item">${indent}└─ ...</div>`;
}
const indent = ' '.repeat(depth);
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
try {
const response = await fetch(path);
const htmlContent = await response.text();
@@ -801,6 +869,25 @@
const subdirs = doc.querySelectorAll('h2 + ul li a');
const items = doc.querySelectorAll('.grid-item');
// Check if this is an empty directory (only one subdirectory, no items)
if (items.length === 0 && subdirs.length === 1) {
// This is an empty directory, collapse it with its child
const subdir = subdirs[0];
const href = subdir.getAttribute('href');
if (href) {
const baseUrl = path.replace(/\/[^\/]*$/, '/');
const subPath = baseUrl + href;
// Get the collapsed path by following the chain of empty directories
const collapsedPath = await this.getCollapsedPath(path, subPath);
return await this.buildCollapsedTreeItem(collapsedPath, depth, currentPath, maxDepth);
}
}
// Normal directory processing
const indent = ' '.repeat(depth);
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
const totalItems = items.length + subdirs.length;
const arrow = depth === 0 ? '' : '└─ ';
@@ -823,11 +910,108 @@
return html;
} catch (error) {
const indent = ' '.repeat(depth);
const arrow = depth === 0 ? '' : '└─ ';
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
return `<div class="tree-item">${indent}${arrow}📁 ${folderName} (error loading)</div>`;
}
}
/**
* Get the collapsed path by following empty directories
*/
async getCollapsedPath(startPath, currentPath) {
const pathSegments = [];
let path = startPath;
while (true) {
try {
const response = await fetch(path);
const htmlContent = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const subdirs = doc.querySelectorAll('h2 + ul li a');
const items = doc.querySelectorAll('.grid-item');
const folderName = path.split('/').filter(p => p !== '' && p !== 'index.html').pop() || 'Gallery';
pathSegments.push({ name: folderName, path: path });
// If this directory has items or multiple subdirectories, stop collapsing
if (items.length > 0 || subdirs.length !== 1) {
break;
}
// Continue to the single subdirectory
const subdir = subdirs[0];
const href = subdir.getAttribute('href');
if (href) {
const baseUrl = path.replace(/\/[^\/]*$/, '/');
path = baseUrl + href;
} else {
break;
}
} catch (error) {
break;
}
}
return {
segments: pathSegments,
finalPath: path
};
}
/**
* Build a collapsed tree item for empty directory chains
*/
async buildCollapsedTreeItem(collapsedPath, depth, currentPath, maxDepth) {
const indent = ' '.repeat(depth);
const arrow = depth === 0 ? '' : '└─ ';
// Create the collapsed display name
const displayName = collapsedPath.segments.map(seg => seg.name).join(' / ');
const finalPath = collapsedPath.finalPath;
// Get information about the final directory
let totalItems = 0;
let subdirs = [];
try {
const response = await fetch(finalPath);
const htmlContent = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const subdirElements = doc.querySelectorAll('h2 + ul li a');
const items = doc.querySelectorAll('.grid-item');
totalItems = items.length + subdirElements.length;
subdirs = Array.from(subdirElements);
} catch (error) {
// Handle error case
}
let html = '';
if (finalPath === currentPath) {
html += `<div class="tree-item">${indent}${arrow}📁 <span class="tree-current">${displayName}</span> (${totalItems} items)</div>`;
} else {
html += `<div class="tree-item">${indent}${arrow}📁 <a href="${finalPath}" class="tree-link">${displayName}</a> (${totalItems} items)</div>`;
}
// Process subdirectories of the final path
for (let i = 0; i < subdirs.length; i++) {
const subdir = subdirs[i];
const href = subdir.getAttribute('href');
if (href) {
const baseUrl = finalPath.replace(/\/[^\/]*$/, '/');
const subPath = baseUrl + href;
html += await this.buildTreeRecursive(subPath, depth + 1, currentPath, maxDepth);
}
}
return html;
}
/**
* Initialize search functionality with debouncing
*/
@@ -1037,11 +1221,16 @@
const plotPath = pathParts.join(' / ');
const thumbUrl = plotHref.replace('.pdf', '.png');
// Determine the gallery page URL (directory containing the plot)
const plotDir = plotHref.substring(0, plotHref.lastIndexOf('/'));
const galleryUrl = plotDir + '/index.html';
const plotInfo = {
name: plotName,
path: plotPath,
href: plotHref,
thumbUrl: thumbUrl,
galleryUrl: galleryUrl,
timestamp: Date.now()
};
@@ -1074,7 +1263,7 @@
let html = '';
recentPlots.forEach(plot => {
html += `
<div class="recent-plot" onclick="app.openRecentPlot('${plot.href}')" title="${plot.name}">
<div class="recent-plot" onclick="app.openRecentPlot('${plot.galleryUrl || plot.href}', '${plot.name}')" title="${plot.name}">
<img src="${plot.thumbUrl}" class="recent-plot-thumb" alt="${plot.name}" />
<div class="recent-plot-info">
<div class="recent-plot-name">${plot.name}</div>
@@ -1088,10 +1277,13 @@
}
/**
* Open recent plot and close sidebar
* Open recent plot gallery page and highlight thumbnail
*/
openRecentPlot(href) {
window.open(href, '_blank');
openRecentPlot(galleryUrl, plotName) {
// Navigate to gallery page with plot highlight parameter
const url = new URL(galleryUrl, window.location.origin);
url.searchParams.set('highlight', plotName);
window.location.href = url.toString();
this.toggleSidebar();
}
@@ -1118,6 +1310,125 @@
});
}
/**
* Handle thumbnail highlighting from URL parameters
*/
handleThumbnailHighlight() {
const urlParams = new URLSearchParams(window.location.search);
const highlightPlot = urlParams.get('highlight');
if (highlightPlot) {
// Find and highlight the thumbnail
const gridItems = document.querySelectorAll('.grid-item');
gridItems.forEach(item => {
const plotName = item.querySelector('.plot-name');
if (plotName && plotName.textContent.trim() === highlightPlot) {
item.classList.add('highlighted');
// Scroll to the highlighted item
setTimeout(() => {
item.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}, 100);
// Remove highlight after animation
setTimeout(() => {
item.classList.remove('highlighted');
}, 3000);
}
});
// Clean up URL
const newUrl = new URL(window.location);
newUrl.searchParams.delete('highlight');
window.history.replaceState({}, document.title, newUrl.toString());
}
}
/**
* Update gallery statistics display
*/
updateGalleryStats() {
// Use stats passed from Python backend
{% if stats %}
document.getElementById('fileCount').textContent = {{ stats.file_count }};
document.getElementById('folderCount').textContent = {{ stats.folder_count }};
document.getElementById('totalSize').textContent = '{{ stats.total_size }}';
{% else %}
// Fallback: count from DOM if stats not available
const gridItems = document.querySelectorAll('.grid-item');
const subdirLinks = document.querySelectorAll('a[href$="/index.html"]');
document.getElementById('fileCount').textContent = gridItems.length;
document.getElementById('folderCount').textContent = subdirLinks.length;
document.getElementById('totalSize').textContent = 'Unknown';
{% endif %}
// Set last updated time
const now = new Date();
const timeStr = now.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
document.getElementById('lastUpdated').textContent = timeStr;
}
/**
* Calculate approximate total size of displayed files
*/
async calculateApproximateSize() {
const images = document.querySelectorAll('.grid-item img');
let totalSize = 0;
let loadedCount = 0;
const sizeElement = document.getElementById('totalSize');
sizeElement.textContent = 'Loading...';
// Estimate size based on a sample of images
const sampleSize = Math.min(images.length, 5);
const sampleImages = Array.from(images).slice(0, sampleSize);
if (sampleImages.length === 0) {
sizeElement.textContent = '0 KB';
return;
}
// Calculate average size from sample
for (const img of sampleImages) {
try {
const response = await fetch(img.src, { method: 'HEAD' });
const size = parseInt(response.headers.get('content-length') || '0');
if (size > 0) {
totalSize += size;
loadedCount++;
}
} catch (e) {
// Fallback: estimate 100KB per image
totalSize += 102400;
loadedCount++;
}
}
if (loadedCount > 0) {
const averageSize = totalSize / loadedCount;
const estimatedTotal = averageSize * images.length;
sizeElement.textContent = this.formatFileSize(estimatedTotal);
} else {
sizeElement.textContent = 'Unknown';
}
}
/**
* Format file size in human readable format
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
/**
* Initialize keyboard shortcuts
*/
@@ -1199,76 +1510,6 @@
}, 3000);
}
}
/**
* Navigate to deepest content folder automatically
*/
async goToContent() {
const contentBtn = document.getElementById('contentBtn');
contentBtn.disabled = true;
contentBtn.textContent = '🔍 Searching...';
let currentPath = window.location.pathname;
let visited = new Set();
let deepestWithContent = currentPath;
while (true) {
if (visited.has(currentPath)) break;
visited.add(currentPath);
try {
const response = await fetch(currentPath);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const items = doc.querySelectorAll('.grid-item');
const subdirs = doc.querySelectorAll('h2 + ul li a');
if (items.length > 0) {
deepestWithContent = currentPath;
}
if (subdirs.length === 1) {
const nextPath = subdirs[0].getAttribute('href');
if (nextPath) {
const baseUrl = currentPath.replace(/\/[^\/]*$/, '/');
currentPath = baseUrl + nextPath;
} else {
break;
}
} else {
break;
}
} catch (error) {
console.error('Error fetching:', error);
break;
}
}
if (deepestWithContent !== window.location.pathname) {
window.location.href = deepestWithContent;
} else {
contentBtn.textContent = '✓ Already at content';
setTimeout(() => {
contentBtn.disabled = false;
contentBtn.textContent = '🎯 Go to Content';
}, 2000);
}
}
/**
* Hide 'Go to Content' button if not needed
*/
hideContentButtonIfNeeded() {
const items = document.querySelectorAll('.grid-item');
const subdirs = document.querySelectorAll('h2 + ul li a');
const contentBtn = document.getElementById('contentBtn');
if (items.length > 0 && subdirs.length === 0) {
contentBtn.style.display = 'none';
}
}
}
// ========================================
@@ -1279,7 +1520,6 @@
function toggleTheme() { app.toggleTheme(); }
function toggleSidebar() { app.toggleSidebar(); }
function refreshGallery() { app.refreshGallery(); }
function goToContent() { app.goToContent(); }
// ========================================
// INITIALIZE APPLICATION