216 lines
13 KiB
HTML
216 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Portal files · Admin · CM4 Provisioning</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0a0e14; --bg-secondary: #11151c; --bg-tertiary: #1a1f2b; --bg-card: #151a24;
|
|
--accent: #00d4aa; --accent-dim: #00b894; --text: #e6e8eb; --text-dim: #8b949e; --text-muted: #5c6370;
|
|
--border: #2d333b; --danger: #f87171; --radius: 10px; --radius-sm: 6px;
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: 'Outfit', sans-serif; background: var(--bg-primary); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
|
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 1rem; }
|
|
.header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
|
.header h1 { font-size: 1.25rem; font-weight: 700; }
|
|
.header span { color: var(--text-dim); font-size: 0.9rem; }
|
|
.nav a { color: var(--text-dim); text-decoration: none; margin-right: 1rem; }
|
|
.nav a:hover { color: var(--accent); }
|
|
.nav a.active { color: var(--accent); font-weight: 600; }
|
|
.header a { color: var(--text-dim); text-decoration: none; margin-left: 0.5rem; }
|
|
.header a:hover { color: var(--accent); }
|
|
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
|
.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.75rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
|
.btn { padding: 0.4rem 0.8rem; font-size: 0.8rem; font-weight: 500; font-family: inherit; border-radius: var(--radius-sm); cursor: pointer; border: none; transition: background 0.15s, color 0.15s; }
|
|
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
|
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
|
.btn-primary { background: var(--accent); color: var(--bg-primary); }
|
|
.btn-primary:hover { background: var(--accent-dim); }
|
|
.btn-sm { padding: 0.3rem 0.5rem; font-size: 0.75rem; }
|
|
.btn-danger { background: rgba(248,113,113,0.2); color: var(--danger); }
|
|
.btn-danger:hover { background: rgba(248,113,113,0.3); }
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|
th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
|
|
td { padding: 0.5rem 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
tr:last-child td { border-bottom: none; }
|
|
.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8em; color: var(--text-muted); }
|
|
.empty-msg { text-align: center; padding: 1.5rem; font-size: 0.9rem; color: var(--text-muted); }
|
|
.breadcrumb { margin-bottom: 0.75rem; font-size: 0.85rem; }
|
|
.breadcrumb a { color: var(--text-dim); text-decoration: none; }
|
|
.breadcrumb a:hover { color: var(--accent); }
|
|
.breadcrumb span { color: var(--text-muted); margin: 0 0.25rem; }
|
|
.desc-input { width: 100%; max-width: 280px; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.3rem 0.5rem; border-radius: 4px; }
|
|
.folder-name { font-weight: 500; }
|
|
input[type="text"] { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header class="header">
|
|
<div>
|
|
<h1>Portal files</h1>
|
|
<nav class="nav" style="margin-top:0.35rem;">
|
|
<a href="/admin">Admin</a>
|
|
<a href="/admin/portal-files" class="active">Portal files</a>
|
|
<a href="/admin/cloudinit-build">Cloud-init build</a>
|
|
</nav>
|
|
</div>
|
|
<span>Logged in as <strong>{{ username }}</strong></span>
|
|
<a href="/">Deploy</a>
|
|
<a href="/logout">Log out</a>
|
|
</header>
|
|
|
|
<div class="section">
|
|
<div class="section-title">
|
|
<span id="breadcrumb">Portal files</span>
|
|
<button type="button" class="btn btn-outline btn-sm" id="newFolderBtn">New folder</button>
|
|
<button type="button" class="btn btn-outline btn-sm" id="uploadBtn">Upload file</button>
|
|
<input type="file" id="uploadInput" style="display:none;" />
|
|
</div>
|
|
<p class="mono" style="font-size:0.8rem; margin-bottom:0.5rem;">Served at <strong id="baseUrl">/files/</strong> — use in cloud-init e.g. <code>curl -fsSL "http://SERVER/files/first-boot/splash.png" -o /tmp/splash.png</code></p>
|
|
<p class="mono" style="font-size:0.75rem; color:var(--text-muted); margin-bottom:0.75rem;">Directory on server: <strong id="portalDir">—</strong></p>
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Description</th><th>Actions</th></tr></thead>
|
|
<tbody id="portalBody"></tbody>
|
|
</table>
|
|
<p id="portalEmpty" class="empty-msg" style="display:none;">No files or folders. Create a folder or upload a file.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function authFetch(url, opts) {
|
|
opts = opts || {};
|
|
opts.credentials = opts.credentials || 'same-origin';
|
|
return fetch(url, opts).then(function(r) {
|
|
if (r.status === 401) { window.location = '/login?next=' + encodeURIComponent(window.location.pathname); return Promise.reject(new Error('Login required')); }
|
|
return r;
|
|
});
|
|
}
|
|
function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
|
function fmtSize(n) { if (!n) return '—'; if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
|
|
function fmtDate(ts) { return ts ? new Date(ts*1000).toLocaleString() : '—'; }
|
|
|
|
var currentPath = '';
|
|
var descriptions = {};
|
|
|
|
function buildBreadcrumb() {
|
|
var el = document.getElementById('breadcrumb');
|
|
var parts = currentPath ? currentPath.split('/') : [];
|
|
var html = '<a href="#" data-path="">Portal files</a>';
|
|
parts.forEach(function(p, i) {
|
|
var path = parts.slice(0, i+1).join('/');
|
|
html += ' <span>/</span> <a href="#" data-path="' + escapeHtml(path) + '">' + escapeHtml(p) + '</a>';
|
|
});
|
|
el.innerHTML = html;
|
|
el.querySelectorAll('a[data-path]').forEach(function(a) {
|
|
a.onclick = function(e) { e.preventDefault(); currentPath = a.getAttribute('data-path') || ''; fetchPortal(); };
|
|
});
|
|
}
|
|
|
|
function saveDescription(path, desc) {
|
|
descriptions[path] = desc;
|
|
authFetch('/api/portal-files/descriptions', { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ descriptions: descriptions }) }).then(function(r) { return r.json(); }).catch(function() {});
|
|
}
|
|
|
|
function renderPortal(data) {
|
|
var items = data.items || [];
|
|
var baseUrl = data.base_url || '';
|
|
descriptions = data.descriptions || {};
|
|
currentPath = data.current_path || '';
|
|
document.getElementById('baseUrl').textContent = baseUrl + (currentPath ? currentPath + '/' : '');
|
|
document.getElementById('portalDir').textContent = data.portal_files_dir || '—';
|
|
buildBreadcrumb();
|
|
|
|
var tbody = document.getElementById('portalBody');
|
|
var empty = document.getElementById('portalEmpty');
|
|
tbody.innerHTML = '';
|
|
if (items.length === 0) { empty.style.display = 'block'; return; }
|
|
empty.style.display = 'none';
|
|
|
|
items.forEach(function(it) {
|
|
var tr = document.createElement('tr');
|
|
var desc = descriptions[it.path] || '';
|
|
var typeLabel = it.type === 'folder' ? 'Folder' : 'File';
|
|
var sizeCell = it.type === 'file' ? '<td class="mono">' + fmtSize(it.size) + '</td>' : '<td class="mono">—</td>';
|
|
var descCell = '<td><input type="text" class="desc-input" data-path="' + escapeHtml(it.path) + '" value="' + escapeHtml(desc) + '" placeholder="Comment…" /></td>';
|
|
var actions = '';
|
|
if (it.type === 'folder') {
|
|
actions = '<button type="button" class="btn btn-outline btn-sm open-folder" data-path="' + escapeHtml(it.path) + '">Open</button> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="folder">Delete</button>';
|
|
} else {
|
|
actions = '<a href="' + escapeHtml(baseUrl + it.path) + '" target="_blank" rel="noopener" class="btn btn-outline btn-sm">Open</a> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="file">Delete</button>';
|
|
}
|
|
tr.innerHTML = '<td class="' + (it.type === 'folder' ? 'folder-name' : '') + '">' + escapeHtml(it.name) + (it.type === 'folder' ? ' /' : '') + '</td><td>' + typeLabel + '</td>' + sizeCell + descCell + '<td class="actions-cell">' + actions + '</td>';
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
tbody.querySelectorAll('.desc-input').forEach(function(inp) {
|
|
inp.onblur = function() { saveDescription(inp.getAttribute('data-path'), inp.value.trim()); };
|
|
});
|
|
tbody.querySelectorAll('.open-folder').forEach(function(btn) {
|
|
btn.onclick = function() { currentPath = btn.getAttribute('data-path'); fetchPortal(); };
|
|
});
|
|
tbody.querySelectorAll('.delete-item').forEach(function(btn) {
|
|
btn.onclick = function() {
|
|
var path = btn.getAttribute('data-path');
|
|
var type = btn.getAttribute('data-type');
|
|
if (!confirm('Delete ' + type + ' “‘ + path + '”?')) return;
|
|
authFetch('/api/portal-files/' + encodeURIComponent(path), { method: 'DELETE' }).then(function(r) { return r.json(); }).then(function(d) {
|
|
if (d.ok) fetchPortal(); else alert(d.error || 'Delete failed');
|
|
});
|
|
};
|
|
});
|
|
}
|
|
|
|
function fetchPortal() {
|
|
var url = '/api/portal-files';
|
|
if (currentPath) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'path=' + encodeURIComponent(currentPath);
|
|
authFetch(url).then(function(r) { return r.json(); }).then(renderPortal).catch(function(err) {
|
|
document.getElementById('portalEmpty').style.display = 'block';
|
|
document.getElementById('portalEmpty').textContent = 'Could not load list (session may have expired). Trying read-only list…';
|
|
var fallbackUrl = '/api/portal-files?debug=1' + (currentPath ? '&path=' + encodeURIComponent(currentPath) : '');
|
|
fetch(fallbackUrl).then(function(r) { return r.json(); }).then(function(data) {
|
|
if (data.items && data.items.length) {
|
|
document.getElementById('portalEmpty').style.display = 'none';
|
|
renderPortal(data);
|
|
document.getElementById('portalDir').textContent = (data.portal_files_dir || '—') + ' (read-only; log in to edit)';
|
|
} else {
|
|
document.getElementById('portalEmpty').textContent = 'Server sees ' + (data.items ? data.items.length : 0) + ' item(s) at ' + (data.portal_files_dir || '?') + '. Log in to see and edit.';
|
|
}
|
|
}).catch(function() {
|
|
document.getElementById('portalEmpty').textContent = 'Could not load list. Log out and log in again, then refresh.';
|
|
});
|
|
});
|
|
}
|
|
|
|
document.getElementById('newFolderBtn').onclick = function() {
|
|
var name = prompt('Folder name (no slashes)');
|
|
if (!name || !name.trim()) return;
|
|
name = name.trim().replace(/[/\\]/g, '');
|
|
var path = currentPath ? currentPath + '/' + name : name;
|
|
authFetch('/api/portal-files/folder', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ path: path }) }).then(function(r) { return r.json(); }).then(function(d) {
|
|
if (d.ok) fetchPortal(); else alert(d.error || 'Failed');
|
|
});
|
|
};
|
|
document.getElementById('uploadBtn').onclick = function() { document.getElementById('uploadInput').click(); };
|
|
document.getElementById('uploadInput').onchange = function() {
|
|
var f = this.files && this.files[0];
|
|
if (!f) return;
|
|
var fd = new FormData();
|
|
fd.append('file', f);
|
|
if (currentPath) fd.append('path', currentPath);
|
|
authFetch('/api/portal-files/upload', { method: 'POST', body: fd }).then(function(r) { return r.json(); }).then(function(d) {
|
|
if (d.ok) { fetchPortal(); alert('Uploaded. URL: ' + d.url); } else alert(d.error || 'Upload failed');
|
|
});
|
|
this.value = '';
|
|
};
|
|
|
|
fetchPortal();
|
|
</script>
|
|
</body>
|
|
</html>
|