feat: search, presets, dependency warnings, port conflicts, URL state, drag-drop analyser, diff view, auth + community gallery API (PostgreSQL), professional UI overhaul
This commit is contained in:
222
src/main.js
222
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 => `<div class="dep-warning">⚠️ ${w}</div>`).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 => `<div class="port-conflict">❌ ${c}</div>`).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(`<span class="diff-context"> ${escHtml(beforeLines[bi])}</span>`);
|
||||
bi++; ai++;
|
||||
} else if (ai < afterLines.length && !beforeLines.includes(afterLines[ai])) {
|
||||
output.push(`<span class="diff-add">+ ${escHtml(afterLines[ai])}</span>`);
|
||||
ai++;
|
||||
} else if (bi < beforeLines.length && !afterLines.includes(beforeLines[bi])) {
|
||||
output.push(`<span class="diff-remove">- ${escHtml(beforeLines[bi])}</span>`);
|
||||
bi++;
|
||||
} else {
|
||||
// Changed line
|
||||
if (bi < beforeLines.length) {
|
||||
output.push(`<span class="diff-remove">- ${escHtml(beforeLines[bi])}</span>`);
|
||||
bi++;
|
||||
}
|
||||
if (ai < afterLines.length) {
|
||||
output.push(`<span class="diff-add">+ ${escHtml(afterLines[ai])}</span>`);
|
||||
ai++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function renderAnalyserResults(results) {
|
||||
const container = document.getElementById('analyser-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user