feat: onboarding wizard, PWA support, Oracle Cloud free tier deploy, mode nav update

This commit is contained in:
2026-08-03 23:56:42 +01:00
parent 0c0daf947e
commit ffc9ccd77e
8 changed files with 303 additions and 3 deletions

View File

@@ -9,6 +9,9 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/style.css" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#6366f1" />
<link rel="apple-touch-icon" href="/icon-192.png" />
</head>
<body>
<header class="header">
@@ -16,8 +19,9 @@
<p class="subtitle">Build your self-hosted Docker stack in minutes</p>
<nav class="mode-nav">
<button class="mode-btn active" data-mode="builder">Stack Builder</button>
<button class="mode-btn" data-mode="analyser">Compose Analyser</button>
<button class="mode-btn" data-mode="gallery">Community Stacks</button>
<button class="mode-btn" data-mode="wizard">Wizard</button>
<button class="mode-btn" data-mode="analyser">Analyser</button>
<button class="mode-btn" data-mode="gallery">Community</button>
</nav>
<div class="auth-bar" id="auth-bar">
<button class="auth-btn" id="auth-btn">Sign In</button>
@@ -125,6 +129,7 @@
<button class="tab active" data-tab="compose">docker-compose.yml</button>
<button class="tab" data-tab="env">.env</button>
<button class="tab" data-tab="setup">setup.sh</button>
<button class="tab" data-tab="oracle">☁️ Oracle Cloud</button>
</div>
<pre class="preview-code"><code id="preview-content">Select apps above to see preview...</code></pre>
</div>
@@ -181,6 +186,36 @@
</div>
</div>
<!-- Onboarding Wizard -->
<section class="step wizard-section" id="step-wizard" style="display:none;">
<h2>🧙 Stack Wizard</h2>
<p class="wizard-desc">Tell us what you want to do, and we'll pick the right apps for you.</p>
<div class="wizard-goals" id="wizard-goals">
<label class="wizard-goal"><input type="checkbox" value="movies" /><span>🎬 Watch movies & TV shows</span></label>
<label class="wizard-goal"><input type="checkbox" value="music" /><span>🎵 Stream my music collection</span></label>
<label class="wizard-goal"><input type="checkbox" value="books" /><span>📚 Read ebooks & comics</span></label>
<label class="wizard-goal"><input type="checkbox" value="photos" /><span>📷 Manage & share photos</span></label>
<label class="wizard-goal"><input type="checkbox" value="downloads" /><span>⬇️ Automate downloads</span></label>
<label class="wizard-goal"><input type="checkbox" value="ai" /><span>🤖 Run AI models locally</span></label>
<label class="wizard-goal"><input type="checkbox" value="gaming" /><span>🎮 Host game servers</span></label>
<label class="wizard-goal"><input type="checkbox" value="smart-home" /><span>🏠 Smart home automation</span></label>
<label class="wizard-goal"><input type="checkbox" value="development" /><span>💻 Self-hosted dev tools (Git, CI/CD)</span></label>
<label class="wizard-goal"><input type="checkbox" value="cloud-storage" /><span>☁️ Cloud storage & file sync</span></label>
<label class="wizard-goal"><input type="checkbox" value="privacy" /><span>🕵️ Privacy tools (VPN, ad blocking, private search)</span></label>
<label class="wizard-goal"><input type="checkbox" value="monitoring" /><span>📊 Monitor my server & services</span></label>
<label class="wizard-goal"><input type="checkbox" value="remote-access" /><span>🌐 Access from outside my network</span></label>
<label class="wizard-goal"><input type="checkbox" value="team-chat" /><span>💬 Team communication</span></label>
<label class="wizard-goal"><input type="checkbox" value="notes" /><span>📝 Notes & knowledge base</span></label>
<label class="wizard-goal"><input type="checkbox" value="finance" /><span>💰 Personal finance tracking</span></label>
</div>
<button class="analyse-btn" id="wizard-build-btn">🚀 Build My Stack</button>
<div id="wizard-result" class="wizard-result hidden">
<h3>Your recommended stack:</h3>
<div id="wizard-apps" class="wizard-apps"></div>
<button class="analyse-btn" id="wizard-use-btn" style="margin-top:1rem;">Use This Stack →</button>
</div>
</section>
<!-- Compose Analyser Modal/Section -->
<section class="step" id="step-analyser" style="display:none;">
<h2>🔍 Analyse Existing Docker Compose</h2>
@@ -208,5 +243,6 @@
</section>
<script type="module" src="/src/main.js"></script>
<script>if('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</body>
</html>

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

13
public/manifest.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "StackPilot",
"short_name": "StackPilot",
"description": "Build your self-hosted Docker stack in minutes",
"start_url": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#6366f1",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

40
public/sw.js Normal file
View File

@@ -0,0 +1,40 @@
const CACHE_NAME = 'stackpilot-v1';
const STATIC_ASSETS = ['/', '/index.html'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// Don't cache API calls
if (request.url.includes('/api/')) {
event.respondWith(fetch(request));
return;
}
// Cache-first for static assets
event.respondWith(
caches.match(request).then((cached) => {
const fetched = fetch(request).then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
}).catch(() => cached);
return cached || fetched;
})
);
});

View File

@@ -0,0 +1,98 @@
/**
* Generate a cloud-init script for Oracle Cloud Always Free tier.
* Oracle free tier: 4 ARM Ampere A1 cores, 24GB RAM, 200GB storage.
* This produces a user-data script that installs Docker + Compose,
* sets up the stack, and runs the auto-linker.
*/
export function generateOracleCloudInit(selectedApps, config, composYaml, envContent, setupScript) {
const lines = [
'#!/bin/bash',
'# StackPilot — Oracle Cloud Free Tier Deployment Script',
'# Usage: Paste this as "Cloud-Init Script" when creating your free instance.',
'# Instance shape: VM.Standard.A1.Flex (4 OCPUs, 24GB RAM)',
`# Generated: ${new Date().toISOString()}`,
'',
'set -euo pipefail',
'exec > >(tee /var/log/stackpilot-init.log) 2>&1',
'',
'echo "=== StackPilot Cloud-Init Starting ==="',
'',
'# Update system',
'apt-get update && apt-get upgrade -y',
'',
'# Install Docker',
'curl -fsSL https://get.docker.com | sh',
'systemctl enable docker && systemctl start docker',
'',
'# Install Docker Compose plugin',
'apt-get install -y docker-compose-plugin',
'',
'# Create stackpilot user',
'useradd -m -s /bin/bash -G docker stackpilot || true',
'',
'# Create directories',
`mkdir -p ${config.configPath}`,
`mkdir -p ${config.mediaPath}`,
`mkdir -p ${config.downloadsPath}`,
`chown -R stackpilot:stackpilot ${config.configPath} ${config.mediaPath} ${config.downloadsPath}`,
'',
'# Write docker-compose.yml',
'mkdir -p /home/stackpilot/stack',
'cat > /home/stackpilot/stack/docker-compose.yml << \'COMPOSE_EOF\'',
composYaml,
'COMPOSE_EOF',
'',
'# Write .env',
'cat > /home/stackpilot/stack/.env << \'ENV_EOF\'',
envContent,
'ENV_EOF',
'',
'# Write setup.sh',
'cat > /home/stackpilot/stack/setup.sh << \'SETUP_EOF\'',
setupScript,
'SETUP_EOF',
'chmod +x /home/stackpilot/stack/setup.sh',
'',
'# Set ownership',
'chown -R stackpilot:stackpilot /home/stackpilot/stack',
'',
'# Open firewall ports (Oracle Linux / Ubuntu iptables)',
'iptables -I INPUT -p tcp --match multiport --dports 80,443,8080,8096,8989,7878,9696,8112,32400 -j ACCEPT || true',
'iptables-save > /etc/iptables/rules.v4 2>/dev/null || true',
'',
'# Start the stack',
'cd /home/stackpilot/stack',
'docker compose up -d',
'',
'# Wait and run auto-linker',
'echo "Waiting 60s for services to start..."',
'sleep 60',
'su - stackpilot -c "cd /home/stackpilot/stack && ./setup.sh" || true',
'',
'# Setup auto-start on reboot',
'cat > /etc/systemd/system/stackpilot.service << \'SERVICE_EOF\'',
'[Unit]',
'Description=StackPilot Docker Stack',
'After=docker.service',
'Requires=docker.service',
'',
'[Service]',
'Type=oneshot',
'RemainAfterExit=yes',
'WorkingDirectory=/home/stackpilot/stack',
'ExecStart=/usr/bin/docker compose up -d',
'ExecStop=/usr/bin/docker compose down',
'User=stackpilot',
'',
'[Install]',
'WantedBy=multi-user.target',
'SERVICE_EOF',
'systemctl enable stackpilot.service',
'',
'echo "=== StackPilot Cloud-Init Complete ==="',
`echo "Stack is running! Services: ${selectedApps.filter(a => a.ports?.length > 0).map(a => a.name).join(', ')}"`,
'echo "Check logs: /var/log/stackpilot-init.log"',
];
return lines.join('\n');
}

View File

@@ -2,6 +2,7 @@ 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 { generateOracleCloudInit } from './generators/oracle-cloud.js';
import { trackDownload, fetchPopularity } from './utils/analytics.js';
import { initAuth } from './utils/auth.js';
import { initGallery } from './utils/gallery.js';
@@ -254,6 +255,14 @@ function updatePreview() {
case 'setup':
content.textContent = generateSetupScript(selectedApps, config);
break;
case 'oracle':
content.textContent = generateOracleCloudInit(
selectedApps, config,
generateCompose(selectedApps, config),
generateEnv(selectedApps, config),
generateSetupScript(selectedApps, config)
);
break;
}
}
@@ -387,6 +396,7 @@ initSearch();
initPresets();
initAuth();
initGallery(() => [...state.selected], getConfig);
initWizard();
document.getElementById('download-btn').addEventListener('click', downloadBundle);
// Listen for "Use This Stack" from gallery
@@ -408,6 +418,72 @@ fetchPopularity().then(data => {
}
});
// Wizard
const wizardGoalMap = {
movies: ['jellyfin', 'sonarr', 'radarr', 'prowlarr', 'bazarr', 'jellyseerr', 'qbittorrent', 'flaresolverr'],
music: ['navidrome', 'lidarr', 'prowlarr', 'qbittorrent'],
books: ['calibre-web', 'readarr', 'audiobookshelf', 'kavita', 'prowlarr', 'qbittorrent'],
photos: ['immich', 'postgres', 'redis'],
downloads: ['qbittorrent', 'prowlarr', 'sonarr', 'radarr', 'flaresolverr', 'gluetun'],
ai: ['ollama', 'open-webui', 'comfyui', 'flowise'],
gaming: ['minecraft', 'crafty', 'pterodactyl'],
'smart-home': ['homeassistant', 'mosquitto', 'zigbee2mqtt', 'nodered', 'esphome'],
development: ['gitea', 'code-server', 'drone', 'registry', 'postgres', 'redis'],
'cloud-storage': ['nextcloud', 'syncthing', 'filebrowser'],
privacy: ['adguard-home', 'gluetun', 'searxng', 'vaultwarden', 'tailscale'],
monitoring: ['portainer', 'uptime-kuma', 'grafana', 'prometheus', 'netdata', 'watchtower', 'dozzle'],
'remote-access': ['tailscale', 'nginx-proxy-manager', 'authelia'],
'team-chat': ['matrix-synapse', 'element', 'ntfy'],
notes: ['trilium', 'outline', 'hedgedoc', 'memos', 'postgres'],
finance: ['actual-budget', 'firefly-iii', 'ghostfolio'],
};
function initWizard() {
document.getElementById('wizard-build-btn').addEventListener('click', () => {
const checked = [...document.querySelectorAll('#wizard-goals input:checked')].map(el => el.value);
if (checked.length === 0) return;
const recommended = new Set();
for (const goal of checked) {
const goalApps = wizardGoalMap[goal] || [];
goalApps.forEach(id => recommended.add(id));
}
// Always add essentials if multiple goals
if (checked.length >= 2) {
recommended.add('homepage');
recommended.add('watchtower');
}
const appsList = [...recommended].filter(id => apps.some(a => a.id === id));
const resultEl = document.getElementById('wizard-result');
const appsEl = document.getElementById('wizard-apps');
appsEl.innerHTML = appsList.map(id => {
const app = apps.find(a => a.id === id);
return `<span class="wizard-app-chip">${app ? app.name : id}</span>`;
}).join('');
resultEl.classList.remove('hidden');
resultEl.dataset.apps = JSON.stringify(appsList);
});
document.getElementById('wizard-use-btn').addEventListener('click', () => {
const resultEl = document.getElementById('wizard-result');
const appsList = JSON.parse(resultEl.dataset.apps || '[]');
state.selected = new Set(appsList);
renderAppGrid();
checkDependencies();
checkPortConflicts();
updatePreview();
updateUrlState();
// Switch to builder
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
document.querySelector('.mode-btn[data-mode="builder"]').classList.add('active');
document.querySelector('.container').style.display = 'block';
document.getElementById('step-wizard').style.display = 'none';
});
}
// Search
function initSearch() {
const input = document.getElementById('app-search');
@@ -444,7 +520,7 @@ function initPresets() {
});
}
// Mode switching (builder vs analyser vs gallery)
// Mode switching (builder vs wizard vs analyser vs gallery)
function initModeNav() {
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
@@ -452,6 +528,7 @@ function initModeNav() {
btn.classList.add('active');
const mode = btn.dataset.mode;
document.querySelector('.container').style.display = mode === 'builder' ? 'block' : 'none';
document.getElementById('step-wizard').style.display = mode === 'wizard' ? 'block' : 'none';
document.getElementById('step-analyser').style.display = mode === 'analyser' ? 'block' : 'none';
document.getElementById('step-gallery').style.display = mode === 'gallery' ? 'block' : 'none';
});

View File

@@ -648,6 +648,42 @@ body {
/* Gallery */
.gallery-section { max-width: 1280px; margin: 0 auto; padding: 2.5rem 1.5rem; }
/* Wizard */
.wizard-section { max-width: 1280px; margin: 0 auto; padding: 2.5rem 1.5rem; }
.wizard-desc { color: var(--text-dim); margin-bottom: 1.5rem; font-size: 0.95rem; }
.wizard-goals {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.6rem;
margin-bottom: 1.5rem;
}
.wizard-goal {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.8rem 1rem;
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.9rem;
}
.wizard-goal:hover { border-color: var(--accent); background: var(--surface-hover); }
.wizard-goal input:checked + span { color: var(--accent-hover); }
.wizard-goal input { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
.wizard-result { margin-top: 1.5rem; }
.wizard-result h3 { margin-bottom: 1rem; font-size: 1rem; color: var(--success); }
.wizard-apps { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.wizard-app-chip {
background: var(--accent-glow);
color: var(--accent-hover);
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 500;
}
.gallery-controls {
display: flex;
gap: 0.75rem;