- Container exec/shell via WebSocket + xterm.js (subtle >_ button on cards) - Live dashboard updates via SSE (resource alerts, health changes, update notices) - Docker Compose import with YAML parsing, preview, and dependency-ordered deploy - Volume & network management modal with disk usage overview - CPU/memory resource limits on deploy and live update - Email SMTP notifications (nodemailer) alongside Discord/Telegram/ntfy - Scheduled auto-update scheduler with maintenance windows (daily/weekly/monthly) New deps: ws, js-yaml, nodemailer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1091 lines
47 KiB
JavaScript
1091 lines
47 KiB
JavaScript
// App Selector System
|
||
(function () {
|
||
injectModal('app-selector-modal', `<div id="app-selector-modal" class="weather-modal">
|
||
<div class="app-selector-content">
|
||
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
||
<div id="app-selector-grid" class="app-selector-grid"></div>
|
||
<div style="text-align: center; margin-top: 24px;">
|
||
<button id="app-selector-cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>`);
|
||
|
||
injectModal('app-deploy-modal', `<div id="app-deploy-modal" class="weather-modal">
|
||
<div class="weather-modal-content" style="min-width: 600px; max-width: 700px;">
|
||
<h3 id="app-deploy-title">Deploy Application</h3>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 16px;">
|
||
<!-- Subdomain/Domain -->
|
||
<div>
|
||
<label for="deploy-subdomain" class="form-label-accent-sm">
|
||
🌐 Subdomain or Domain
|
||
</label>
|
||
<input type="text" id="deploy-subdomain" placeholder="uptime"
|
||
style="width: 100%; padding: 10px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.95rem;" />
|
||
<div class="form-hint-sm">
|
||
Your app will be available at: <span id="deploy-url-preview" style="color: var(--accent); font-weight: 500;">uptime.home</span>
|
||
</div>
|
||
<div id="subpath-compat-warning" style="display: none; padding: 8px 12px; border-radius: 6px; background: color-mix(in srgb, #ff9800 10%, var(--card-bg)); border: 1px solid color-mix(in srgb, #ff9800 30%, transparent); margin-top: 8px; font-size: 0.85rem;"></div>
|
||
</div>
|
||
|
||
<!-- DNS Configuration -->
|
||
<div>
|
||
<label class="form-label-accent">
|
||
🗂️ DNS Configuration
|
||
</label>
|
||
<div class="flex-col-gap">
|
||
<label class="radio-option">
|
||
<input type="radio" name="dns-type" value="private" checked style="margin-right: 10px;" />
|
||
<div>
|
||
<div class="fw-500">Private DNS (Technitium)</div>
|
||
<div class="text-hint">Use your local DNS server with custom TLD (.sami, .home, etc.)</div>
|
||
</div>
|
||
</label>
|
||
<label class="radio-option">
|
||
<input type="radio" name="dns-type" value="public" style="margin-right: 10px;" />
|
||
<div>
|
||
<div class="fw-500">Public DNS</div>
|
||
<div class="text-hint">Use a real domain (example.com) - requires DNS provider setup</div>
|
||
</div>
|
||
</label>
|
||
<label class="radio-option">
|
||
<input type="radio" name="dns-type" value="none" style="margin-right: 10px;" />
|
||
<div>
|
||
<div class="fw-500">No DNS (IP:Port only)</div>
|
||
<div class="text-hint">Access via IP address and port - no domain setup</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSL Configuration -->
|
||
<div>
|
||
<label class="form-label-accent">
|
||
🔒 SSL/TLS Certificate
|
||
</label>
|
||
<div class="flex-col-gap">
|
||
<label class="radio-option">
|
||
<input type="radio" name="ssl-type" value="internal" checked style="margin-right: 10px;" />
|
||
<div>
|
||
<div class="fw-500">Internal CA</div>
|
||
<div class="text-hint">Use Caddy's internal certificate authority (self-signed)</div>
|
||
</div>
|
||
</label>
|
||
<label class="radio-option">
|
||
<input type="radio" name="ssl-type" value="letsencrypt" style="margin-right: 10px;" />
|
||
<div>
|
||
<div class="fw-500">Let's Encrypt</div>
|
||
<div class="text-hint">Free public SSL certificate (requires public domain)</div>
|
||
</div>
|
||
</label>
|
||
<label class="radio-option">
|
||
<input type="radio" name="ssl-type" value="none" style="margin-right: 10px;" />
|
||
<div>
|
||
<div class="fw-500">No SSL (HTTP only)</div>
|
||
<div class="text-hint">⚠️ Not recommended - traffic will be unencrypted</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Media Library Path (shown for media apps) -->
|
||
<div id="media-path-section" style="display: none;">
|
||
<label class="form-label-accent">
|
||
📁 Media Library Location
|
||
</label>
|
||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||
<!-- Detected mounts from existing media servers -->
|
||
<div id="detected-mounts-container" style="display: none;">
|
||
<div style="font-size: 0.85rem; color: var(--success); margin-bottom: 6px;">
|
||
✓ Detected from existing media servers:
|
||
</div>
|
||
<div id="detected-mounts-list" style="display: flex; gap: 8px; flex-wrap: wrap;"></div>
|
||
</div>
|
||
|
||
<!-- Path input with browse button -->
|
||
<div class="flex-row-gap">
|
||
<input type="text" id="deploy-media-path" placeholder="/media/Movies, /media/TVShows"
|
||
class="input-flex" style="font-size: 0.95rem;" />
|
||
<button type="button" id="browse-media-btn" style="padding: 10px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; white-space: nowrap;">
|
||
📂 Browse
|
||
</button>
|
||
</div>
|
||
|
||
<div id="media-path-description" class="text-hint">
|
||
Select folders from existing servers, browse, or type paths manually. Separate multiple with commas.
|
||
</div>
|
||
|
||
<!-- Selected paths display -->
|
||
<div id="selected-media-paths" style="display: none; flex-wrap: wrap; gap: 6px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Plex Claim Token (shown only for Plex deployments) -->
|
||
<div id="plex-claim-section" style="display: none;">
|
||
<label class="form-label-accent">
|
||
🔑 Plex Claim Token
|
||
</label>
|
||
<div class="flex-row-gap">
|
||
<input type="text" id="deploy-plex-claim" placeholder="claim-xxxxxxxxxxxxxxxxxxxx"
|
||
class="input-flex" style="font-size: 0.95rem;" />
|
||
<a href="https://plex.tv/claim" target="_blank" rel="noopener noreferrer"
|
||
style="padding: 10px 16px; background: #e5a00d; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; white-space: nowrap; text-decoration: none; display: flex; align-items: center;">
|
||
Get Token
|
||
</a>
|
||
</div>
|
||
<div style="font-size: 0.8rem; color: #e5a00d; margin-top: 4px;">
|
||
Token expires in 4 minutes! Get it right before clicking Deploy. Leave empty to configure Plex manually later.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tailscale Security -->
|
||
<div id="tailscale-section">
|
||
<label class="form-label-accent">
|
||
🔐 Tailscale Security
|
||
</label>
|
||
<div class="flex-col-gap">
|
||
<label class="radio-option">
|
||
<input type="checkbox" id="deploy-tailscale-only" style="margin-right: 10px; width: 18px; height: 18px;" />
|
||
<div>
|
||
<div class="fw-500">Tailscale-Only Access</div>
|
||
<div class="text-hint">Restrict this service to Tailscale users only (100.x.x.x IPs)</div>
|
||
</div>
|
||
</label>
|
||
<div id="tailscale-status" style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||
<span style="color: var(--muted);">Checking Tailscale status...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Advanced Options (Collapsible) -->
|
||
<details>
|
||
<summary style="cursor: pointer; color: var(--accent); font-weight: 500; margin-bottom: 8px;">⚙️ Advanced Options</summary>
|
||
<div style="margin-top: 12px; display: grid; gap: 12px;">
|
||
<div>
|
||
<label for="deploy-port" style="display: block; margin-bottom: 6px;">Custom Port (optional)</label>
|
||
<input type="number" id="deploy-port" placeholder="Leave empty for default"
|
||
class="form-input-card" />
|
||
</div>
|
||
<div>
|
||
<label for="deploy-ip" style="display: block; margin-bottom: 6px;">Target IP Address</label>
|
||
<input type="text" id="deploy-ip" value="localhost"
|
||
class="form-input-card" />
|
||
<div class="form-hint-sm">
|
||
Use 'localhost' for same-host containers, or specific IP for remote services
|
||
</div>
|
||
</div>
|
||
<div id="volume-mounts-section" style="display: none;">
|
||
<label class="form-label-accent-sm">📂 Volume Mounts</label>
|
||
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
|
||
Customize where container data is stored on the host. Media volumes are configured above.
|
||
</div>
|
||
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
|
||
</div>
|
||
<div style="margin-top: 12px;">
|
||
<label class="form-label-accent-sm">⚙️ Resource Limits</label>
|
||
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
|
||
Optional CPU and memory constraints. Leave at 0 for unlimited.
|
||
</div>
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||
<div>
|
||
<label for="deploy-cpu-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">CPU Cores</label>
|
||
<input type="number" id="deploy-cpu-limit" value="0" min="0" max="64" step="0.25" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
||
</div>
|
||
<div>
|
||
<label for="deploy-memory-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">Memory (MB)</label>
|
||
<input type="number" id="deploy-memory-limit" value="0" min="0" max="131072" step="64" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||
<button id="app-deploy-cancel">Cancel</button>
|
||
<button id="app-deploy-confirm" class="btn-accent">
|
||
🚀 Deploy
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>`);
|
||
|
||
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 = '<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>';
|
||
|
||
// Fetch templates if not cached
|
||
if (!apiTemplates) {
|
||
const success = await fetchAppTemplates();
|
||
if (!success) {
|
||
grid.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';
|
||
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
|
||
? `<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${widgetEnabled ? '#2ecc7130' : '#e74c3c30'}; color: ${widgetEnabled ? '#2ecc71' : '#e74c3c'}; font-weight: 600;">${widgetEnabled ? 'ON' : 'OFF'}</div>`
|
||
: '';
|
||
|
||
// Show difficulty badge (non-widgets only)
|
||
const difficultyBadge = !isWidget && app.difficulty ?
|
||
`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${
|
||
app.difficulty === 'Easy' ? '#2ecc71' :
|
||
app.difficulty === 'Intermediate' ? '#f39c12' : '#e74c3c'
|
||
}20; color: ${
|
||
app.difficulty === 'Easy' ? '#2ecc71' :
|
||
app.difficulty === 'Intermediate' ? '#f39c12' : '#e74c3c'
|
||
};">${escapeHtml(app.difficulty)}</div>` : '';
|
||
|
||
option.innerHTML = `
|
||
<div class="app-option-icon">${escapeHtml(app.icon || '📦')}</div>
|
||
<div class="app-option-name">${escapeHtml(app.name)}</div>
|
||
<div class="app-option-desc">${escapeHtml(app.description || '')}</div>
|
||
${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 = '<span style="color: #ff9800;">⚠ <strong>' + appTemplate.name + '</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>';
|
||
} else if (support === 'strip') {
|
||
subpathWarning.style.display = 'block';
|
||
subpathWarning.innerHTML = '<span style="color: var(--muted);">ⓘ ' + appTemplate.name + ' has unverified subdirectory support. It may require additional configuration.</span>';
|
||
} 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 = `<span style="font-weight: 500;">${escapeHtml(mount.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(mount.sourceImage)}</span>`;
|
||
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 = `
|
||
<input type="text" class="vol-host-path" data-container-path="${containerPath}" value="${hostDefault}"
|
||
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
|
||
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">→ ${containerPath}</span>
|
||
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">📂</button>
|
||
`;
|
||
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 = '<span style="color: var(--muted);">Checking port...</span>';
|
||
|
||
const result = await checkPortAvailability(portToCheck);
|
||
if (result.available) {
|
||
portStatus.innerHTML = `<span style="color: #4caf50;">Port ${escapeHtml(String(portToCheck))} is available</span>`;
|
||
} else {
|
||
const suggestedPort = await getSuggestedPort(defaultPort);
|
||
portStatus.innerHTML = `
|
||
<span style="color: #e74c3c;">Port ${escapeHtml(portToCheck)} in use by ${escapeHtml(result.conflict?.usedBy || 'unknown')}</span>
|
||
`;
|
||
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 = `<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(suggestedPort))}</span>`;
|
||
};
|
||
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 = `
|
||
<span style="color: #4caf50;">Connected</span>
|
||
<span style="color: var(--muted); margin-left: 8px;">${data.self?.hostname} (${data.self?.ip})</span>
|
||
<span style="color: var(--muted); margin-left: 8px;">| ${data.deviceCount} devices</span>
|
||
`;
|
||
} else if (data.installed) {
|
||
tailscaleStatus.innerHTML = `<span style="color: #ff9800;">Not connected</span>`;
|
||
} else {
|
||
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Not available</span>`;
|
||
tailscaleCheckbox.disabled = true;
|
||
}
|
||
} catch (e) {
|
||
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Could not check status</span>`;
|
||
}
|
||
|
||
// 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,
|
||
resources: {
|
||
cpus: parseFloat(document.getElementById('deploy-cpu-limit').value) || 0,
|
||
memory: parseFloat(document.getElementById('deploy-memory-limit').value) || 0,
|
||
}
|
||
};
|
||
|
||
// 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 = '<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';
|
||
|
||
try {
|
||
const response = await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(path)}`);
|
||
const result = await response.json();
|
||
|
||
if (!result.success) {
|
||
folderBrowserList.innerHTML = `<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(result.error)}</div>`;
|
||
return;
|
||
}
|
||
|
||
currentBrowserPath = result.path || '';
|
||
folderBrowserPath.textContent = currentBrowserPath || 'Select a drive...';
|
||
|
||
let html = '';
|
||
|
||
// Add parent folder navigation
|
||
if (result.parent && result.parent !== result.path) {
|
||
html += `<div class="folder-item" data-path="${escapeHtml(result.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||
<span style="font-size: 1.2rem;">⬆️</span>
|
||
<span style="color: var(--muted);">.. Parent Directory</span>
|
||
</div>`;
|
||
}
|
||
|
||
// Add folders
|
||
if (result.items.length === 0 && !result.parent) {
|
||
html += '<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>';
|
||
} else if (result.items.length === 0) {
|
||
html += '<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>';
|
||
} 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 += `<div class="folder-item" data-path="${escapeHtml(item.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${selectedStyle}">
|
||
<span style="font-size: 1.2rem;">${icon}</span>
|
||
<span style="flex: 1;">${escapeHtml(item.name)}</span>
|
||
${isSelected ? '<span style="color: var(--success);">✓</span>' : ''}
|
||
</div>`;
|
||
});
|
||
}
|
||
|
||
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 = `<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function updateSelectedFoldersDisplay() {
|
||
if (selectedFolders.length === 0) {
|
||
folderBrowserSelected.style.display = 'none';
|
||
return;
|
||
}
|
||
folderBrowserSelected.style.display = 'block';
|
||
folderBrowserSelectedList.innerHTML = selectedFolders.map(path => `
|
||
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
|
||
${escapeHtml(path)}
|
||
<button type="button" onclick="removeSelectedFolder('${escapeHtml(path)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">×</button>
|
||
</span>
|
||
`).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();
|
||
})();
|