// App Selector System
(function () {
injectModal('app-selector-modal', `
`);
injectModal('app-deploy-modal', `
Deploy Application
Token expires in 4 minutes! Get it right before clicking Deploy. Leave empty to configure Plex manually later.
Checking Tailscale status...
āļø Advanced Options
Customize where container data is stored on the host. Media volumes are configured above.
`);
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 = 'E:/Movies, E:/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();
})();