feat: onboarding wizard, PWA support, Oracle Cloud free tier deploy, mode nav update
This commit is contained in:
40
index.html
40
index.html
@@ -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
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
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
13
public/manifest.json
Normal file
13
public/manifest.json
Normal 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
40
public/sw.js
Normal 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;
|
||||
})
|
||||
);
|
||||
});
|
||||
98
src/generators/oracle-cloud.js
Normal file
98
src/generators/oracle-cloud.js
Normal 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');
|
||||
}
|
||||
79
src/main.js
79
src/main.js
@@ -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';
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user