summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app.rb113
-rw-r--r--public/css/style.css46
-rw-r--r--views/person.erb129
3 files changed, 280 insertions, 8 deletions
diff --git a/app.rb b/app.rb
index 9e9cb9d..ece8b18 100644
--- a/app.rb
+++ b/app.rb
@@ -1044,7 +1044,8 @@ end
get '/people/:slug' do
data = load_people_data
- person = (data['people'] || {}).values.find { |p| p['slug'] == params[:slug] }
+ people = data['people'] || {}
+ person = people.values.find { |p| p['slug'] == params[:slug] }
halt 404 unless person
seen = {}
@@ -1057,11 +1058,121 @@ get '/people/:slug' do
@title = person['name']
@person_name = person['name']
+ @person_slug = params[:slug]
@photos = photos
@count = photos.length
+
+ if admin?
+ @named_people = people
+ .reject { |_, p| p['slug'] == params[:slug] || p['name'].nil? || p['name'] == '__pool__' }
+ .map { |k, p| { uuid: k, name: p['name'] } }
+ .sort_by { |x| x[:name].downcase }
+ @album_dirs = ([MEDIA_ROOT] + Dir.glob("#{MEDIA_ROOT}/**/*/").sort)
+ .map { |d| d.delete_prefix(MEDIA_ROOT).delete_prefix('/') }
+ .select { |d| load_album(File.join(MEDIA_ROOT, d.empty? ? '' : d))['visible'] != false }
+ end
+
erb :person
end
+post '/admin/people/:slug/bulk_reassign' do
+ require_admin!
+ rels = Array(params['rels[]'])
+ to_uuid = params['to_uuid'].to_s.strip
+ halt 400 if rels.empty? || to_uuid.empty?
+
+ data = load_people_data
+ people = data['people'] || {}
+ src = people.find { |_, p| p['slug'] == params[:slug] }
+ halt 404 unless src
+ src_uuid, src_data = src
+ halt 404 unless people.key?(to_uuid)
+
+ to_move = (src_data['members'] || []).select { |m| rels.include?(m['rel']) }
+ src_data['members'] -= to_move
+ people[to_uuid]['members'].concat(to_move)
+ people.delete(src_uuid) if src_data['members'].empty?
+ data['people'] = people
+ atomic_write(PEOPLE_PATH, JSON.pretty_generate(data))
+
+ redirect "/people/#{params[:slug]}"
+end
+
+post '/admin/photos/move_album' do
+ require_admin!
+ rels = Array(params['rels[]'])
+ dst_rel = params['dst_rel'].to_s.strip.gsub(/\/+$/, '')
+ halt 400 if rels.empty? || dst_rel =~ /\.\./
+
+ dst_abs = dst_rel.empty? ? MEDIA_ROOT : File.expand_path(dst_rel, MEDIA_ROOT)
+ halt 400 unless dst_abs.start_with?("#{MEDIA_ROOT}/") || dst_abs == MEDIA_ROOT
+ halt 400 unless File.directory?(dst_abs)
+
+ moved = []
+ rels.each do |rel|
+ rel = rel.gsub(/\.\./, '')
+ src_abs = File.expand_path(rel, MEDIA_ROOT)
+ next unless src_abs.start_with?("#{MEDIA_ROOT}/") && File.file?(src_abs)
+ filename = File.basename(src_abs)
+ src_dir = File.dirname(src_abs)
+ dst_file = File.join(dst_abs, filename)
+ next if src_dir == dst_abs
+ FileUtils.mv(src_abs, dst_file)
+ moved << { old_rel: rel, new_rel: [dst_rel, filename].reject(&:empty?).join('/'),
+ src_dir: src_dir, filename: filename }
+ end
+
+ # Update album.json in affected source directories
+ moved.group_by { |m| m[:src_dir] }.each do |src_dir, entries|
+ aj_path = File.join(src_dir, 'album.json')
+ aj = File.exist?(aj_path) ? JSON.parse(File.read(aj_path)) : default_album
+ entries.each { |e| (aj['files'] || {}).delete(e[:filename]) }
+ atomic_write(aj_path, JSON.pretty_generate(aj))
+
+ fj_path = File.join(src_dir, 'faces.json')
+ if File.exist?(fj_path)
+ fj = JSON.parse(File.read(fj_path))
+ entries.each { |e| fj.delete(e[:filename]) }
+ atomic_write(fj_path, JSON.pretty_generate(fj))
+ end
+ end
+
+ # Update faces.json in destination
+ unless moved.empty?
+ dst_fj_path = File.join(dst_abs, 'faces.json')
+ dst_fj = File.exist?(dst_fj_path) ? JSON.parse(File.read(dst_fj_path)) : {}
+ moved.each do |m|
+ src_fj = File.join(m[:src_dir], 'faces.json')
+ if File.exist?(src_fj)
+ src_data = JSON.parse(File.read(src_fj))
+ dst_fj[m[:filename]] = src_data[m[:filename]] if src_data[m[:filename]]
+ end
+ end
+ atomic_write(dst_fj_path, JSON.pretty_generate(dst_fj))
+ end
+
+ # Update people.json rel paths
+ unless moved.empty?
+ rel_map = moved.each_with_object({}) { |m, h| h[m[:old_rel]] = m[:new_rel] }
+ pd = load_people_data
+ (pd['people'] || {}).each_value do |p|
+ (p['members'] || []).each do |m|
+ m['rel'] = rel_map[m['rel']] if rel_map[m['rel']]
+ end
+ end
+ (pd['blacklist'] || []).each do |m|
+ m['rel'] = rel_map[m['rel']] if rel_map[m['rel']]
+ end
+ pool = (pd['people'] || {})['__pool__']
+ (pool['members'] || []).each { |m| m['rel'] = rel_map[m['rel']] if rel_map[m['rel']] } if pool
+ atomic_write(PEOPLE_PATH, JSON.pretty_generate(pd))
+ end
+
+ return_to = params['return_to'].to_s
+ return_to = '/people' unless return_to.start_with?('/')
+ redirect return_to
+end
+
# ── Thumbnail generation ───────────────────────────────────────────────────────
def generate_thumb(source, dest, ext)
diff --git a/public/css/style.css b/public/css/style.css
index f522bff..93345ad 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -548,6 +548,52 @@ tr.delete-marked td { background: rgba(192,57,43,.08); }
.person-name { padding: 7px 10px 2px; font-size: .9rem; font-weight: 500; color: var(--text); }
.person-count { padding: 0 10px 8px; font-size: .75rem; color: var(--text-dim); }
+/* ── Person page bulk selection ────────────────────────────────────────── */
+.person-page-wrap { display: flex; align-items: flex-start; gap: 0; }
+.bulk-panel {
+ width: 220px; flex-shrink: 0; position: sticky; top: 16px;
+ background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 14px 16px; margin-right: 20px;
+}
+.bulk-panel-title { font-size: .95rem; font-weight: 600; margin: 0 0 8px; }
+.bulk-panel-hint { font-size: .82rem; color: var(--text-dim); margin: 0; }
+.bulk-selected-count { font-size: .9rem; margin: 0; }
+.bulk-sep { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
+.bulk-label { display: block; font-size: .8rem; color: var(--text-dim); margin-bottom: 4px; }
+.bulk-select, .bulk-input {
+ width: 100%; background: var(--bg3); border: 1px solid var(--border);
+ border-radius: var(--radius); color: var(--text); padding: 5px 8px;
+ font-size: .82rem; margin-bottom: 8px; box-sizing: border-box;
+}
+.bulk-select:focus, .bulk-input:focus { border-color: var(--accent); outline: none; }
+.bulk-action-btn { width: 100%; text-align: center; }
+.person-page-main { flex: 1; min-width: 0; }
+
+/* Selectable photo cards */
+.selectable-card { cursor: pointer; position: relative; }
+.selectable-card .thumb-wrap { position: relative; }
+.select-checkbox {
+ position: absolute; top: 6px; left: 6px;
+ width: 20px; height: 20px; border-radius: 4px;
+ border: 2px solid rgba(255,255,255,.7);
+ background: rgba(0,0,0,.35);
+ display: flex; align-items: center; justify-content: center;
+ opacity: 0; transition: opacity .12s;
+ pointer-events: none;
+}
+.selectable-card:hover .select-checkbox { opacity: 1; }
+.selectable-card.selected .select-checkbox {
+ opacity: 1; background: var(--accent); border-color: var(--accent);
+}
+.selectable-card.selected .select-checkbox::after {
+ content: ''; display: block;
+ width: 5px; height: 9px;
+ border: 2px solid #fff; border-top: none; border-left: none;
+ transform: rotate(45deg) translate(-1px, -1px);
+}
+.selectable-card.selected .thumb-wrap img { opacity: .75; }
+.selectable-card.selected { outline: 2px solid var(--accent); outline-offset: -2px; }
+
/* ── Admin upload ──────────────────────────────────────────────────────── */
.admin-upload { margin-top: 32px; }
.admin-upload h2 { font-size: 1rem; color: var(--text-dim); margin-bottom: 6px; }
diff --git a/views/person.erb b/views/person.erb
index 1bb25e2..b64dcbd 100644
--- a/views/person.erb
+++ b/views/person.erb
@@ -1,3 +1,57 @@
+<% if admin? %>
+<div class="person-page-wrap">
+ <aside class="bulk-panel" id="bulk-panel">
+ <h2 class="bulk-panel-title">Selection</h2>
+ <p class="bulk-panel-hint" id="bulk-hint">Click photos to select them.</p>
+ <p class="bulk-selected-count" id="bulk-count" style="display:none">
+ <strong id="bulk-n">0</strong> selected
+ &nbsp;<a href="#" id="bulk-clear" style="font-size:.8rem">Clear</a>
+ </p>
+
+ <div id="bulk-actions" style="display:none">
+ <hr class="bulk-sep">
+
+ <% unless @named_people.empty? %>
+ <form id="reassign-form" method="post"
+ action="/admin/people/<%= ERB::Util.url_encode(@person_slug) %>/bulk_reassign">
+ <label class="bulk-label">Reassign to person</label>
+ <select name="to_uuid" class="bulk-select" required>
+ <option value="">— Select person —</option>
+ <% @named_people.each do |p| %>
+ <option value="<%= ERB::Util.html_escape(p[:uuid]) %>"><%= ERB::Util.html_escape(p[:name]) %></option>
+ <% end %>
+ </select>
+ <div id="reassign-rels"></div>
+ <button type="submit" class="btn btn-sm bulk-action-btn"
+ onclick="return populateRels('reassign-rels') && confirm('Reassign ' + selectedRels.size + ' photo(s)?')">
+ Reassign
+ </button>
+ </form>
+ <% end %>
+
+ <form id="move-form" method="post" action="/admin/photos/move_album"
+ style="margin-top:16px">
+ <input type="hidden" name="return_to" value="/people/<%= ERB::Util.url_encode(@person_slug) %>">
+ <label class="bulk-label">Move to album</label>
+ <input type="text" name="dst_rel" list="album-dirs" class="bulk-input"
+ placeholder="album/path" autocomplete="off">
+ <datalist id="album-dirs">
+ <% (@album_dirs || []).each do |d| %>
+ <option value="<%= ERB::Util.html_escape(d) %>">
+ <% end %>
+ </datalist>
+ <div id="move-rels"></div>
+ <button type="submit" class="btn btn-sm bulk-action-btn"
+ onclick="return populateRels('move-rels') && confirm('Move ' + selectedRels.size + ' photo(s) to this album?')">
+ Move
+ </button>
+ </form>
+ </div>
+ </aside>
+
+ <div class="person-page-main">
+<% end %>
+
<div class="album-header">
<div class="breadcrumbs">
<a href="/people">People</a>
@@ -11,16 +65,77 @@
<% if @photos.empty? %>
<p class="empty-album">No photos found.</p>
<% else %>
- <div class="grid">
+ <div class="grid" id="photo-grid">
<% @photos.each do |photo| %>
<% album_url = photo[:dir_rel].empty? ? '/browse/' : "/browse/#{ERB::Util.html_escape(photo[:dir_rel])}" %>
- <a href="<%= album_url %>?photo=<%= ERB::Util.url_encode(photo[:filename]) %>"
- class="card" style="text-decoration:none">
- <div class="thumb-wrap">
- <img src="/thumb/<%= ERB::Util.html_escape(photo[:rel]) %>"
- loading="lazy" alt="<%= ERB::Util.html_escape(photo[:filename]) %>">
+ <% if admin? %>
+ <div class="card selectable-card" data-rel="<%= ERB::Util.html_escape(photo[:rel]) %>"
+ data-href="<%= album_url %>?photo=<%= ERB::Util.url_encode(photo[:filename]) %>">
+ <div class="thumb-wrap">
+ <img src="/thumb/<%= ERB::Util.html_escape(photo[:rel]) %>"
+ loading="lazy" alt="<%= ERB::Util.html_escape(photo[:filename]) %>">
+ <span class="select-checkbox" aria-hidden="true"></span>
+ </div>
</div>
- </a>
+ <% else %>
+ <a href="<%= album_url %>?photo=<%= ERB::Util.url_encode(photo[:filename]) %>"
+ class="card" style="text-decoration:none">
+ <div class="thumb-wrap">
+ <img src="/thumb/<%= ERB::Util.html_escape(photo[:rel]) %>"
+ loading="lazy" alt="<%= ERB::Util.html_escape(photo[:filename]) %>">
+ </div>
+ </a>
+ <% end %>
<% end %>
</div>
<% end %>
+
+<% if admin? %>
+ </div><%# .person-page-main %>
+</div><%# .person-page-wrap %>
+
+<script>
+var selectedRels = new Set();
+
+function updatePanel() {
+ var n = selectedRels.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-actions').style.display = n ? '' : 'none';
+}
+
+function populateRels(containerId) {
+ if (!selectedRels.size) { alert('No photos selected.'); return false; }
+ var c = document.getElementById(containerId);
+ c.innerHTML = '';
+ selectedRels.forEach(function(rel) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden'; inp.name = 'rels[]'; inp.value = rel;
+ c.appendChild(inp);
+ });
+ return true;
+}
+
+document.querySelectorAll('.selectable-card').forEach(function(card) {
+ card.addEventListener('click', function(e) {
+ if (e.target.closest('.select-checkbox') || e.shiftKey || e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ var rel = card.dataset.rel;
+ if (selectedRels.has(rel)) { selectedRels.delete(rel); card.classList.remove('selected'); }
+ else { selectedRels.add(rel); card.classList.add('selected'); }
+ updatePanel();
+ } else {
+ window.open(card.dataset.href, '_blank');
+ }
+ });
+});
+
+document.getElementById('bulk-clear').addEventListener('click', function(e) {
+ e.preventDefault();
+ selectedRels.clear();
+ document.querySelectorAll('.selectable-card.selected').forEach(function(c) { c.classList.remove('selected'); });
+ updatePanel();
+});
+</script>
+<% end %>