diff options
| author | Ken D'Ambrosio <ken@jots.org> | 2026-06-08 21:09:47 +0000 |
|---|---|---|
| committer | Ken D'Ambrosio <ken@jots.org> | 2026-06-08 21:09:47 +0000 |
| commit | 7f6325fe213ed46ff5479ffd34b0e212426d48f2 (patch) | |
| tree | 46430a22dc791ce5c8018eeb7bce2c857fd17cd6 | |
| parent | 00f63c03b7c5de68aea6a2305886bc1953a722b6 (diff) | |
Add people cluster detail page with face move/merge and hover preview
Each cluster in /admin/people now links to a detail page showing all
faces in a grid. From there you can rename the cluster, move individual
faces to another named person (or spin off a new cluster), or merge the
entire cluster into another. Hovering any face crop shows the original
full photo for context.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | app.rb | 77 | ||||
| -rw-r--r-- | public/css/style.css | 32 | ||||
| -rw-r--r-- | views/admin/people.erb | 31 | ||||
| -rw-r--r-- | views/admin/person_detail.erb | 107 |
4 files changed, 245 insertions, 2 deletions
@@ -895,6 +895,83 @@ post '/admin/people/:uuid' do redirect '/admin/people' end +get '/admin/people/:uuid' do + require_admin! + data = load_people_data + people = data['people'] || {} + halt 404 unless people.key?(params[:uuid]) + + pd = people[params[:uuid]] + @title = pd['name'] || 'Unnamed cluster' + @uuid = params[:uuid] + @name = pd['name'] + @members = pd['members'] || [] + @count = @members.length + + @named_others = people + .reject { |k, _| k == params[:uuid] } + .select { |_, v| v['name'] } + .map { |k, v| { uuid: k, name: v['name'] } } + .sort_by { |x| x[:name].downcase } + + @all_others = people + .reject { |k, _| k == params[:uuid] } + .map { |k, v| { uuid: k, name: v['name'] || "(unnamed · #{(v['members'] || []).length})" } } + .sort_by { |x| x[:name].downcase } + + erb :'admin/person_detail' +end + +post '/admin/people/:uuid/move' do + require_admin! + src = params[:uuid] + rel = params['rel'] + box = JSON.parse(params['box']).map(&:to_i) + to = params['to'].to_s.strip + + return redirect "/admin/people/#{src}" if to.empty? + + data = load_people_data + people = data['people'] || {} + halt 404 unless people.key?(src) + + member = people[src]['members'].find { |m| m['rel'] == rel && m['box'].map(&:to_i) == box } + halt 404 unless member + people[src]['members'].delete(member) + + if to == 'new' + new_uid = SecureRandom.uuid + people[new_uid] = { 'name' => nil, 'slug' => nil, 'members' => [member] } + else + halt 404 unless people.key?(to) + people[to]['members'] << member + end + + data['people'] = people + atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) + + people[src] ? redirect("/admin/people/#{src}") : redirect('/admin/people') +end + +post '/admin/people/:uuid/merge' do + require_admin! + src = params[:uuid] + into = params['into'].to_s.strip + + return redirect '/admin/people' if into.empty? + + data = load_people_data + people = data['people'] || {} + halt 404 unless people.key?(src) && people.key?(into) + + people[into]['members'].concat(people[src]['members']) + people.delete(src) + data['people'] = people + + atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) + redirect "/admin/people/#{into}" +end + get '/people' do data = load_people_data people = (data['people'] || {}).select { |_, p| p['name'] && p['slug'] } diff --git a/public/css/style.css b/public/css/style.css index 47147f4..7b0ce1a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -477,6 +477,38 @@ tr.delete-marked td { background: rgba(192,57,43,.08); } } .name-input:focus { border-color: var(--accent); outline: none; } +/* ── People — detail page ──────────────────────────────────────────────── */ +.face-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 10px; + margin-top: 16px; +} +.face-detail-card { + background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); + padding: 8px; display: flex; flex-direction: column; align-items: center; gap: 6px; +} +.face-detail-thumb img { border-radius: 50%; object-fit: cover; display: block; } +.face-detail-thumb a { display: block; line-height: 0; cursor: zoom-in; } +.face-move-form { display: flex; flex-direction: column; gap: 4px; width: 100%; } +.face-move-select { + width: 100%; + background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); padding: 3px 6px; font-size: .75rem; cursor: pointer; +} +.face-move-select:focus { border-color: var(--accent); outline: none; } + +/* Shared hover preview (face crops) */ +#face-hover-preview { + display: none; position: fixed; z-index: 9999; pointer-events: none; + padding: 4px; border: 2px solid var(--border); border-radius: var(--radius); + background: var(--bg2); box-shadow: 0 8px 24px rgba(0,0,0,.6); +} +#face-hover-preview img { + display: block; width: 300px; height: 300px; object-fit: cover; + border-radius: calc(var(--radius) - 2px); +} + /* ── People — public ───────────────────────────────────────────────────── */ .people-grid { margin-top: 8px; } diff --git a/views/admin/people.erb b/views/admin/people.erb index 6b7a4d0..c17e847 100644 --- a/views/admin/people.erb +++ b/views/admin/people.erb @@ -37,10 +37,14 @@ <div class="face-samples"> <% c[:samples].each do |s| %> <img src="/face/<%= ERB::Util.html_escape(s[:rel]) %>?box=<%= ERB::Util.html_escape(s[:box].join(',')) %>" - width="72" height="72" loading="lazy"> + width="72" height="72" loading="lazy" + class="face-list-thumb" + data-thumb="/thumb/<%= ERB::Util.html_escape(s[:rel]) %>"> <% end %> <% if c[:count] > c[:samples].length %> - <span class="face-more">+<%= c[:count] - c[:samples].length %></span> + <a href="/admin/people/<%= ERB::Util.url_encode(c[:uuid]) %>" class="face-more"> + +<%= c[:count] - c[:samples].length %> more + </a> <% end %> </div> <div class="people-admin-meta"> @@ -54,6 +58,7 @@ <a href="/people/<%= ERB::Util.url_encode(c[:slug]) %>" class="btn btn-sm" target="_blank">↗</a> <% end %> </form> + <a href="/admin/people/<%= ERB::Util.url_encode(c[:uuid]) %>" class="btn btn-sm">View all</a> </div> </div> <% end %> @@ -66,7 +71,29 @@ <% end %> </div> +<div id="face-hover-preview"><img alt=""></div> + <script> +(function () { + const preview = document.getElementById('face-hover-preview'); + const pimg = preview.querySelector('img'); + function reposition(e) { + const pw = 312, ph = 312; + let 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'; + } + document.querySelectorAll('.face-list-thumb').forEach(function (img) { + img.style.cursor = 'zoom-in'; + img.addEventListener('mouseenter', function (e) { + pimg.src = img.dataset.thumb; preview.style.display = 'block'; reposition(e); + }); + img.addEventListener('mousemove', reposition); + img.addEventListener('mouseleave', function () { preview.style.display = 'none'; }); + }); +})(); + async function startRecluster() { const btn = document.getElementById('cluster-btn'); const panel = document.getElementById('cluster-panel'); diff --git a/views/admin/person_detail.erb b/views/admin/person_detail.erb new file mode 100644 index 0000000..448aaff --- /dev/null +++ b/views/admin/person_detail.erb @@ -0,0 +1,107 @@ +<div class="admin-people"> + <div class="admin-nav"> + <a href="/admin/people" class="btn btn-sm">← All Clusters</a> + <% if @name %> + <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> + + <h1><%= @name ? ERB::Util.html_escape(@name) : 'Unnamed cluster' %></h1> + <p class="update-hint"><%= @count %> photo<%= @count == 1 ? '' : 's' %> in this cluster</p> + + <%# ── Name ────────────────────────────────────────────────────────────── %> + <section style="margin-bottom:20px"> + <form method="post" action="/admin/people/<%= ERB::Util.url_encode(@uuid) %>" class="name-form"> + <input type="text" name="name" value="<%= ERB::Util.html_escape(@name.to_s) %>" + placeholder="Enter name…" class="name-input" style="max-width:300px"> + <button type="submit" class="btn">Save Name</button> + </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> + <% end %> + </select> + <button type="submit" class="btn" + onclick="return confirm('Merge all <%= @count %> photos into the selected person?')">Merge</button> + </form> + </section> + <% end %> + + <%# ── Face grid ────────────────────────────────────────────────────────── %> + <p class="update-hint"> + Hover a face to see the full photo. + Click to open in the album. + <% unless @named_others.empty? %>Use the drop-down to move a face to another person.<% 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> + <% unless @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="">Move to…</option> + <% @named_others.each do |p| %> + <option value="<%= ERB::Util.html_escape(p[:uuid]) %>"><%= ERB::Util.html_escape(p[:name]) %></option> + <% end %> + <option value="new">New person</option> + </select> + </form> + <% end %> + </div> + <% end %> + </div> +</div> + +<div id="face-hover-preview"><img alt=""></div> + +<script> +(function () { + const preview = document.getElementById('face-hover-preview'); + const pimg = preview.querySelector('img'); + + function reposition(e) { + const pw = 312, ph = 312; + let 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'; + } + + 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> |
