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">
|
<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">×</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 -->
|
<!-- 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>
|
||||||
|
|||||||
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 { 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';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/style.css
128
src/style.css
@@ -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
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