599 lines
21 KiB
JavaScript
599 lines
21 KiB
JavaScript
import { apps, categories } from './catalog/apps.js';
|
||
import { generateCompose, generateEnv } from './generators/compose.js';
|
||
import { generateSetupScript } from './generators/setup.js';
|
||
import { analyseCompose, fixCompose } from './generators/analyser.js';
|
||
import { trackDownload, fetchPopularity } from './utils/analytics.js';
|
||
import { initAuth } from './utils/auth.js';
|
||
import { initGallery } from './utils/gallery.js';
|
||
import JSZip from 'jszip';
|
||
import './style.css';
|
||
|
||
// OS-specific default paths
|
||
const osDefaults = {
|
||
linux: {
|
||
configPath: '/opt/stackpilot/config',
|
||
mediaPath: '/mnt/media',
|
||
downloadsPath: '/mnt/downloads',
|
||
timezone: 'Europe/Dublin',
|
||
},
|
||
macos: {
|
||
configPath: '~/docker/stackpilot/config',
|
||
mediaPath: '~/Media',
|
||
downloadsPath: '~/Downloads/stackpilot',
|
||
timezone: 'Europe/Dublin',
|
||
},
|
||
windows: {
|
||
configPath: 'C:\\stackpilot\\config',
|
||
mediaPath: 'D:\\Media',
|
||
downloadsPath: 'D:\\Downloads',
|
||
timezone: 'Europe/Dublin',
|
||
},
|
||
};
|
||
|
||
// State
|
||
const state = {
|
||
selected: new Set(),
|
||
activeTab: 'compose',
|
||
os: 'linux',
|
||
popularity: null,
|
||
searchQuery: '',
|
||
};
|
||
|
||
// Presets
|
||
const presets = {
|
||
media: ['jellyfin', 'sonarr', 'radarr', 'prowlarr', 'qbittorrent', 'bazarr', 'jellyseerr', 'flaresolverr'],
|
||
arr: ['sonarr', 'radarr', 'lidarr', 'readarr', 'prowlarr', 'bazarr', 'qbittorrent', 'flaresolverr', 'recyclarr'],
|
||
ai: ['ollama', 'open-webui', 'comfyui', 'stable-diffusion', 'flowise', 'anything-llm', 'whisper'],
|
||
dev: ['gitea', 'code-server', 'drone', 'registry', 'portainer', 'postgres', 'redis'],
|
||
homelab: ['homepage', 'portainer', 'uptime-kuma', 'watchtower', 'tailscale', 'vaultwarden', 'ntfy', 'adguard-home'],
|
||
gaming: ['minecraft', 'valheim', 'palworld', 'satisfactory', 'crafty', 'pterodactyl'],
|
||
};
|
||
|
||
// Get config from inputs
|
||
function getConfig() {
|
||
return {
|
||
configPath: document.getElementById('config-path').value || '/opt/stackpilot/config',
|
||
mediaPath: document.getElementById('media-path').value || '/mnt/media',
|
||
downloadsPath: document.getElementById('downloads-path').value || '/mnt/downloads',
|
||
timezone: document.getElementById('timezone').value || 'Europe/Dublin',
|
||
tailscale: {
|
||
enabled: document.getElementById('tailscale-enable').checked,
|
||
authKey: document.getElementById('ts-authkey').value,
|
||
hostname: document.getElementById('ts-hostname').value || 'stackpilot',
|
||
funnel: document.getElementById('ts-funnel').checked,
|
||
subnetRouter: document.getElementById('ts-subnet').checked,
|
||
},
|
||
};
|
||
}
|
||
|
||
// Render app grid
|
||
function renderAppGrid() {
|
||
const grid = document.getElementById('app-grid');
|
||
grid.innerHTML = '';
|
||
|
||
// Selection badge
|
||
const badge = document.createElement('div');
|
||
badge.className = `selection-badge${state.selected.size === 0 ? ' hidden' : ''}`;
|
||
badge.textContent = `${state.selected.size} app${state.selected.size !== 1 ? 's' : ''} selected`;
|
||
grid.appendChild(badge);
|
||
|
||
const query = state.searchQuery.toLowerCase();
|
||
|
||
for (const cat of categories) {
|
||
let catApps = apps.filter(a => a.category === cat.id);
|
||
|
||
// Filter by search
|
||
if (query) {
|
||
catApps = catApps.filter(a =>
|
||
a.name.toLowerCase().includes(query) ||
|
||
a.description.toLowerCase().includes(query) ||
|
||
a.id.includes(query)
|
||
);
|
||
}
|
||
|
||
if (catApps.length === 0) continue;
|
||
|
||
const group = document.createElement('div');
|
||
group.className = 'category-group';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'category-header';
|
||
|
||
const title = document.createElement('h3');
|
||
title.className = 'category-title';
|
||
title.textContent = `${cat.icon} ${cat.name}`;
|
||
|
||
const selectAllBtn = document.createElement('button');
|
||
const allSelected = catApps.every(a => state.selected.has(a.id));
|
||
selectAllBtn.className = 'category-select-all';
|
||
selectAllBtn.textContent = allSelected ? 'Deselect all' : 'Select all';
|
||
selectAllBtn.addEventListener('click', () => {
|
||
if (allSelected) {
|
||
catApps.forEach(a => state.selected.delete(a.id));
|
||
} else {
|
||
catApps.forEach(a => state.selected.add(a.id));
|
||
}
|
||
renderAppGrid();
|
||
checkDependencies();
|
||
checkPortConflicts();
|
||
updatePreview();
|
||
});
|
||
|
||
header.appendChild(title);
|
||
header.appendChild(selectAllBtn);
|
||
group.appendChild(header);
|
||
|
||
const appsDiv = document.createElement('div');
|
||
appsDiv.className = 'category-apps';
|
||
|
||
for (const app of catApps) {
|
||
const card = document.createElement('div');
|
||
card.className = `app-card${state.selected.has(app.id) ? ' selected' : ''}`;
|
||
const popCount = state.popularity?.[app.id] || 0;
|
||
const popBadge = popCount > 0 ? `<span class="pop-badge" title="${popCount} downloads">🔥 ${popCount}</span>` : '';
|
||
card.innerHTML = `
|
||
<div class="app-check">${state.selected.has(app.id) ? '✓' : ''}</div>
|
||
<div class="app-name">${app.name} ${popBadge}</div>
|
||
<div class="app-desc">${app.description}</div>
|
||
${app.url ? `<a class="app-link" href="${app.url}" target="_blank" rel="noopener" onclick="event.stopPropagation()">GitHub →</a>` : ''}
|
||
`;
|
||
card.addEventListener('click', () => toggleApp(app.id));
|
||
appsDiv.appendChild(card);
|
||
}
|
||
|
||
group.appendChild(appsDiv);
|
||
grid.appendChild(group);
|
||
}
|
||
}
|
||
|
||
// Toggle app selection
|
||
function toggleApp(id) {
|
||
if (state.selected.has(id)) {
|
||
state.selected.delete(id);
|
||
} else {
|
||
state.selected.add(id);
|
||
}
|
||
renderAppGrid();
|
||
checkDependencies();
|
||
checkPortConflicts();
|
||
updatePreview();
|
||
updateUrlState();
|
||
}
|
||
|
||
// Dependency warnings
|
||
function checkDependencies() {
|
||
const warnings = [];
|
||
const ids = state.selected;
|
||
|
||
if (ids.has('jellyseerr') && !ids.has('jellyfin')) warnings.push('Jellyseerr requires Jellyfin to function');
|
||
if (ids.has('overseerr') && !ids.has('plex')) warnings.push('Overseerr requires Plex to function');
|
||
if (ids.has('bazarr') && !ids.has('sonarr') && !ids.has('radarr')) warnings.push('Bazarr needs Sonarr or Radarr to manage subtitles for');
|
||
if (ids.has('prowlarr') && !ids.has('sonarr') && !ids.has('radarr') && !ids.has('lidarr') && !ids.has('readarr')) warnings.push('Prowlarr has no Arr apps to sync indexers to');
|
||
if ((ids.has('sonarr') || ids.has('radarr')) && !ids.has('qbittorrent') && !ids.has('transmission') && !ids.has('deluge') && !ids.has('sabnzbd') && !ids.has('nzbget')) warnings.push('Sonarr/Radarr need a download client (qBittorrent, Transmission, etc.)');
|
||
if (ids.has('open-webui') && !ids.has('ollama')) warnings.push('Open WebUI needs Ollama for local LLM inference');
|
||
if (ids.has('immich') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Immich requires PostgreSQL and Redis');
|
||
if (ids.has('outline') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Outline requires PostgreSQL and Redis');
|
||
if (ids.has('bookstack') && !ids.has('mariadb')) warnings.push('BookStack requires MariaDB');
|
||
if (ids.has('rocketchat') && !ids.has('mongodb')) warnings.push('Rocket.Chat requires MongoDB');
|
||
|
||
const el = document.getElementById('dependency-warnings');
|
||
el.innerHTML = warnings.map(w => `<div class="dep-warning">⚠️ ${w}</div>`).join('');
|
||
}
|
||
|
||
// Port conflict detection
|
||
function checkPortConflicts() {
|
||
const selectedApps = apps.filter(a => state.selected.has(a.id));
|
||
const portMap = {};
|
||
const conflicts = [];
|
||
|
||
for (const app of selectedApps) {
|
||
if (!app.ports) continue;
|
||
for (const port of app.ports) {
|
||
const hostPort = `${port.host}/${port.protocol}`;
|
||
if (portMap[hostPort]) {
|
||
conflicts.push(`Port ${port.host} conflict: ${portMap[hostPort]} and ${app.name}`);
|
||
} else {
|
||
portMap[hostPort] = app.name;
|
||
}
|
||
}
|
||
}
|
||
|
||
const el = document.getElementById('port-conflicts');
|
||
el.innerHTML = conflicts.map(c => `<div class="port-conflict">❌ ${c}</div>`).join('');
|
||
}
|
||
|
||
// URL state (shareable selections)
|
||
function updateUrlState() {
|
||
const selected = [...state.selected];
|
||
if (selected.length > 0) {
|
||
const url = new URL(window.location);
|
||
url.searchParams.set('apps', selected.join(','));
|
||
window.history.replaceState({}, '', url);
|
||
} else {
|
||
const url = new URL(window.location);
|
||
url.searchParams.delete('apps');
|
||
window.history.replaceState({}, '', url);
|
||
}
|
||
}
|
||
|
||
function loadUrlState() {
|
||
const url = new URL(window.location);
|
||
const appsParam = url.searchParams.get('apps');
|
||
if (appsParam) {
|
||
const appIds = appsParam.split(',');
|
||
for (const id of appIds) {
|
||
if (apps.some(a => a.id === id)) {
|
||
state.selected.add(id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update preview
|
||
function updatePreview() {
|
||
const selectedApps = apps.filter(a => state.selected.has(a.id));
|
||
const config = getConfig();
|
||
const content = document.getElementById('preview-content');
|
||
const downloadBtn = document.getElementById('download-btn');
|
||
|
||
if (selectedApps.length === 0) {
|
||
content.textContent = 'Select apps above to see preview...';
|
||
downloadBtn.disabled = true;
|
||
return;
|
||
}
|
||
|
||
downloadBtn.disabled = false;
|
||
|
||
switch (state.activeTab) {
|
||
case 'compose':
|
||
content.textContent = generateCompose(selectedApps, config);
|
||
break;
|
||
case 'env':
|
||
content.textContent = generateEnv(selectedApps, config);
|
||
break;
|
||
case 'setup':
|
||
content.textContent = generateSetupScript(selectedApps, config);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Tab switching
|
||
function initTabs() {
|
||
document.querySelectorAll('.tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
state.activeTab = tab.dataset.tab;
|
||
updatePreview();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Download bundle
|
||
async function downloadBundle() {
|
||
const selectedApps = apps.filter(a => state.selected.has(a.id));
|
||
const config = getConfig();
|
||
|
||
const zip = new JSZip();
|
||
zip.file('docker-compose.yml', generateCompose(selectedApps, config));
|
||
zip.file('.env', generateEnv(selectedApps, config));
|
||
zip.file('setup.sh', generateSetupScript(selectedApps, config));
|
||
zip.file('README.md', generateReadme(selectedApps, config));
|
||
|
||
const blob = await zip.generateAsync({ type: 'blob' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'stackpilot-bundle.zip';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
|
||
// Track popularity
|
||
trackDownload([...state.selected]);
|
||
}
|
||
|
||
function generateReadme(selectedApps, config) {
|
||
const lines = [
|
||
'# StackPilot Bundle',
|
||
'',
|
||
`Generated: ${new Date().toISOString()}`,
|
||
'',
|
||
'## Quick Start',
|
||
'',
|
||
'```bash',
|
||
'# 1. Review and edit .env file',
|
||
'nano .env',
|
||
'',
|
||
'# 2. Create directories',
|
||
`mkdir -p ${config.configPath} ${config.mediaPath} ${config.downloadsPath}`,
|
||
'',
|
||
'# 3. Start the stack',
|
||
'docker compose up -d',
|
||
'',
|
||
'# 4. Run auto-linker (after all services are healthy)',
|
||
'chmod +x setup.sh',
|
||
'./setup.sh',
|
||
'```',
|
||
'',
|
||
'## Services',
|
||
'',
|
||
'| Service | URL | Port |',
|
||
'|---------|-----|------|',
|
||
];
|
||
|
||
for (const app of selectedApps) {
|
||
if (app.ports?.length > 0) {
|
||
lines.push(`| ${app.name} | http://localhost:${app.ports[0].host} | ${app.ports[0].host} |`);
|
||
} else {
|
||
lines.push(`| ${app.name} | (no web UI) | — |`);
|
||
}
|
||
}
|
||
|
||
lines.push('', '## Notes', '');
|
||
lines.push('- Default qBittorrent credentials: admin / adminadmin (change immediately)');
|
||
lines.push('- Arr apps generate their API keys on first run — setup.sh reads them from config.xml');
|
||
lines.push('- Run `docker compose logs -f` to monitor startup');
|
||
lines.push('- Run `docker compose down` to stop all services');
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// Config change listeners
|
||
function initConfigListeners() {
|
||
document.querySelectorAll('.config-grid input').forEach(input => {
|
||
input.addEventListener('input', updatePreview);
|
||
});
|
||
}
|
||
|
||
// Tailscale toggle
|
||
function initTailscale() {
|
||
const enable = document.getElementById('tailscale-enable');
|
||
const options = document.getElementById('tailscale-options');
|
||
enable.addEventListener('change', () => {
|
||
options.classList.toggle('hidden', !enable.checked);
|
||
updatePreview();
|
||
});
|
||
document.getElementById('ts-funnel').addEventListener('change', updatePreview);
|
||
document.getElementById('ts-subnet').addEventListener('change', updatePreview);
|
||
document.getElementById('ts-authkey').addEventListener('input', updatePreview);
|
||
document.getElementById('ts-hostname').addEventListener('input', updatePreview);
|
||
}
|
||
|
||
// OS selector
|
||
function initOsSelector() {
|
||
document.querySelectorAll('input[name="os"]').forEach(radio => {
|
||
radio.addEventListener('change', (e) => {
|
||
state.os = e.target.value;
|
||
const defaults = osDefaults[state.os];
|
||
document.getElementById('config-path').value = defaults.configPath;
|
||
document.getElementById('media-path').value = defaults.mediaPath;
|
||
document.getElementById('downloads-path').value = defaults.downloadsPath;
|
||
document.getElementById('timezone').value = defaults.timezone;
|
||
updatePreview();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Init
|
||
loadUrlState();
|
||
renderAppGrid();
|
||
initTabs();
|
||
initConfigListeners();
|
||
initTailscale();
|
||
initOsSelector();
|
||
initModeNav();
|
||
initAnalyser();
|
||
initSearch();
|
||
initPresets();
|
||
initAuth();
|
||
initGallery(() => [...state.selected], getConfig);
|
||
document.getElementById('download-btn').addEventListener('click', downloadBundle);
|
||
|
||
// Listen for "Use This Stack" from gallery
|
||
window.addEventListener('use-stack', (e) => {
|
||
const { apps: stackApps } = e.detail;
|
||
state.selected = new Set(stackApps);
|
||
renderAppGrid();
|
||
checkDependencies();
|
||
checkPortConflicts();
|
||
updatePreview();
|
||
updateUrlState();
|
||
});
|
||
|
||
// Load popularity data (async, non-blocking)
|
||
fetchPopularity().then(data => {
|
||
if (data) {
|
||
state.popularity = data;
|
||
renderAppGrid();
|
||
}
|
||
});
|
||
|
||
// Search
|
||
function initSearch() {
|
||
const input = document.getElementById('app-search');
|
||
input.addEventListener('input', (e) => {
|
||
state.searchQuery = e.target.value;
|
||
renderAppGrid();
|
||
});
|
||
}
|
||
|
||
// Presets
|
||
function initPresets() {
|
||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const preset = presets[btn.dataset.preset];
|
||
if (!preset) return;
|
||
for (const id of preset) {
|
||
state.selected.add(id);
|
||
}
|
||
renderAppGrid();
|
||
checkDependencies();
|
||
checkPortConflicts();
|
||
updatePreview();
|
||
updateUrlState();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Mode switching (builder vs analyser vs gallery)
|
||
function initModeNav() {
|
||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const mode = btn.dataset.mode;
|
||
document.querySelector('.container').style.display = mode === 'builder' ? 'block' : 'none';
|
||
document.getElementById('step-analyser').style.display = mode === 'analyser' ? 'block' : 'none';
|
||
document.getElementById('step-gallery').style.display = mode === 'gallery' ? 'block' : 'none';
|
||
});
|
||
});
|
||
}
|
||
|
||
// Analyser
|
||
function initAnalyser() {
|
||
const dropZone = document.getElementById('drop-zone');
|
||
const textarea = document.getElementById('analyser-input');
|
||
|
||
// Drag and drop
|
||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragging'); });
|
||
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('dragging'); });
|
||
dropZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dropZone.classList.remove('dragging');
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => { textarea.value = ev.target.result; };
|
||
reader.readAsText(file);
|
||
}
|
||
});
|
||
|
||
// Analyse button
|
||
document.getElementById('analyse-btn').addEventListener('click', () => {
|
||
const input = textarea.value.trim();
|
||
if (!input) return;
|
||
const results = analyseCompose(input);
|
||
renderAnalyserResults(results);
|
||
|
||
const fixBtn = document.getElementById('show-fixed-btn');
|
||
const hasFixable = results.warnings.length > 0 || results.suggestions.length > 0;
|
||
fixBtn.classList.toggle('hidden', !hasFixable);
|
||
// Hide previous fixed/diff views
|
||
document.getElementById('fixed-preview').classList.add('hidden');
|
||
document.getElementById('diff-preview').classList.add('hidden');
|
||
document.getElementById('view-toggle').classList.add('hidden');
|
||
});
|
||
|
||
// Show fixed button
|
||
document.getElementById('show-fixed-btn').addEventListener('click', () => {
|
||
const input = textarea.value.trim();
|
||
if (!input) return;
|
||
const fixed = fixCompose(input);
|
||
document.getElementById('fixed-content').textContent = fixed;
|
||
document.getElementById('fixed-preview').classList.remove('hidden');
|
||
document.getElementById('diff-preview').classList.add('hidden');
|
||
document.getElementById('view-toggle').classList.remove('hidden');
|
||
|
||
// Generate diff
|
||
const diffHtml = generateDiff(input, fixed);
|
||
document.getElementById('diff-content').innerHTML = diffHtml;
|
||
|
||
// Set active view
|
||
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelector('.view-btn[data-view="fixed"]').classList.add('active');
|
||
});
|
||
|
||
// View toggle (fixed vs diff)
|
||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const view = btn.dataset.view;
|
||
document.getElementById('fixed-preview').classList.toggle('hidden', view !== 'fixed');
|
||
document.getElementById('diff-preview').classList.toggle('hidden', view !== 'diff');
|
||
});
|
||
});
|
||
|
||
// Download fixed
|
||
document.getElementById('download-fixed-btn').addEventListener('click', () => {
|
||
const fixed = document.getElementById('fixed-content').textContent;
|
||
const blob = new Blob([fixed], { type: 'text/yaml' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'docker-compose-fixed.yml';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
}
|
||
|
||
// Simple line-by-line diff
|
||
function generateDiff(before, after) {
|
||
const beforeLines = before.split('\n');
|
||
const afterLines = after.split('\n');
|
||
const output = [];
|
||
|
||
// Simple LCS-based approach for readable diff
|
||
const beforeSet = new Set(beforeLines.map((l, i) => `${i}:${l}`));
|
||
const afterSet = new Set(afterLines);
|
||
|
||
let bi = 0, ai = 0;
|
||
while (bi < beforeLines.length || ai < afterLines.length) {
|
||
if (bi < beforeLines.length && ai < afterLines.length && beforeLines[bi] === afterLines[ai]) {
|
||
output.push(`<span class="diff-context"> ${escHtml(beforeLines[bi])}</span>`);
|
||
bi++; ai++;
|
||
} else if (ai < afterLines.length && !beforeLines.includes(afterLines[ai])) {
|
||
output.push(`<span class="diff-add">+ ${escHtml(afterLines[ai])}</span>`);
|
||
ai++;
|
||
} else if (bi < beforeLines.length && !afterLines.includes(beforeLines[bi])) {
|
||
output.push(`<span class="diff-remove">- ${escHtml(beforeLines[bi])}</span>`);
|
||
bi++;
|
||
} else {
|
||
// Changed line
|
||
if (bi < beforeLines.length) {
|
||
output.push(`<span class="diff-remove">- ${escHtml(beforeLines[bi])}</span>`);
|
||
bi++;
|
||
}
|
||
if (ai < afterLines.length) {
|
||
output.push(`<span class="diff-add">+ ${escHtml(afterLines[ai])}</span>`);
|
||
ai++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return output.join('\n');
|
||
}
|
||
|
||
function escHtml(str) {
|
||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function renderAnalyserResults(results) {
|
||
const container = document.getElementById('analyser-results');
|
||
container.innerHTML = '';
|
||
|
||
// Summary badges
|
||
const summary = document.createElement('div');
|
||
summary.className = 'result-summary';
|
||
if (results.errors.length) summary.innerHTML += `<span class="result-badge error">❌ ${results.errors.length} Error${results.errors.length > 1 ? 's' : ''}</span>`;
|
||
if (results.warnings.length) summary.innerHTML += `<span class="result-badge warning">⚠️ ${results.warnings.length} Warning${results.warnings.length > 1 ? 's' : ''}</span>`;
|
||
if (results.suggestions.length) summary.innerHTML += `<span class="result-badge suggestion">💡 ${results.suggestions.length} Suggestion${results.suggestions.length > 1 ? 's' : ''}</span>`;
|
||
if (results.info.length) summary.innerHTML += `<span class="result-badge info">ℹ️ ${results.info.length} Info</span>`;
|
||
container.appendChild(summary);
|
||
|
||
// Cards
|
||
const allResults = [
|
||
...results.errors.map(r => ({ ...r, type: 'error' })),
|
||
...results.warnings.map(r => ({ ...r, type: 'warning' })),
|
||
...results.suggestions.map(r => ({ ...r, type: 'suggestion' })),
|
||
...results.info.map(r => ({ ...r, type: 'info' })),
|
||
];
|
||
|
||
for (const item of allResults) {
|
||
const card = document.createElement('div');
|
||
card.className = `result-card ${item.type}`;
|
||
card.innerHTML = `<div class="result-title">${item.title}</div><div class="result-detail">${item.detail}</div>`;
|
||
container.appendChild(card);
|
||
}
|
||
}
|