diff --git a/welcome/app.js b/welcome/app.js index 1e2c98a..ee31acf 100644 --- a/welcome/app.js +++ b/welcome/app.js @@ -1,13 +1,148 @@ /** * n8n-install Welcome Page * Dynamic rendering of services and credentials from data.json - * Supabase-inspired design + * Supabase-inspired design with cinematic animations */ (function() { 'use strict'; - // Service metadata - hardcoded info about each service + // ============================================ + // 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 = '') => ` + ` + }; + + // ============================================ + // DATA - Service metadata and commands + // ============================================ const SERVICE_METADATA = { 'n8n': { name: 'n8n', @@ -228,7 +363,6 @@ } }; - // Make commands data const COMMANDS = [ { cmd: 'make status', desc: 'Show container status' }, { cmd: 'make logs', desc: 'View logs (all services)' }, @@ -241,371 +375,9 @@ { cmd: 'make clean', desc: 'Remove unused Docker resources' } ]; - // DOM Elements - const servicesContainer = document.getElementById('services-container'); - const quickstartContainer = document.getElementById('quickstart-container'); - const commandsContainer = document.getElementById('commands-container'); - const domainInfo = document.getElementById('domain-info'); - const errorToast = document.getElementById('error-toast'); - const errorMessage = document.getElementById('error-message'); - - /** - * Create a copy button for any text - */ - function createCopyButton(textToCopy) { - const copyBtn = document.createElement('button'); - copyBtn.className = 'p-1.5 rounded-lg hover:bg-surface-400 transition-colors focus:outline-none focus:ring-2 focus:ring-brand/50'; - copyBtn.innerHTML = ` - - - `; - copyBtn.title = 'Copy to clipboard'; - - copyBtn.addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(textToCopy); - const copyIcon = copyBtn.querySelector('.copy-icon'); - const checkIcon = copyBtn.querySelector('.check-icon'); - copyIcon.classList.add('hidden'); - checkIcon.classList.remove('hidden'); - setTimeout(() => { - copyIcon.classList.remove('hidden'); - checkIcon.classList.add('hidden'); - }, 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }); - - return copyBtn; - } - - /** - * Show error toast - */ - function showError(message) { - errorMessage.textContent = message; - errorToast.classList.remove('hidden'); - setTimeout(() => { - errorToast.classList.remove('translate-y-20', 'opacity-0'); - }, 10); - - setTimeout(() => { - errorToast.classList.add('translate-y-20', 'opacity-0'); - setTimeout(() => errorToast.classList.add('hidden'), 300); - }, 5000); - } - - /** - * Create password field with toggle and copy buttons - */ - function createPasswordField(password) { - const container = document.createElement('div'); - container.className = 'flex items-center gap-1'; - - const passwordSpan = document.createElement('span'); - passwordSpan.className = 'font-mono text-sm select-all text-gray-300'; - passwordSpan.textContent = '*'.repeat(Math.min(password.length, 12)); - passwordSpan.dataset.password = password; - passwordSpan.dataset.hidden = 'true'; - - // Toggle visibility button (eye icon) - const toggleBtn = document.createElement('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.innerHTML = ` - - - `; - toggleBtn.title = 'Toggle visibility'; - - // Toggle password visibility on click - toggleBtn.addEventListener('click', () => { - const isHidden = passwordSpan.dataset.hidden === 'true'; - const eyeOpen = toggleBtn.querySelector('.eye-open'); - const eyeClosed = toggleBtn.querySelector('.eye-closed'); - - if (isHidden) { - passwordSpan.textContent = passwordSpan.dataset.password; - passwordSpan.dataset.hidden = 'false'; - eyeOpen.classList.add('hidden'); - eyeClosed.classList.remove('hidden'); - } else { - passwordSpan.textContent = '*'.repeat(Math.min(password.length, 12)); - passwordSpan.dataset.hidden = 'true'; - eyeOpen.classList.remove('hidden'); - eyeClosed.classList.add('hidden'); - } - }); - - // Copy button - const copyBtn = document.createElement('button'); - copyBtn.className = 'p-1.5 rounded-lg hover:bg-surface-400 transition-colors focus:outline-none focus:ring-2 focus:ring-brand/50'; - copyBtn.innerHTML = ` - - - `; - copyBtn.title = 'Copy to clipboard'; - - copyBtn.addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(password); - // Show checkmark - const copyIcon = copyBtn.querySelector('.copy-icon'); - const checkIcon = copyBtn.querySelector('.check-icon'); - copyIcon.classList.add('hidden'); - checkIcon.classList.remove('hidden'); - // Revert after 2 seconds - setTimeout(() => { - copyIcon.classList.remove('hidden'); - checkIcon.classList.add('hidden'); - }, 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }); - - container.appendChild(passwordSpan); - container.appendChild(toggleBtn); - container.appendChild(copyBtn); - - return container; - } - - /** - * 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('div'); - card.className = 'bg-surface-100 rounded-xl border border-surface-400 p-5 hover:border-brand/30 hover:bg-surface-200 transition-all'; - - // Build credentials section - let credentialsHtml = ''; - if (serviceData.credentials) { - const creds = serviceData.credentials; - - if (creds.note) { - credentialsHtml = ` -
${escapeHtml(creds.note)}
-${escapeHtml(metadata.description)}
- ${serviceData.hostname ? ` - - ${escapeHtml(serviceData.hostname)} - - - ` : 'Internal service'} - ${extraHtml} -No services configured. Run the installer to set up services.
-${escapeHtml(item.description)}
-${escapeHtml(item.cmd)}
- ${escapeHtml(item.desc)}
- `;
-
- grid.appendChild(cmdEl);
- });
-
- commandsContainer.appendChild(grid);
- }
+ // ============================================
+ // UTILS - Helper functions
+ // ============================================
/**
* Escape HTML to prevent XSS
@@ -617,10 +389,459 @@
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';
+
+ // Password display
+ const passwordSpan = document.createElement('span');
+ passwordSpan.className = 'font-mono text-sm select-all text-gray-300';
+ 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(passwordSpan);
+ container.appendChild(toggleBtn);
+ 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';
+
+ const labelSpan = document.createElement('span');
+ labelSpan.className = 'text-gray-500 text-sm';
+ 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';
+
+ const valueSpan = document.createElement('span');
+ valueSpan.className = 'font-mono text-sm select-all text-gray-300';
+ valueSpan.textContent = value;
+
+ valueContainer.appendChild(valueSpan);
+ valueContainer.appendChild(createCopyButton(value));
+ row.appendChild(valueContainer);
+ }
+
+ return row;
+ }
+
+ /**
+ * Create credentials section for a service card
+ */
+ function createCredentialsSection(creds) {
+ const section = document.createElement('div');
+ section.className = 'mt-4 pt-4 border-t border-surface-400 space-y-2';
+
+ if (creds.note) {
+ const noteP = document.createElement('p');
+ noteP.className = 'text-sm text-gray-500 italic';
+ noteP.textContent = creds.note;
+ section.appendChild(noteP);
+ return section;
+ }
+
+ 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));
+ }
+
+ return section;
+ }
+
+ /**
+ * Create extra info section (internal URLs, etc.)
+ */
+ function createExtraSection(extra) {
+ const items = [];
+
+ if (extra.internal_api) {
+ items.push(`Internal: ${escapeHtml(extra.internal_api)}`);
+ }
+ if (extra.internal_url) {
+ items.push(`Internal: ${escapeHtml(extra.internal_url)}`);
+ }
+ if (extra.workers) {
+ items.push(`Workers: ${escapeHtml(extra.workers)}`);
+ }
+ if (extra.recommendation) {
+ items.push(`${escapeHtml(extra.recommendation)}`);
+ }
+
+ if (items.length === 0) return null;
+
+ const container = document.createElement('div');
+ container.className = 'mt-2 flex flex-wrap gap-2';
+ container.innerHTML = items.join('');
+ return container;
+ }
+
+ /**
+ * 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';
+ 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);
+
+ // Link or internal service indicator
+ 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);
+ } else {
+ const internalSpan = document.createElement('span');
+ internalSpan.className = 'text-sm text-gray-600 italic';
+ internalSpan.textContent = 'Internal service';
+ content.appendChild(internalSpan);
+ }
+
+ // Extra info
+ if (serviceData.extra) {
+ const extraSection = createExtraSection(serviceData.extra);
+ if (extraSection) content.appendChild(extraSection);
+ }
+
+ header.appendChild(iconDiv);
+ header.appendChild(content);
+
+ return header;
+ }
+
+ /**
+ * Render a single service card (no setTimeout hack)
+ */
+ 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-glow';
+ // card.className = 'bg-surface-100 rounded-xl border border-surface-400 p-5 hover:border-brand/30 hover:bg-surface-200 transition-all hover-glow';
+
+ // Build card using DOM API (no innerHTML + setTimeout hack)
+ const header = createCardHeader(metadata, serviceData);
+ card.appendChild(header);
+
+ // Credentials section
+ if (serviceData.credentials && Object.keys(serviceData.credentials).length > 0) {
+ const credsSection = createCredentialsSection(serviceData.credentials);
+ card.appendChild(credsSection);
+ }
+
+ 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 domainInfo = document.getElementById('domain-info');
+ const errorToast = document.getElementById('error-toast');
+ const errorMessage = document.getElementById('error-message');
+
+ /**
+ * 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');
+ }
+ });
+ }
+
+ /**
+ * Show error toast
+ */
+ function showError(message) {
+ if (!errorToast || !errorMessage) return;
+
+ errorMessage.textContent = message;
+ errorToast.classList.remove('hidden');
+
+ requestAnimationFrame(() => {
+ errorToast.classList.remove('translate-y-20', 'opacity-0');
+ });
+
+ setTimeout(() => {
+ errorToast.classList.add('translate-y-20', 'opacity-0');
+ setTimeout(() => errorToast.classList.add('hidden'), 300);
+ }, 5000);
+ }
+
+ /**
+ * 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.
+${escapeHtml(item.description)}
+${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 error state in services container
+ */
+ function renderServicesError() {
+ if (!servicesContainer) return;
+
+ servicesContainer.innerHTML = `
+ Make sure the installation completed successfully and data.json was generated.
+Make sure the installation completed successfully and data.json was generated.
-
Your self-hosted automation platform is ready to use
@@ -90,11 +144,7 @@
Your Services
Keep your services up to date with the latest features and security patches:
make update
@@ -141,56 +187,52 @@