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:
2026-08-03 23:39:15 +01:00
parent 341700ddac
commit 9a98985d3e
7 changed files with 837 additions and 88 deletions

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function renderAnalyserResults(results) {
const container = document.getElementById('analyser-results');
container.innerHTML = '';