feat: community gallery UI (auth modals, stack cards, star/use, share), search, presets, dependency/port warnings
This commit is contained in:
49
index.html
49
index.html
@@ -17,7 +17,11 @@
|
||||
<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>
|
||||
</nav>
|
||||
<div class="auth-bar" id="auth-bar">
|
||||
<button class="auth-btn" id="auth-btn">Sign In</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
@@ -131,6 +135,51 @@
|
||||
<p>StackPilot — No server required. Everything runs in your browser.</p>
|
||||
</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">×</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">×</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 -->
|
||||
<section class="step" id="step-analyser" style="display:none;">
|
||||
<h2>🔍 Analyse Existing Docker Compose</h2>
|
||||
|
||||
18
src/main.js
18
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';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
128
src/style.css
128
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); }
|
||||
|
||||
111
src/utils/auth.js
Normal file
111
src/utils/auth.js
Normal 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
146
src/utils/gallery.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let timer;
|
||||
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
Reference in New Issue
Block a user