525 lines
22 KiB
HTML
525 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
|
<title>Projects — Z-AMPP</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=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="css/common.css">
|
|
<style>
|
|
.proj-shell { display: flex; flex: 1; overflow: hidden; }
|
|
|
|
.proj-list-panel {
|
|
width: 340px;
|
|
flex-shrink: 0;
|
|
border-right: 1px solid var(--border);
|
|
display: flex; flex-direction: column;
|
|
background: oklch(11% 0.018 250 / 0.6);
|
|
}
|
|
.proj-list-header {
|
|
padding: 16px 18px 12px;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
gap: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.proj-list-title {
|
|
font-size: 11px; font-weight: 600;
|
|
letter-spacing: 0.16em; text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
}
|
|
.proj-list-count {
|
|
font-size: 11px; font-family: var(--font-mono);
|
|
color: var(--text-tertiary);
|
|
}
|
|
.proj-list-search {
|
|
padding: 8px 14px 0;
|
|
}
|
|
.proj-list-search input {
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
background: oklch(15% 0.020 250);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-sm);
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
}
|
|
.proj-list { flex: 1; overflow: auto; padding: 8px; }
|
|
.proj-list-empty {
|
|
padding: 32px 18px;
|
|
text-align: center;
|
|
color: var(--text-tertiary);
|
|
font-size: 13px;
|
|
}
|
|
.proj-row {
|
|
display: flex; flex-direction: column; gap: 6px;
|
|
padding: 12px 14px;
|
|
border-radius: var(--r-sm);
|
|
border: 1px solid transparent;
|
|
cursor: pointer;
|
|
transition: background 120ms ease, border-color 120ms ease;
|
|
}
|
|
.proj-row:hover { background: oklch(15% 0.020 250 / 0.5); }
|
|
.proj-row.active {
|
|
background: oklch(18% 0.030 260 / 0.7);
|
|
border-color: oklch(45% 0.20 266 / 0.45);
|
|
}
|
|
.proj-row-name {
|
|
font-size: 14px; font-weight: 500;
|
|
color: var(--text-primary);
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.proj-row-meta {
|
|
display: flex; gap: 12px; align-items: center;
|
|
font-size: 11px; color: var(--text-tertiary);
|
|
font-family: var(--font-mono);
|
|
}
|
|
.proj-row-meta b { color: var(--text-secondary); font-weight: 600; }
|
|
|
|
.proj-detail {
|
|
flex: 1; display: flex; flex-direction: column;
|
|
overflow: hidden;
|
|
background: var(--bg-base);
|
|
}
|
|
.proj-detail-empty {
|
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
|
color: var(--text-tertiary); font-size: 13px;
|
|
flex-direction: column; gap: 12px;
|
|
}
|
|
.proj-detail-header {
|
|
padding: 24px 32px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.proj-detail-eyebrow {
|
|
font-size: 11px; font-weight: 600;
|
|
letter-spacing: 0.16em; text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
margin-bottom: 8px;
|
|
}
|
|
.proj-detail-title {
|
|
display: flex; align-items: center; gap: 12px;
|
|
font-size: 22px; font-weight: 600;
|
|
letter-spacing: -0.01em;
|
|
color: var(--text-primary);
|
|
}
|
|
.proj-detail-title input {
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: var(--r-sm);
|
|
padding: 4px 8px;
|
|
color: var(--text-primary);
|
|
font: inherit;
|
|
min-width: 240px;
|
|
}
|
|
.proj-detail-title input:focus,
|
|
.proj-detail-title input:hover {
|
|
border-color: var(--border);
|
|
background: oklch(13% 0.018 250);
|
|
}
|
|
.proj-detail-desc {
|
|
margin-top: 8px;
|
|
font-size: 13px; color: var(--text-secondary);
|
|
line-height: 1.55;
|
|
}
|
|
.proj-detail-desc textarea {
|
|
width: 100%; min-height: 56px; resize: vertical;
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: var(--r-sm);
|
|
padding: 6px 8px;
|
|
color: var(--text-secondary); font: inherit;
|
|
}
|
|
.proj-detail-desc textarea:focus,
|
|
.proj-detail-desc textarea:hover {
|
|
border-color: var(--border);
|
|
background: oklch(13% 0.018 250);
|
|
}
|
|
.proj-detail-stats {
|
|
display: flex; gap: 24px;
|
|
margin-top: 14px;
|
|
font-size: 12px; font-family: var(--font-mono);
|
|
color: var(--text-tertiary);
|
|
}
|
|
.proj-detail-stats b { color: var(--text-primary); font-weight: 600; }
|
|
.proj-detail-actions {
|
|
margin-top: 16px;
|
|
display: flex; gap: 8px;
|
|
}
|
|
|
|
.proj-bins {
|
|
flex: 1; overflow: auto;
|
|
padding: 24px 32px 40px;
|
|
}
|
|
.proj-bins-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
}
|
|
.proj-bins-title {
|
|
font-size: 11px; font-weight: 600;
|
|
letter-spacing: 0.16em; text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
}
|
|
.proj-bin-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.proj-bin-card {
|
|
position: relative;
|
|
padding: 16px;
|
|
background: oklch(13% 0.018 250 / 0.6);
|
|
border: 1px solid oklch(28% 0.04 260 / 0.4);
|
|
border-radius: var(--r-sm);
|
|
display: flex; flex-direction: column; gap: 6px;
|
|
}
|
|
.proj-bin-card-name {
|
|
font-size: 14px; font-weight: 500;
|
|
color: var(--text-primary);
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.proj-bin-card-meta {
|
|
font-size: 11px; color: var(--text-tertiary);
|
|
font-family: var(--font-mono);
|
|
}
|
|
.proj-bin-card-actions {
|
|
position: absolute; top: 10px; right: 10px;
|
|
display: flex; gap: 4px;
|
|
opacity: 0; transition: opacity 120ms ease;
|
|
}
|
|
.proj-bin-card:hover .proj-bin-card-actions { opacity: 1; }
|
|
.proj-bin-empty {
|
|
grid-column: 1 / -1;
|
|
padding: 32px;
|
|
text-align: center;
|
|
color: var(--text-tertiary);
|
|
font-size: 13px;
|
|
border: 1px dashed var(--border);
|
|
border-radius: var(--r-sm);
|
|
}
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed; inset: 0;
|
|
background: oklch(0% 0 0 / 0.6);
|
|
backdrop-filter: blur(4px);
|
|
display: none; align-items: center; justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
.modal-overlay.open { display: flex; }
|
|
.modal {
|
|
width: min(420px, 90vw);
|
|
background: oklch(15% 0.025 250);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
box-shadow: 0 30px 60px -20px oklch(0% 0 0 / 0.5);
|
|
}
|
|
.modal h3 {
|
|
font-size: 16px; font-weight: 600;
|
|
margin-bottom: 12px;
|
|
color: var(--text-primary);
|
|
}
|
|
.modal label {
|
|
display: block;
|
|
font-size: 11px; font-weight: 600;
|
|
letter-spacing: 0.06em; text-transform: uppercase;
|
|
color: var(--text-tertiary);
|
|
margin-bottom: 6px; margin-top: 12px;
|
|
}
|
|
.modal input, .modal textarea {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
background: oklch(11% 0.015 250);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-sm);
|
|
color: var(--text-primary);
|
|
font: inherit; font-size: 14px;
|
|
}
|
|
.modal input:focus, .modal textarea:focus {
|
|
outline: none; border-color: oklch(45% 0.20 266 / 0.6);
|
|
}
|
|
.modal-actions {
|
|
display: flex; gap: 8px; justify-content: flex-end;
|
|
margin-top: 20px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="shell">
|
|
<nav class="sidebar" aria-label="Main navigation">
|
|
<div class="sidebar-brand">
|
|
<img src="img/dragon-logo.png?v=1" alt="Z-AMPP" class="sidebar-logo">
|
|
<span class="sidebar-brand-name">Z-AMPP</span>
|
|
</div>
|
|
<nav class="sidebar-nav">
|
|
<a href="home.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>Home</a>
|
|
<a href="index.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>Library</a>
|
|
<a href="projects.html" class="nav-item active"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>Projects</a>
|
|
<a href="upload.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>Ingest</a>
|
|
<a href="recorders.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>Recorders</a>
|
|
<a href="capture.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>Capture</a>
|
|
<a href="jobs.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>Jobs</a>
|
|
<a href="edit.html" class="nav-item" ><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>Editor</a>
|
|
</nav>
|
|
</nav>
|
|
|
|
<div class="main">
|
|
<header class="topbar">
|
|
<div class="topbar-left">
|
|
<span class="page-title">Projects</span>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<button class="btn btn-primary btn-sm" onclick="openNewProject()">
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 3v10M3 8h10"/></svg>
|
|
New project
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="proj-shell">
|
|
<!-- Left: project list -->
|
|
<aside class="proj-list-panel">
|
|
<div class="proj-list-header">
|
|
<span class="proj-list-title">All Projects</span>
|
|
<span class="proj-list-count" id="projCount">--</span>
|
|
</div>
|
|
<div class="proj-list-search">
|
|
<input type="text" id="projSearch" placeholder="Search projects…" />
|
|
</div>
|
|
<div class="proj-list" id="projList">
|
|
<div class="proj-list-empty">Loading…</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Right: detail / bins -->
|
|
<section class="proj-detail" id="projDetail">
|
|
<div class="proj-detail-empty">
|
|
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" style="opacity:0.4">
|
|
<path d="M3 9a1 1 0 0 1 1-1h9l3 3h12a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/>
|
|
</svg>
|
|
<div>Select a project on the left, or create a new one.</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New project modal -->
|
|
<div class="modal-overlay" id="newProjModal">
|
|
<div class="modal">
|
|
<h3>New project</h3>
|
|
<label>Name</label>
|
|
<input type="text" id="newProjName" placeholder="e.g. 2026 Sunday Service" autocomplete="off" />
|
|
<label>Description (optional)</label>
|
|
<textarea id="newProjDesc" rows="2" placeholder="A short note about the project"></textarea>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeModal('newProjModal')">Cancel</button>
|
|
<button class="btn btn-primary btn-sm" onclick="createProject()">Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New bin modal -->
|
|
<div class="modal-overlay" id="newBinModal">
|
|
<div class="modal">
|
|
<h3>New bin</h3>
|
|
<label>Name</label>
|
|
<input type="text" id="newBinName" placeholder="e.g. Cameras, B-Roll, Interviews" autocomplete="off" />
|
|
<div class="modal-actions">
|
|
<button class="btn btn-ghost btn-sm" onclick="closeModal('newBinModal')">Cancel</button>
|
|
<button class="btn btn-primary btn-sm" onclick="createBin()">Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="js/api.js"></script>
|
|
<script src="js/topbar-strip.js"></script>
|
|
<script>
|
|
const state = { projects: [], filtered: [], selectedId: null, bins: [], assetsByProject: {} };
|
|
|
|
// ── Modal helpers ─────────────────────────
|
|
function openModal(id) { document.getElementById(id).classList.add('open'); setTimeout(() => { const i = document.querySelector('#' + id + ' input'); if (i) i.focus(); }, 50); }
|
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
|
function openNewProject() { document.getElementById('newProjName').value = ''; document.getElementById('newProjDesc').value = ''; openModal('newProjModal'); }
|
|
function openNewBin() { if (!state.selectedId) return; document.getElementById('newBinName').value = ''; openModal('newBinModal'); }
|
|
|
|
// ── API ────────────────────────────────────
|
|
async function api(path, opts = {}) {
|
|
const r = await fetch('/api/v1' + path, Object.assign({ credentials: 'include', headers: { 'Content-Type': 'application/json' } }, opts));
|
|
if (!r.ok) throw new Error('HTTP ' + r.status + ' on ' + path);
|
|
return r.json();
|
|
}
|
|
|
|
async function loadAll() {
|
|
try {
|
|
const projs = await api('/projects');
|
|
state.projects = Array.isArray(projs) ? projs : [];
|
|
// Hydrate asset counts per project
|
|
const counts = await Promise.all(state.projects.map(async (p) => {
|
|
try {
|
|
const r = await api('/assets?project_id=' + encodeURIComponent(p.id) + '&limit=1');
|
|
return { id: p.id, count: r.total ?? 0 };
|
|
} catch { return { id: p.id, count: 0 }; }
|
|
}));
|
|
counts.forEach(c => { state.assetsByProject[c.id] = c.count; });
|
|
filterAndRender();
|
|
if (state.selectedId) await loadBins(state.selectedId);
|
|
} catch (e) {
|
|
document.getElementById('projList').innerHTML = '<div class="proj-list-empty" style="color:var(--status-red)">Failed to load: ' + e.message + '</div>';
|
|
}
|
|
}
|
|
|
|
function filterAndRender() {
|
|
const q = (document.getElementById('projSearch').value || '').trim().toLowerCase();
|
|
state.filtered = state.projects.filter(p => !q || p.name.toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q));
|
|
document.getElementById('projCount').textContent = state.filtered.length + (state.filtered.length === state.projects.length ? '' : ' / ' + state.projects.length);
|
|
const list = document.getElementById('projList');
|
|
if (state.filtered.length === 0) {
|
|
list.innerHTML = '<div class="proj-list-empty">' + (q ? 'No matches.' : 'No projects yet. Create one with the button above.') + '</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = state.filtered.map(p => {
|
|
const cnt = state.assetsByProject[p.id] ?? 0;
|
|
const active = p.id === state.selectedId ? ' active' : '';
|
|
const created = new Date(p.created_at).toLocaleDateString();
|
|
return '<div class="proj-row' + active + '" onclick="selectProject(\'' + p.id + '\')"><div class="proj-row-name">' + esc(p.name) + '</div><div class="proj-row-meta"><span><b>' + cnt + '</b> assets</span><span>' + created + '</span></div></div>';
|
|
}).join('');
|
|
}
|
|
|
|
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
|
|
|
// ── Selection / detail ─────────────────────
|
|
async function selectProject(id) {
|
|
state.selectedId = id;
|
|
filterAndRender();
|
|
await loadBins(id);
|
|
}
|
|
|
|
async function loadBins(projectId) {
|
|
const p = state.projects.find(x => x.id === projectId);
|
|
if (!p) return;
|
|
try {
|
|
state.bins = await api('/bins?project_id=' + encodeURIComponent(projectId));
|
|
} catch { state.bins = []; }
|
|
renderDetail(p);
|
|
}
|
|
|
|
function renderDetail(p) {
|
|
const host = document.getElementById('projDetail');
|
|
const cnt = state.assetsByProject[p.id] ?? 0;
|
|
const created = new Date(p.created_at).toLocaleString();
|
|
host.innerHTML =
|
|
'<div class="proj-detail-header">' +
|
|
'<div class="proj-detail-eyebrow">Project</div>' +
|
|
'<div class="proj-detail-title"><input id="detailName" value="' + esc(p.name) + '" onblur="renameProject(\'' + p.id + '\', this.value)"></div>' +
|
|
'<div class="proj-detail-desc"><textarea id="detailDesc" placeholder="Description…" onblur="updateDesc(\'' + p.id + '\', this.value)">' + esc(p.description || '') + '</textarea></div>' +
|
|
'<div class="proj-detail-stats">' +
|
|
'<span><b>' + cnt + '</b> assets</span>' +
|
|
'<span><b>' + state.bins.length + '</b> bins</span>' +
|
|
'<span>Created ' + created + '</span>' +
|
|
'</div>' +
|
|
'<div class="proj-detail-actions">' +
|
|
'<button class="btn btn-primary btn-sm" onclick="openNewBin()"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 3v10M3 8h10"/></svg>New bin</button>' +
|
|
'<button class="btn btn-ghost btn-sm" onclick="location.href=\'index.html?project=' + p.id + '\'">Open in Library</button>' +
|
|
'<button class="btn btn-danger btn-sm" style="margin-left:auto" onclick="deleteProject(\'' + p.id + '\')">Delete project</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="proj-bins">' +
|
|
'<div class="proj-bins-header"><span class="proj-bins-title">Bins</span></div>' +
|
|
'<div class="proj-bin-grid">' +
|
|
(state.bins.length === 0
|
|
? '<div class="proj-bin-empty">No bins yet. Use <b>New bin</b> above to make your first one.</div>'
|
|
: state.bins.map(b => binCard(b)).join('')) +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
function binCard(b) {
|
|
return '<div class="proj-bin-card">' +
|
|
'<div class="proj-bin-card-name">' +
|
|
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>' +
|
|
esc(b.name) +
|
|
'</div>' +
|
|
'<div class="proj-bin-card-meta">Created ' + new Date(b.created_at).toLocaleDateString() + '</div>' +
|
|
'<div class="proj-bin-card-actions">' +
|
|
'<button class="btn btn-ghost btn-sm" style="padding:4px 8px" onclick="renameBinPrompt(\'' + b.id + '\', \'' + esc(b.name).replace(/'/g, "\\'") + '\')">Rename</button>' +
|
|
'<button class="btn btn-ghost btn-sm" style="padding:4px 8px;color:var(--status-red)" onclick="deleteBin(\'' + b.id + '\')">Delete</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
// ── Mutations ──────────────────────────────
|
|
async function createProject() {
|
|
const name = document.getElementById('newProjName').value.trim();
|
|
const description = document.getElementById('newProjDesc').value.trim();
|
|
if (!name) return alert('Name is required');
|
|
try {
|
|
const p = await api('/projects', { method: 'POST', body: JSON.stringify({ name, description }) });
|
|
closeModal('newProjModal');
|
|
state.selectedId = p.id;
|
|
await loadAll();
|
|
} catch (e) { alert('Create failed: ' + e.message); }
|
|
}
|
|
|
|
async function renameProject(id, name) {
|
|
name = (name || '').trim();
|
|
if (!name) return loadAll();
|
|
const cur = state.projects.find(p => p.id === id);
|
|
if (cur && cur.name === name) return;
|
|
try { await api('/projects/' + id, { method: 'PATCH', body: JSON.stringify({ name }) }); await loadAll(); }
|
|
catch (e) { alert('Rename failed: ' + e.message); }
|
|
}
|
|
|
|
async function updateDesc(id, description) {
|
|
const cur = state.projects.find(p => p.id === id);
|
|
if (cur && (cur.description || '') === description) return;
|
|
try { await api('/projects/' + id, { method: 'PATCH', body: JSON.stringify({ description }) }); await loadAll(); }
|
|
catch (e) { alert('Save failed: ' + e.message); }
|
|
}
|
|
|
|
async function deleteProject(id) {
|
|
const p = state.projects.find(x => x.id === id);
|
|
const cnt = state.assetsByProject[id] ?? 0;
|
|
if (!confirm('Delete project "' + p.name + '"?\n\nThis will also delete its ' + cnt + ' asset(s) and any bins. This cannot be undone.')) return;
|
|
try {
|
|
await api('/projects/' + id, { method: 'DELETE' });
|
|
state.selectedId = null;
|
|
document.getElementById('projDetail').innerHTML = '<div class="proj-detail-empty"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" style="opacity:0.4"><path d="M3 9a1 1 0 0 1 1-1h9l3 3h12a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/></svg><div>Select a project on the left, or create a new one.</div></div>';
|
|
await loadAll();
|
|
} catch (e) { alert('Delete failed: ' + e.message); }
|
|
}
|
|
|
|
async function createBin() {
|
|
const name = document.getElementById('newBinName').value.trim();
|
|
if (!name || !state.selectedId) return;
|
|
try {
|
|
await api('/bins', { method: 'POST', body: JSON.stringify({ project_id: state.selectedId, name }) });
|
|
closeModal('newBinModal');
|
|
await loadBins(state.selectedId);
|
|
} catch (e) { alert('Create bin failed: ' + e.message); }
|
|
}
|
|
|
|
async function renameBinPrompt(id, current) {
|
|
const name = prompt('Rename bin', current);
|
|
if (!name || name === current) return;
|
|
try { await api('/bins/' + id, { method: 'PATCH', body: JSON.stringify({ name }) }); await loadBins(state.selectedId); }
|
|
catch (e) { alert('Rename failed: ' + e.message); }
|
|
}
|
|
|
|
async function deleteBin(id) {
|
|
if (!confirm('Delete this bin? Assets inside the bin will become un-binned (still in the project).')) return;
|
|
try { await api('/bins/' + id, { method: 'DELETE' }); await loadBins(state.selectedId); }
|
|
catch (e) { alert('Delete failed: ' + e.message); }
|
|
}
|
|
|
|
// ── Init ────────────────────────────────────
|
|
document.getElementById('projSearch').addEventListener('input', filterAndRender);
|
|
document.querySelectorAll('.modal-overlay').forEach(el => el.addEventListener('click', e => { if (e.target === el) el.classList.remove('open'); }));
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open')); });
|
|
loadAll();
|
|
</script>
|
|
</body>
|
|
</html>
|