Add Cantaloupe Sync admin UI page
- New 'Cantaloupe Sync' nav item in sidebar with sync icon - Section 1: Sync Overview showing latest batch status, diff badges - Section 2: Pending Batch Review with approve/reject buttons and diff detail - Section 3: Import History with expandable rows for full diff view - Added static file serving (FileResponse) to admin_server.py - Fixed formatDate to handle space-separated SQLite datetimes - Dark theme matching existing admin UI, vanilla JS with fetch()
This commit is contained in:
@@ -24,6 +24,7 @@ from typing import Optional
|
||||
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from cantaloupe_sync import router as cantaloupe_router, create_sync_tables
|
||||
@@ -354,6 +355,20 @@ app.add_middleware(
|
||||
# Mount Cantaloupe sync routes
|
||||
app.include_router(cantaloupe_router)
|
||||
|
||||
# Serve admin frontend SPA (single index.html, no external assets needed)
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_frontend(full_path: str = ""):
|
||||
"""Serve the admin SPA — all routes return index.html."""
|
||||
if FRONTEND_INDEX.is_file():
|
||||
return FileResponse(str(FRONTEND_INDEX))
|
||||
return JSONResponse({"detail": "Frontend not found"}, status_code=404)
|
||||
|
||||
|
||||
# ─── Auth Middleware ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+379
-2
@@ -532,6 +532,62 @@
|
||||
.sidebar .sidebar-header .sh-icon { font-size: 22px; }
|
||||
.main { margin-left: 56px; padding: 16px; max-width: calc(100vw - 56px); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
CANTALOUPE SYNC
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.cs-diff-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.cs-diff-badge {
|
||||
background: var(--card2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.cs-diff-badge .csd-val { font-size: 24px; font-weight: 800; }
|
||||
.cs-diff-badge .csd-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text2); margin-top: 2px; }
|
||||
.cs-diff-badge.add .csd-val { color: var(--green); }
|
||||
.cs-diff-badge.chg .csd-val { color: var(--amber); }
|
||||
.cs-diff-badge.rem .csd-val { color: var(--red); }
|
||||
.cs-diff-badge.unc .csd-val { color: var(--text2); }
|
||||
|
||||
.cs-batch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cs-batch-row:hover { background: rgba(255,255,255,0.03); }
|
||||
.cs-batch-row .csb-status { width: 80px; flex-shrink: 0; }
|
||||
.cs-batch-row .csb-time { width: 160px; flex-shrink: 0; color: var(--text2); }
|
||||
.cs-batch-row .csb-rows { width: 80px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||
.cs-batch-row .csb-chevron { font-size: 11px; color: var(--text2); transition: transform 0.2s; flex-shrink: 0; }
|
||||
.cs-batch-row.expanded .csb-chevron { transform: rotate(90deg); }
|
||||
|
||||
.cs-batch-detail {
|
||||
display: none;
|
||||
padding: 0 12px 14px 36px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.cs-batch-detail.open { display: block; }
|
||||
|
||||
.cs-changes-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
|
||||
.cs-changes-table th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); color: var(--text2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.cs-changes-table td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.03); vertical-align: middle; }
|
||||
.cs-changes-table .cs-old { color: var(--red); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cs-changes-table .cs-new { color: var(--green); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cs-changes-table .cs-arrow { color: var(--text2); padding: 0 4px; }
|
||||
|
||||
.cs-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -581,6 +637,9 @@
|
||||
<button class="nav-item" data-page="export" onclick="switchPage('export')">
|
||||
<span class="ni-icon">📤</span><span>Export</span>
|
||||
</button>
|
||||
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')">
|
||||
<span class="ni-icon">🔄</span><span>Cantaloupe Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<button id="logoutBtn" class="logout-btn" onclick="doLogout()">🚪 Sign out</button>
|
||||
@@ -747,6 +806,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CANTALOUPE SYNC ── -->
|
||||
<div id="pageCantaloupe" class="page hidden">
|
||||
<div class="page-title">
|
||||
<span>Cantaloupe Sync</span>
|
||||
<div class="pt-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="runSync()" id="csSyncBtn">🔄 Sync Now</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="loadCantaloupeSync()">↻ Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Sync Overview -->
|
||||
<div class="card" id="csOverview">
|
||||
<div class="card-title">Latest Sync Status</div>
|
||||
<div id="csOverviewContent"><div class="loading-row"><div class="spinner"></div> Loading...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Pending Batch Review -->
|
||||
<div class="card hidden" id="csPendingReview">
|
||||
<div class="card-title">Pending Batch — Review Changes</div>
|
||||
<div id="csPendingContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Import History -->
|
||||
<div class="card">
|
||||
<div class="card-title">Import History</div>
|
||||
<div id="csHistoryContent"><div class="loading-row"><div class="spinner"></div> Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── DETAIL PAGES ── -->
|
||||
<div id="pageDetail" class="page hidden">
|
||||
<div class="page-title">
|
||||
@@ -873,6 +961,7 @@ function switchPage(page) {
|
||||
else if (page === 'users') loadUsers();
|
||||
else if (page === 'customers') loadCustomers();
|
||||
else if (page === 'activity') loadActivity();
|
||||
else if (page === 'cantaloupe') loadCantaloupeSync();
|
||||
}
|
||||
function closeDetail() {
|
||||
if (AppState._prevPage) switchPage(AppState._prevPage);
|
||||
@@ -1927,6 +2016,287 @@ async function confirmResetDb() {
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// CANTALOUPE SYNC
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
let csBatches = [];
|
||||
let csLatestBatch = null;
|
||||
|
||||
async function loadCantaloupeSync() {
|
||||
try {
|
||||
const [batches] = await Promise.all([
|
||||
api('/api/admin/cantaloupe/batches?limit=20'),
|
||||
]);
|
||||
csBatches = batches || [];
|
||||
|
||||
// Find the latest batch
|
||||
csLatestBatch = csBatches.length > 0 ? csBatches[0] : null;
|
||||
renderCsOverview();
|
||||
renderCsPendingReview();
|
||||
renderCsHistory();
|
||||
} catch (e) {
|
||||
document.getElementById('csOverviewContent').innerHTML =
|
||||
`<div style="color:var(--red);text-align:center;padding:12px;">Failed to load: ${esc(e.message)}</div>`;
|
||||
document.getElementById('csHistoryContent').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section 1: Overview ──
|
||||
function renderCsOverview() {
|
||||
const el = document.getElementById('csOverviewContent');
|
||||
if (!csLatestBatch) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">🔄</div>No sync batches yet. Click "Sync Now" to import data.</div>';
|
||||
return;
|
||||
}
|
||||
const b = csLatestBatch;
|
||||
const ds = b.diff_summary || {};
|
||||
const statusTag = csStatusBadge(b.status);
|
||||
const lastSync = formatDate(b.created_at);
|
||||
const rowCount = b.row_count || 0;
|
||||
const dsInfo = (ds.new_assets || ds.changed_assets || ds.removed_assets)
|
||||
? `<div class="cs-diff-summary">
|
||||
${ds.new_assets && ds.new_assets.length > 0 ? `<div class="cs-diff-badge add"><div class="csd-val">${ds.new_assets.length}</div><div class="csd-label">New</div></div>` : ''}
|
||||
${ds.changed_assets && ds.changed_assets.length > 0 ? `<div class="cs-diff-badge chg"><div class="csd-val">${ds.changed_assets.length}</div><div class="csd-label">Changed</div></div>` : ''}
|
||||
${ds.removed_assets && ds.removed_assets.length > 0 ? `<div class="cs-diff-badge rem"><div class="csd-val">${ds.removed_assets.length}</div><div class="csd-label">Removed</div></div>` : ''}
|
||||
${ds.unchanged_assets && ds.unchanged_assets.length > 0 ? `<div class="cs-diff-badge unc"><div class="csd-val">${ds.unchanged_assets.length}</div><div class="csd-label">Unchanged</div></div>` : ''}
|
||||
</div>` : '';
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<span style="font-size:14px;font-weight:600;">Batch #${b.id}</span>
|
||||
${statusTag}
|
||||
<span class="text-muted">${lastSync}</span>
|
||||
<span class="text-muted">${rowCount} rows</span>
|
||||
${b.error_message ? `<span class="text-danger text-sm">⚠ ${esc(b.error_message)}</span>` : ''}
|
||||
</div>
|
||||
${dsInfo}
|
||||
`;
|
||||
}
|
||||
|
||||
function csStatusBadge(status) {
|
||||
const map = {
|
||||
pending: '<span class="status-tag" style="background:var(--amber-bg);color:var(--amber);">PENDING</span>',
|
||||
approved: '<span class="status-tag active">APPROVED</span>',
|
||||
rejected: '<span class="status-tag" style="background:var(--red-bg);color:var(--red);">REJECTED</span>',
|
||||
error: '<span class="status-tag" style="background:var(--red-bg);color:var(--red);">ERROR</span>',
|
||||
};
|
||||
return map[status] || `<span class="status-tag">${esc(status)}</span>`;
|
||||
}
|
||||
|
||||
// ── Section 2: Pending Review ──
|
||||
function renderCsPendingReview() {
|
||||
const card = document.getElementById('csPendingReview');
|
||||
const el = document.getElementById('csPendingContent');
|
||||
// Find a pending batch
|
||||
const pending = csBatches.find(b => b.status === 'pending');
|
||||
if (!pending) {
|
||||
card.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
card.classList.remove('hidden');
|
||||
const ds = pending.diff_summary || {};
|
||||
const newCount = (ds.new_assets || []).length;
|
||||
const chgCount = (ds.changed_assets || []).length;
|
||||
const remCount = (ds.removed_assets || []).length;
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="margin-bottom:10px;">
|
||||
<strong>Batch #${pending.id}</strong>
|
||||
<span class="text-muted"> · ${formatDate(pending.created_at)} · ${pending.row_count} rows</span>
|
||||
</div>
|
||||
<div class="cs-diff-summary">
|
||||
<div class="cs-diff-badge add"><div class="csd-val">${newCount}</div><div class="csd-label">New</div></div>
|
||||
<div class="cs-diff-badge chg"><div class="csd-val">${chgCount}</div><div class="csd-label">Changed</div></div>
|
||||
<div class="cs-diff-badge rem"><div class="csd-val">${remCount}</div><div class="csd-label">Removed</div></div>
|
||||
</div>
|
||||
<div id="csPendingDetail"></div>
|
||||
<div class="cs-actions">
|
||||
<button class="btn btn-green" onclick="approveBatch(${pending.id})">✓ Approve</button>
|
||||
<button class="btn btn-danger" onclick="rejectBatch(${pending.id})">✕ Reject</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Load full detail for the pending batch
|
||||
loadPendingBatchDetail(pending.id);
|
||||
}
|
||||
|
||||
async function loadPendingBatchDetail(batchId) {
|
||||
const el = document.getElementById('csPendingDetail');
|
||||
try {
|
||||
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
|
||||
renderBatchDiffDetail(el, batch);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="text-danger text-sm">Failed to load detail: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBatchDiffDetail(el, batch) {
|
||||
const dr = batch.diff_rows || {};
|
||||
const newItems = dr.new || [];
|
||||
const removedItems = dr.removed || [];
|
||||
const changedItems = dr.changed || [];
|
||||
|
||||
let html = '';
|
||||
|
||||
// New assets table
|
||||
if (newItems.length > 0) {
|
||||
html += `<div class="card-title" style="margin-top:8px;color:var(--green);">+ New Assets (${newItems.length})</div>`;
|
||||
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th><th>Customer</th><th>Location</th></tr></thead><tbody>`;
|
||||
html += newItems.map(r => `<tr>
|
||||
<td><code class="mono">${esc(r.machine_id)}</code></td>
|
||||
<td>${esc(r.name || '—')}</td>
|
||||
<td>${esc(r.customer || '—')}</td>
|
||||
<td>${esc(r.location || '—')}</td>
|
||||
</tr>`).join('');
|
||||
html += `</tbody></table>`;
|
||||
}
|
||||
|
||||
// Changed assets table
|
||||
if (changedItems.length > 0) {
|
||||
html += `<div class="card-title" style="margin-top:8px;color:var(--amber);">~ Changed Assets (${changedItems.length})</div>`;
|
||||
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th><th>Field</th><th>Old Value</th><th></th><th>New Value</th></tr></thead><tbody>`;
|
||||
html += changedItems.map(r => {
|
||||
const changes = r.changes || [];
|
||||
const firstRow = `<td rowspan="${changes.length || 1}"><code class="mono">${esc(r.machine_id)}</code></td><td rowspan="${changes.length || 1}">${esc(r.name || '—')}</td>`;
|
||||
if (changes.length === 0) {
|
||||
return `<tr>${firstRow}<td colspan="4" class="text-muted">No field-level changes</td></tr>`;
|
||||
}
|
||||
return changes.map((ch, i) => {
|
||||
const row = i === 0 ? firstRow : '';
|
||||
return `<tr>${row}
|
||||
<td style="font-weight:600;">${esc(ch.field)}</td>
|
||||
<td class="cs-old">${esc(String(ch.old_value ?? '—'))}</td>
|
||||
<td class="cs-arrow">→</td>
|
||||
<td class="cs-new">${esc(String(ch.new_value ?? '—'))}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}).join('');
|
||||
html += `</tbody></table>`;
|
||||
}
|
||||
|
||||
// Removed assets table
|
||||
if (removedItems.length > 0) {
|
||||
html += `<div class="card-title" style="margin-top:8px;color:var(--red);">− Removed Assets (${removedItems.length})</div>`;
|
||||
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th></tr></thead><tbody>`;
|
||||
html += removedItems.map(r => `<tr>
|
||||
<td><code class="mono">${esc(r.machine_id)}</code></td>
|
||||
<td>${esc(r.name || '—')}</td>
|
||||
</tr>`).join('');
|
||||
html += `</tbody></table>`;
|
||||
}
|
||||
|
||||
if (newItems.length === 0 && changedItems.length === 0 && removedItems.length === 0) {
|
||||
html = '<div class="text-muted text-sm">No changes detected.</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Section 3: Import History ──
|
||||
function renderCsHistory() {
|
||||
const el = document.getElementById('csHistoryContent');
|
||||
if (csBatches.length === 0) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No import history</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = csBatches.map(b => {
|
||||
const ds = b.diff_summary || {};
|
||||
const newCount = (ds.new_assets || []).length;
|
||||
const chgCount = (ds.changed_assets || []).length;
|
||||
const remCount = (ds.removed_assets || []).length;
|
||||
const summaryParts = [];
|
||||
if (newCount) summaryParts.push(`+${newCount}`);
|
||||
if (chgCount) summaryParts.push(`~${chgCount}`);
|
||||
if (remCount) summaryParts.push(`−${remCount}`);
|
||||
return `
|
||||
<div class="cs-batch-row" onclick="toggleBatchHistory(${b.id})" id="csBatchRow_${b.id}">
|
||||
<span class="csb-status">${csStatusBadge(b.status)}</span>
|
||||
<span class="csb-time">${formatDate(b.created_at)}</span>
|
||||
<span class="csb-rows">${b.row_count} rows</span>
|
||||
<span class="text-muted text-sm" style="flex:1;">${summaryParts.join(' ')}</span>
|
||||
<span class="csb-chevron">▶</span>
|
||||
</div>
|
||||
<div class="cs-batch-detail" id="csBatchDetail_${b.id}">
|
||||
<div class="loading-row"><div class="spinner"></div> Loading...</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleBatchHistory(batchId) {
|
||||
const row = document.getElementById('csBatchRow_' + batchId);
|
||||
const detail = document.getElementById('csBatchDetail_' + batchId);
|
||||
if (!row || !detail) return;
|
||||
|
||||
const isExpanded = row.classList.contains('expanded');
|
||||
if (isExpanded) {
|
||||
row.classList.remove('expanded');
|
||||
detail.classList.remove('open');
|
||||
return;
|
||||
}
|
||||
|
||||
row.classList.add('expanded');
|
||||
detail.classList.add('open');
|
||||
|
||||
// Fetch detail if not already loaded
|
||||
if (detail.dataset.loaded !== '1') {
|
||||
try {
|
||||
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
|
||||
// Render into a temporary container, then swap
|
||||
const tmp = document.createElement('div');
|
||||
renderBatchDiffDetail(tmp, batch);
|
||||
detail.innerHTML = tmp.innerHTML;
|
||||
detail.dataset.loaded = '1';
|
||||
} catch (e) {
|
||||
detail.innerHTML = `<div class="text-danger text-sm">Failed to load: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sync Now ──
|
||||
async function runSync() {
|
||||
const btn = document.getElementById('csSyncBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<div class="spinner"></div> Syncing...';
|
||||
try {
|
||||
const result = await api('/api/admin/cantaloupe/sync', { method: 'POST' });
|
||||
showToast('Sync completed — batch #' + result.id);
|
||||
await loadCantaloupeSync();
|
||||
} catch (e) {
|
||||
showToast('Sync failed: ' + e.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '🔄 Sync Now';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Approve / Reject ──
|
||||
async function approveBatch(batchId) {
|
||||
const confirmed = await showModal('Approve Batch',
|
||||
`Apply all ${csLatestBatch && csLatestBatch.id === batchId ? 'pending ' : ''}changes from batch #${batchId} to live data?`,
|
||||
'Approve', 'btn-green');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const result = await api(`/api/admin/cantaloupe/batches/${batchId}/approve`, { method: 'POST' });
|
||||
showToast('Batch approved — ' + (result.applied_stats ? JSON.stringify(result.applied_stats) : 'done'));
|
||||
await loadCantaloupeSync();
|
||||
} catch (e) {
|
||||
showToast('Approve failed: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectBatch(batchId) {
|
||||
const confirmed = await showModal('Reject Batch',
|
||||
`Discard batch #${batchId}? This cannot be undone.`,
|
||||
'Reject', 'btn-danger');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api(`/api/admin/cantaloupe/batches/${batchId}/reject`, { method: 'POST' });
|
||||
showToast('Batch rejected');
|
||||
await loadCantaloupeSync();
|
||||
} catch (e) {
|
||||
showToast('Reject failed: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// HELPERS
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1937,9 +2307,16 @@ function esc(s) {
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso + (iso.includes('T') ? '' : 'T00:00:00'));
|
||||
// Handle space-separated datetime (SQLite format): "2026-05-21 23:16:21"
|
||||
let normalized = iso;
|
||||
if (!iso.includes('T') && iso.includes(' ')) {
|
||||
normalized = iso.replace(' ', 'T');
|
||||
} else if (!iso.includes('T')) {
|
||||
normalized = iso + 'T00:00:00';
|
||||
}
|
||||
const d = new Date(normalized);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
|
||||
Reference in New Issue
Block a user