summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKen D'Ambrosio <ken@jots.org>2026-06-08 21:09:47 +0000
committerKen D'Ambrosio <ken@jots.org>2026-06-08 21:09:47 +0000
commit7f6325fe213ed46ff5479ffd34b0e212426d48f2 (patch)
tree46430a22dc791ce5c8018eeb7bce2c857fd17cd6
parent00f63c03b7c5de68aea6a2305886bc1953a722b6 (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.rb77
-rw-r--r--public/css/style.css32
-rw-r--r--views/admin/people.erb31
-rw-r--r--views/admin/person_detail.erb107
4 files changed, 245 insertions, 2 deletions
diff --git a/app.rb b/app.rb
index 3978c54..7ce9d22 100644
--- a/app.rb
+++ b/app.rb
@@ -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>