feat: add real-time backend online/offline status indicator and live upload byte progress

This commit is contained in:
katR 2026-09-12 10:07:59 +05:30
parent 5d426ff72a
commit 7f3c84763e
4 changed files with 134 additions and 4 deletions

View file

@ -46,13 +46,70 @@ h1 {
margin-bottom: 4px; margin-bottom: 4px;
} }
.subtitle-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 0.78rem;
letter-spacing: 0.04em;
}
.subtitle { .subtitle {
font-size: 0.8rem;
color: #555; color: #555;
letter-spacing: 0.05em;
text-transform: lowercase; text-transform: lowercase;
} }
.status-sep {
color: #333;
}
.backend-status {
display: inline-flex;
align-items: center;
gap: 5px;
color: #666;
font-size: 0.72rem;
cursor: pointer;
user-select: none;
transition: color 0.15s ease;
}
.backend-status:hover {
color: #999;
}
.status-dot {
width: 6px;
height: 6px;
background: #444;
display: inline-block;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.backend-status.online {
color: #4ade80;
}
.backend-status.online .status-dot {
background: #4ade80;
box-shadow: 0 0 6px rgba(74, 222, 128, 0.45);
}
.backend-status.offline {
color: #ff5555;
}
.backend-status.offline .status-dot {
background: #ff5555;
box-shadow: 0 0 6px rgba(255, 85, 85, 0.45);
}
.backend-status.checking {
color: #eab308;
}
.backend-status.checking .status-dot {
background: #eab308;
}
/* Upload Drop Zone */ /* Upload Drop Zone */
.drop-zone { .drop-zone {
border: 1px dashed #2a2a2a; border: 1px dashed #2a2a2a;
@ -149,6 +206,14 @@ h1 {
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.progress-text {
margin-top: 8px;
font-size: 0.72rem;
color: #777;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
/* Files Section */ /* Files Section */
.files-section { .files-section {
margin-top: 42px; margin-top: 42px;

View file

@ -15,7 +15,14 @@
<main class="container"> <main class="container">
<header class="header"> <header class="header">
<h1>katdrop</h1> <h1>katdrop</h1>
<p class="subtitle">// minimal file transfer</p> <div class="subtitle-row">
<span class="subtitle">// minimal file transfer</span>
<span class="status-sep">•</span>
<div id="backendStatus" class="backend-status" title="Click to refresh status">
<span class="status-dot"></span>
<span id="statusText" class="status-text">connecting...</span>
</div>
</div>
</header> </header>
<div class="drop-zone" id="dropZone"> <div class="drop-zone" id="dropZone">
@ -27,6 +34,7 @@
<div id="fileSelection" class="file-selection"></div> <div id="fileSelection" class="file-selection"></div>
<div class="progress-wrap" id="progressWrap"> <div class="progress-wrap" id="progressWrap">
<div class="progress-bar" id="progressBar"></div> <div class="progress-bar" id="progressBar"></div>
<div class="progress-text" id="progressText"></div>
</div> </div>
</div> </div>

View file

@ -11,8 +11,11 @@
const fileSelection = document.getElementById('fileSelection'); const fileSelection = document.getElementById('fileSelection');
const progressWrap = document.getElementById('progressWrap'); const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar'); const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const fileList = document.getElementById('fileList'); const fileList = document.getElementById('fileList');
const fileCount = document.getElementById('fileCount'); const fileCount = document.getElementById('fileCount');
const backendStatus = document.getElementById('backendStatus');
const statusText = document.getElementById('statusText');
const toastEl = document.getElementById('toast'); const toastEl = document.getElementById('toast');
let selectedFiles = []; let selectedFiles = [];
@ -38,6 +41,39 @@
}, 2200); }, 2200);
} }
// Utility: Format bytes
function formatBytes(bytes) {
if (!bytes || 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];
}
// Backend Status Check
async function checkBackendStatus() {
if (!backendStatus || !statusText) return;
backendStatus.className = 'backend-status checking';
statusText.textContent = 'checking...';
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000);
const res = await fetch(`${API_BASE}/health`, { signal: controller.signal });
clearTimeout(timeoutId);
if (res.ok) {
backendStatus.className = 'backend-status online';
statusText.textContent = 'online';
} else {
throw new Error('Non-200');
}
} catch (err) {
backendStatus.className = 'backend-status offline';
statusText.textContent = 'offline';
}
}
// Interactive Particle Background // Interactive Particle Background
function initInteractiveParticles() { function initInteractiveParticles() {
const canvas = document.getElementById('particles'); const canvas = document.getElementById('particles');
@ -332,14 +368,19 @@
if (e.lengthComputable) { if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100); const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = `${pct}%`; progressBar.style.width = `${pct}%`;
if (progressText) {
progressText.textContent = `uploading: ${pct}% (${formatBytes(e.loaded)} / ${formatBytes(e.total)})`;
}
} }
}); });
xhr.onload = () => { xhr.onload = () => {
progressBar.style.width = '100%'; progressBar.style.width = '100%';
if (progressText) progressText.textContent = 'processing...';
setTimeout(() => { setTimeout(() => {
progressWrap.classList.remove('active'); progressWrap.classList.remove('active');
progressBar.style.width = '0%'; progressBar.style.width = '0%';
if (progressText) progressText.textContent = '';
fileInput.value = ''; fileInput.value = '';
updateFileSelection([]); updateFileSelection([]);
fetchFiles(); fetchFiles();
@ -350,7 +391,9 @@
xhr.onerror = () => { xhr.onerror = () => {
progressWrap.classList.remove('active'); progressWrap.classList.remove('active');
uploadBtn.disabled = false; uploadBtn.disabled = false;
showToast('upload failed'); if (progressText) progressText.textContent = '';
showToast('upload failed - check connection');
checkBackendStatus();
}; };
xhr.open('POST', `${API_BASE}/upload`); xhr.open('POST', `${API_BASE}/upload`);
@ -366,6 +409,13 @@
uploadSelectedFiles(); uploadSelectedFiles();
}); });
if (backendStatus) {
backendStatus.addEventListener('click', () => {
checkBackendStatus();
fetchFiles();
});
}
// Drag and drop // Drag and drop
['dragenter', 'dragover'].forEach(name => { ['dragenter', 'dragover'].forEach(name => {
dropZone.addEventListener(name, e => { dropZone.addEventListener(name, e => {
@ -392,5 +442,7 @@
// Init // Init
initInteractiveParticles(); initInteractiveParticles();
checkBackendStatus();
fetchFiles(); fetchFiles();
setInterval(checkBackendStatus, 15000);
})(); })();

View file

@ -104,6 +104,11 @@ app.use((req, res, next) => {
app.use(express.json()); app.use(express.json());
app.use(express.static(publicDir)); app.use(express.static(publicDir));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: Math.floor(process.uptime()) });
});
// Upload endpoint (supports single or multiple files) // Upload endpoint (supports single or multiple files)
app.post('/upload', upload.array('file', 20), (req, res) => { app.post('/upload', upload.array('file', 20), (req, res) => {
if (!req.files || req.files.length === 0) { if (!req.files || req.files.length === 0) {