From 9a98985d3e237c57c5aa44169eb1194d23033775 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Mon, 3 Aug 2026 23:39:15 +0100 Subject: [PATCH] feat: search, presets, dependency warnings, port conflicts, URL state, drag-drop analyser, diff view, auth + community gallery API (PostgreSQL), professional UI overhaul --- api/.env | 3 + api/package-lock.json | 283 ++++++++++++++++++++++++++++++++++++++++- api/package.json | 7 +- api/server.js | 288 ++++++++++++++++++++++++++++++------------ index.html | 29 ++++- src/main.js | 222 +++++++++++++++++++++++++++++++- src/style.css | 93 ++++++++++++++ 7 files changed, 837 insertions(+), 88 deletions(-) create mode 100644 api/.env diff --git a/api/.env b/api/.env new file mode 100644 index 0000000..eec03f6 --- /dev/null +++ b/api/.env @@ -0,0 +1,3 @@ +DATABASE_URL=postgresql://fitcoach:localdev@192.168.4.20:5432/stackpilot +JWT_SECRET=stackpilot-jwt-secret-change-in-production +PORT=3052 diff --git a/api/package-lock.json b/api/package-lock.json index c536b11..c3ffc9b 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -8,8 +8,13 @@ "name": "stackpilot-api", "version": "1.0.0", "dependencies": { + "bcryptjs": "^2.4.3", "cors": "2.8.5", - "express": "4.21.2" + "dotenv": "^16.4.7", + "express": "4.21.2", + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.1", + "sql.js": "^1.11.0" } }, "node_modules/accepts": { @@ -31,6 +36,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -55,6 +66,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -170,6 +187,18 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -184,6 +213,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -451,6 +489,97 @@ "node": ">= 0.10" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.2", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -583,6 +712,131 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.6.tgz", + "integrity": "sha512-lqIfH7bdgsxHAY/ZnUOwm+aCFKrsHBDhSFuk9O0B9uCqJAIkrKTo/+LQqLPLUS4e04+jCmQVikxE3QipH5chPw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -661,6 +915,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", @@ -793,6 +1059,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sql.js": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.11.0.tgz", + "integrity": "sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -850,6 +1122,15 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/api/package.json b/api/package.json index 89122fe..7fc37a3 100644 --- a/api/package.json +++ b/api/package.json @@ -6,7 +6,12 @@ "start": "node server.js" }, "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "2.8.5", + "dotenv": "^16.4.7", "express": "4.21.2", - "cors": "2.8.5" + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.1", + "sql.js": "^1.11.0" } } diff --git a/api/server.js b/api/server.js index 35bed9e..0ac385d 100644 --- a/api/server.js +++ b/api/server.js @@ -1,90 +1,237 @@ import express from 'express'; import cors from 'cors'; -import { readFileSync, writeFileSync, existsSync } from 'fs'; +import pg from 'pg'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; +import { config } from 'dotenv'; + +config(); const __dirname = dirname(fileURLToPath(import.meta.url)); -const DATA_FILE = join(__dirname, 'data', 'popularity.json'); -const DOWNLOADS_LOG = join(__dirname, 'data', 'downloads.json'); +const DATA_DIR = join(__dirname, 'data'); +mkdirSync(DATA_DIR, { recursive: true }); const app = express(); app.use(cors()); app.use(express.json()); -// Ensure data dir exists -import { mkdirSync } from 'fs'; -mkdirSync(join(__dirname, 'data'), { recursive: true }); +const { Pool } = pg; +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -// Load or init popularity data -function loadPopularity() { - if (existsSync(DATA_FILE)) { - return JSON.parse(readFileSync(DATA_FILE, 'utf8')); +const JWT_SECRET = process.env.JWT_SECRET || 'stackpilot-dev-secret'; + +// ============ AUTH MIDDLEWARE ============ +function authMiddleware(req, res, next) { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'Authentication required' }); + try { + req.user = jwt.verify(token, JWT_SECRET); + next(); + } catch (e) { + res.status(401).json({ error: 'Invalid token' }); } - return {}; } -function savePopularity(data) { - writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); -} - -function loadDownloads() { - if (existsSync(DOWNLOADS_LOG)) { - return JSON.parse(readFileSync(DOWNLOADS_LOG, 'utf8')); +function optionalAuth(req, res, next) { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (token) { + try { req.user = jwt.verify(token, JWT_SECRET); } catch (e) {} } - return { total: 0, history: [] }; + next(); } -function saveDownloads(data) { - writeFileSync(DOWNLOADS_LOG, JSON.stringify(data, null, 2)); -} +// ============ AUTH ROUTES ============ +app.post('/api/auth/register', async (req, res) => { + const { username, email, password, display_name } = req.body; + if (!username || !email || !password) { + return res.status(400).json({ error: 'username, email, and password required' }); + } + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + try { + const hash = await bcrypt.hash(password, 10); + const result = await pool.query( + 'INSERT INTO users (username, email, password_hash, display_name) VALUES ($1, $2, $3, $4) RETURNING id, username, email, display_name, created_at', + [username, email, hash, display_name || username] + ); + const user = result.rows[0]; + const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ user, token }); + } catch (e) { + if (e.code === '23505') { + return res.status(409).json({ error: 'Username or email already exists' }); + } + res.status(500).json({ error: 'Registration failed' }); + } +}); + +app.post('/api/auth/login', async (req, res) => { + const { email, password } = req.body; + if (!email || !password) { + return res.status(400).json({ error: 'email and password required' }); + } + try { + const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]); + const user = result.rows[0]; + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ + user: { id: user.id, username: user.username, email: user.email, display_name: user.display_name }, + token + }); + } catch (e) { + res.status(500).json({ error: 'Login failed' }); + } +}); + +app.get('/api/auth/me', authMiddleware, async (req, res) => { + const result = await pool.query('SELECT id, username, email, display_name, avatar_url, created_at FROM users WHERE id = $1', [req.user.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'User not found' }); + res.json(result.rows[0]); +}); + +// ============ COMMUNITY STACKS (GALLERY) ============ +app.get('/api/stacks', optionalAuth, async (req, res) => { + const { sort = 'stars', search, tag, limit = 20, offset = 0 } = req.query; + let query = 'SELECT s.*, u.username, u.display_name FROM stacks s JOIN users u ON s.user_id = u.id WHERE s.is_public = true'; + const params = []; + let paramIdx = 1; + + if (search) { + query += ` AND (s.name ILIKE $${paramIdx} OR s.description ILIKE $${paramIdx})`; + params.push(`%${search}%`); + paramIdx++; + } + if (tag) { + query += ` AND $${paramIdx} = ANY(s.tags)`; + params.push(tag); + paramIdx++; + } + + const orderMap = { stars: 'stars DESC', downloads: 'downloads DESC', recent: 'created_at DESC' }; + query += ` ORDER BY ${orderMap[sort] || 'stars DESC'}`; + query += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`; + params.push(limit, offset); + + const result = await pool.query(query, params); + + // If user is logged in, mark which ones they've starred + if (req.user) { + const starResult = await pool.query('SELECT stack_id FROM stars WHERE user_id = $1', [req.user.id]); + const starred = new Set(starResult.rows.map(r => r.stack_id)); + result.rows.forEach(s => { s.starred_by_me = starred.has(s.id); }); + } + + res.json(result.rows); +}); + +app.get('/api/stacks/:id', optionalAuth, async (req, res) => { + const result = await pool.query( + 'SELECT s.*, u.username, u.display_name FROM stacks s JOIN users u ON s.user_id = u.id WHERE s.id = $1', + [req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Stack not found' }); + const stack = result.rows[0]; + if (!stack.is_public && (!req.user || req.user.id !== stack.user_id)) { + return res.status(404).json({ error: 'Stack not found' }); + } + res.json(stack); +}); + +app.post('/api/stacks', authMiddleware, async (req, res) => { + const { name, description, apps, config: stackConfig, tags, is_public = true } = req.body; + if (!name || !apps || !Array.isArray(apps) || apps.length === 0) { + return res.status(400).json({ error: 'name and apps array required' }); + } + const result = await pool.query( + 'INSERT INTO stacks (user_id, name, description, apps, config, tags, is_public) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *', + [req.user.id, name, description || '', apps, stackConfig || {}, tags || [], is_public] + ); + res.json(result.rows[0]); +}); + +app.put('/api/stacks/:id', authMiddleware, async (req, res) => { + const { name, description, apps, config: stackConfig, tags, is_public } = req.body; + const existing = await pool.query('SELECT * FROM stacks WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); + if (existing.rows.length === 0) return res.status(404).json({ error: 'Stack not found or not yours' }); + + const result = await pool.query( + 'UPDATE stacks SET name = COALESCE($1, name), description = COALESCE($2, description), apps = COALESCE($3, apps), config = COALESCE($4, config), tags = COALESCE($5, tags), is_public = COALESCE($6, is_public), updated_at = NOW() WHERE id = $7 RETURNING *', + [name, description, apps, stackConfig, tags, is_public, req.params.id] + ); + res.json(result.rows[0]); +}); + +app.delete('/api/stacks/:id', authMiddleware, async (req, res) => { + await pool.query('DELETE FROM stars WHERE stack_id = $1', [req.params.id]); + const result = await pool.query('DELETE FROM stacks WHERE id = $1 AND user_id = $2 RETURNING id', [req.params.id, req.user.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Stack not found or not yours' }); + res.json({ ok: true }); +}); + +// Star/unstar +app.post('/api/stacks/:id/star', authMiddleware, async (req, res) => { + try { + await pool.query('INSERT INTO stars (user_id, stack_id) VALUES ($1, $2)', [req.user.id, req.params.id]); + await pool.query('UPDATE stacks SET stars = stars + 1 WHERE id = $1', [req.params.id]); + res.json({ starred: true }); + } catch (e) { + if (e.code === '23505') { + // Already starred — unstar + await pool.query('DELETE FROM stars WHERE user_id = $1 AND stack_id = $2', [req.user.id, req.params.id]); + await pool.query('UPDATE stacks SET stars = GREATEST(stars - 1, 0) WHERE id = $1', [req.params.id]); + res.json({ starred: false }); + } else { + res.status(500).json({ error: 'Failed to star' }); + } + } +}); + +// Track stack download +app.post('/api/stacks/:id/download', async (req, res) => { + await pool.query('UPDATE stacks SET downloads = downloads + 1 WHERE id = $1', [req.params.id]); + res.json({ ok: true }); +}); + +// My stacks +app.get('/api/my/stacks', authMiddleware, async (req, res) => { + const result = await pool.query('SELECT * FROM stacks WHERE user_id = $1 ORDER BY updated_at DESC', [req.user.id]); + res.json(result.rows); +}); + +// ============ ANALYTICS (from before) ============ +const POPULARITY_FILE = join(DATA_DIR, 'popularity.json'); +const DOWNLOADS_FILE = join(DATA_DIR, 'downloads.json'); + +function loadJson(path, fallback) { return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : fallback; } +function saveJson(path, data) { writeFileSync(path, JSON.stringify(data, null, 2)); } -// POST /api/analytics/download — record a bundle download app.post('/api/analytics/download', (req, res) => { const { apps, timestamp } = req.body; - if (!apps || !Array.isArray(apps)) { - return res.status(400).json({ error: 'apps array required' }); - } - - // Update popularity counts - const popularity = loadPopularity(); - for (const appId of apps) { - popularity[appId] = (popularity[appId] || 0) + 1; - } - savePopularity(popularity); - - // Log the download - const downloads = loadDownloads(); + if (!apps || !Array.isArray(apps)) return res.status(400).json({ error: 'apps array required' }); + const popularity = loadJson(POPULARITY_FILE, {}); + for (const appId of apps) { popularity[appId] = (popularity[appId] || 0) + 1; } + saveJson(POPULARITY_FILE, popularity); + const downloads = loadJson(DOWNLOADS_FILE, { total: 0, history: [] }); downloads.total += 1; - downloads.history.push({ - timestamp: timestamp || new Date().toISOString(), - apps, - count: apps.length, - }); - // Keep last 10,000 entries to avoid unbounded growth - if (downloads.history.length > 10000) { - downloads.history = downloads.history.slice(-10000); - } - saveDownloads(downloads); - + downloads.history.push({ timestamp: timestamp || new Date().toISOString(), apps, count: apps.length }); + if (downloads.history.length > 10000) downloads.history = downloads.history.slice(-10000); + saveJson(DOWNLOADS_FILE, downloads); res.json({ ok: true, total_downloads: downloads.total }); }); -// GET /api/analytics/popularity — get app download counts -app.get('/api/analytics/popularity', (req, res) => { - const popularity = loadPopularity(); - res.json(popularity); -}); +app.get('/api/analytics/popularity', (req, res) => { res.json(loadJson(POPULARITY_FILE, {})); }); -// GET /api/analytics/stats — overview stats app.get('/api/analytics/stats', (req, res) => { - const popularity = loadPopularity(); - const downloads = loadDownloads(); - - // Top apps - const sorted = Object.entries(popularity) - .sort((a, b) => b[1] - a[1]); - + const popularity = loadJson(POPULARITY_FILE, {}); + const downloads = loadJson(DOWNLOADS_FILE, { total: 0, history: [] }); + const sorted = Object.entries(popularity).sort((a, b) => b[1] - a[1]); res.json({ total_downloads: downloads.total, unique_apps_selected: sorted.length, @@ -93,23 +240,6 @@ app.get('/api/analytics/stats', (req, res) => { }); }); -// GET /api/analytics/app/:id — stats for a specific app -app.get('/api/analytics/app/:id', (req, res) => { - const popularity = loadPopularity(); - const count = popularity[req.params.id] || 0; - const downloads = loadDownloads(); - const total = downloads.total || 1; - const percentage = ((count / total) * 100).toFixed(1); - - res.json({ - id: req.params.id, - download_count: count, - percentage: `${percentage}%`, - total_bundles: total, - }); -}); - +// ============ START ============ const PORT = process.env.PORT || 3052; -app.listen(PORT, () => { - console.log(`StackPilot Analytics API running on port ${PORT}`); -}); +app.listen(PORT, () => { console.log(`StackPilot API running on port ${PORT}`); }); diff --git a/index.html b/index.html index 3acc7a4..e298633 100644 --- a/index.html +++ b/index.html @@ -61,6 +61,20 @@

2. Select Apps

+ +
+ Quick stacks: + + + + + + +
+
+
@@ -120,16 +134,27 @@ diff --git a/src/main.js b/src/main.js index 63bdd62..aefd27d 100644 --- a/src/main.js +++ b/src/main.js @@ -34,6 +34,17 @@ const state = { activeTab: 'compose', os: 'linux', popularity: null, + searchQuery: '', +}; + +// Presets +const presets = { + media: ['jellyfin', 'sonarr', 'radarr', 'prowlarr', 'qbittorrent', 'bazarr', 'jellyseerr', 'flaresolverr'], + arr: ['sonarr', 'radarr', 'lidarr', 'readarr', 'prowlarr', 'bazarr', 'qbittorrent', 'flaresolverr', 'recyclarr'], + ai: ['ollama', 'open-webui', 'comfyui', 'stable-diffusion', 'flowise', 'anything-llm', 'whisper'], + dev: ['gitea', 'code-server', 'drone', 'registry', 'portainer', 'postgres', 'redis'], + homelab: ['homepage', 'portainer', 'uptime-kuma', 'watchtower', 'tailscale', 'vaultwarden', 'ntfy', 'adguard-home'], + gaming: ['minecraft', 'valheim', 'palworld', 'satisfactory', 'crafty', 'pterodactyl'], }; // Get config from inputs @@ -64,8 +75,20 @@ function renderAppGrid() { badge.textContent = `${state.selected.size} app${state.selected.size !== 1 ? 's' : ''} selected`; grid.appendChild(badge); + const query = state.searchQuery.toLowerCase(); + for (const cat of categories) { - const catApps = apps.filter(a => a.category === cat.id); + let catApps = apps.filter(a => a.category === cat.id); + + // Filter by search + if (query) { + catApps = catApps.filter(a => + a.name.toLowerCase().includes(query) || + a.description.toLowerCase().includes(query) || + a.id.includes(query) + ); + } + if (catApps.length === 0) continue; const group = document.createElement('div'); @@ -89,6 +112,8 @@ function renderAppGrid() { catApps.forEach(a => state.selected.add(a.id)); } renderAppGrid(); + checkDependencies(); + checkPortConflicts(); updatePreview(); }); @@ -127,7 +152,79 @@ function toggleApp(id) { state.selected.add(id); } renderAppGrid(); + checkDependencies(); + checkPortConflicts(); updatePreview(); + updateUrlState(); +} + +// Dependency warnings +function checkDependencies() { + const warnings = []; + const ids = state.selected; + + if (ids.has('jellyseerr') && !ids.has('jellyfin')) warnings.push('Jellyseerr requires Jellyfin to function'); + if (ids.has('overseerr') && !ids.has('plex')) warnings.push('Overseerr requires Plex to function'); + if (ids.has('bazarr') && !ids.has('sonarr') && !ids.has('radarr')) warnings.push('Bazarr needs Sonarr or Radarr to manage subtitles for'); + if (ids.has('prowlarr') && !ids.has('sonarr') && !ids.has('radarr') && !ids.has('lidarr') && !ids.has('readarr')) warnings.push('Prowlarr has no Arr apps to sync indexers to'); + if ((ids.has('sonarr') || ids.has('radarr')) && !ids.has('qbittorrent') && !ids.has('transmission') && !ids.has('deluge') && !ids.has('sabnzbd') && !ids.has('nzbget')) warnings.push('Sonarr/Radarr need a download client (qBittorrent, Transmission, etc.)'); + if (ids.has('open-webui') && !ids.has('ollama')) warnings.push('Open WebUI needs Ollama for local LLM inference'); + if (ids.has('immich') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Immich requires PostgreSQL and Redis'); + if (ids.has('outline') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Outline requires PostgreSQL and Redis'); + if (ids.has('bookstack') && !ids.has('mariadb')) warnings.push('BookStack requires MariaDB'); + if (ids.has('rocketchat') && !ids.has('mongodb')) warnings.push('Rocket.Chat requires MongoDB'); + + const el = document.getElementById('dependency-warnings'); + el.innerHTML = warnings.map(w => `
⚠️ ${w}
`).join(''); +} + +// Port conflict detection +function checkPortConflicts() { + const selectedApps = apps.filter(a => state.selected.has(a.id)); + const portMap = {}; + const conflicts = []; + + for (const app of selectedApps) { + if (!app.ports) continue; + for (const port of app.ports) { + const hostPort = `${port.host}/${port.protocol}`; + if (portMap[hostPort]) { + conflicts.push(`Port ${port.host} conflict: ${portMap[hostPort]} and ${app.name}`); + } else { + portMap[hostPort] = app.name; + } + } + } + + const el = document.getElementById('port-conflicts'); + el.innerHTML = conflicts.map(c => `
❌ ${c}
`).join(''); +} + +// URL state (shareable selections) +function updateUrlState() { + const selected = [...state.selected]; + if (selected.length > 0) { + const url = new URL(window.location); + url.searchParams.set('apps', selected.join(',')); + window.history.replaceState({}, '', url); + } else { + const url = new URL(window.location); + url.searchParams.delete('apps'); + window.history.replaceState({}, '', url); + } +} + +function loadUrlState() { + const url = new URL(window.location); + const appsParam = url.searchParams.get('apps'); + if (appsParam) { + const appIds = appsParam.split(','); + for (const id of appIds) { + if (apps.some(a => a.id === id)) { + state.selected.add(id); + } + } + } } // Update preview @@ -276,6 +373,7 @@ function initOsSelector() { } // Init +loadUrlState(); renderAppGrid(); initTabs(); initConfigListeners(); @@ -283,16 +381,45 @@ initTailscale(); initOsSelector(); initModeNav(); initAnalyser(); +initSearch(); +initPresets(); document.getElementById('download-btn').addEventListener('click', downloadBundle); // Load popularity data (async, non-blocking) fetchPopularity().then(data => { if (data) { state.popularity = data; - renderAppGrid(); // Re-render with badges + renderAppGrid(); } }); +// Search +function initSearch() { + const input = document.getElementById('app-search'); + input.addEventListener('input', (e) => { + state.searchQuery = e.target.value; + renderAppGrid(); + }); +} + +// Presets +function initPresets() { + document.querySelectorAll('.preset-btn').forEach(btn => { + btn.addEventListener('click', () => { + const preset = presets[btn.dataset.preset]; + if (!preset) return; + for (const id of preset) { + state.selected.add(id); + } + renderAppGrid(); + checkDependencies(); + checkPortConflicts(); + updatePreview(); + updateUrlState(); + }); + }); +} + // Mode switching (builder vs analyser) function initModeNav() { document.querySelectorAll('.mode-btn').forEach(btn => { @@ -308,26 +435,70 @@ function initModeNav() { // Analyser function initAnalyser() { + const dropZone = document.getElementById('drop-zone'); + const textarea = document.getElementById('analyser-input'); + + // Drag and drop + dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragging'); }); + dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('dragging'); }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('dragging'); + const file = e.dataTransfer.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (ev) => { textarea.value = ev.target.result; }; + reader.readAsText(file); + } + }); + + // Analyse button document.getElementById('analyse-btn').addEventListener('click', () => { - const input = document.getElementById('analyser-input').value.trim(); + const input = textarea.value.trim(); if (!input) return; const results = analyseCompose(input); renderAnalyserResults(results); - // Show "Show Fixed Compose" button if there are fixable issues const fixBtn = document.getElementById('show-fixed-btn'); const hasFixable = results.warnings.length > 0 || results.suggestions.length > 0; fixBtn.classList.toggle('hidden', !hasFixable); + // Hide previous fixed/diff views + document.getElementById('fixed-preview').classList.add('hidden'); + document.getElementById('diff-preview').classList.add('hidden'); + document.getElementById('view-toggle').classList.add('hidden'); }); + // Show fixed button document.getElementById('show-fixed-btn').addEventListener('click', () => { - const input = document.getElementById('analyser-input').value.trim(); + const input = textarea.value.trim(); if (!input) return; const fixed = fixCompose(input); document.getElementById('fixed-content').textContent = fixed; document.getElementById('fixed-preview').classList.remove('hidden'); + document.getElementById('diff-preview').classList.add('hidden'); + document.getElementById('view-toggle').classList.remove('hidden'); + + // Generate diff + const diffHtml = generateDiff(input, fixed); + document.getElementById('diff-content').innerHTML = diffHtml; + + // Set active view + document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active')); + document.querySelector('.view-btn[data-view="fixed"]').classList.add('active'); }); + // View toggle (fixed vs diff) + document.querySelectorAll('.view-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const view = btn.dataset.view; + document.getElementById('fixed-preview').classList.toggle('hidden', view !== 'fixed'); + document.getElementById('diff-preview').classList.toggle('hidden', view !== 'diff'); + }); + }); + + // Download fixed document.getElementById('download-fixed-btn').addEventListener('click', () => { const fixed = document.getElementById('fixed-content').textContent; const blob = new Blob([fixed], { type: 'text/yaml' }); @@ -340,6 +511,47 @@ function initAnalyser() { }); } +// Simple line-by-line diff +function generateDiff(before, after) { + const beforeLines = before.split('\n'); + const afterLines = after.split('\n'); + const output = []; + + // Simple LCS-based approach for readable diff + const beforeSet = new Set(beforeLines.map((l, i) => `${i}:${l}`)); + const afterSet = new Set(afterLines); + + let bi = 0, ai = 0; + while (bi < beforeLines.length || ai < afterLines.length) { + if (bi < beforeLines.length && ai < afterLines.length && beforeLines[bi] === afterLines[ai]) { + output.push(` ${escHtml(beforeLines[bi])}`); + bi++; ai++; + } else if (ai < afterLines.length && !beforeLines.includes(afterLines[ai])) { + output.push(`+ ${escHtml(afterLines[ai])}`); + ai++; + } else if (bi < beforeLines.length && !afterLines.includes(beforeLines[bi])) { + output.push(`- ${escHtml(beforeLines[bi])}`); + bi++; + } else { + // Changed line + if (bi < beforeLines.length) { + output.push(`- ${escHtml(beforeLines[bi])}`); + bi++; + } + if (ai < afterLines.length) { + output.push(`+ ${escHtml(afterLines[ai])}`); + ai++; + } + } + } + + return output.join('\n'); +} + +function escHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>'); +} + function renderAnalyserResults(results) { const container = document.getElementById('analyser-results'); container.innerHTML = ''; diff --git a/src/style.css b/src/style.css index c3284d6..f793535 100644 --- a/src/style.css +++ b/src/style.css @@ -170,6 +170,56 @@ body { } /* App Grid */ +.search-bar { margin-bottom: 1rem; } +.search-bar input { + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 0.85rem 1.25rem; + border-radius: 999px; + font-size: 0.9rem; + transition: all 0.2s ease; +} +.search-bar input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } +.presets-bar { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} +.presets-label { font-size: 0.8rem; color: var(--text-muted); font-weight: 500; } +.preset-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-dim); + padding: 0.4rem 0.9rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} +.preset-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-glow); } +.dependency-warnings, .port-conflicts { margin-bottom: 1rem; } +.dep-warning, .port-conflict { + background: rgba(245,158,11,0.06); + border: 1px solid rgba(245,158,11,0.2); + border-radius: var(--radius-sm); + padding: 0.6rem 1rem; + font-size: 0.8rem; + color: var(--warning); + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} +.port-conflict { + background: rgba(239,68,68,0.06); + border-color: rgba(239,68,68,0.2); + color: var(--danger); +} .category-group { margin-bottom: 2.5rem; } .category-header { display: flex; @@ -391,6 +441,49 @@ body { /* Analyser */ .analyser-desc { color: var(--text-dim); margin-bottom: 1.25rem; font-size: 0.9rem; } +.drop-zone { position: relative; margin-bottom: 1.25rem; } +.drop-overlay { + position: absolute; + inset: 0; + background: rgba(99,102,241,0.1); + border: 2px dashed var(--accent); + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + font-weight: 600; + color: var(--accent); + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} +.drop-zone.dragging .drop-overlay { opacity: 1; } +.drop-zone.dragging .analyser-textarea { border-color: var(--accent); } +.view-toggle { + display: inline-flex; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.2rem; + margin-top: 1rem; + margin-bottom: 0.5rem; +} +.view-btn { + background: transparent; + border: none; + color: var(--text-muted); + padding: 0.4rem 1rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} +.view-btn.active { background: var(--accent); color: white; } +.diff-code .diff-add { color: var(--success); } +.diff-code .diff-remove { color: var(--danger); opacity: 0.7; } +.diff-code .diff-context { color: var(--text-muted); } .analyser-textarea { width: 100%; min-height: 280px;