diff --git a/classifier/css/layout.css b/classifier/css/layout.css index aa48b51..bb0d45a 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -582,6 +582,11 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o .copy-choice h3 { margin: 0 0 0.5rem; font-size: 1.15rem; } .copy-choice p { margin: 0 0 1.1rem; color: var(--text-muted); font-size: 0.85rem; line-height: 1.5; } .copy-choice code { font-size: 0.82em; } +.copy-choice__select { + width: 100%; margin: 0 0 1rem; padding: 0.45rem 0.55rem; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.9rem; +} .copy-choice__btns { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; } /* ── By-tracking merged-cell table ──────────────────────────────────────── */ diff --git a/classifier/js/copy.js b/classifier/js/copy.js index 8f346ba..c6e3698 100644 --- a/classifier/js/copy.js +++ b/classifier/js/copy.js @@ -197,37 +197,72 @@ return s; } - // Copy straight into the server's archive over HTTP (PUT per file, mkdir as - // needed). Uses the zddc-source HTTP handle, so the SAME copy engine writes - // /// under the chosen archive URL. + // Copy straight into a project's archive on the server over HTTP (PUT per + // file, mkdir as needed). Uses the zddc-source HTTP handle, so the SAME copy + // engine writes /// under + // /archive/. The user picks any project they can access. async function copyToServer(todo) { var src = window.zddc && window.zddc.source; if (!src || location.protocol === 'file:') { toast('Server copy needs the classifier to be served by a zddc-server (open it over http).', 'error'); return; } - var url = serverArchiveUrl || guessArchiveUrl(); - url = prompt('Server archive URL to file into (canonical copies go under ///):', url); - if (!url) return; - if (url.charAt(url.length - 1) !== '/') url += '/'; - serverArchiveUrl = url; - var out; + var projects = await fetchAccessProjects(); + if (projects == null) { toast('Could not load your projects from the server.', 'error'); return; } + if (!projects.length) { toast('No projects you can access on this server.', 'warning'); return; } + var proj = await chooseProject(projects); + if (!proj) return; + var archive; try { - var u = new URL(url, location.origin); - out = new src.HttpDirectoryHandle(u.href, 'archive'); - } catch (e) { toast('Bad archive URL — ' + (e.message || e), 'error'); return; } + var rel = proj.url || ('/' + proj.name + '/'); + if (rel.charAt(rel.length - 1) !== '/') rel += '/'; + archive = new URL(rel + 'archive/', location.origin).href; + } catch (e) { toast('Bad project URL — ' + (e.message || e), 'error'); return; } + var out = new src.HttpDirectoryHandle(archive, 'archive'); var s = await copyTo(out, todo); - summary(s, 'server archive'); + summary(s, (proj.title || proj.name) + ' / archive'); return s; } - // Best-guess archive root from the current page: the path up to and including - // the first "archive/" segment, else the served directory. - function guessArchiveUrl() { - var p = location.pathname; - var m = /^(.*?\/archive\/)/.exec(p); - return location.origin + (m ? m[1] : p.replace(/[^/]*$/, '')); + // The caller's accessible projects (read view from /.profile/access). Write + // permission is enforced server-side on PUT, so a 403 surfaces per file. + async function fetchAccessProjects() { + try { + var resp = await fetch('/.profile/access', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin', cache: 'no-cache' }); + if (!resp.ok) return null; + if ((resp.headers.get('Content-Type') || '').toLowerCase().indexOf('json') === -1) return null; + var data = await resp.json(); + return Array.isArray(data.projects) ? data.projects : []; + } catch (e) { return null; } + } + function chooseProject(projects) { + return new Promise(function (resolve) { + var done = false; + function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); } + function onKey(e) { if (e.key === 'Escape') finish(null); } + var back = document.createElement('div'); back.className = 'copy-choice__backdrop'; + var box = document.createElement('div'); box.className = 'copy-choice'; + var h = document.createElement('h3'); h.textContent = 'Copy to a project archive'; + var p = document.createElement('p'); + p.innerHTML = 'Files go to <project>/archive/<party>/<received|issued>/<transmittal>/. Pick a project you can access.'; + var sel = document.createElement('select'); sel.className = 'copy-choice__select'; + projects.forEach(function (pr, i) { + var o = document.createElement('option'); o.value = String(i); + o.textContent = pr.name + (pr.title ? ' — ' + pr.title : ''); + sel.appendChild(o); + }); + var row = document.createElement('div'); row.className = 'copy-choice__btns'; + var go = document.createElement('button'); go.className = 'btn btn-primary'; go.textContent = 'Copy here'; + go.addEventListener('click', function () { finish(projects[Number(sel.value)] || null); }); + var cancel = document.createElement('button'); cancel.className = 'btn btn-secondary'; cancel.textContent = 'Cancel'; + cancel.addEventListener('click', function () { finish(null); }); + row.appendChild(go); row.appendChild(cancel); + box.appendChild(h); box.appendChild(p); box.appendChild(sel); box.appendChild(row); + back.appendChild(box); + back.addEventListener('click', function (e) { if (e.target === back) finish(null); }); + document.addEventListener('keydown', onKey); + document.body.appendChild(back); + }); } - var serverArchiveUrl = null; // Tiny modal: choose server archive vs local folder. Resolves 'server' | // 'local' | null. The server option is offered only over http(s).