/** * n8n-install Welcome Page * Dynamic rendering of services and credentials from data.json */ (function() { 'use strict'; // ============================================ // CINEMATIC ANIMATIONS MODULE // ============================================ const CinematicAnimations = { CONFETTI_STORAGE_KEY: 'n8n_install_welcomed', isFirstVisit() { return !localStorage.getItem(this.CONFETTI_STORAGE_KEY); }, markVisited() { localStorage.setItem(this.CONFETTI_STORAGE_KEY, Date.now().toString()); }, triggerConfetti() { if (!window.confetti) return; const colors = ['#3ECF8E', '#24B374', '#47DF97', '#ffffff', '#75E7B1']; const defaults = { spread: 60, ticks: 100, gravity: 1, decay: 0.94, startVelocity: 30, colors: colors }; function fire(particleRatio, opts) { confetti({ ...defaults, ...opts, particleCount: Math.floor(200 * particleRatio) }); } // Staggered confetti bursts fire(0.25, { spread: 26, startVelocity: 55 }); fire(0.2, { spread: 60 }); fire(0.35, { spread: 100, decay: 0.91, scalar: 0.8 }); fire(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 }); fire(0.1, { spread: 120, startVelocity: 45 }); // Side cannons setTimeout(() => { confetti({ particleCount: 50, angle: 60, spread: 55, origin: { x: 0 }, colors: colors }); confetti({ particleCount: 50, angle: 120, spread: 55, origin: { x: 1 }, colors: colors }); }, 250); }, init() { // Check for first visit and trigger confetti if (this.isFirstVisit()) { setTimeout(() => { this.triggerConfetti(); this.markVisited(); }, 800); } } }; // ============================================ // ICONS - SVG icons as template functions // ============================================ const Icons = { copy: (className = '') => ` `, check: (className = '') => ` `, eyeOpen: (className = '') => ` `, eyeClosed: (className = '') => ` `, externalLink: (className = '') => ` `, server: (className = '') => ` `, bolt: (className = '') => ` `, refresh: (className = '') => ` `, terminal: (className = '') => ` `, book: (className = '') => ` `, warning: (className = '') => ` `, changelog: (className = '') => ` ` }; // ============================================ // DATA - Service metadata and commands // ============================================ const SERVICE_METADATA = { 'n8n': { name: 'n8n', description: 'Workflow Automation', icon: 'n8n', color: 'bg-[#EA4B71]', category: 'automation', docsUrl: 'https://docs.n8n.io' }, 'flowise': { name: 'Flowise', description: 'AI Agent Builder', icon: 'FL', color: 'bg-[#673AB7]', category: 'ai', docsUrl: 'https://docs.flowiseai.com' }, 'open-webui': { name: 'Open WebUI', description: 'ChatGPT-like Interface', icon: 'AI', color: 'bg-black', category: 'ai', docsUrl: 'https://docs.openwebui.com' }, 'grafana': { name: 'Grafana', description: 'Monitoring Dashboard', icon: 'GF', color: 'bg-[#F46800]', category: 'monitoring', docsUrl: 'https://grafana.com/docs' }, 'prometheus': { name: 'Prometheus', description: 'Metrics Collection', icon: 'PM', color: 'bg-[#E6522C]', category: 'monitoring', docsUrl: 'https://prometheus.io/docs' }, 'portainer': { name: 'Portainer', description: 'Docker Management UI', icon: 'PT', color: 'bg-[#13BEF9]', category: 'infra', docsUrl: 'https://docs.portainer.io' }, 'databasus': { name: 'Databasus', description: 'Database Backups & Monitoring', icon: 'DB', color: 'bg-[#155DFC]', category: 'database', docsUrl: 'https://databasus.com/' }, 'langfuse': { name: 'Langfuse', description: 'AI Observability', icon: 'LF', color: 'bg-[#0A60B5]', category: 'ai', docsUrl: 'https://langfuse.com/docs' }, 'supabase': { name: 'Supabase', description: 'Backend as a Service', icon: 'SB', color: 'bg-[#3ECF8E]', category: 'database', docsUrl: 'https://supabase.com/docs' }, 'dify': { name: 'Dify', description: 'AI Application Platform', icon: 'DF', color: 'bg-[#1C64F2]', category: 'ai', docsUrl: 'https://docs.dify.ai' }, 'qdrant': { name: 'Qdrant', description: 'Vector Database', icon: 'QD', color: 'bg-[#DC244C]', category: 'database', docsUrl: 'https://qdrant.tech/documentation' }, 'weaviate': { name: 'Weaviate', description: 'Vector Database', icon: 'WV', color: 'bg-[#38B349]', category: 'database', docsUrl: 'https://weaviate.io/developers/weaviate' }, 'neo4j': { name: 'Neo4j', description: 'Graph Database', icon: 'N4', color: 'bg-[#0A6190]', category: 'database', docsUrl: 'https://neo4j.com/docs' }, 'nocodb': { name: 'NocoDB', description: 'Spreadsheet Database', icon: 'NC', color: 'bg-[#3433C1]', category: 'database', docsUrl: 'https://nocodb.com/docs/product-docs' }, 'searxng': { name: 'SearXNG', description: 'Private Metasearch Engine', icon: 'SX', color: 'bg-[#3EBBE7]', category: 'tools', docsUrl: 'https://docs.searxng.org' }, 'ragapp': { name: 'RAGApp', description: 'RAG UI & API', icon: 'RA', color: 'bg-amber-500', category: 'ai', docsUrl: 'https://github.com/ragapp/ragapp' }, 'ragflow': { name: 'RAGFlow', description: 'Document Understanding RAG', icon: 'RF', color: 'bg-[#6C63FF]', category: 'ai', docsUrl: 'https://ragflow.io/docs' }, 'lightrag': { name: 'LightRAG', description: 'Graph-based RAG', icon: 'LR', color: 'bg-lime-500', category: 'ai', docsUrl: 'https://github.com/HKUDS/LightRAG' }, 'letta': { name: 'Letta', description: 'Agent Server & SDK', icon: 'LT', color: 'bg-violet-500', category: 'ai', docsUrl: 'https://docs.letta.com' }, 'comfyui': { name: 'ComfyUI', description: 'Stable Diffusion UI', icon: 'CU', color: 'bg-[#172DD7]', category: 'ai', docsUrl: 'https://docs.comfy.org' }, 'libretranslate': { name: 'LibreTranslate', description: 'Translation API', icon: 'TR', color: 'bg-[#295F98]', category: 'tools', docsUrl: 'https://libretranslate.com/docs' }, 'docling': { name: 'Docling', description: 'Document Converter', icon: 'DL', color: 'bg-[#006699]', category: 'tools', docsUrl: 'https://docling-project.github.io/docling' }, 'paddleocr': { name: 'PaddleOCR', description: 'OCR API Server', icon: 'OC', color: 'bg-[#2932E1]', category: 'tools', docsUrl: 'https://www.paddleocr.ai/latest/en/index.html' }, 'postiz': { name: 'Postiz', description: 'Social Publishing Platform', icon: 'PZ', color: 'bg-violet-500', category: 'tools', docsUrl: 'https://docs.postiz.com' }, 'temporal-ui': { name: 'Temporal UI', description: 'Postiz Workflow Orchestration', icon: 'TM', color: 'bg-violet-500', category: 'tools', docsUrl: 'https://docs.temporal.io/' }, 'waha': { name: 'WAHA', description: 'WhatsApp HTTP API', icon: 'WA', color: 'bg-[#25D366]', category: 'tools', docsUrl: 'https://waha.devlike.pro/docs' }, 'crawl4ai': { name: 'Crawl4AI', description: 'Web Crawler for AI', icon: 'C4', color: 'bg-[#50FFFF]', category: 'tools', docsUrl: 'https://docs.crawl4ai.com' }, 'gost': { name: 'Gost Proxy', description: 'Proxy for Outbound Traffic', icon: 'GP', color: 'bg-[#4051B5]', category: 'infra', docsUrl: 'https://gost.run/en/' }, 'gotenberg': { name: 'Gotenberg', description: 'PDF Generator API', icon: 'GT', color: 'bg-red-500', category: 'tools', docsUrl: 'https://gotenberg.dev/docs/getting-started/introduction' }, 'ollama': { name: 'Ollama', description: 'Local LLM Runner', icon: 'OL', color: 'bg-black', category: 'ai', docsUrl: 'https://docs.ollama.com/' }, 'redis': { name: 'Redis (Valkey)', description: 'In-Memory Data Store', icon: 'RD', color: 'bg-[#D82C20]', category: 'infra', docsUrl: 'https://valkey.io/docs' }, 'postgres': { name: 'PostgreSQL', description: 'Relational Database', icon: 'PG', color: 'bg-[#336791]', category: 'infra', docsUrl: 'https://www.postgresql.org/docs' }, 'python-runner': { name: 'Python Runner', description: 'Custom Python Scripts', icon: 'PY', color: 'bg-[#3776AB]', category: 'tools', docsUrl: 'https://docs.python.org' }, 'cloudflare-tunnel': { name: 'Cloudflare Tunnel', description: 'Zero-Trust Network Access', icon: 'CF', color: 'bg-[#F48120]', category: 'infra', docsUrl: 'https://developers.cloudflare.com/cloudflare-one/connections/connect-apps' } }; const COMMANDS = [ { cmd: 'make status', desc: 'Show container status' }, { cmd: 'make logs', desc: 'View logs (all services)' }, { cmd: 'make logs s=', desc: 'View logs for specific service' }, { cmd: 'make monitor', desc: 'Live CPU/memory monitoring' }, { cmd: 'make restart', desc: 'Restart all services' }, { cmd: 'make stop', desc: 'Stop all services' }, { cmd: 'make start', desc: 'Start all services' }, { cmd: 'make show-restarts', desc: 'Show restart count per container' }, { cmd: 'make doctor', desc: 'Run system diagnostics' }, { cmd: 'make update', desc: 'Update system and services' }, { cmd: 'make git-pull', desc: 'Update for forks (merge from upstream)' }, { cmd: 'make import', desc: 'Import n8n workflows (use n=10 to limit)' }, { cmd: 'make clean', desc: 'Remove unused Docker resources' } ]; // ============================================ // UTILS - Helper functions // ============================================ /** * Escape HTML to prevent XSS */ function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Copy text to clipboard and show visual feedback */ async function copyToClipboard(text, button) { try { await navigator.clipboard.writeText(text); showCopySuccess(button); return true; } catch (err) { console.error('Failed to copy:', err); return false; } } /** * Show copy success visual feedback */ function showCopySuccess(button) { const copyIcon = button.querySelector('.icon-copy'); const checkIcon = button.querySelector('.icon-check'); if (copyIcon && checkIcon) { copyIcon.classList.add('hidden'); checkIcon.classList.remove('hidden'); setTimeout(() => { copyIcon.classList.remove('hidden'); checkIcon.classList.add('hidden'); }, 2000); } } /** * Toggle password visibility */ function togglePasswordVisibility(button, passwordSpan, password) { const isHidden = passwordSpan.dataset.hidden === 'true'; const eyeOpen = button.querySelector('.icon-eye-open'); const eyeClosed = button.querySelector('.icon-eye-closed'); if (isHidden) { passwordSpan.textContent = password; passwordSpan.dataset.hidden = 'false'; button.setAttribute('aria-pressed', 'true'); eyeOpen?.classList.add('hidden'); eyeClosed?.classList.remove('hidden'); } else { passwordSpan.textContent = '*'.repeat(Math.min(password.length, 12)); passwordSpan.dataset.hidden = 'true'; button.setAttribute('aria-pressed', 'false'); eyeOpen?.classList.remove('hidden'); eyeClosed?.classList.add('hidden'); } } // ============================================ // COMPONENTS - UI component functions // ============================================ /** * Create a copy button for any text */ function createCopyButton(textToCopy) { const button = document.createElement('button'); button.type = 'button'; button.className = 'p-1.5 rounded-lg hover:bg-surface-400 transition-colors focus:outline-none focus:ring-2 focus:ring-brand/50'; button.setAttribute('aria-label', 'Copy to clipboard'); button.innerHTML = ` ${Icons.copy('w-4 h-4 text-gray-500 hover:text-brand transition-colors icon-copy')} ${Icons.check('w-4 h-4 text-brand icon-check hidden')} `; button.addEventListener('click', () => copyToClipboard(textToCopy, button)); return button; } /** * Create password field with toggle and copy buttons */ function createPasswordField(password) { const container = document.createElement('div'); container.className = 'flex items-center gap-1 min-w-0'; // Password display const passwordSpan = document.createElement('span'); passwordSpan.className = 'font-mono text-sm select-all text-gray-300 break-all'; passwordSpan.textContent = '*'.repeat(Math.min(password.length, 12)); passwordSpan.dataset.password = password; passwordSpan.dataset.hidden = 'true'; // Toggle visibility button const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.className = 'p-1.5 rounded-lg hover:bg-surface-400 transition-colors focus:outline-none focus:ring-2 focus:ring-brand/50'; toggleBtn.setAttribute('aria-label', 'Toggle password visibility'); toggleBtn.setAttribute('aria-pressed', 'false'); toggleBtn.innerHTML = ` ${Icons.eyeOpen('w-4 h-4 text-gray-500 hover:text-brand transition-colors icon-eye-open')} ${Icons.eyeClosed('w-4 h-4 text-gray-500 hover:text-brand transition-colors icon-eye-closed hidden')} `; toggleBtn.addEventListener('click', () => { togglePasswordVisibility(toggleBtn, passwordSpan, password); }); // Copy button (reusing the component) const copyBtn = createCopyButton(password); container.appendChild(toggleBtn); container.appendChild(passwordSpan); container.appendChild(copyBtn); return container; } /** * Create a credential row with label and value */ function createCredentialRow(label, value, isSecret) { const row = document.createElement('div'); row.className = 'flex justify-between items-center gap-2 min-w-0'; const labelSpan = document.createElement('span'); labelSpan.className = 'text-gray-500 text-sm flex-shrink-0'; labelSpan.textContent = `${label}:`; row.appendChild(labelSpan); if (isSecret) { row.appendChild(createPasswordField(value)); } else { const valueContainer = document.createElement('div'); valueContainer.className = 'flex items-center gap-1 min-w-0'; const valueSpan = document.createElement('span'); valueSpan.className = 'font-mono text-sm select-all text-gray-300 break-all'; valueSpan.textContent = value; valueContainer.appendChild(valueSpan); valueContainer.appendChild(createCopyButton(value)); row.appendChild(valueContainer); } return row; } /** * Mapping of extra fields to display labels * Fields not in this map are skipped (internal_api, internal_url shown in header) */ const EXTRA_FIELD_LABELS = { workers: { label: 'Workers', isSecret: false }, mounted_dir: { label: 'Mount', isSecret: false }, entry_file: { label: 'Entry', isSecret: false }, logs_command: { label: 'Logs', isSecret: false }, dashboard: { label: 'Dashboard', isLink: true }, docs: { label: 'Docs', isLink: true }, api_endpoint: { label: 'API', isLink: true }, admin: { label: 'Admin', isLink: true }, ui: { label: 'UI', isLink: true }, service_role_key: { label: 'Service Key', isSecret: true }, bolt_port: { label: 'Bolt Port', isSecret: false }, pg_host: { label: 'PG Host', isSecret: false }, pg_port: { label: 'PG Port', isSecret: false }, pg_user: { label: 'PG User', isSecret: false }, pg_password: { label: 'PG Password', isSecret: true }, pg_db: { label: 'PG Database', isSecret: false }, swagger_user: { label: 'Swagger User', isSecret: false }, swagger_pass: { label: 'Swagger Pass', isSecret: true }, internal_host: { label: 'Internal Host', isSecret: false }, internal_port: { label: 'Internal Port', isSecret: false }, database: { label: 'Database', isSecret: false }, proxy_url: { label: 'Proxy URL', isSecret: true }, upstream_proxy: { label: 'Upstream', isSecret: true } }; /** * Create a link row with label and "Link" text + icon * @param {string} label - Base label (e.g., "Dashboard", "Docs") * @param {string} url - The URL to link to * @param {string} serviceName - Optional service name to prefix label (e.g., "Qdrant") */ function createLinkRow(label, url, serviceName) { const row = document.createElement('div'); row.className = 'flex justify-between items-center'; // Create service-specific label like "Qdrant Dashboard" instead of just "Dashboard" const fullLabel = serviceName ? `${serviceName} ${label}` : label; const labelSpan = document.createElement('span'); labelSpan.className = 'text-gray-500 text-sm'; labelSpan.textContent = `${fullLabel}:`; row.appendChild(labelSpan); // Container to align with other rows that have copy buttons const valueContainer = document.createElement('div'); valueContainer.className = 'flex items-center gap-1 pr-2'; const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener'; link.className = 'text-brand hover:text-brand-400 text-sm font-medium inline-flex items-center gap-1 group transition-colors'; link.innerHTML = ` Link ${Icons.externalLink('w-3 h-3 group-hover:translate-x-0.5 transition-transform')} `; valueContainer.appendChild(link); row.appendChild(valueContainer); return row; } /** * Create bottom section with credentials and extra info * @param {Object} creds - Credentials object * @param {Object} extra - Extra data object * @param {string} serviceName - Service name for link labels */ function createBottomSection(creds, extra, serviceName) { const section = document.createElement('div'); section.className = 'mt-4 pt-4 border-t border-surface-400 space-y-1'; // Handle credentials note (special case - just show note text) if (creds && creds.note) { const noteP = document.createElement('p'); noteP.className = 'text-sm text-gray-500 italic whitespace-pre-line'; noteP.textContent = creds.note; section.appendChild(noteP); } // Add credential fields if (creds) { if (creds.username) { section.appendChild(createCredentialRow('Username', creds.username, false)); } if (creds.password) { section.appendChild(createCredentialRow('Password', creds.password, true)); } if (creds.api_key) { section.appendChild(createCredentialRow('API Key', creds.api_key, true)); } if (creds.user_token) { section.appendChild(createCredentialRow('User Token', creds.user_token, true)); } } // Add extra fields (skip internal_api/internal_url - shown in header) if (extra) { for (const [key, value] of Object.entries(extra)) { // Skip fields shown in header if (key === 'internal_api' || key === 'internal_url') continue; // Handle recommendation as plain italic text (like credentials.note) if (key === 'recommendation' && value) { const noteP = document.createElement('p'); noteP.className = 'text-sm text-gray-500 italic'; noteP.textContent = value; section.appendChild(noteP); continue; } const fieldConfig = EXTRA_FIELD_LABELS[key]; if (fieldConfig && value) { if (fieldConfig.isLink) { section.appendChild(createLinkRow(fieldConfig.label, value, serviceName)); } else { section.appendChild(createCredentialRow(fieldConfig.label, value, fieldConfig.isSecret)); } } } } return section; } /** * Create service card header */ function createCardHeader(metadata, serviceData) { const header = document.createElement('div'); header.className = 'flex items-start gap-4'; // Icon const iconDiv = document.createElement('div'); iconDiv.className = `${metadata.color} w-11 h-11 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0 shadow-lg`; iconDiv.textContent = metadata.icon; // Content const content = document.createElement('div'); content.className = 'flex-1 min-w-0'; const title = document.createElement('h3'); title.className = 'font-semibold text-white'; if (metadata.docsUrl) { const titleLink = document.createElement('a'); titleLink.href = metadata.docsUrl; titleLink.target = '_blank'; titleLink.rel = 'noopener'; titleLink.className = 'hover:text-brand transition-colors'; titleLink.textContent = metadata.name; title.appendChild(titleLink); } else { title.textContent = metadata.name; } const desc = document.createElement('p'); desc.className = 'text-sm text-gray-500 mb-2'; desc.textContent = metadata.description; content.appendChild(title); content.appendChild(desc); // Check if service has internal URL in extra const hasInternalUrl = serviceData.extra && (serviceData.extra.internal_api || serviceData.extra.internal_url); // External link (if hostname exists) if (serviceData.hostname) { const link = document.createElement('a'); link.href = `https://${serviceData.hostname}`; link.target = '_blank'; link.rel = 'noopener'; link.className = 'text-brand hover:text-brand-400 text-sm font-medium inline-flex items-center gap-1 group transition-colors'; link.innerHTML = ` ${escapeHtml(serviceData.hostname)} ${Icons.externalLink('w-3 h-3 group-hover:translate-x-0.5 transition-transform')} `; content.appendChild(link); } // Internal URL (shown from extra section, no duplicate "Internal service" text) if (hasInternalUrl) { const internalDiv = document.createElement('div'); internalDiv.className = serviceData.hostname ? 'mt-1' : ''; const internalUrl = serviceData.extra.internal_api || serviceData.extra.internal_url; internalDiv.innerHTML = `Internal: ${escapeHtml(internalUrl)}`; content.appendChild(internalDiv); } // Only show "Internal service" if no hostname AND no internal URL if (!serviceData.hostname && !hasInternalUrl) { const internalSpan = document.createElement('span'); internalSpan.className = 'text-sm text-gray-600 italic'; internalSpan.textContent = 'Internal service'; content.appendChild(internalSpan); } // Make icon clickable if docsUrl is available if (metadata.docsUrl) { const iconLink = document.createElement('a'); iconLink.href = metadata.docsUrl; iconLink.target = '_blank'; iconLink.rel = 'noopener'; iconLink.title = `Open ${metadata.name} documentation`; iconLink.className = 'flex-shrink-0 hover:scale-110 transition-transform'; iconLink.appendChild(iconDiv); header.appendChild(iconLink); } else { header.appendChild(iconDiv); } header.appendChild(content); return header; } /** * Render a single service card */ function renderServiceCard(key, serviceData) { const metadata = SERVICE_METADATA[key] || { name: key, description: '', icon: key.substring(0, 2).toUpperCase(), color: 'bg-gray-600' }; const card = document.createElement('article'); card.className = 'bg-surface-100 rounded-xl border border-surface-400 p-5 hover:border-brand/30 hover:bg-surface-200 transition-all min-w-0 overflow-hidden'; // Build card using DOM API (no innerHTML + setTimeout hack) const header = createCardHeader(metadata, serviceData); card.appendChild(header); // Bottom section with credentials and extra info const hasCredentials = serviceData.credentials && Object.keys(serviceData.credentials).length > 0; const hasExtra = serviceData.extra && Object.keys(serviceData.extra).some( key => key !== 'internal_api' && key !== 'internal_url' && EXTRA_FIELD_LABELS[key] ); if (hasCredentials || hasExtra) { const bottomSection = createBottomSection(serviceData.credentials, serviceData.extra, metadata.name); card.appendChild(bottomSection); } return card; } // ============================================ // APP - Initialization and rendering // ============================================ // DOM Elements const servicesContainer = document.getElementById('services-container'); const quickstartContainer = document.getElementById('quickstart-container'); const commandsContainer = document.getElementById('commands-container'); const changelogContainer = document.getElementById('changelog-container'); const domainInfo = document.getElementById('domain-info'); /** * Inject section icons from JS (replaces inline SVG in HTML) */ function injectSectionIcons() { document.querySelectorAll('[data-section-icon]').forEach(container => { const iconName = container.dataset.sectionIcon; if (Icons[iconName]) { container.innerHTML = Icons[iconName]('w-5 h-5 text-brand'); } }); } /** * Render all services */ function renderServices(services) { if (!servicesContainer) return; servicesContainer.innerHTML = ''; if (!services || Object.keys(services).length === 0) { servicesContainer.innerHTML = `

No services configured. Run the installer to set up services.

`; return; } // Sort all services alphabetically by display name const sortedKeys = Object.keys(services).sort((a, b) => { const aName = (SERVICE_METADATA[a]?.name || a).toLowerCase(); const bName = (SERVICE_METADATA[b]?.name || b).toLowerCase(); return aName.localeCompare(bName); }); // Use DocumentFragment for better performance const fragment = document.createDocumentFragment(); sortedKeys.forEach(key => { fragment.appendChild(renderServiceCard(key, services[key])); }); servicesContainer.appendChild(fragment); } /** * Render quick start steps */ function renderQuickStart(steps) { if (!quickstartContainer) return; quickstartContainer.innerHTML = ''; if (!steps || steps.length === 0) { // Default steps if none provided steps = [ { step: 1, title: 'Log into n8n', description: 'Use the email you provided during installation' }, { step: 2, title: 'Create your first workflow', description: 'Start with a Manual Trigger + HTTP Request nodes' }, { step: 3, title: 'Monitor your system', description: 'Use Grafana to track performance' } ]; } const fragment = document.createDocumentFragment(); steps.forEach(item => { const stepEl = document.createElement('div'); stepEl.className = 'flex items-start gap-4 p-4 bg-surface-100 rounded-xl border border-surface-400 hover:border-brand/30 hover:bg-surface-200 transition-all'; stepEl.innerHTML = `
${item.step}

${escapeHtml(item.title)}

${escapeHtml(item.description)}

`; fragment.appendChild(stepEl); }); quickstartContainer.appendChild(fragment); } /** * Render make commands */ function renderCommands() { if (!commandsContainer) return; commandsContainer.innerHTML = ''; const grid = document.createElement('div'); grid.className = 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'; COMMANDS.forEach(item => { const cmdEl = document.createElement('div'); cmdEl.className = 'flex items-start gap-3 p-3 rounded-lg bg-surface-200/50 border border-surface-400 hover:border-brand/30 hover:bg-surface-300 transition-all'; // Command content const content = document.createElement('div'); content.className = 'flex flex-col gap-1 flex-1 min-w-0'; content.innerHTML = ` ${escapeHtml(item.cmd)} ${escapeHtml(item.desc)} `; // Copy button const copyBtn = createCopyButton(item.cmd); copyBtn.className = 'p-1.5 rounded-lg hover:bg-surface-400 transition-colors focus:outline-none focus:ring-2 focus:ring-brand/50 flex-shrink-0'; cmdEl.appendChild(content); cmdEl.appendChild(copyBtn); grid.appendChild(cmdEl); }); commandsContainer.appendChild(grid); } /** * Render changelog content */ function renderChangelog(content) { if (!changelogContainer) return; changelogContainer.innerHTML = ''; if (!content) { changelogContainer.innerHTML = `

Changelog not available

`; return; } const pre = document.createElement('pre'); pre.className = 'text-sm text-gray-300 font-mono whitespace-pre-wrap break-words leading-relaxed'; pre.textContent = content; changelogContainer.appendChild(pre); } /** * Render error state in services container */ function renderServicesError() { if (!servicesContainer) return; servicesContainer.innerHTML = ` `; } /** * Load data and render page */ async function init() { // Inject section icons injectSectionIcons(); // Always render commands (static content) renderCommands(); // Fetch both JSON files in parallel for better performance // Each fetch is handled independently - changelog failure won't affect main data const [changelogResult, dataResult] = await Promise.allSettled([ fetch('changelog.json').then(r => r.ok ? r.json() : null), fetch('data.json').then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))) ]); // Handle changelog (independent - failures don't break the page) if (changelogResult.status === 'fulfilled' && changelogResult.value?.content) { renderChangelog(changelogResult.value.content); } else { if (changelogResult.status === 'rejected') { console.error('Error loading changelog:', changelogResult.reason); } renderChangelog(null); } // Handle main data if (dataResult.status === 'fulfilled' && dataResult.value) { const data = dataResult.value; // Update domain info if (domainInfo) { if (data.domain) { domainInfo.textContent = `Domain: ${data.domain}`; } if (data.generated_at) { const date = new Date(data.generated_at); domainInfo.textContent += ` | Generated: ${date.toLocaleString()}`; } } // Render services renderServices(data.services); // Render quick start renderQuickStart(data.quick_start); } else { console.error('Error loading data:', dataResult.reason); // Show error in UI renderServicesError(); // Still render default quick start renderQuickStart(null); } // Initialize confetti animation on first visit CinematicAnimations.init(); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();