Update
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__pycache__
|
||||
+82
-2
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user