// App Selector System (function () { injectModal('app-selector-modal', `

Choose an App

`); injectModal('app-deploy-modal', `

Deploy Application

Your app will be available at: uptime.home
Checking Tailscale status...
āš™ļø Advanced Options
Use 'localhost' for same-host containers, or specific IP for remote services
`); const APPS_KEY = 'custom-apps'; // Cache for API templates let apiTemplates = null; let apiCategories = null; const modal = document.getElementById('app-selector-modal'); const grid = document.getElementById('app-selector-grid'); // Fetch app templates from API async function fetchAppTemplates() { try { const response = await fetch('/api/v1/apps/templates'); const data = await response.json(); if (data.success) { apiTemplates = data.templates; apiCategories = data.categories; return true; } } catch (e) { console.error('Failed to fetch app templates:', e); } return false; } // Check port availability async function checkPortAvailability(port) { try { const response = await fetch(`/api/v1/apps/ports/${port}/check`); const data = await response.json(); return data; } catch (e) { console.error('Failed to check port:', e); return { available: true }; // Assume available on error } } // Get suggested available port async function getSuggestedPort(basePort) { try { const response = await fetch(`/api/v1/apps/ports/${basePort}/suggest`); const data = await response.json(); if (data.success) { return data.suggestedPort; } } catch (e) { console.error('Failed to get suggested port:', e); } return basePort; } // Build app selector grid from API templates async function buildAppSelector() { grid.innerHTML = '
Loading app templates...
'; // Fetch templates if not cached if (!apiTemplates) { const success = await fetchAppTemplates(); if (!success) { grid.innerHTML = '
Failed to load app templates. Please try again.
'; return; } } grid.innerHTML = ''; // Group templates by category const byCategory = {}; for (const [appId, template] of Object.entries(apiTemplates)) { const category = template.category || 'Other'; if (!byCategory[category]) { byCategory[category] = []; } byCategory[category].push({ id: appId, ...template }); } // Sort categories by the order in apiCategories if available const categoryOrder = apiCategories ? Object.keys(apiCategories) : Object.keys(byCategory).sort(); for (const category of categoryOrder) { const apps = byCategory[category]; if (!apps || apps.length === 0) continue; // Sort apps by popularity (descending) apps.sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); // Category header with icon and color from API const header = document.createElement('div'); header.className = 'app-category-header'; const categoryInfo = apiCategories?.[category] || {}; header.innerHTML = `${escapeHtml(categoryInfo.icon || '')} ${escapeHtml(category)}`; if (categoryInfo.color) { header.style.borderBottomColor = categoryInfo.color; } grid.appendChild(header); // App options apps.forEach(app => { const option = document.createElement('div'); option.className = 'app-option'; // Widget enabled badge const isWidget = app.isDashboardWidget; const widgetEnabled = isWidget && safeGet('widget-' + app.id + '-enabled') !== 'false'; const widgetBadge = isWidget ? `
${widgetEnabled ? 'ON' : 'OFF'}
` : ''; // Show difficulty badge (non-widgets only) const difficultyBadge = !isWidget && app.difficulty ? `
${escapeHtml(app.difficulty)}
` : ''; option.innerHTML = `
${escapeHtml(app.icon || 'šŸ“¦')}
${escapeHtml(app.name)}
${escapeHtml(app.description || '')}
${widgetBadge}${difficultyBadge} `; if (isWidget) { option.onclick = () => toggleDashboardWidget(app, option); } else { option.onclick = () => showDeployConfig(app); } grid.appendChild(option); }); } // Render recipe cards at the end of the grid if (window.renderRecipeCards) { await window.renderRecipeCards(grid); } } // Toggle a dashboard widget on/off function toggleDashboardWidget(app, optionEl) { const key = 'widget-' + app.id + '-enabled'; const currentlyEnabled = safeGet(key) !== 'false'; const newState = !currentlyEnabled; safeSet(key, String(newState)); // Update visibility immediately const selector = app.widgetSelector; if (selector) { const el = document.querySelector(selector); if (el) el.style.display = newState ? '' : 'none'; } // Update the badge in the app selector card const badge = optionEl.querySelector('div[style*="border-radius: 4px"]'); if (badge) { badge.textContent = newState ? 'ON' : 'OFF'; badge.style.background = newState ? '#2ecc7130' : '#e74c3c30'; badge.style.color = newState ? '#2ecc71' : '#e74c3c'; } showNotification(`${app.name} widget ${newState ? 'enabled' : 'disabled'}`, 'success', 2000); } // Show deployment configuration modal async function showDeployConfig(appTemplate) { const deployModal = document.getElementById('app-deploy-modal'); const title = document.getElementById('app-deploy-title'); const subdomainInput = document.getElementById('deploy-subdomain'); const urlPreview = document.getElementById('deploy-url-preview'); const ipInput = document.getElementById('deploy-ip'); const portInput = document.getElementById('deploy-port'); const tailscaleCheckbox = document.getElementById('deploy-tailscale-only'); const tailscaleStatus = document.getElementById('tailscale-status'); // Check for existing container with same image try { const checkResponse = await secureFetch('/api/v1/apps/check-existing', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appId: appTemplate.id }) }); const checkResult = await checkResponse.json(); if (checkResult.success && checkResult.exists) { const container = checkResult.container; const useExisting = confirm( `Found existing ${appTemplate.name} container:\n\n` + `Container: ${container.name}\n` + `Status: ${container.status}\n` + `Port: ${container.primaryPort || 'N/A'}\n\n` + `Would you like to use this existing container?\n\n` + `Click OK to configure DNS/Caddy for the existing container.\n` + `Click Cancel to deploy a new container.` ); if (useExisting) { // Store existing container info for deployment appTemplate._useExisting = true; appTemplate._existingContainer = container; } } } catch (e) { // Ignore container check errors } // Set title with app info title.textContent = `Deploy ${appTemplate.name}`; // Pre-fill subdomain from template or app ID const defaultSubdomain = appTemplate.subdomain || appTemplate.id.replace(/-/g, ''); subdomainInput.value = defaultSubdomain; // Show subpath compatibility warning in subdirectory mode const subpathWarning = document.getElementById('subpath-compat-warning'); if (subpathWarning) { if (SITE.routingMode === 'subdirectory') { const support = appTemplate.subpathSupport || 'strip'; if (support === 'none') { subpathWarning.style.display = 'block'; subpathWarning.innerHTML = '' + appTemplate.name + ' does not support subdirectory mode. It may not work correctly at a subpath.'; } else if (support === 'strip') { subpathWarning.style.display = 'block'; subpathWarning.innerHTML = 'ⓘ ' + appTemplate.name + ' has unverified subdirectory support. It may require additional configuration.'; } else { subpathWarning.style.display = 'none'; } } else { subpathWarning.style.display = 'none'; } } // Pre-select DNS/SSL from site config (set during setup wizard) const cfgDnsType = SITE.defaults.dnsType || (SITE.configurationType === 'public' ? 'public' : 'private'); const cfgSslType = SITE.defaults.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'internal'); const dnsRadio = document.querySelector(`input[name="dns-type"][value="${cfgDnsType}"]`); const sslRadio = document.querySelector(`input[name="ssl-type"][value="${cfgSslType}"]`); if (dnsRadio) dnsRadio.checked = true; else document.querySelector('input[name="dns-type"][value="private"]').checked = true; if (sslRadio) sslRadio.checked = true; else document.querySelector('input[name="ssl-type"][value="internal"]').checked = true; ipInput.value = SITE.defaults.targetIP || 'localhost'; tailscaleCheckbox.checked = false; // Move DNS/SSL into Advanced section when already configured const dnsSection = document.querySelector('#app-deploy-modal .flex-col-gap')?.closest('div'); const advancedDetails = document.querySelector('#app-deploy-modal details'); const advancedContent = advancedDetails?.querySelector('div'); if (advancedDetails && advancedContent && (SITE.configurationType === 'public' || SITE.configurationType === 'homelab')) { // Move DNS and SSL sections inside Advanced const dnsSectionEl = document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest('div.flex-col-gap')?.parentElement; const sslSectionEl = document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest('div.flex-col-gap')?.parentElement; if (dnsSectionEl && !dnsSectionEl.dataset.moved) { advancedContent.appendChild(dnsSectionEl); dnsSectionEl.dataset.moved = '1'; } if (sslSectionEl && !sslSectionEl.dataset.moved) { advancedContent.appendChild(sslSectionEl); sslSectionEl.dataset.moved = '1'; } } // Handle media path section for media apps const mediaPathSection = document.getElementById('media-path-section'); const mediaPathInput = document.getElementById('deploy-media-path'); const mediaPathDescription = document.getElementById('media-path-description'); if (appTemplate.mediaMount) { mediaPathSection.style.display = 'block'; mediaPathInput.value = ''; mediaPathInput.placeholder = '/media/Movies, /media/TVShows or click Browse'; // Fetch detected mounts from existing media servers const detectedMountsContainer = document.getElementById('detected-mounts-container'); const detectedMountsList = document.getElementById('detected-mounts-list'); try { const mountsResponse = await fetch('/api/v1/media/detected-mounts'); const mountsResult = await mountsResponse.json(); if (mountsResult.success && mountsResult.mounts.length > 0) { detectedMountsContainer.style.display = 'block'; detectedMountsList.innerHTML = ''; // Auto-fill media path with all detected mounts const autoPaths = [...new Set(mountsResult.mounts.map(m => m.hostPath))]; mediaPathInput.value = autoPaths.join(', '); mountsResult.mounts.forEach(mount => { const btn = document.createElement('button'); btn.type = 'button'; const isSelected = autoPaths.includes(mount.hostPath); btn.style.cssText = `padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${isSelected ? '40%' : '15%'}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`; btn.innerHTML = `${escapeHtml(mount.folderName)}
from ${escapeHtml(mount.sourceImage)}`; btn.title = `${mount.hostPath} (from ${mount.sourceContainer})`; btn.onclick = () => { const currentPaths = mediaPathInput.value.split(',').map(p => p.trim()).filter(p => p); const idx = currentPaths.indexOf(mount.hostPath); if (idx >= 0) { currentPaths.splice(idx, 1); btn.style.background = 'color-mix(in srgb, var(--success) 15%, var(--card-bg))'; } else { currentPaths.push(mount.hostPath); btn.style.background = 'color-mix(in srgb, var(--success) 40%, var(--card-bg))'; } mediaPathInput.value = currentPaths.join(', '); }; detectedMountsList.appendChild(btn); }); } else { detectedMountsContainer.style.display = 'none'; } } catch (e) { detectedMountsContainer.style.display = 'none'; } // Set up browse button document.getElementById('browse-media-btn').onclick = () => { openFolderBrowser(mediaPathInput); }; } else { mediaPathSection.style.display = 'none'; mediaPathInput.value = ''; document.getElementById('detected-mounts-container').style.display = 'none'; } // Show Plex claim token section for Plex deployments const plexClaimSection = document.getElementById('plex-claim-section'); if (plexClaimSection) { if (appTemplate.id === 'plex' || appTemplate.claimToken) { plexClaimSection.style.display = 'block'; document.getElementById('deploy-plex-claim').value = ''; } else { plexClaimSection.style.display = 'none'; } } // Populate volume mounts in Advanced Options const volumeSection = document.getElementById('volume-mounts-section'); const volumeList = document.getElementById('volume-mounts-list'); volumeList.innerHTML = ''; if (appTemplate.docker?.volumes?.length) { const mediaContainerPath = appTemplate.mediaMount?.containerPath; const nonMediaVolumes = appTemplate.docker.volumes.filter(v => !v.includes('{{MEDIA_PATH}}') && !(mediaContainerPath && v.endsWith(':' + mediaContainerPath))); if (nonMediaVolumes.length > 0) { volumeSection.style.display = 'block'; nonMediaVolumes.forEach((vol, i) => { const [hostDefault, containerPath] = vol.split(':'); const row = document.createElement('div'); row.style.cssText = 'display: flex; gap: 6px; align-items: center;'; row.innerHTML = ` → ${containerPath} `; volumeList.appendChild(row); row.querySelector('.vol-browse-btn').onclick = () => { const input = row.querySelector('.vol-host-path'); openFolderBrowser(input); }; }); } else { volumeSection.style.display = 'none'; } } else { volumeSection.style.display = 'none'; } // Set default port from template and check availability const defaultPort = appTemplate.defaultPort || 8080; portInput.value = ''; portInput.placeholder = `Default: ${defaultPort}`; // Add port status element if not exists let portStatus = document.getElementById('deploy-port-status'); if (!portStatus) { portStatus = document.createElement('div'); portStatus.id = 'deploy-port-status'; portStatus.style.cssText = 'font-size: 0.8rem; margin-top: 4px;'; portInput.parentNode.appendChild(portStatus); } // Check default port availability async function checkAndUpdatePortStatus() { const portToCheck = portInput.value || defaultPort; portStatus.innerHTML = 'Checking port...'; const result = await checkPortAvailability(portToCheck); if (result.available) { portStatus.innerHTML = `Port ${escapeHtml(String(portToCheck))} is available`; } else { const suggestedPort = await getSuggestedPort(defaultPort); portStatus.innerHTML = ` Port ${escapeHtml(portToCheck)} in use by ${escapeHtml(result.conflict?.usedBy || 'unknown')} `; const useBtn = document.createElement('button'); useBtn.type = 'button'; useBtn.textContent = `Use ${suggestedPort}`; useBtn.style.cssText = 'margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;'; useBtn.onclick = () => { document.getElementById('deploy-port').value = suggestedPort; portStatus.innerHTML = `Using suggested port ${escapeHtml(String(suggestedPort))}`; }; portStatus.appendChild(useBtn); } } // Check port on input change (debounced) let portCheckTimeout; portInput.oninput = function() { clearTimeout(portCheckTimeout); portCheckTimeout = setTimeout(checkAndUpdatePortStatus, 500); }; // Initial port check checkAndUpdatePortStatus(); // Fetch Tailscale status try { const response = await fetch('/api/v1/tailscale/status'); const data = await response.json(); if (data.success && data.installed && data.connected) { tailscaleStatus.innerHTML = ` Connected ${data.self?.hostname} (${data.self?.ip}) | ${data.deviceCount} devices `; } else if (data.installed) { tailscaleStatus.innerHTML = `Not connected`; } else { tailscaleStatus.innerHTML = `Not available`; tailscaleCheckbox.disabled = true; } } catch (e) { tailscaleStatus.innerHTML = `Could not check status`; } // Update URL preview in real-time function updateUrlPreview() { const subdomain = subdomainInput.value || 'subdomain'; const dnsType = document.querySelector('input[name="dns-type"]:checked').value; const sslType = document.querySelector('input[name="ssl-type"]:checked').value; let url = ''; if (SITE.routingMode === 'subdirectory' && SITE.domain) { // Subdirectory mode: domain.com/app url = `https://${SITE.domain}/${subdomain}`; } else if (dnsType === 'private') { const protocol = sslType === 'none' ? 'http' : 'https'; url = `${protocol}://${buildDomain(subdomain)}`; } else if (dnsType === 'public') { const protocol = sslType === 'none' ? 'http' : 'https'; const domain = SITE.domain || subdomain; url = SITE.domain ? `${protocol}://${subdomain}.${SITE.domain}` : `${protocol}://${subdomain}`; } else { const port = portInput.value || appTemplate.defaultPort || DC.DEFAULTS.SERVICE_PORT; url = `http://${ipInput.value}:${port}`; } urlPreview.textContent = url; } // Attach listeners subdomainInput.oninput = updateUrlPreview; ipInput.oninput = updateUrlPreview; portInput.oninput = updateUrlPreview; document.querySelectorAll('input[name="dns-type"]').forEach(radio => { radio.onchange = updateUrlPreview; }); document.querySelectorAll('input[name="ssl-type"]').forEach(radio => { radio.onchange = updateUrlPreview; }); updateUrlPreview(); // Close app selector, open deploy config modal.classList.remove('show'); deployModal.classList.add('show'); // Store app template for deployment deployModal.dataset.appTemplate = JSON.stringify(appTemplate); } // Add app to grid with full Docker deployment async function addAppToGrid(deployConfig) { const appTemplate = deployConfig.appTemplate; const customApps = safeGetJSON(APPS_KEY, []); // Check if using existing container const usingExisting = appTemplate._useExisting && appTemplate._existingContainer; // Check if app already exists - skip if using existing container (user already confirmed) const existingApp = customApps.find(a => a.id === deployConfig.subdomain); if (existingApp && !usingExisting) { const confirmed = confirm(`An app with subdomain "${deployConfig.subdomain}" already exists. Redeploy?`); if (!confirmed) return; } // Remove from localStorage to allow redeployment/update if (existingApp) { const index = customApps.indexOf(existingApp); customApps.splice(index, 1); safeSet(APPS_KEY, JSON.stringify(customApps)); } // Check port availability before deployment (skip if using existing container) if (!usingExisting) { const portToUse = deployConfig.port || appTemplate.defaultPort || 8080; showNotification(`Checking port ${portToUse} availability...`, 'info', 0); const portCheck = await checkPortAvailability(portToUse); if (!portCheck.available) { const suggestedPort = await getSuggestedPort(appTemplate.defaultPort || 8080); const useAlternate = confirm( `Port ${portToUse} is already in use by ${portCheck.conflict?.usedBy || 'another container'}.\n\n` + `Would you like to use port ${suggestedPort} instead?` ); if (useAlternate) { deployConfig.port = suggestedPort; } else { showNotification('Deployment cancelled - port conflict', 'error', 5000); return; } } } else { // Use existing container's port deployConfig.port = appTemplate._existingContainer.primaryPort; } showNotification( usingExisting ? `Configuring ${appTemplate.name} with existing container...` : `Deploying ${appTemplate.name}...`, 'info', 0 ); try { // Prepare deployment config from user's choices const apiDeployConfig = { appId: appTemplate.id, config: { subdomain: deployConfig.subdomain, ip: deployConfig.ip, createDns: deployConfig.dnsType === 'private', // Only create DNS for private port: deployConfig.port || appTemplate.defaultPort || null, // Use custom, template default, or null sslType: deployConfig.sslType, dnsType: deployConfig.dnsType, tailscaleOnly: deployConfig.tailscaleOnly || false, // Tailscale-only access restriction mediaPath: deployConfig.mediaPath || null, // Media folder path for media apps plexClaimToken: deployConfig.plexClaimToken || null, // Plex claim token for auto-claim customVolumes: deployConfig.customVolumes || null // Custom volume mount overrides } }; // Add existing container info if using existing if (usingExisting) { apiDeployConfig.config.useExisting = true; apiDeployConfig.config.existingContainerId = appTemplate._existingContainer.id; apiDeployConfig.config.existingPort = appTemplate._existingContainer.primaryPort; // Use existing container's port if no custom port specified if (!deployConfig.port && appTemplate._existingContainer.primaryPort) { apiDeployConfig.config.port = appTemplate._existingContainer.primaryPort; } } // Call deployment API const response = await secureFetch('/api/v1/apps/deploy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(apiDeployConfig) }); const result = await response.json(); if (result.success) { // Add to saved apps (store IP for later deletion) const newApp = { id: deployConfig.subdomain, // Use subdomain as ID name: appTemplate.name, logo: `/assets/${appTemplate.id}.png`, containerId: result.containerId, url: result.url, ip: deployConfig.ip, // Store IP for DNS record deletion appTemplate: appTemplate.id, // Store original template ID tailscaleOnly: deployConfig.tailscaleOnly || false // Tailscale protection status }; customApps.push(newApp); safeSet(APPS_KEY, JSON.stringify(customApps)); // Add to APPS array and rebuild grid // Access APPS from parent scope via window if (window.APPS && !window.APPS.some(a => a.id === appTemplate.id)) { window.APPS.push(newApp); if (typeof window.buildGrid === 'function') { window.buildGrid(); } if (typeof window.refreshAll === 'function') { setTimeout(() => window.refreshAll(), 500); } } // Show success with URL (and warning if DNS failed) let message = result.usedExisting ? `${appTemplate.name} configured with existing container!\nURL: ${result.url}` : `${appTemplate.name} deployed successfully!\nURL: ${result.url}`; if (result.warning) { message += `\n\n⚠ Warning: ${result.warning}`; } showNotification(message, 'success', 8000); // Clean up temporary properties delete appTemplate._useExisting; delete appTemplate._existingContainer; // For HTTPS URLs, check SSL certificate status if (result.url && result.url.startsWith('https://')) { checkSSLCertificate(result.url, appTemplate.name); } // Show setup instructions if available if (result.setupInstructions && result.setupInstructions.length > 0) { setTimeout(() => { const instructions = result.setupInstructions.join('\n'); showNotification(`Setup Instructions for ${appTemplate.name}: ${instructions}`, 'info', 10000); }, 1000); } } else { throw new Error(result.error || 'Deployment failed'); } } catch (error) { console.error('Deployment error:', error); showNotification( `Failed to deploy ${appTemplate.name}: ${error.message}`, 'error', 8000 ); } } // Check SSL certificate status and notify when ready async function checkSSLCertificate(url, appName) { showNotification(`ā³ Generating SSL certificate for ${appName}...`, 'warning', 60000); let attempts = 0; const maxAttempts = 12; // 60 seconds total (5 second intervals) const checkCert = async () => { attempts++; try { // Try to fetch the URL - if SSL works, this will succeed const response = await fetch(url, { method: 'HEAD', mode: 'no-cors' // Avoid CORS issues }); // If we get here, SSL is working showNotification(`āœ… ${appName} is ready! SSL certificate generated.`, 'success', 5000); return true; } catch (error) { // SSL not ready yet if (attempts < maxAttempts) { setTimeout(checkCert, 5000); // Check again in 5 seconds } else { showNotification( `āš ļø ${appName} deployed but SSL certificate may still be generating.\nTry refreshing in a moment if you see a certificate error.`, 'warning', 10000 ); } return false; } }; // Start checking after 3 seconds (give Caddy time to start) setTimeout(checkCert, 3000); } // Load custom apps on startup function loadCustomApps() { const customApps = safeGetJSON(APPS_KEY, []); customApps.forEach(app => { if (!window.APPS.some(a => a.id === app.id)) { window.APPS.push(app); } }); } // Event listeners document.getElementById('add-service-btn')?.addEventListener('click', () => { buildAppSelector(); modal.classList.add('show'); }); wireModal(modal, document.getElementById('app-selector-cancel')); // Deploy modal event listeners const deployModal = document.getElementById('app-deploy-modal'); document.getElementById('app-deploy-cancel')?.addEventListener('click', () => { deployModal.classList.remove('show'); }); document.getElementById('app-deploy-confirm')?.addEventListener('click', () => { // Get user configuration const appTemplate = JSON.parse(deployModal.dataset.appTemplate); const mediaPath = document.getElementById('deploy-media-path').value.trim(); // Collect custom volume overrides const customVolumes = []; document.querySelectorAll('#volume-mounts-list .vol-host-path').forEach(input => { customVolumes.push({ hostPath: input.value.trim(), containerPath: input.dataset.containerPath }); }); const deployConfig = { appTemplate: appTemplate, subdomain: document.getElementById('deploy-subdomain').value.trim(), dnsType: document.querySelector('input[name="dns-type"]:checked').value, sslType: document.querySelector('input[name="ssl-type"]:checked').value, ip: document.getElementById('deploy-ip').value.trim(), port: document.getElementById('deploy-port').value.trim(), tailscaleOnly: document.getElementById('deploy-tailscale-only').checked, mediaPath: mediaPath || null, plexClaimToken: document.getElementById('deploy-plex-claim')?.value.trim() || null, customVolumes: customVolumes.length > 0 ? customVolumes : null }; // Validate subdomain if (!deployConfig.subdomain) { showNotification('Please enter a subdomain or domain name', 'warning'); return; } // Validate media path for media apps if (appTemplate.mediaMount?.required && !mediaPath) { showNotification('Please enter a media library path for this application', 'warning'); return; } // Close deploy modal deployModal.classList.remove('show'); // Start deployment addAppToGrid(deployConfig); }); wireModal(deployModal); // ===== FOLDER BROWSER FUNCTIONALITY ===== const folderBrowserModal = document.getElementById('folder-browser-modal'); const folderBrowserPath = document.getElementById('folder-browser-path'); const folderBrowserList = document.getElementById('folder-browser-list'); const folderBrowserSelected = document.getElementById('folder-browser-selected'); const folderBrowserSelectedList = document.getElementById('folder-browser-selected-list'); let currentBrowserPath = ''; let selectedFolders = []; let targetMediaInput = null; window.openFolderBrowser = function(mediaInput) { targetMediaInput = mediaInput; selectedFolders = mediaInput.value.split(',').map(p => p.trim()).filter(p => p); currentBrowserPath = ''; updateSelectedFoldersDisplay(); loadFolderContents(''); folderBrowserModal.classList.add('show'); }; async function loadFolderContents(path) { folderBrowserPath.textContent = path || 'Select a drive...'; folderBrowserList.innerHTML = '
Loading...
'; try { const response = await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(path)}`); const result = await response.json(); if (!result.success) { folderBrowserList.innerHTML = `
Error: ${escapeHtml(result.error)}
`; return; } currentBrowserPath = result.path || ''; folderBrowserPath.textContent = currentBrowserPath || 'Select a drive...'; let html = ''; // Add parent folder navigation if (result.parent && result.parent !== result.path) { html += `
ā¬†ļø .. Parent Directory
`; } // Add folders if (result.items.length === 0 && !result.parent) { html += '
No browseable drives configured. Check your docker-compose.yml volume mounts.
'; } else if (result.items.length === 0) { html += '
No subfolders found
'; } else { result.items.forEach(item => { const icon = item.type === 'drive' ? 'šŸ’¾' : 'šŸ“'; const isSelected = selectedFolders.includes(item.path); const selectedStyle = isSelected ? 'background: color-mix(in srgb, var(--success) 20%, transparent);' : ''; html += `
${icon} ${escapeHtml(item.name)} ${isSelected ? 'āœ“' : ''}
`; }); } folderBrowserList.innerHTML = html; // Add click handlers folderBrowserList.querySelectorAll('.folder-item').forEach(item => { item.addEventListener('click', () => { loadFolderContents(item.dataset.path); }); item.addEventListener('mouseenter', () => { item.style.background = 'var(--card-bg)'; }); item.addEventListener('mouseleave', () => { const isSelected = selectedFolders.includes(item.dataset.path); item.style.background = isSelected ? 'color-mix(in srgb, var(--success) 20%, transparent)' : ''; }); }); } catch (error) { folderBrowserList.innerHTML = `
Failed to load: ${escapeHtml(error.message)}
`; } } function updateSelectedFoldersDisplay() { if (selectedFolders.length === 0) { folderBrowserSelected.style.display = 'none'; return; } folderBrowserSelected.style.display = 'block'; folderBrowserSelectedList.innerHTML = selectedFolders.map(path => ` ${escapeHtml(path)} `).join(''); } window.removeSelectedFolder = function(path) { selectedFolders = selectedFolders.filter(p => p !== path); updateSelectedFoldersDisplay(); loadFolderContents(currentBrowserPath); // Refresh to update checkmarks }; document.getElementById('folder-browser-select-current').addEventListener('click', () => { if (currentBrowserPath && !selectedFolders.includes(currentBrowserPath)) { selectedFolders.push(currentBrowserPath); updateSelectedFoldersDisplay(); loadFolderContents(currentBrowserPath); // Refresh to show checkmark } }); wireModal(folderBrowserModal, document.getElementById('folder-browser-cancel')); document.getElementById('folder-browser-done').addEventListener('click', () => { if (targetMediaInput) { targetMediaInput.value = selectedFolders.join(', '); } folderBrowserModal.classList.remove('show'); }); // Load custom apps on page load loadCustomApps(); })();