From b47fdda4fe1bf6fe90d0ba30eedac435dde7c034 Mon Sep 17 00:00:00 2001 From: Ken D'Ambrosio Date: Tue, 12 May 2026 14:45:00 +0000 Subject: Add photo counts, EXIF details, video duration badges, slideshow launcher UI - Album cards show recursive photo count (bubbles up through sub-albums). - Lightbox info panel shows camera, aperture, shutter speed, and ISO; update.rb now extracts and stores these EXIF fields. - Video thumbnail cards show a duration badge (e.g. "1:23"). - Slideshow launcher redesigned: button on its own line, with Shuffle / Full screen / Interval options on a second line, all inside a rounded border to make the grouping clear. - Fixed album-actions alignment so Interval sits level with the checkboxes. Co-Authored-By: Claude Sonnet 4.6 --- app.rb | 30 ++++++++++++++++++++++++++++++ public/css/style.css | 7 +++++-- public/js/album.js | 4 ++++ scripts/update.rb | 26 ++++++++++++++++++++------ views/album.erb | 13 ++++++++----- 5 files changed, 67 insertions(+), 13 deletions(-) diff --git a/app.rb b/app.rb index 2cd40b2..1fc5f69 100644 --- a/app.rb +++ b/app.rb @@ -96,6 +96,11 @@ helpers do taken_at: meta['taken_at'], width: meta['width'], height: meta['height'], + duration: meta['duration'], + camera: meta['camera'], + aperture: meta['aperture'], + shutter: meta['shutter'], + iso: meta['iso'], } end @@ -118,11 +123,36 @@ helpers do name: name, title: sub_data['title'] || name, cover: album_cover(sub_dir, sub_data), + count: media_count(sub_dir, sub_data), } end data['sort_reverse'] ? albums.reverse : albums end + def media_count(dir, data) + files = data['files'] || {} + direct = Dir.children(dir) + .count { |n| MEDIA_EXTS.include?(File.extname(n).downcase.delete_prefix('.')) && + (admin? || (files[n] || {}).fetch('visible', true)) } + sub_total = Dir.children(dir) + .select { |n| !n.start_with?('.') && File.directory?(File.join(dir, n)) } + .sum do |n| + sub_dir = File.join(dir, n) + sub_data = load_album(sub_dir) + next 0 if sub_data['visible'] == false && !admin? + media_count(sub_dir, sub_data) + end + direct + sub_total + end + + def format_duration(secs) + return nil unless secs + s = secs.to_i + h, s = s.divmod(3600) + m, s = s.divmod(60) + h > 0 ? format('%d:%02d:%02d', h, m, s) : format('%d:%02d', m, s) + end + def album_cover(dir, data) cover = data['cover'] return cover if cover && cover != '__random__' && File.exist?(File.join(dir, cover)) diff --git a/public/css/style.css b/public/css/style.css index b0c73dd..b5d713e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -81,7 +81,8 @@ main { max-width: 1400px; margin: 0 auto; padding: 24px; } .breadcrumbs .sep { margin: 0 4px; } .album-desc { color: var(--text-dim); margin-bottom: 10px; } -.album-actions { margin-top: 10px; } +.ss-launcher { margin-top: 10px; display: inline-flex; flex-direction: column; gap: 8px; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; } +.ss-opts { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } /* ── Grid ──────────────────────────────────────────────────────────────── */ .grid-section { margin-bottom: 32px; } @@ -147,7 +148,9 @@ main { max-width: 1400px; margin: 0 auto; padding: 24px; } pointer-events: none; } -.album-label { padding: 6px 8px; font-size: .88rem; font-weight: 500; color: var(--text); } +.album-label { padding: 6px 8px; font-size: .88rem; font-weight: 500; color: var(--text); display: flex; align-items: baseline; justify-content: space-between; gap: 6px; } +.album-count { font-size: .75rem; font-weight: 400; color: var(--text-dim); white-space: nowrap; } +.duration-badge { position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,.65); color: #fff; font-size: .72rem; padding: 2px 5px; border-radius: 3px; pointer-events: none; } #album-search { display: block; width: 100%; max-width: 280px; margin-bottom: 14px; padding: 5px 10px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: .9rem; } .card-meta { padding: 6px 8px; } .card-caption { font-size: .8rem; color: var(--text-dim); } diff --git a/public/js/album.js b/public/js/album.js index c3f775f..3fca29b 100644 --- a/public/js/album.js +++ b/public/js/album.js @@ -45,6 +45,10 @@ function lbBuildInfo(e) { rows.push(['Date', d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })]); } if (e.width && e.height) rows.push(['Dimensions', `${e.width} × ${e.height}`]); + if (e.camera) rows.push(['Camera', e.camera]); + if (e.aperture) rows.push(['Aperture', e.aperture]); + if (e.shutter) rows.push(['Shutter', e.shutter]); + if (e.iso) rows.push(['ISO', e.iso]); document.getElementById('lb-info-dl').innerHTML = rows.map(([k, v]) => `
${k}
${v}
`).join(''); document.getElementById('lb-info-panel').classList.add('hidden'); diff --git a/scripts/update.rb b/scripts/update.rb index 9052341..0b052ba 100644 --- a/scripts/update.rb +++ b/scripts/update.rb @@ -80,15 +80,29 @@ end # ── Metadata enrichment ──────────────────────────────────────────────────────── def enrich_image(full, name, meta) - # EXIF date (skip if already recorded) - if meta['taken_at'].nil? + needs_exif = meta['taken_at'].nil? || meta['camera'].nil? || + meta['aperture'].nil? || meta['shutter'].nil? || meta['iso'].nil? + if needs_exif begin exif = MiniExiftool.new(full, numerical: false) - raw = exif.date_time_original || exif.create_date || exif.date_time - if raw - meta['taken_at'] = raw.respond_to?(:strftime) ? raw.strftime('%Y-%m-%dT%H:%M:%S') : raw.to_s - puts " #{name}: taken_at = #{meta['taken_at']}" + + if meta['taken_at'].nil? + raw = exif.date_time_original || exif.create_date || exif.date_time + if raw + meta['taken_at'] = raw.respond_to?(:strftime) ? raw.strftime('%Y-%m-%dT%H:%M:%S') : raw.to_s + puts " #{name}: taken_at = #{meta['taken_at']}" + end + end + + if meta['camera'].nil? + make = exif.make.to_s.strip + model = exif.model.to_s.strip + cam = model.downcase.start_with?(make.downcase) ? model : [make, model].reject(&:empty?).join(' ') + meta['camera'] = cam.empty? ? nil : cam end + meta['aperture'] ||= exif.f_number ? "f/#{exif.f_number}" : nil + meta['shutter'] ||= exif.exposure_time&.to_s + meta['iso'] ||= (exif.iso_speed_ratings || exif.iso)&.to_i rescue StandardError => e warn " #{name}: EXIF error — #{e.message}" end diff --git a/views/album.erb b/views/album.erb index ee6e548..3dc4fdb 100644 --- a/views/album.erb +++ b/views/album.erb @@ -10,14 +10,16 @@ <% end %>

<%= @title %>

<% if @desc %>

<%= @desc %>

<% end %> -
- <% if @rel.empty? ? @albums.any? : @entries.any? { |e| %i[image video].include?(e[:type]) } %> - Slideshow + <% if @rel.empty? ? @albums.any? : @entries.any? { |e| %i[image video].include?(e[:type]) } %> +
+ Slideshow +
- <% end %> +
+ <% end %>
<% unless @albums.empty? %> @@ -39,7 +41,7 @@
📁
<% end %> -
<%= a[:title] %>
+
<%= a[:title] %><% if a[:count] && a[:count] > 0 %><%= a[:count] %><% end %>
<% end %> @@ -62,6 +64,7 @@
<%= ERB::Util.html_escape(e[:title]) %> <% if e[:type] == :video %><% end %> + <% if e[:type] == :video && e[:duration] %><%= format_duration(e[:duration]) %><% end %> <% if e[:type] == :audio %><% end %>
<% if e[:caption] %> -- cgit v1.2.3