feat: add real-time backend online/offline status indicator and live upload byte progress
This commit is contained in:
parent
5d426ff72a
commit
7f3c84763e
4 changed files with 134 additions and 4 deletions
|
|
@ -46,13 +46,70 @@ h1 {
|
|||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: #555;
|
||||
letter-spacing: 0.05em;
|
||||
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 */
|
||||
.drop-zone {
|
||||
border: 1px dashed #2a2a2a;
|
||||
|
|
@ -149,6 +206,14 @@ h1 {
|
|||
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 {
|
||||
margin-top: 42px;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,14 @@
|
|||
<main class="container">
|
||||
<header class="header">
|
||||
<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>
|
||||
|
||||
<div class="drop-zone" id="dropZone">
|
||||
|
|
@ -27,6 +34,7 @@
|
|||
<div id="fileSelection" class="file-selection"></div>
|
||||
<div class="progress-wrap" id="progressWrap">
|
||||
<div class="progress-bar" id="progressBar"></div>
|
||||
<div class="progress-text" id="progressText"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@
|
|||
const fileSelection = document.getElementById('fileSelection');
|
||||
const progressWrap = document.getElementById('progressWrap');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const fileList = document.getElementById('fileList');
|
||||
const fileCount = document.getElementById('fileCount');
|
||||
const backendStatus = document.getElementById('backendStatus');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const toastEl = document.getElementById('toast');
|
||||
|
||||
let selectedFiles = [];
|
||||
|
|
@ -38,6 +41,39 @@
|
|||
}, 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
|
||||
function initInteractiveParticles() {
|
||||
const canvas = document.getElementById('particles');
|
||||
|
|
@ -332,14 +368,19 @@
|
|||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
progressBar.style.width = `${pct}%`;
|
||||
if (progressText) {
|
||||
progressText.textContent = `uploading: ${pct}% (${formatBytes(e.loaded)} / ${formatBytes(e.total)})`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
progressBar.style.width = '100%';
|
||||
if (progressText) progressText.textContent = 'processing...';
|
||||
setTimeout(() => {
|
||||
progressWrap.classList.remove('active');
|
||||
progressBar.style.width = '0%';
|
||||
if (progressText) progressText.textContent = '';
|
||||
fileInput.value = '';
|
||||
updateFileSelection([]);
|
||||
fetchFiles();
|
||||
|
|
@ -350,7 +391,9 @@
|
|||
xhr.onerror = () => {
|
||||
progressWrap.classList.remove('active');
|
||||
uploadBtn.disabled = false;
|
||||
showToast('upload failed');
|
||||
if (progressText) progressText.textContent = '';
|
||||
showToast('upload failed - check connection');
|
||||
checkBackendStatus();
|
||||
};
|
||||
|
||||
xhr.open('POST', `${API_BASE}/upload`);
|
||||
|
|
@ -366,6 +409,13 @@
|
|||
uploadSelectedFiles();
|
||||
});
|
||||
|
||||
if (backendStatus) {
|
||||
backendStatus.addEventListener('click', () => {
|
||||
checkBackendStatus();
|
||||
fetchFiles();
|
||||
});
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
['dragenter', 'dragover'].forEach(name => {
|
||||
dropZone.addEventListener(name, e => {
|
||||
|
|
@ -392,5 +442,7 @@
|
|||
|
||||
// Init
|
||||
initInteractiveParticles();
|
||||
checkBackendStatus();
|
||||
fetchFiles();
|
||||
setInterval(checkBackendStatus, 15000);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ app.use((req, res, next) => {
|
|||
app.use(express.json());
|
||||
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)
|
||||
app.post('/upload', upload.array('file', 20), (req, res) => {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue