adds delete and archive abilities
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "BookManager.h"
|
||||
#include "html/FilesPageHtml.generated.h"
|
||||
#include "html/HomePageHtml.generated.h"
|
||||
#include "util/StringUtils.h"
|
||||
@@ -106,6 +107,11 @@ void CrossPointWebServer::begin() {
|
||||
// Delete file/folder endpoint
|
||||
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
|
||||
|
||||
// Archive/Unarchive endpoints
|
||||
server->on("/archive", HTTP_POST, [this] { handleArchive(); });
|
||||
server->on("/unarchive", HTTP_POST, [this] { handleUnarchive(); });
|
||||
server->on("/api/archived", HTTP_GET, [this] { handleArchivedList(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
@@ -602,6 +608,7 @@ void CrossPointWebServer::handleDelete() const {
|
||||
|
||||
String itemPath = server->arg("path");
|
||||
const String itemType = server->hasArg("type") ? server->arg("type") : "file";
|
||||
const bool isArchived = server->hasArg("archived") && server->arg("archived") == "true";
|
||||
|
||||
// Validate path
|
||||
if (itemPath.isEmpty() || itemPath == "/") {
|
||||
@@ -617,8 +624,8 @@ void CrossPointWebServer::handleDelete() const {
|
||||
// Security check: prevent deletion of protected items
|
||||
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
|
||||
|
||||
// Check if item starts with a dot (hidden/system file)
|
||||
if (itemName.startsWith(".")) {
|
||||
// Check if item starts with a dot (hidden/system file) - but allow archived items
|
||||
if (itemName.startsWith(".") && !isArchived) {
|
||||
Serial.printf("[%lu] [WEB] Delete rejected - hidden/system item: %s\n", millis(), itemPath.c_str());
|
||||
server->send(403, "text/plain", "Cannot delete system files");
|
||||
return;
|
||||
@@ -633,18 +640,19 @@ void CrossPointWebServer::handleDelete() const {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if item exists
|
||||
if (!SdMan.exists(itemPath.c_str())) {
|
||||
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
|
||||
server->send(404, "text/plain", "Item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [WEB] Attempting to delete %s: %s\n", millis(), itemType.c_str(), itemPath.c_str());
|
||||
Serial.printf("[%lu] [WEB] Attempting to delete %s (archived=%d): %s\n", millis(), itemType.c_str(), isArchived,
|
||||
itemPath.c_str());
|
||||
|
||||
bool success = false;
|
||||
|
||||
if (itemType == "folder") {
|
||||
// Check if item exists
|
||||
if (!SdMan.exists(itemPath.c_str())) {
|
||||
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
|
||||
server->send(404, "text/plain", "Item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// For folders, try to remove (will fail if not empty)
|
||||
FsFile dir = SdMan.open(itemPath.c_str());
|
||||
if (dir && dir.isDirectory()) {
|
||||
@@ -662,8 +670,13 @@ void CrossPointWebServer::handleDelete() const {
|
||||
}
|
||||
success = SdMan.rmdir(itemPath.c_str());
|
||||
} else {
|
||||
// For files, use remove
|
||||
success = SdMan.remove(itemPath.c_str());
|
||||
// For files, use BookManager to also clean up cache and recent books
|
||||
if (isArchived) {
|
||||
// For archived books, just pass the filename
|
||||
success = BookManager::deleteBook(itemName.c_str(), true);
|
||||
} else {
|
||||
success = BookManager::deleteBook(itemPath.c_str(), false);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
@@ -675,6 +688,90 @@ void CrossPointWebServer::handleDelete() const {
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleArchive() const {
|
||||
if (!server->hasArg("path")) {
|
||||
server->send(400, "text/plain", "Missing path");
|
||||
return;
|
||||
}
|
||||
|
||||
String bookPath = server->arg("path");
|
||||
|
||||
// Validate path
|
||||
if (bookPath.isEmpty() || bookPath == "/") {
|
||||
server->send(400, "text/plain", "Invalid path");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure path starts with /
|
||||
if (!bookPath.startsWith("/")) {
|
||||
bookPath = "/" + bookPath;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [WEB] Archiving book: %s\n", millis(), bookPath.c_str());
|
||||
|
||||
if (BookManager::archiveBook(bookPath.c_str())) {
|
||||
server->send(200, "text/plain", "Book archived successfully");
|
||||
} else {
|
||||
server->send(500, "text/plain", "Failed to archive book");
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleUnarchive() const {
|
||||
if (!server->hasArg("filename")) {
|
||||
server->send(400, "text/plain", "Missing filename");
|
||||
return;
|
||||
}
|
||||
|
||||
const String filename = server->arg("filename");
|
||||
|
||||
if (filename.isEmpty()) {
|
||||
server->send(400, "text/plain", "Invalid filename");
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [WEB] Unarchiving book: %s\n", millis(), filename.c_str());
|
||||
|
||||
// Get the original path before unarchiving (for response)
|
||||
const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename.c_str());
|
||||
|
||||
if (BookManager::unarchiveBook(filename.c_str())) {
|
||||
// Return JSON with the original path
|
||||
String response = "{\"success\":true,\"originalPath\":\"";
|
||||
response += originalPath.c_str();
|
||||
response += "\"}";
|
||||
server->send(200, "application/json", response);
|
||||
} else {
|
||||
server->send(500, "text/plain", "Failed to unarchive book");
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleArchivedList() const {
|
||||
Serial.printf("[%lu] [WEB] Fetching archived books list\n", millis());
|
||||
|
||||
const auto archivedBooks = BookManager::listArchivedBooks();
|
||||
|
||||
// Build JSON response
|
||||
String response = "[";
|
||||
bool first = true;
|
||||
for (const auto& filename : archivedBooks) {
|
||||
if (!first) {
|
||||
response += ",";
|
||||
}
|
||||
first = false;
|
||||
|
||||
const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename);
|
||||
|
||||
response += "{\"filename\":\"";
|
||||
response += filename.c_str();
|
||||
response += "\",\"originalPath\":\"";
|
||||
response += originalPath.c_str();
|
||||
response += "\"}";
|
||||
}
|
||||
response += "]";
|
||||
|
||||
server->send(200, "application/json", response);
|
||||
}
|
||||
|
||||
// WebSocket callback trampoline
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
|
||||
@@ -60,4 +60,7 @@ class CrossPointWebServer {
|
||||
void handleUploadPost() const;
|
||||
void handleCreateFolder() const;
|
||||
void handleDelete() const;
|
||||
void handleArchive() const;
|
||||
void handleUnarchive() const;
|
||||
void handleArchivedList() const;
|
||||
};
|
||||
|
||||
@@ -322,8 +322,8 @@
|
||||
.folder-btn:hover {
|
||||
background-color: #d68910;
|
||||
}
|
||||
/* Delete button styles */
|
||||
.delete-btn {
|
||||
/* Action button styles */
|
||||
.delete-btn, .archive-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -337,10 +337,75 @@
|
||||
background-color: #fee;
|
||||
color: #e74c3c;
|
||||
}
|
||||
.archive-btn:hover {
|
||||
background-color: #e8f4fd;
|
||||
color: #3498db;
|
||||
}
|
||||
.actions-col {
|
||||
width: 60px;
|
||||
width: 90px;
|
||||
text-align: center;
|
||||
}
|
||||
/* Archived files button */
|
||||
.archived-action-btn {
|
||||
background-color: #9b59b6;
|
||||
}
|
||||
.archived-action-btn:hover {
|
||||
background-color: #8e44ad;
|
||||
}
|
||||
/* Archive modal styles */
|
||||
.archive-warning {
|
||||
color: #3498db;
|
||||
font-weight: 600;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.archive-btn-confirm {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
.archive-btn-confirm:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
/* Archived files list */
|
||||
.archived-file-row {
|
||||
background-color: #f3e5f5 !important;
|
||||
}
|
||||
.archived-file-row:hover {
|
||||
background-color: #e1bee7 !important;
|
||||
}
|
||||
.original-path {
|
||||
font-size: 0.8em;
|
||||
color: #7f8c8d;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.restore-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #95a5a6;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.restore-btn:hover {
|
||||
background-color: #e8f6e9;
|
||||
color: #27ae60;
|
||||
}
|
||||
.archive-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
background-color: #9b59b6;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
margin-left: 10px;
|
||||
}
|
||||
/* Failed uploads banner */
|
||||
.failed-uploads-banner {
|
||||
background-color: #fff3cd;
|
||||
@@ -586,6 +651,7 @@
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
|
||||
<button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
|
||||
<button class="action-btn archived-action-btn" onclick="openArchivedModal()">📦 Archived <span id="archivedCount"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -659,12 +725,58 @@
|
||||
<p class="delete-item-name" id="deleteItemName"></p>
|
||||
<input type="hidden" id="deleteItemPath">
|
||||
<input type="hidden" id="deleteItemType">
|
||||
<input type="hidden" id="deleteItemArchived" value="false">
|
||||
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
|
||||
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archive Confirmation Modal -->
|
||||
<div class="modal-overlay" id="archiveModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeArchiveModal()">×</button>
|
||||
<h3>📦 Archive Book</h3>
|
||||
<div class="folder-form">
|
||||
<p class="archive-warning">📦 Book will be moved to archive</p>
|
||||
<p class="file-info">Reading progress will be saved. You can restore it later.</p>
|
||||
<p class="delete-item-name" id="archiveItemName"></p>
|
||||
<input type="hidden" id="archiveItemPath">
|
||||
<button class="archive-btn-confirm" onclick="confirmArchive()">Archive</button>
|
||||
<button class="delete-btn-cancel" onclick="closeArchiveModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archived Files Modal -->
|
||||
<div class="modal-overlay" id="archivedModal">
|
||||
<div class="modal" style="max-width: 600px;">
|
||||
<button class="modal-close" onclick="closeArchivedModal()">×</button>
|
||||
<h3>📦 Archived Books</h3>
|
||||
<div id="archivedFilesList">
|
||||
<div class="loader-container">
|
||||
<span class="loader"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore Confirmation Modal -->
|
||||
<div class="modal-overlay" id="restoreModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeRestoreModal()">×</button>
|
||||
<h3>📤 Restore Book</h3>
|
||||
<div class="folder-form">
|
||||
<p class="file-info">Restore book to its original location:</p>
|
||||
<p class="delete-item-name" id="restoreItemName"></p>
|
||||
<p class="original-path" id="restoreOriginalPath"></p>
|
||||
<input type="hidden" id="restoreItemFilename">
|
||||
<button class="archive-btn-confirm" onclick="confirmRestore()">Restore</button>
|
||||
<button class="delete-btn-cancel" onclick="closeRestoreModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// get current path from query parameter
|
||||
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
||||
@@ -750,6 +862,12 @@
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Check if file is a book format (can be archived)
|
||||
function isBookFormat(filename) {
|
||||
const ext = filename.toLowerCase();
|
||||
return ext.endsWith('.epub') || ext.endsWith('.txt') || ext.endsWith('.xtc') || ext.endsWith('.xtch');
|
||||
}
|
||||
|
||||
sortedFiles.forEach(file => {
|
||||
if (file.isDirectory) {
|
||||
let folderPath = currentPath;
|
||||
@@ -773,7 +891,12 @@
|
||||
fileTableContent += '</td>';
|
||||
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
|
||||
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||||
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button></td>`;
|
||||
fileTableContent += '<td class="actions-col">';
|
||||
if (isBookFormat(file.name)) {
|
||||
fileTableContent += `<button class="archive-btn" onclick="openArchiveModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}')" title="Archive book">📦</button>`;
|
||||
}
|
||||
fileTableContent += `<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button>`;
|
||||
fileTableContent += '</td>';
|
||||
fileTableContent += '</tr>';
|
||||
}
|
||||
});
|
||||
@@ -1176,10 +1299,11 @@ function retryAllFailedUploads() {
|
||||
}
|
||||
|
||||
// Delete functions
|
||||
function openDeleteModal(name, path, isFolder) {
|
||||
function openDeleteModal(name, path, isFolder, isArchived = false) {
|
||||
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
|
||||
document.getElementById('deleteItemPath').value = path;
|
||||
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
|
||||
document.getElementById('deleteItemArchived').value = isArchived ? 'true' : 'false';
|
||||
document.getElementById('deleteModal').classList.add('open');
|
||||
}
|
||||
|
||||
@@ -1190,17 +1314,26 @@ function retryAllFailedUploads() {
|
||||
function confirmDelete() {
|
||||
const path = document.getElementById('deleteItemPath').value;
|
||||
const itemType = document.getElementById('deleteItemType').value;
|
||||
const isArchived = document.getElementById('deleteItemArchived').value === 'true';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('path', path);
|
||||
formData.append('type', itemType);
|
||||
if (isArchived) {
|
||||
formData.append('archived', 'true');
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/delete', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
window.location.reload();
|
||||
if (isArchived) {
|
||||
closeDeleteModal();
|
||||
loadArchivedFiles();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
alert('Failed to delete: ' + xhr.responseText);
|
||||
closeDeleteModal();
|
||||
@@ -1215,7 +1348,166 @@ function retryAllFailedUploads() {
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Archive functions
|
||||
function openArchiveModal(name, path) {
|
||||
document.getElementById('archiveItemName').textContent = '📄 ' + name;
|
||||
document.getElementById('archiveItemPath').value = path;
|
||||
document.getElementById('archiveModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeArchiveModal() {
|
||||
document.getElementById('archiveModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function confirmArchive() {
|
||||
const path = document.getElementById('archiveItemPath').value;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('path', path);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/archive', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to archive: ' + xhr.responseText);
|
||||
closeArchiveModal();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
alert('Failed to archive - network error');
|
||||
closeArchiveModal();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Archived files modal
|
||||
function openArchivedModal() {
|
||||
document.getElementById('archivedModal').classList.add('open');
|
||||
loadArchivedFiles();
|
||||
}
|
||||
|
||||
function closeArchivedModal() {
|
||||
document.getElementById('archivedModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function loadArchivedFiles() {
|
||||
const container = document.getElementById('archivedFilesList');
|
||||
container.innerHTML = '<div class="loader-container"><span class="loader"></span></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/archived');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load archived files');
|
||||
}
|
||||
const archivedFiles = await response.json();
|
||||
|
||||
// Update the badge count
|
||||
const countSpan = document.getElementById('archivedCount');
|
||||
if (archivedFiles.length > 0) {
|
||||
countSpan.textContent = '(' + archivedFiles.length + ')';
|
||||
} else {
|
||||
countSpan.textContent = '';
|
||||
}
|
||||
|
||||
if (archivedFiles.length === 0) {
|
||||
container.innerHTML = '<div class="no-files">No archived books</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="file-table">';
|
||||
html += '<tr><th>Book</th><th class="actions-col">Actions</th></tr>';
|
||||
|
||||
archivedFiles.forEach(file => {
|
||||
html += '<tr class="archived-file-row">';
|
||||
html += '<td>';
|
||||
html += '<span class="file-icon">📄</span>' + escapeHtml(file.filename);
|
||||
html += '<div class="original-path">Original: ' + escapeHtml(file.originalPath) + '</div>';
|
||||
html += '</td>';
|
||||
html += '<td class="actions-col">';
|
||||
html += `<button class="restore-btn" onclick="openRestoreModal('${file.filename.replaceAll("'", "\\'")}', '${file.originalPath.replaceAll("'", "\\'")}')" title="Restore book">📤</button>`;
|
||||
html += `<button class="delete-btn" onclick="openDeleteModal('${file.filename.replaceAll("'", "\\'")}', '${file.filename.replaceAll("'", "\\'")}', false, true)" title="Delete permanently">🗑️</button>`;
|
||||
html += '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
container.innerHTML = '<div class="no-files">Failed to load archived files</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Restore functions
|
||||
function openRestoreModal(filename, originalPath) {
|
||||
document.getElementById('restoreItemName').textContent = '📄 ' + filename;
|
||||
document.getElementById('restoreOriginalPath').textContent = '→ ' + originalPath;
|
||||
document.getElementById('restoreItemFilename').value = filename;
|
||||
document.getElementById('restoreModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeRestoreModal() {
|
||||
document.getElementById('restoreModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function confirmRestore() {
|
||||
const filename = document.getElementById('restoreItemFilename').value;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('filename', filename);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/unarchive', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
alert('Book restored to: ' + result.originalPath);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
closeRestoreModal();
|
||||
loadArchivedFiles();
|
||||
// Also refresh the main file list in case we're viewing that folder
|
||||
hydrate();
|
||||
} else {
|
||||
alert('Failed to restore: ' + xhr.responseText);
|
||||
closeRestoreModal();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
alert('Failed to restore - network error');
|
||||
closeRestoreModal();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Load archived count on page load
|
||||
async function loadArchivedCount() {
|
||||
try {
|
||||
const response = await fetch('/api/archived');
|
||||
if (response.ok) {
|
||||
const archivedFiles = await response.json();
|
||||
const countSpan = document.getElementById('archivedCount');
|
||||
if (archivedFiles.length > 0) {
|
||||
countSpan.textContent = '(' + archivedFiles.length + ')';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
hydrate();
|
||||
loadArchivedCount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user