8dc105ff3e
- Delete React files (App.jsx, main.jsx, App.css) - Create modular vanilla JS structure in client/src/: - state.js (global state object) - api.js (all fetch calls) - sse.js (SSE connection management) - ui/auth.js (authentication UI) - ui/downloads.js (downloads rendering) - ui/history.js (history section) - ui/statusPanel.js (status panel) - ui/webhooks.js (webhook management) - ui/filters.js (download client filter) - ui/theme.js (theme switching) - ui/tabs.js (tab navigation) - utils/format.js (formatting utilities) - utils/storage.js (localStorage helpers) - main.js (DOMContentLoaded bootstrap) - Update vite.config.js for vanilla build outputting to ../public/app.js - Build succeeds: 14 modules, 43.88 kB output
567 lines
21 KiB
JavaScript
567 lines
21 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
|
|
import { state } from '../state.js';
|
|
import { formatSize, formatSpeed, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
|
|
import { handleBlocklistSearch } from '../api.js';
|
|
|
|
export function renderDownloads() {
|
|
const downloadsList = document.getElementById('downloads-list');
|
|
const noDownloads = document.getElementById('no-downloads');
|
|
|
|
// Filter downloads by selected clients
|
|
let filteredDownloads = state.downloads;
|
|
if (state.selectedDownloadClients.length > 0) {
|
|
// Map indices to client objects, then filter by both client type and instanceId
|
|
const selectedClients = state.selectedDownloadClients.map(idx => state.downloadClients[idx]).filter(Boolean);
|
|
filteredDownloads = state.downloads.filter(d =>
|
|
selectedClients.some(c => c.type === d.client && c.id === d.instanceId)
|
|
);
|
|
}
|
|
|
|
// Sort downloads by client order (matching the order in downloadClients)
|
|
if (state.downloadClients.length > 0) {
|
|
const clientOrder = new Map(state.downloadClients.map((c, idx) => [c.id, idx]));
|
|
filteredDownloads = [...filteredDownloads].sort((a, b) => {
|
|
const orderA = clientOrder.get(a.instanceId) ?? Infinity;
|
|
const orderB = clientOrder.get(b.instanceId) ?? Infinity;
|
|
return orderA - orderB;
|
|
});
|
|
}
|
|
|
|
if (filteredDownloads.length === 0) {
|
|
noDownloads.style.display = 'block';
|
|
downloadsList.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
noDownloads.style.display = 'none';
|
|
|
|
// Get existing cards
|
|
const existingCards = new Map();
|
|
downloadsList.querySelectorAll('.download-card').forEach(card => {
|
|
existingCards.set(card.dataset.id, card);
|
|
});
|
|
|
|
// Track which downloads we've processed
|
|
const processedIds = new Set();
|
|
|
|
filteredDownloads.forEach(download => {
|
|
const id = download.title;
|
|
processedIds.add(id);
|
|
|
|
const existingCard = existingCards.get(id);
|
|
if (existingCard) {
|
|
// Update existing card
|
|
updateDownloadCard(existingCard, download);
|
|
} else {
|
|
// Create new card
|
|
const card = createDownloadCard(download);
|
|
downloadsList.appendChild(card);
|
|
}
|
|
});
|
|
|
|
// Remove cards for downloads that no longer exist
|
|
existingCards.forEach((card, id) => {
|
|
if (!processedIds.has(id)) {
|
|
card.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
export function updateDownloadCard(card, download) {
|
|
// Remove old header-right container if it exists
|
|
const oldRightSide = card.querySelector('.download-header-right');
|
|
if (oldRightSide) {
|
|
oldRightSide.remove();
|
|
}
|
|
|
|
// Remove old user badges directly in header
|
|
const oldBadges = card.querySelectorAll('.download-header .download-user-badge');
|
|
oldBadges.forEach(badge => badge.remove());
|
|
|
|
// Remove old client logo from header (old structure)
|
|
const oldLogoInHeader = card.querySelector('.download-header .download-client-logo-wrapper');
|
|
if (oldLogoInHeader) {
|
|
oldLogoInHeader.remove();
|
|
}
|
|
|
|
// Remove old client logo from card (new structure) if it exists
|
|
const oldLogoInCard = card.querySelector('.download-card-logo-wrapper');
|
|
if (oldLogoInCard) {
|
|
oldLogoInCard.remove();
|
|
}
|
|
|
|
// Add new right-side container with user badge only
|
|
const header = card.querySelector('.download-header');
|
|
if (header && !header.querySelector('.download-header-right')) {
|
|
const rightSide = document.createElement('div');
|
|
rightSide.className = 'download-header-right';
|
|
|
|
if (state.showAll && download.tagBadges && download.tagBadges.length > 0) {
|
|
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
|
|
const matched = download.tagBadges.filter(b => b.matchedUser);
|
|
for (const b of unmatched) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'download-user-badge unmatched';
|
|
badge.textContent = b.label;
|
|
rightSide.appendChild(badge);
|
|
}
|
|
for (const b of matched) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'download-user-badge';
|
|
badge.textContent = b.matchedUser;
|
|
rightSide.appendChild(badge);
|
|
}
|
|
} else if (download.matchedUserTag) {
|
|
const matchedBadge = document.createElement('span');
|
|
matchedBadge.className = 'download-user-badge';
|
|
matchedBadge.textContent = download.matchedUserTag;
|
|
rightSide.appendChild(matchedBadge);
|
|
}
|
|
|
|
header.appendChild(rightSide);
|
|
}
|
|
|
|
// Add client logo to card (positioned at bottom right via CSS)
|
|
if (download.client && !card.querySelector('.download-card-logo-wrapper')) {
|
|
const clientLogoWrapper = document.createElement('span');
|
|
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
|
|
|
const clientLogo = document.createElement('img');
|
|
clientLogo.className = 'download-client-logo';
|
|
clientLogo.src = `/images/clients/${download.client}.svg`;
|
|
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
|
clientLogo.title = download.instanceName || download.client;
|
|
clientLogo.onerror = () => {
|
|
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
|
clientLogoWrapper.classList.add('fallback');
|
|
};
|
|
|
|
clientLogoWrapper.appendChild(clientLogo);
|
|
card.appendChild(clientLogoWrapper);
|
|
}
|
|
|
|
// Update status
|
|
const statusEl = card.querySelector('.download-status');
|
|
if (statusEl && statusEl.textContent !== download.status) {
|
|
statusEl.textContent = download.status;
|
|
statusEl.className = `download-status ${download.status}`;
|
|
}
|
|
|
|
// Update progress bar and missing pieces
|
|
const progressContainer = card.querySelector('.progress-container');
|
|
if (progressContainer && download.progress !== undefined) {
|
|
const progressBar = progressContainer.querySelector('.progress-bar');
|
|
const progressText = progressContainer.querySelector('.progress-text');
|
|
const missingText = progressContainer.querySelector('.missing-text');
|
|
|
|
if (progressBar) {
|
|
const downloaded = progressBar.querySelector('.downloaded');
|
|
if (downloaded) {
|
|
downloaded.style.width = download.progress + '%';
|
|
}
|
|
}
|
|
|
|
if (progressText) {
|
|
progressText.textContent = download.progress + '%';
|
|
}
|
|
|
|
if (missingText) {
|
|
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
|
const missingMb = parseFloat(download.mbmissing) || 0;
|
|
if (missingMb > 0 && totalMb > 0) {
|
|
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
|
} else {
|
|
missingText.textContent = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update speed
|
|
const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value');
|
|
if (speedEl && download.speed !== undefined) {
|
|
speedEl.textContent = download.speed;
|
|
}
|
|
|
|
// Update ETA
|
|
const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value');
|
|
if (etaEl && download.eta !== undefined) {
|
|
etaEl.textContent = download.eta;
|
|
}
|
|
|
|
// Update qBittorrent-specific fields
|
|
if (download.qbittorrent) {
|
|
const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value');
|
|
if (seedsEl && download.seeds !== undefined) {
|
|
seedsEl.textContent = download.seeds;
|
|
}
|
|
|
|
const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value');
|
|
if (peersEl && download.peers !== undefined) {
|
|
peersEl.textContent = download.peers;
|
|
}
|
|
|
|
const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
|
|
if (availabilityItem && download.availability !== undefined) {
|
|
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
|
|
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function handleBlocklistSearchClick(btn, download) {
|
|
if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return;
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = '⏳ Working…';
|
|
|
|
try {
|
|
await handleBlocklistSearch(download);
|
|
btn.textContent = '✓ Done — searching…';
|
|
btn.className = 'blocklist-search-btn success';
|
|
} catch (err) {
|
|
console.error('[Blocklist] Error:', err);
|
|
btn.disabled = false;
|
|
btn.textContent = '⛔ Blocklist & Search';
|
|
btn.className = 'blocklist-search-btn error';
|
|
btn.title = `Failed: ${err.message}`;
|
|
setTimeout(() => {
|
|
btn.className = 'blocklist-search-btn';
|
|
btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
|
}, 4000);
|
|
}
|
|
}
|
|
|
|
export function createDownloadCard(download) {
|
|
const card = document.createElement('div');
|
|
card.className = `download-card ${download.type}`;
|
|
card.dataset.id = download.title;
|
|
|
|
// Cover art
|
|
if (download.coverArt) {
|
|
const coverDiv = document.createElement('div');
|
|
coverDiv.className = 'download-cover';
|
|
const coverImg = document.createElement('img');
|
|
// Proxy cover art through the server so the CSP img-src 'self' rule
|
|
// is satisfied (external poster URLs would be blocked otherwise).
|
|
coverImg.src = download.coverArt
|
|
? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt)
|
|
: '';
|
|
coverImg.alt = download.movieName || download.seriesName || download.title;
|
|
coverImg.loading = 'lazy';
|
|
coverDiv.appendChild(coverImg);
|
|
card.appendChild(coverDiv);
|
|
}
|
|
|
|
// Info wrapper
|
|
const infoDiv = document.createElement('div');
|
|
infoDiv.className = 'download-info';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'download-header';
|
|
|
|
const type = document.createElement('span');
|
|
type.className = `download-type ${download.type}`;
|
|
if (download.type === 'series') {
|
|
type.textContent = '📺 Series';
|
|
} else if (download.type === 'movie') {
|
|
type.textContent = '🎬 Movie';
|
|
} else if (download.type === 'torrent') {
|
|
const instName = download.instanceName ? ` (${download.instanceName})` : '';
|
|
type.textContent = `📥 Torrent${instName}`;
|
|
} else {
|
|
type.textContent = download.type;
|
|
}
|
|
|
|
const status = document.createElement('span');
|
|
status.className = `download-status ${download.status}`;
|
|
status.textContent = download.status;
|
|
|
|
header.appendChild(type);
|
|
header.appendChild(status);
|
|
|
|
if (download.importIssues && download.importIssues.length > 0) {
|
|
const issueBadge = document.createElement('span');
|
|
issueBadge.className = 'import-issue-badge';
|
|
issueBadge.textContent = 'Import Pending';
|
|
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
|
header.appendChild(issueBadge);
|
|
}
|
|
|
|
if ((state.isAdmin || download.canBlocklist) && download.arrQueueId) {
|
|
const blBtn = document.createElement('button');
|
|
blBtn.className = 'blocklist-search-btn';
|
|
blBtn.textContent = '⛔ Blocklist & Search';
|
|
blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
|
blBtn.addEventListener('click', () => handleBlocklistSearchClick(blBtn, download));
|
|
header.appendChild(blBtn);
|
|
}
|
|
|
|
// Right side container for user badge only
|
|
const rightSide = document.createElement('div');
|
|
rightSide.className = 'download-header-right';
|
|
|
|
if (state.showAll && download.tagBadges && download.tagBadges.length > 0) {
|
|
// In showAll mode: render all tags classified by whether they match an Emby user.
|
|
// Unmatched (no known Emby user) → amber, leftmost.
|
|
// Matched → show Emby display name in accent colour, rightmost.
|
|
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
|
|
const matched = download.tagBadges.filter(b => b.matchedUser);
|
|
for (const b of unmatched) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'download-user-badge unmatched';
|
|
badge.textContent = b.label;
|
|
rightSide.appendChild(badge);
|
|
}
|
|
for (const b of matched) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'download-user-badge';
|
|
badge.textContent = b.matchedUser;
|
|
rightSide.appendChild(badge);
|
|
}
|
|
} else if (download.matchedUserTag) {
|
|
// Normal (non-showAll) view: show only the current user's matched tag
|
|
const matchedBadge = document.createElement('span');
|
|
matchedBadge.className = 'download-user-badge';
|
|
matchedBadge.textContent = download.matchedUserTag;
|
|
rightSide.appendChild(matchedBadge);
|
|
}
|
|
|
|
header.appendChild(rightSide);
|
|
|
|
// Add client logo to card (positioned at bottom right via CSS)
|
|
if (download.client) {
|
|
const clientLogoWrapper = document.createElement('span');
|
|
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
|
|
|
const clientLogo = document.createElement('img');
|
|
clientLogo.className = 'download-client-logo';
|
|
clientLogo.src = `/images/clients/${download.client}.svg`;
|
|
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
|
clientLogo.title = download.instanceName || download.client;
|
|
clientLogo.onerror = () => {
|
|
// Fallback to text if image fails to load
|
|
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
|
clientLogoWrapper.classList.add('fallback');
|
|
};
|
|
|
|
clientLogoWrapper.appendChild(clientLogo);
|
|
card.appendChild(clientLogoWrapper);
|
|
}
|
|
|
|
const title = document.createElement('h3');
|
|
title.className = 'download-title';
|
|
title.textContent = download.title;
|
|
|
|
infoDiv.appendChild(header);
|
|
infoDiv.appendChild(title);
|
|
|
|
if (download.seriesName) {
|
|
const series = document.createElement('p');
|
|
series.className = 'download-series';
|
|
if (state.isAdmin && download.arrLink) {
|
|
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
|
|
} else {
|
|
series.textContent = `Series: ${download.seriesName}`;
|
|
}
|
|
infoDiv.appendChild(series);
|
|
const epEl = formatEpisodeInfo(download.episodes);
|
|
if (epEl) infoDiv.appendChild(epEl);
|
|
}
|
|
|
|
if (download.movieName) {
|
|
const movie = document.createElement('p');
|
|
movie.className = 'download-movie';
|
|
if (state.isAdmin && download.arrLink) {
|
|
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
|
|
} else {
|
|
movie.textContent = `Movie: ${download.movieName}`;
|
|
}
|
|
infoDiv.appendChild(movie);
|
|
}
|
|
|
|
if (state.showAll && download.tagBadges && download.tagBadges.length > 0) {
|
|
// In showAll mode: render all tags classified by whether they match an Emby user.
|
|
// Unmatched (no known Emby user) → amber, leftmost.
|
|
// Matched → show Emby display name in accent colour, rightmost.
|
|
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
|
|
const matched = download.tagBadges.filter(b => b.matchedUser);
|
|
for (const b of unmatched) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'download-user-badge unmatched';
|
|
badge.textContent = b.label;
|
|
header.appendChild(badge);
|
|
}
|
|
for (const b of matched) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'download-user-badge';
|
|
badge.textContent = b.matchedUser;
|
|
header.appendChild(badge);
|
|
}
|
|
} else if (download.matchedUserTag) {
|
|
// Normal (non-showAll) view: show only the current user's matched tag
|
|
const matchedBadge = document.createElement('span');
|
|
matchedBadge.className = 'download-user-badge';
|
|
matchedBadge.textContent = download.matchedUserTag;
|
|
header.appendChild(matchedBadge);
|
|
}
|
|
|
|
// Add client logo
|
|
if (download.client) {
|
|
const clientLogoWrapper = document.createElement('span');
|
|
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
|
|
|
const clientLogo = document.createElement('img');
|
|
clientLogo.className = 'download-client-logo';
|
|
clientLogo.src = `/images/clients/${download.client}.svg`;
|
|
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
|
clientLogo.title = download.instanceName || download.client;
|
|
clientLogo.onerror = () => {
|
|
// Fallback to text if image fails to load
|
|
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
|
clientLogoWrapper.classList.add('fallback');
|
|
};
|
|
|
|
clientLogoWrapper.appendChild(clientLogo);
|
|
header.appendChild(clientLogoWrapper);
|
|
}
|
|
|
|
const details = document.createElement('div');
|
|
details.className = 'download-details';
|
|
|
|
const size = createDetailItem('Size', formatSize(download.size));
|
|
details.appendChild(size);
|
|
|
|
if (download.progress !== undefined) {
|
|
const progressItem = document.createElement('div');
|
|
progressItem.className = 'detail-item progress-item';
|
|
progressItem.dataset.label = 'Progress';
|
|
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.className = 'detail-label';
|
|
labelSpan.textContent = 'Progress';
|
|
|
|
const valueDiv = document.createElement('div');
|
|
valueDiv.className = 'progress-container';
|
|
|
|
// Progress bar with segments
|
|
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
|
const missingMb = parseFloat(download.mbmissing) || 0;
|
|
const downloadedMb = totalMb - missingMb;
|
|
const progressPercent = parseFloat(download.progress) || 0;
|
|
const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0;
|
|
|
|
const progressBar = document.createElement('div');
|
|
progressBar.className = 'progress-bar';
|
|
|
|
// Downloaded portion (green)
|
|
if (progressPercent > 0) {
|
|
const downloaded = document.createElement('div');
|
|
downloaded.className = 'progress-segment downloaded';
|
|
downloaded.style.width = progressPercent + '%';
|
|
progressBar.appendChild(downloaded);
|
|
}
|
|
|
|
valueDiv.appendChild(progressBar);
|
|
|
|
// Text showing percentage
|
|
const progressText = document.createElement('span');
|
|
progressText.className = 'progress-text';
|
|
progressText.textContent = download.progress + '%';
|
|
valueDiv.appendChild(progressText);
|
|
|
|
// Missing pieces text (only for torrent clients like qBittorrent)
|
|
if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && missingMb > 0 && totalMb > 0) {
|
|
const missingText = document.createElement('span');
|
|
missingText.className = 'missing-text';
|
|
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
|
valueDiv.appendChild(missingText);
|
|
}
|
|
|
|
progressItem.appendChild(labelSpan);
|
|
progressItem.appendChild(valueDiv);
|
|
details.appendChild(progressItem);
|
|
}
|
|
|
|
if (download.speed && download.speed > 0) {
|
|
const speed = createDetailItem('Speed', formatSpeed(download.speed));
|
|
details.appendChild(speed);
|
|
}
|
|
|
|
if (download.eta) {
|
|
const eta = createDetailItem('ETA', download.eta);
|
|
details.appendChild(eta);
|
|
}
|
|
|
|
// qBittorrent-specific fields
|
|
if (download.qbittorrent) {
|
|
if (download.seeds !== undefined) {
|
|
const seeds = createDetailItem('Seeds', download.seeds);
|
|
details.appendChild(seeds);
|
|
}
|
|
|
|
if (download.peers !== undefined) {
|
|
const peers = createDetailItem('Peers', download.peers);
|
|
details.appendChild(peers);
|
|
}
|
|
|
|
if (download.availability !== undefined) {
|
|
const availability = createDetailItem('Availability', `${download.availability}%`);
|
|
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
|
|
details.appendChild(availability);
|
|
}
|
|
}
|
|
|
|
if (download.completedAt) {
|
|
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
|
details.appendChild(completed);
|
|
}
|
|
|
|
if (state.isAdmin && (download.downloadPath || download.targetPath)) {
|
|
const pathsDiv = document.createElement('div');
|
|
pathsDiv.className = 'download-paths';
|
|
if (download.downloadPath) {
|
|
const dlPath = document.createElement('div');
|
|
dlPath.className = 'path-item';
|
|
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
|
|
pathsDiv.appendChild(dlPath);
|
|
}
|
|
if (download.targetPath) {
|
|
const tgtPath = document.createElement('div');
|
|
tgtPath.className = 'path-item';
|
|
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
|
|
pathsDiv.appendChild(tgtPath);
|
|
}
|
|
details.appendChild(pathsDiv);
|
|
}
|
|
|
|
infoDiv.appendChild(details);
|
|
card.appendChild(infoDiv);
|
|
|
|
return card;
|
|
}
|
|
|
|
export function createDetailItem(label, value) {
|
|
const item = document.createElement('div');
|
|
item.className = 'detail-item';
|
|
item.dataset.label = label;
|
|
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.className = 'detail-label';
|
|
labelSpan.textContent = label;
|
|
|
|
const valueSpan = document.createElement('span');
|
|
valueSpan.className = 'detail-value';
|
|
valueSpan.textContent = value;
|
|
|
|
item.appendChild(labelSpan);
|
|
item.appendChild(valueSpan);
|
|
|
|
return item;
|
|
}
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return 'N/A';
|
|
return new Date(dateString).toLocaleString();
|
|
}
|