summaryrefslogtreecommitdiffstats
path: root/views/admin
diff options
context:
space:
mode:
authorKen D'Ambrosio <ken@jots.org>2026-06-12 14:01:48 +0000
committerKen D'Ambrosio <ken@jots.org>2026-06-12 14:01:48 +0000
commitcfb814470864785565f33e4bebd2aca7e67c16ae (patch)
tree4c9dff75a5e18bbeff964fef8d8856365633bbdf /views/admin
parentb9a3ce6942e917c8e5046d652b7742cfe5f960ec (diff)
Move bulk selection to admin cluster detail page (correct page)
The bulk selection panel now lives on /admin/people/:uuid — the face crop grid page — which is what was actually requested. A sticky left panel shows the cluster name, the name form, a selection counter, and bulk action controls. Clicking a face crop toggles selection; clicking the photo link still opens the album. Bulk actions: move selected faces to a named person, move to pool, or blacklist. The per-face individual dropdowns are replaced by the panel. Merge-entire-cluster and Blacklist-cluster moved to collapsible/button in the panel too. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'views/admin')
-rw-r--r--views/admin/person_detail.erb327
1 files changed, 183 insertions, 144 deletions
diff --git a/views/admin/person_detail.erb b/views/admin/person_detail.erb
index 7a1ea0a..fbce8af 100644
--- a/views/admin/person_detail.erb
+++ b/views/admin/person_detail.erb
@@ -1,158 +1,208 @@
-<div class="admin-people">
- <div class="admin-nav">
- <a href="/admin/people" class="btn btn-sm">← All Clusters</a>
- <% if @name && !@is_pool %>
- <a href="/people/<%= ERB::Util.url_encode(@name.downcase.gsub(/[^a-z0-9]+/,'-').gsub(/^-+|-+$/,'')) %>"
- class="btn btn-sm" target="_blank">Public Page ↗</a>
- <% end %>
- </div>
+<div class="person-detail-wrap">
- <h1><%= ERB::Util.html_escape(@title) %></h1>
- <p class="update-hint"><%= @count %> photo<%= @count == 1 ? '' : 's' %> in this cluster</p>
+ <%# ── Left action panel ──────────────────────────────────────────────────── %>
+ <aside class="bulk-panel" id="bulk-panel">
+ <div class="admin-nav" style="margin-bottom:14px">
+ <a href="/admin/people" class="btn btn-sm">← All Clusters</a>
+ <% if @name && !@is_pool %>
+ <a href="/people/<%= ERB::Util.url_encode(@name.downcase.gsub(/[^a-z0-9]+/,'-').gsub(/^-+|-+$/,'')) %>"
+ class="btn btn-sm" target="_blank">↗</a>
+ <% end %>
+ </div>
- <div class="people-info-box">
- <% if @is_pool %>
- These faces were removed from other clusters and are waiting to be assigned.
- Use <strong>Assign to&hellip;</strong> to move a face to a named person, or
- <strong>Blacklist</strong> to permanently exclude it from future clustering.
- <% else %>
- <strong>Move to pool</strong> sends a face to a holding area where you can assign it later.
- <strong>Blacklist cluster</strong> permanently excludes all faces in this cluster from future clustering.
- <br>
- <em>No photo files are ever modified &mdash; only the clustering metadata is affected.</em>
- <% end %>
- </div>
+ <h2 class="bulk-panel-title"><%= ERB::Util.html_escape(@title) %></h2>
+ <p class="bulk-panel-hint"><%= @count %> face<%= @count == 1 ? '' : 's' %></p>
- <% unless @is_pool %>
- <%# ── Name ──────────────────────────────────────────────────────────────── %>
- <section style="margin-bottom:20px">
- <% if @count == 1 && !@name %>
- <% m = @members.first; rel = m['rel']; box = m['box'] %>
- <% parts = rel.split('/'); fname = parts.last; dir_rel = parts[0..-2].join('/') %>
- <% album_url = dir_rel.empty? ? '/browse/' : "/browse/#{ERB::Util.html_escape(dir_rel)}" %>
- <div class="new-person-hero">
- <div class="face-detail-thumb" data-thumb="/thumb/<%= ERB::Util.html_escape(rel) %>">
- <a href="<%= album_url %>?photo=<%= ERB::Util.url_encode(fname) %>" target="_blank">
- <img src="/face/<%= ERB::Util.html_escape(rel) %>?box=<%= ERB::Util.html_escape(box.join(',')) %>"
- width="140" height="140">
- </a>
+ <% unless @is_pool %>
+ <%# ── Name form ─────────────────────────────────────────────────────────── %>
+ <div style="margin-top:14px">
+ <% if @count == 1 && !@name %>
+ <% m = @members.first; rel = m['rel']; box = m['box'] %>
+ <% parts = rel.split('/'); fname = parts.last; dir_rel = parts[0..-2].join('/') %>
+ <% album_url = dir_rel.empty? ? '/browse/' : "/browse/#{ERB::Util.html_escape(dir_rel)}" %>
+ <div class="new-person-hero">
+ <div class="face-detail-thumb" data-thumb="/thumb/<%= ERB::Util.html_escape(rel) %>">
+ <a href="<%= album_url %>?photo=<%= ERB::Util.url_encode(fname) %>" target="_blank">
+ <img src="/face/<%= ERB::Util.html_escape(rel) %>?box=<%= ERB::Util.html_escape(box.join(',')) %>"
+ width="140" height="140">
+ </a>
+ </div>
+ <p class="update-hint" style="margin-top:6px;font-size:.75rem">Hover to preview · Click to open</p>
</div>
- <p class="update-hint" style="margin-top:8px">Hover to see full photo &middot; Click to open in album</p>
- </div>
+ <% end %>
+ <form id="name-form" method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>" class="name-form">
+ <input type="text" name="name" id="name-input"
+ value="<%= ERB::Util.html_escape(@name.to_s) %>"
+ placeholder="Name this person…" class="name-input bulk-input" autocomplete="off">
+ <button type="submit" class="btn btn-sm bulk-action-btn" style="margin-top:4px">Save Name</button>
+ </form>
+ <form id="merge-into-form" method="post"
+ action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/merge" style="display:none">
+ <input type="hidden" name="into" id="merge-into-uuid">
+ </form>
+ </div>
<% end %>
- <form id="name-form" method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>" class="name-form">
- <input type="text" name="name" id="name-input"
- value="<%= ERB::Util.html_escape(@name.to_s) %>"
- placeholder="Enter name…" class="name-input" style="max-width:300px"
- autocomplete="off">
- <button type="submit" class="btn">Save Name</button>
- </form>
- <form id="merge-into-form" method="post"
- action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/merge"
- style="display:none">
- <input type="hidden" name="into" id="merge-into-uuid">
- </form>
- </section>
-
- <%# ── Merge entire cluster ──────────────────────────────────────────────── %>
- <% unless @all_others.empty? %>
- <section class="admin-update" style="margin-bottom:28px">
- <h2>Merge entire cluster into another person</h2>
- <p class="update-hint">Moves all <%= @count %> photos and removes this cluster.</p>
- <form method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/merge"
- style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
- <select name="into" class="name-input" style="max-width:300px">
- <option value="">— Select person —</option>
- <% @all_others.each do |p| %>
- <option value="<%= ERB::Util.html_escape(p[:uuid]) %>"><%= ERB::Util.html_escape(p[:name]) %></option>
+
+ <hr class="bulk-sep">
+
+ <%# ── Selection actions ──────────────────────────────────────────────────── %>
+ <p class="bulk-panel-hint" id="bulk-hint">Click faces to select them.</p>
+ <p style="display:none;margin:0" id="bulk-count">
+ <strong id="bulk-n">0</strong> selected
+ &nbsp;<a href="#" id="bulk-clear" style="font-size:.8rem">Clear</a>
+ </p>
+
+ <form id="bulk-form" method="post"
+ action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/bulk_move"
+ style="display:none;margin-top:10px">
+ <div id="bulk-entries"></div>
+ <label class="bulk-label">Action</label>
+ <select name="bulk_action" id="bulk-action-select" class="bulk-select">
+ <option value="">— Choose action —</option>
+ <% @named_others.each do |p| %>
+ <option value="<%= ERB::Util.html_escape(p[:uuid]) %>">Move to: <%= ERB::Util.html_escape(p[:name]) %></option>
<% end %>
+ <option value="pool">Move to pool</option>
+ <option value="blacklist">Blacklist</option>
</select>
- <button type="submit" class="btn"
- onclick="return confirm('Merge all <%= @count %> photos into the selected person?')">Merge</button>
- </form>
- </section>
- <% end %>
-
- <%# ── Delete cluster (blacklists all members) ───────────────────────────── %>
- <section style="margin-bottom:28px">
- <form method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/delete"
- onsubmit="return confirm('Blacklist this entire cluster (<%= @count %> photo<%= @count == 1 ? '' : 's' %>)? These faces will never be re-clustered.')">
- <button type="submit" class="btn btn-danger">Blacklist cluster</button>
+ <button type="submit" class="btn btn-sm bulk-action-btn"
+ onclick="return confirmBulk()">Apply</button>
</form>
- </section>
- <% else %>
- <%# ── Pool: blacklist all ────────────────────────────────────────────────── %>
- <section style="margin-bottom:28px">
- <form method="post" action="/admin/people/__pool__/blacklist_all"
- onsubmit="return confirm('Blacklist all <%= @count %> faces in the pool? They will never be re-clustered.')">
- <button type="submit" class="btn btn-danger">Blacklist all in pool</button>
- </form>
- </section>
- <% end %>
- <%# ── Face grid ──────────────────────────────────────────────────────────── %>
- <p class="update-hint">
- Hover a face to see the full photo. Click to open in the album.
+ <hr class="bulk-sep" style="margin-top:16px">
+
+ <%# ── Cluster-level actions ──────────────────────────────────────────────── %>
+ <% unless @is_pool || @all_others.empty? %>
+ <details style="margin-bottom:10px">
+ <summary class="bulk-label" style="cursor:pointer">Merge entire cluster…</summary>
+ <form method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/merge"
+ style="margin-top:8px">
+ <select name="into" class="bulk-select">
+ <option value="">— Select person —</option>
+ <% @all_others.each do |p| %>
+ <option value="<%= ERB::Util.html_escape(p[:uuid]) %>"><%= ERB::Util.html_escape(p[:name]) %></option>
+ <% end %>
+ </select>
+ <button type="submit" class="btn btn-sm btn-danger bulk-action-btn"
+ style="margin-top:4px"
+ onclick="return confirm('Merge all <%= @count %> faces into the selected person?')">Merge</button>
+ </form>
+ </details>
+ <% end %>
+
<% if @is_pool %>
- Use the drop-down to assign a face to a named person, or blacklist it permanently.
- <% elsif !@named_others.empty? %>
- Use the drop-down to move a face to another person.
+ <form method="post" action="/admin/people/__pool__/blacklist_all"
+ onsubmit="return confirm('Blacklist all <%= @count %> faces in the pool?')">
+ <button type="submit" class="btn btn-sm btn-danger bulk-action-btn">Blacklist all in pool</button>
+ </form>
+ <% else %>
+ <form method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/delete"
+ onsubmit="return confirm('Blacklist all <%= @count %> faces in this cluster? They will never be re-clustered.')">
+ <button type="submit" class="btn btn-sm btn-danger bulk-action-btn">Blacklist cluster</button>
+ </form>
<% end %>
- </p>
-
- <div class="face-detail-grid">
- <% @members.each do |m| %>
- <% rel = m['rel']; box = m['box'] %>
- <% parts = rel.split('/'); fname = parts.last; dir_rel = parts[0..-2].join('/') %>
- <% album_url = dir_rel.empty? ? '/browse/' : "/browse/#{ERB::Util.html_escape(dir_rel)}" %>
- <div class="face-detail-card">
- <div class="face-detail-thumb" data-thumb="/thumb/<%= ERB::Util.html_escape(rel) %>">
- <a href="<%= album_url %>?photo=<%= ERB::Util.url_encode(fname) %>" target="_blank">
- <img src="/face/<%= ERB::Util.html_escape(rel) %>?box=<%= ERB::Util.html_escape(box.join(',')) %>"
- width="100" height="100" loading="lazy">
- </a>
+
+ <div class="people-info-box" style="margin-top:14px">
+ <% if @is_pool %>
+ Faces removed from other clusters. Assign them to a person or blacklist permanently.
+ <% else %>
+ <strong>Move to pool</strong> holds faces for later review.
+ <strong>Blacklist</strong> permanently excludes from clustering.
+ <em>No photo files are ever modified.</em>
+ <% end %>
+ </div>
+ </aside>
+
+ <%# ── Face grid ──────────────────────────────────────────────────────────── %>
+ <div class="person-detail-main">
+ <div class="face-detail-grid" id="face-grid">
+ <% @members.each do |m| %>
+ <% rel = m['rel']; box = m['box'] %>
+ <% parts = rel.split('/'); fname = parts.last; dir_rel = parts[0..-2].join('/') %>
+ <% album_url = dir_rel.empty? ? '/browse/' : "/browse/#{ERB::Util.html_escape(dir_rel)}" %>
+ <% entry_json = ERB::Util.html_escape({rel: rel, box: box}.to_json) %>
+ <div class="face-detail-card selectable-face" data-entry="<%= entry_json %>">
+ <div class="face-detail-thumb" data-thumb="/thumb/<%= ERB::Util.html_escape(rel) %>">
+ <a href="<%= album_url %>?photo=<%= ERB::Util.url_encode(fname) %>" target="_blank"
+ class="face-photo-link">
+ <img src="/face/<%= ERB::Util.html_escape(rel) %>?box=<%= ERB::Util.html_escape(box.join(',')) %>"
+ width="100" height="100" loading="lazy">
+ </a>
+ <span class="select-checkbox" aria-hidden="true"></span>
+ </div>
</div>
- <% if @is_pool || !@named_others.empty? %>
- <form method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>/move"
- class="face-move-form">
- <input type="hidden" name="rel" value="<%= ERB::Util.html_escape(rel) %>">
- <input type="hidden" name="box" value="<%= ERB::Util.html_escape(box.to_json) %>">
- <select name="to" class="face-move-select"
- onchange="if(this.value) this.form.submit()">
- <option value=""><%= @is_pool ? 'Assign to…' : 'Move to…' %></option>
- <% @named_others.each do |p| %>
- <option value="<%= ERB::Util.html_escape(p[:uuid]) %>"><%= ERB::Util.html_escape(p[:name]) %></option>
- <% end %>
- <% unless @is_pool %>
- <option value="new">New person</option>
- <option value="remove">Move to pool</option>
- <% end %>
- <option value="blacklist">Blacklist</option>
- </select>
- </form>
- <% end %>
- </div>
- <% end %>
+ <% end %>
+ </div>
</div>
-</div>
+
+</div><%# .person-detail-wrap %>
<div id="face-hover-preview"><img alt=""></div>
<script>
(function () {
- const preview = document.getElementById('face-hover-preview');
- const pimg = preview.querySelector('img');
-
+ // ── Hover preview ────────────────────────────────────────────────────────
+ var preview = document.getElementById('face-hover-preview');
+ var pimg = preview.querySelector('img');
function reposition(e) {
- const pw = 312, ph = 312;
- let x = e.clientX + 18, y = e.clientY + 18;
+ var pw = 312, ph = 312;
+ var x = e.clientX + 18, y = e.clientY + 18;
if (x + pw > window.innerWidth) x = e.clientX - pw - 8;
if (y + ph > window.innerHeight) y = e.clientY - ph - 8;
- preview.style.left = x + 'px';
- preview.style.top = y + 'px';
+ preview.style.left = x + 'px'; preview.style.top = y + 'px';
+ }
+ document.querySelectorAll('.face-detail-thumb').forEach(function (el) {
+ el.addEventListener('mouseenter', function (e) {
+ pimg.src = el.dataset.thumb; preview.style.display = 'block'; reposition(e);
+ });
+ el.addEventListener('mousemove', reposition);
+ el.addEventListener('mouseleave', function () { preview.style.display = 'none'; });
+ });
+
+ // ── Selection ────────────────────────────────────────────────────────────
+ var selected = new Set();
+
+ function updatePanel() {
+ var n = selected.size;
+ document.getElementById('bulk-n').textContent = n;
+ document.getElementById('bulk-count').style.display = n ? '' : 'none';
+ document.getElementById('bulk-hint').style.display = n ? 'none' : '';
+ document.getElementById('bulk-form').style.display = n ? '' : 'none';
}
- // Duplicate name check
+ document.querySelectorAll('.selectable-face').forEach(function (card) {
+ card.addEventListener('click', function (e) {
+ if (e.target.closest('.face-photo-link') && !e.target.closest('.select-checkbox')) return;
+ e.preventDefault();
+ var key = card.dataset.entry;
+ if (selected.has(key)) { selected.delete(key); card.classList.remove('selected'); }
+ else { selected.add(key); card.classList.add('selected'); }
+ updatePanel();
+ });
+ });
+
+ document.getElementById('bulk-clear').addEventListener('click', function (e) {
+ e.preventDefault();
+ selected.clear();
+ document.querySelectorAll('.selectable-face.selected').forEach(function (c) { c.classList.remove('selected'); });
+ updatePanel();
+ });
+
+ window.confirmBulk = function () {
+ var action = document.getElementById('bulk-action-select').value;
+ if (!action) { alert('Please choose an action.'); return false; }
+ var c = document.getElementById('bulk-entries');
+ c.innerHTML = '';
+ selected.forEach(function (entry) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden'; inp.name = 'entries[]'; inp.value = entry;
+ c.appendChild(inp);
+ });
+ var label = document.getElementById('bulk-action-select').selectedOptions[0].text;
+ return confirm(selected.size + ' face(s) → ' + label + '. Continue?');
+ };
+
+ // ── Duplicate name check ─────────────────────────────────────────────────
var existingNames = <%= @existing_names_json || '[]' %>;
var nameForm = document.getElementById('name-form');
var nameInput = document.getElementById('name-input');
@@ -168,7 +218,7 @@
if (match) {
e.preventDefault();
var msg = '"' + match.name + '" already exists.\n\n'
- + 'OK — add this photo to ' + match.name + "'s cluster\n"
+ + 'OK — add this face to ' + match.name + "'s cluster\n"
+ 'Cancel — create a new separate person named "' + val + '"';
if (confirm(msg)) {
document.getElementById('merge-into-uuid').value = match.uuid;
@@ -180,16 +230,5 @@
}
});
}
-
- document.querySelectorAll('.face-detail-thumb').forEach(function (el) {
- el.querySelector('img').style.cursor = 'zoom-in';
- el.addEventListener('mouseenter', function (e) {
- pimg.src = el.dataset.thumb;
- preview.style.display = 'block';
- reposition(e);
- });
- el.addEventListener('mousemove', reposition);
- el.addEventListener('mouseleave', function () { preview.style.display = 'none'; });
- });
})();
</script>