StackPilot — No server required. Everything runs in your browser.
+ +🌐 Community Stacks
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sign In
+ +
+
+
+
+
+ Share Your Stack
+ +🔍 Analyse Existing Docker Compose
diff --git a/src/main.js b/src/main.js index aefd27d..e449dc3 100644 --- a/src/main.js +++ b/src/main.js @@ -3,6 +3,8 @@ 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'; @@ -383,8 +385,21 @@ 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) { @@ -420,7 +435,7 @@ function initPresets() { }); } -// Mode switching (builder vs analyser) +// Mode switching (builder vs analyser vs gallery) function initModeNav() { document.querySelectorAll('.mode-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -429,6 +444,7 @@ function initModeNav() { 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'; }); }); } diff --git a/src/style.css b/src/style.css index f793535..439d4d1 100644 --- a/src/style.css +++ b/src/style.css @@ -577,6 +577,134 @@ body { border-top: 1px solid var(--border); } +/* Auth Bar */ +.auth-bar { position: absolute; top: 1.5rem; right: 1.5rem; } +.auth-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-dim); + padding: 0.5rem 1.2rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} +.auth-btn:hover { border-color: var(--accent); color: var(--accent); } +.auth-btn.logged-in { color: var(--success); border-color: rgba(34,197,94,0.3); } + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.15s ease; +} +.modal-overlay.hidden { display: none; } +.modal { + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 2rem; + width: 100%; + max-width: 400px; + position: relative; + box-shadow: var(--shadow-lg); +} +.modal h2 { margin-bottom: 1.25rem; font-size: 1.2rem; } +.modal-close { + position: absolute; + top: 1rem; + right: 1rem; + background: none; + border: none; + color: var(--text-muted); + font-size: 1.5rem; + cursor: pointer; + line-height: 1; +} +.modal-close:hover { color: var(--text); } +.auth-form { display: flex; flex-direction: column; gap: 0.75rem; } +.auth-input { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + font-size: 0.9rem; +} +.auth-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } +.auth-toggle { font-size: 0.8rem; color: var(--text-muted); text-align: center; margin-top: 0.5rem; } +.auth-toggle a { color: var(--accent); text-decoration: none; } +.auth-toggle a:hover { text-decoration: underline; } +.auth-error { color: var(--danger); font-size: 0.8rem; text-align: center; } + +/* Gallery */ +.gallery-section { max-width: 1280px; margin: 0 auto; padding: 2.5rem 1.5rem; } +.gallery-controls { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} +.gallery-search { + flex: 1; + min-width: 200px; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 0.6rem 1rem; + border-radius: 999px; + font-size: 0.85rem; +} +.gallery-search:focus { outline: none; border-color: var(--accent); } +.gallery-sort { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 0.6rem 1rem; + border-radius: var(--radius-sm); + font-size: 0.8rem; +} +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} +.stack-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + transition: all 0.2s ease; +} +.stack-card:hover { border-color: var(--border-light); transform: translateY(-2px); box-shadow: var(--shadow-md); } +.stack-card .stack-name { font-weight: 600; font-size: 1rem; margin-bottom: 0.3rem; } +.stack-card .stack-desc { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem; line-height: 1.4; } +.stack-card .stack-meta { display: flex; gap: 1rem; font-size: 0.75rem; color: var(--text-muted); align-items: center; } +.stack-card .stack-apps { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.75rem; } +.stack-card .stack-app-tag { background: var(--accent-glow); color: var(--accent-hover); padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.65rem; font-weight: 500; } +.stack-card .stack-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; } +.stack-card .stack-action-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + padding: 0.35rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.15s ease; +} +.stack-card .stack-action-btn:hover { border-color: var(--accent); color: var(--accent); } +.stack-card .stack-action-btn.starred { color: var(--warning); border-color: rgba(245,158,11,0.3); } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + /* Scrollbar */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: var(--bg); } diff --git a/src/utils/auth.js b/src/utils/auth.js new file mode 100644 index 0000000..47230c6 --- /dev/null +++ b/src/utils/auth.js @@ -0,0 +1,111 @@ +const API_URL = '/api'; + +let currentUser = null; +let authToken = localStorage.getItem('stackpilot_token'); + +// Check if logged in on load +export async function initAuth() { + if (authToken) { + try { + const res = await fetch(`${API_URL}/auth/me`, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + if (res.ok) { + currentUser = await res.json(); + updateAuthUI(); + } else { + logout(); + } + } catch (e) { + // API not available — silent fail + } + } + setupAuthListeners(); +} + +function setupAuthListeners() { + const authBtn = document.getElementById('auth-btn'); + const modal = document.getElementById('auth-modal'); + const closeBtn = document.getElementById('modal-close'); + const switchBtn = document.getElementById('auth-switch'); + const form = document.getElementById('auth-form'); + + authBtn.addEventListener('click', () => { + if (currentUser) { + if (confirm('Sign out?')) logout(); + } else { + modal.classList.remove('hidden'); + } + }); + + closeBtn.addEventListener('click', () => modal.classList.add('hidden')); + modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.add('hidden'); }); + + let isSignUp = false; + switchBtn.addEventListener('click', (e) => { + e.preventDefault(); + isSignUp = !isSignUp; + document.getElementById('auth-modal-title').textContent = isSignUp ? 'Sign Up' : 'Sign In'; + document.getElementById('auth-username').style.display = isSignUp ? 'block' : 'none'; + form.querySelector('button[type="submit"]').textContent = isSignUp ? 'Sign Up' : 'Sign In'; + switchBtn.textContent = isSignUp ? 'Sign In' : 'Sign Up'; + document.querySelector('.auth-toggle').firstChild.textContent = isSignUp ? 'Already have an account? ' : "Don't have an account? "; + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = document.getElementById('auth-error'); + errorEl.classList.add('hidden'); + + const email = document.getElementById('auth-email').value; + const password = document.getElementById('auth-password').value; + const username = document.getElementById('auth-username').value; + + const endpoint = isSignUp ? '/auth/register' : '/auth/login'; + const body = isSignUp ? { username, email, password } : { email, password }; + + try { + const res = await fetch(`${API_URL}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + errorEl.textContent = data.error; + errorEl.classList.remove('hidden'); + return; + } + authToken = data.token; + currentUser = data.user; + localStorage.setItem('stackpilot_token', authToken); + updateAuthUI(); + modal.classList.add('hidden'); + } catch (err) { + errorEl.textContent = 'Connection error — is the API running?'; + errorEl.classList.remove('hidden'); + } + }); +} + +function updateAuthUI() { + const btn = document.getElementById('auth-btn'); + if (currentUser) { + btn.textContent = `👤 ${currentUser.display_name || currentUser.username}`; + btn.classList.add('logged-in'); + } else { + btn.textContent = 'Sign In'; + btn.classList.remove('logged-in'); + } +} + +function logout() { + authToken = null; + currentUser = null; + localStorage.removeItem('stackpilot_token'); + updateAuthUI(); +} + +export function getToken() { return authToken; } +export function getUser() { return currentUser; } +export function isLoggedIn() { return !!currentUser; } diff --git a/src/utils/gallery.js b/src/utils/gallery.js new file mode 100644 index 0000000..fde5fee --- /dev/null +++ b/src/utils/gallery.js @@ -0,0 +1,146 @@ +import { getToken, isLoggedIn } from './auth.js'; + +const API_URL = '/api'; + +let galleryStacks = []; + +export function initGallery(getSelectedApps, getConfig) { + loadGallery(); + + document.getElementById('gallery-search').addEventListener('input', debounce(loadGallery, 300)); + document.getElementById('gallery-sort').addEventListener('change', loadGallery); + + // Share button + document.getElementById('share-stack-btn').addEventListener('click', () => { + if (!isLoggedIn()) { + document.getElementById('auth-modal').classList.remove('hidden'); + return; + } + if (getSelectedApps().length === 0) { + alert('Select some apps in the Stack Builder first!'); + return; + } + document.getElementById('share-modal').classList.remove('hidden'); + }); + + document.getElementById('share-modal-close').addEventListener('click', () => { + document.getElementById('share-modal').classList.add('hidden'); + }); + + document.getElementById('share-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const name = document.getElementById('share-name').value.trim(); + const desc = document.getElementById('share-desc').value.trim(); + const tags = document.getElementById('share-tags').value.split(',').map(t => t.trim()).filter(Boolean); + + const res = await fetch(`${API_URL}/stacks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ + name, + description: desc, + apps: getSelectedApps(), + config: getConfig(), + tags, + }), + }); + + if (res.ok) { + document.getElementById('share-modal').classList.add('hidden'); + document.getElementById('share-form').reset(); + loadGallery(); + } + }); +} + +async function loadGallery() { + const search = document.getElementById('gallery-search').value.trim(); + const sort = document.getElementById('gallery-sort').value; + + let url = `${API_URL}/stacks?sort=${sort}&limit=30`; + if (search) url += `&search=${encodeURIComponent(search)}`; + + try { + const res = await fetch(url, { + headers: getToken() ? { Authorization: `Bearer ${getToken()}` } : {}, + }); + if (res.ok) { + galleryStacks = await res.json(); + renderGallery(); + } + } catch (e) { + document.getElementById('gallery-grid').innerHTML = 'Unable to load community stacks. Is the API running?
'; + } +} + +function renderGallery() { + const grid = document.getElementById('gallery-grid'); + if (galleryStacks.length === 0) { + grid.innerHTML = 'No stacks shared yet. Be the first!
'; + return; + } + + grid.innerHTML = galleryStacks.map(stack => ` +
+
+ `).join('');
+
+ // Event listeners
+ grid.querySelectorAll('[data-action="star"]').forEach(btn => {
+ btn.addEventListener('click', () => starStack(btn.dataset.id));
+ });
+ grid.querySelectorAll('[data-action="use"]').forEach(btn => {
+ btn.addEventListener('click', () => useStack(btn.dataset.id));
+ });
+}
+
+async function starStack(id) {
+ if (!isLoggedIn()) {
+ document.getElementById('auth-modal').classList.remove('hidden');
+ return;
+ }
+ await fetch(`${API_URL}/stacks/${id}/star`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${getToken()}` },
+ });
+ loadGallery();
+}
+
+async function useStack(id) {
+ const stack = galleryStacks.find(s => s.id === parseInt(id));
+ if (!stack) return;
+
+ // Track download
+ fetch(`${API_URL}/stacks/${id}/download`, { method: 'POST' });
+
+ // Dispatch event for main.js to load these apps
+ window.dispatchEvent(new CustomEvent('use-stack', { detail: { apps: stack.apps, config: stack.config } }));
+
+ // Switch to builder mode
+ 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-gallery').style.display = 'none';
+ document.getElementById('step-analyser').style.display = 'none';
+}
+
+function esc(str) {
+ return str.replace(/&/g, '&').replace(//g, '>');
+}
+
+function debounce(fn, ms) {
+ let timer;
+ return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
+}
${esc(stack.name)}
+ ${esc(stack.description || '')}
+ ${stack.apps.slice(0, 8).map(a => `${a}`).join('')}${stack.apps.length > 8 ? `+${stack.apps.length - 8}` : ''}
+
+ ⭐ ${stack.stars}
+ 📦 ${stack.downloads}
+ by ${esc(stack.display_name || stack.username)}
+
+
+
+
+
+