feat: community gallery UI (auth modals, stack cards, star/use, share), search, presets, dependency/port warnings

This commit is contained in:
2026-08-03 23:42:19 +01:00
parent 9a98985d3e
commit 46906c46b3
5 changed files with 451 additions and 1 deletions

View File

@@ -17,7 +17,11 @@
<nav class="mode-nav"> <nav class="mode-nav">
<button class="mode-btn active" data-mode="builder">Stack Builder</button> <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="analyser">Compose Analyser</button>
<button class="mode-btn" data-mode="gallery">Community Stacks</button>
</nav> </nav>
<div class="auth-bar" id="auth-bar">
<button class="auth-btn" id="auth-btn">Sign In</button>
</div>
</header> </header>
<main class="container"> <main class="container">
@@ -131,6 +135,51 @@
<p>StackPilot — No server required. Everything runs in your browser.</p> <p>StackPilot — No server required. Everything runs in your browser.</p>
</footer> </footer>
<!-- Community Gallery -->
<section class="step gallery-section" id="step-gallery" style="display:none;">
<h2>🌐 Community Stacks</h2>
<div class="gallery-controls">
<input type="text" id="gallery-search" class="gallery-search" placeholder="Search community stacks..." />
<select id="gallery-sort" class="gallery-sort">
<option value="stars">⭐ Most Starred</option>
<option value="downloads">📦 Most Downloaded</option>
<option value="recent">🕐 Most Recent</option>
</select>
<button class="analyse-btn" id="share-stack-btn" style="margin:0;padding:0.5rem 1.2rem;font-size:0.85rem;">+ Share My Stack</button>
</div>
<div id="gallery-grid" class="gallery-grid"></div>
</section>
<!-- Auth Modal -->
<div class="modal-overlay hidden" id="auth-modal">
<div class="modal">
<button class="modal-close" id="modal-close">&times;</button>
<h2 id="auth-modal-title">Sign In</h2>
<form id="auth-form" class="auth-form">
<input type="text" id="auth-username" placeholder="Username" class="auth-input" style="display:none;" />
<input type="email" id="auth-email" placeholder="Email" class="auth-input" />
<input type="password" id="auth-password" placeholder="Password" class="auth-input" />
<button type="submit" class="analyse-btn" style="width:100%;margin:0;">Sign In</button>
<p class="auth-toggle">Don't have an account? <a href="#" id="auth-switch">Sign Up</a></p>
<p class="auth-error hidden" id="auth-error"></p>
</form>
</div>
</div>
<!-- Share Stack Modal -->
<div class="modal-overlay hidden" id="share-modal">
<div class="modal">
<button class="modal-close" id="share-modal-close">&times;</button>
<h2>Share Your Stack</h2>
<form id="share-form" class="auth-form">
<input type="text" id="share-name" placeholder="Stack name (e.g. My Media Setup)" class="auth-input" required />
<textarea id="share-desc" placeholder="Description (optional)" class="auth-input" style="min-height:80px;resize:vertical;"></textarea>
<input type="text" id="share-tags" placeholder="Tags (comma-separated, e.g. media, arr, beginner)" class="auth-input" />
<button type="submit" class="analyse-btn" style="width:100%;margin:0;">🚀 Share Stack</button>
</form>
</div>
</div>
<!-- Compose Analyser Modal/Section --> <!-- Compose Analyser Modal/Section -->
<section class="step" id="step-analyser" style="display:none;"> <section class="step" id="step-analyser" style="display:none;">
<h2>🔍 Analyse Existing Docker Compose</h2> <h2>🔍 Analyse Existing Docker Compose</h2>

View File

@@ -3,6 +3,8 @@ import { generateCompose, generateEnv } from './generators/compose.js';
import { generateSetupScript } from './generators/setup.js'; import { generateSetupScript } from './generators/setup.js';
import { analyseCompose, fixCompose } from './generators/analyser.js'; import { analyseCompose, fixCompose } from './generators/analyser.js';
import { trackDownload, fetchPopularity } from './utils/analytics.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 JSZip from 'jszip';
import './style.css'; import './style.css';
@@ -383,8 +385,21 @@ initModeNav();
initAnalyser(); initAnalyser();
initSearch(); initSearch();
initPresets(); initPresets();
initAuth();
initGallery(() => [...state.selected], getConfig);
document.getElementById('download-btn').addEventListener('click', downloadBundle); 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) // Load popularity data (async, non-blocking)
fetchPopularity().then(data => { fetchPopularity().then(data => {
if (data) { if (data) {
@@ -420,7 +435,7 @@ function initPresets() {
}); });
} }
// Mode switching (builder vs analyser) // Mode switching (builder vs analyser vs gallery)
function initModeNav() { function initModeNav() {
document.querySelectorAll('.mode-btn').forEach(btn => { document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -429,6 +444,7 @@ function initModeNav() {
const mode = btn.dataset.mode; const mode = btn.dataset.mode;
document.querySelector('.container').style.display = mode === 'builder' ? 'block' : 'none'; document.querySelector('.container').style.display = mode === 'builder' ? 'block' : 'none';
document.getElementById('step-analyser').style.display = mode === 'analyser' ? 'block' : 'none'; document.getElementById('step-analyser').style.display = mode === 'analyser' ? 'block' : 'none';
document.getElementById('step-gallery').style.display = mode === 'gallery' ? 'block' : 'none';
}); });
}); });
} }

View File

@@ -577,6 +577,134 @@ body {
border-top: 1px solid var(--border); 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 */ /* Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg); } ::-webkit-scrollbar-track { background: var(--bg); }

111
src/utils/auth.js Normal file
View File

@@ -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; }

146
src/utils/gallery.js Normal file
View File

@@ -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 = '<p style="color:var(--text-muted)">Unable to load community stacks. Is the API running?</p>';
}
}
function renderGallery() {
const grid = document.getElementById('gallery-grid');
if (galleryStacks.length === 0) {
grid.innerHTML = '<p style="color:var(--text-muted)">No stacks shared yet. Be the first!</p>';
return;
}
grid.innerHTML = galleryStacks.map(stack => `
<div class="stack-card" data-id="${stack.id}">
<div class="stack-name">${esc(stack.name)}</div>
<div class="stack-desc">${esc(stack.description || '')}</div>
<div class="stack-apps">${stack.apps.slice(0, 8).map(a => `<span class="stack-app-tag">${a}</span>`).join('')}${stack.apps.length > 8 ? `<span class="stack-app-tag">+${stack.apps.length - 8}</span>` : ''}</div>
<div class="stack-meta">
<span>⭐ ${stack.stars}</span>
<span>📦 ${stack.downloads}</span>
<span>by ${esc(stack.display_name || stack.username)}</span>
</div>
<div class="stack-actions">
<button class="stack-action-btn${stack.starred_by_me ? ' starred' : ''}" data-action="star" data-id="${stack.id}">${stack.starred_by_me ? '⭐ Starred' : '☆ Star'}</button>
<button class="stack-action-btn" data-action="use" data-id="${stack.id}">🚀 Use This Stack</button>
</div>
</div>
`).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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function debounce(fn, ms) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
}