/* * SysDeck - Photos Panel (v0.0.35) * Author: Jeremy Anderson (https://dcos.net) * * v0.0.35 directive: "as well as a photo manager of equal quality. * with its own module." Following the Jellyfin pattern: each * supported backend ships as a systemd service with a built-in admin * web UI; the bridge starts/stops/restarts the service via systemctl * and the panel iframes the running admin UI. Equal quality means * the same service-control + iframe-load shape as Jellyfin. * * Multi-backend design — same shape as the DB Control module * (plugins/sysdeck-db/db.js): the operator chooses the backend * installed on the host; the bridge auto-detects each backend via * CLI availability and/or systemd unit presence. * * Supported backends (see bridge/photos.py BACKEND_REGISTRY): * PhotoPrism — single Go binary · MIT · port 2342 * Piwigo — single PHP-FPM app · GPL-2.0 · port 80 * Lychee — single PHP-FPM app · MIT · port 80 * Nextcloud Memories — Nextcloud plugin · AGPL-3.0 · port 80 * LibrePhotos — Django + React · MIT · port 3000 * * Excluded (intentionally): NFS-mounted photo libraries (no admin * panel — use the Remote FS module for storage management), Amanda * (backup system, not a photo manager), Google Photos / iCloud / * etc. (cloud-only, no on-host admin panel reachable). * * Bridge surface (see shared/bridge.js → bridge.photos): * summary() → {backends, totalBackends, installedCount, runningCount} * status(id) → single-backend detail dict * start(id) → {action, rc, success, output, stderr} * stop(id) → same shape * restart(id) → same shape * webStatus(id) → {running, url, port, ...} */ export async function mount(panel, { bridge, EventBus }) { panel.innerHTML = renderSkeleton(); let summary = {}; try { summary = await bridge.photos.summary(); } catch (err) { panel.innerHTML = renderError(err); return; } const backends = summary.backends || []; const installed = backends.filter((b) => b.status !== 'uninstalled'); const running = backends.filter((b) => b.status === 'running'); panel.innerHTML = `

Photo Manager

Self-hosted photo & album management · multi-backend · ${summary.totalBackends || backends.length} supported · ${summary.installedCount || installed.length} installed · ${summary.runningCount || running.length} running

Backends

${summary.totalBackends || backends.length}
total supported

Installed

${installed.length}
${installed.length ? 'detected on host' : 'none — install one'}

Running

${running.length}
${running.length ? 'admin UIs live' : 'all stopped'}
${renderBackendsGrid(backends, { bridge, EventBus })}

The bridge runs systemctl start/stop/restart for the chosen backend via the cockpit superuser channel (polkit org.sysdeck.photos.modify). The admin UI loads in an iframe once the service is running. Each backend is a separate process — no third-party code is bundled in the suite.

`; wireEvents(panel, { bridge, EventBus }); EventBus.emit('photos.loaded', { installed: installed.length, running: running.length }); } // ── Backend cards ────────────────────────────────────────────────── function renderBackendsGrid(backends, { bridge, EventBus }) { if (!backends.length) { return `

Backends (0)

No photo backends registered.

`; } return backends.map((b) => renderBackendCard(b)).join(''); } function renderBackendCard(b) { if (b.status === 'uninstalled') { return `

${escapeHtml(b.name)} · ${escapeHtml(b.id)}

${escapeHtml(b.family)}

Not installed. ${escapeHtml(b.name)} is ${escapeHtml(b.license)} licensed — ${escapeHtml(b.homepage)}

${escapeHtml(b.installHint)}

`; } const running = b.status === 'running'; return `

${escapeHtml(b.name)} · ${escapeHtml(b.id)}

Status${statusBadge(b.status)}
Service${escapeHtml(b.serviceUnit || '—')}
Port${b.port}
URL${running ? `${escapeHtml(b.url)}` : '— (service stopped)'}
License${escapeHtml(b.license)} · ${escapeHtml(b.homepage)}
${running ? renderWebIframe(b) : `

Click ▶ Start to launch ${escapeHtml(b.name)}. The admin UI will load in an iframe below.

`}
`; } function renderWebIframe(b) { return `
`; } // ── Event wiring ──────────────────────────────────────────────────── function wireEvents(panel, { bridge, EventBus }) { const action = async (btn, method, label) => { const id = btn.dataset.id; if (!id) return; btn.disabled = true; const original = btn.textContent; btn.textContent = `${label} ...`; try { const r = await bridge.photos[method](id); if (r?.error) { alert(`${label} ${id} failed:\n${r.error}`); } else if (!r?.success && method !== 'restart') { alert(`${label} ${id} failed:\n${r?.stderr || r?.output || 'unknown'}`); } setTimeout(() => mount(panel, { bridge, EventBus }), 1000); } catch (err) { btn.disabled = false; btn.textContent = original; alert(`${label} ${id} error: ${err.message || err}`); } }; panel.querySelectorAll('.btn-photos-start').forEach((btn) => { btn.addEventListener('click', (ev) => action(ev.currentTarget, 'start', 'Start')); }); panel.querySelectorAll('.btn-photos-stop').forEach((btn) => { btn.addEventListener('click', (ev) => action(ev.currentTarget, 'stop', 'Stop')); }); panel.querySelectorAll('.btn-photos-restart').forEach((btn) => { btn.addEventListener('click', (ev) => action(ev.currentTarget, 'restart', 'Restart')); }); panel.querySelectorAll('.btn-photos-refresh').forEach((btn) => { btn.addEventListener('click', () => { mount(panel, { bridge, EventBus }); }); }); } // ── Utilities ─────────────────────────────────────────────────────── function statusBadge(status) { const map = { 'running': 'running', 'starting': 'starting', 'stopped': 'stopped', 'error': 'error', 'uninstalled': 'uninstalled', 'unknown': 'unknown', }; return map[status] || `${escapeHtml(status || 'unknown')}`; } function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderSkeleton() { return `
`; } function renderError(err) { return `

Photos bridge unavailable

${escapeHtml(String(err.message || err))}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/photos.py.

`; }