summaryrefslogtreecommitdiffstats
path: root/views/admin/people.erb
diff options
context:
space:
mode:
Diffstat (limited to 'views/admin/people.erb')
-rw-r--r--views/admin/people.erb111
1 files changed, 111 insertions, 0 deletions
diff --git a/views/admin/people.erb b/views/admin/people.erb
new file mode 100644
index 0000000..6b7a4d0
--- /dev/null
+++ b/views/admin/people.erb
@@ -0,0 +1,111 @@
+<div class="admin-people">
+ <div class="admin-nav">
+ <a href="/admin/edit/" class="btn btn-sm">← Albums</a>
+ <% unless @clusters.empty? %>
+ <a href="/people" class="btn btn-sm" target="_blank">Public Page ↗</a>
+ <% end %>
+ </div>
+
+ <h1>People</h1>
+
+ <section class="admin-update">
+ <h2>Clustering</h2>
+ <p class="update-hint">
+ <%= @total %> cluster<%= @total == 1 ? '' : 's' %> &middot;
+ <%= @named_count %> named
+ <% if @updated_at %>&middot; last run <%= @updated_at %><% end %>
+ </p>
+ <p class="update-hint">
+ Re-clustering reads all faces.json files and groups similar faces together.
+ Existing names are preserved. Run again whenever the face daemon has processed a significant batch of new photos.
+ </p>
+ <button id="cluster-btn" class="btn" onclick="startRecluster()">Re-cluster Faces</button>
+ <div id="cluster-panel" class="update-panel hidden">
+ <div id="cluster-status" class="update-status"></div>
+ <pre id="cluster-log" class="update-log"></pre>
+ </div>
+ </section>
+
+ <% if @clusters.empty? %>
+ <p class="empty-album" style="margin-top: 40px">
+ No face data yet — the face daemon is still processing, or run Re-cluster once it has finished a pass.
+ </p>
+ <% else %>
+ <div class="people-admin-list">
+ <% @clusters.each do |c| %>
+ <div class="people-admin-row<%= c[:name] ? '' : ' unnamed-cluster' %>">
+ <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">
+ <% end %>
+ <% if c[:count] > c[:samples].length %>
+ <span class="face-more">+<%= c[:count] - c[:samples].length %></span>
+ <% end %>
+ </div>
+ <div class="people-admin-meta">
+ <span class="face-count"><%= c[:count] %> photo<%= c[:count] == 1 ? '' : 's' %></span>
+ <form method="post" action="/admin/people/<%= ERB::Util.url_encode(c[:uuid]) %>" class="name-form">
+ <input type="text" name="name"
+ value="<%= ERB::Util.html_escape(c[:name].to_s) %>"
+ placeholder="Enter name…" class="name-input">
+ <button type="submit" class="btn btn-sm">Save</button>
+ <% if c[:slug] %>
+ <a href="/people/<%= ERB::Util.url_encode(c[:slug]) %>" class="btn btn-sm" target="_blank">↗</a>
+ <% end %>
+ </form>
+ </div>
+ </div>
+ <% end %>
+ <% if @total > @clusters.length %>
+ <p class="update-hint" style="margin-top: 14px">
+ Showing <%= @clusters.length %> of <%= @total %> clusters (named first, then largest unnamed).
+ </p>
+ <% end %>
+ </div>
+ <% end %>
+</div>
+
+<script>
+async function startRecluster() {
+ const btn = document.getElementById('cluster-btn');
+ const panel = document.getElementById('cluster-panel');
+ const log = document.getElementById('cluster-log');
+ const status = document.getElementById('cluster-status');
+
+ btn.disabled = true;
+ btn.textContent = 'Clustering…';
+ log.textContent = '';
+ status.textContent = 'Running…';
+ status.className = 'update-status running';
+ panel.classList.remove('hidden');
+
+ const res = await fetch('/admin/people/recluster', { method: 'POST' });
+ const { job_id } = await res.json();
+
+ let seen = 0;
+ const poll = setInterval(async () => {
+ const r = await fetch('/admin/update/' + job_id);
+ const data = await r.json();
+ const fresh = data.lines.slice(seen);
+ if (fresh.length) {
+ log.textContent += fresh.join('\n') + '\n';
+ log.scrollTop = log.scrollHeight;
+ seen = data.lines.length;
+ }
+ if (data.status !== 'running') {
+ clearInterval(poll);
+ btn.disabled = false;
+ btn.textContent = 'Re-cluster Faces';
+ if (data.status === 'done') {
+ status.textContent = 'Done ✓';
+ status.className = 'update-status done';
+ setTimeout(() => location.reload(), 1200);
+ } else {
+ status.textContent = 'Error ✗';
+ status.className = 'update-status error';
+ }
+ }
+ }, 1500);
+}
+</script>