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

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