diff options
| author | Ken D'Ambrosio <ken@jots.org> | 2026-05-12 14:45:00 +0000 |
|---|---|---|
| committer | Ken D'Ambrosio <ken@jots.org> | 2026-05-12 14:45:00 +0000 |
| commit | b47fdda4fe1bf6fe90d0ba30eedac435dde7c034 (patch) | |
| tree | 81f1921bedaf9e86e65d511c6bacbddd4697c7df | |
| parent | 67a19fed3ff7ff9a40d489863fcef432cdba0913 (diff) | |
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 <noreply@anthropic.com>
| -rw-r--r-- | app.rb | 30 | ||||
| -rw-r--r-- | public/css/style.css | 7 | ||||
| -rw-r--r-- | public/js/album.js | 4 | ||||
| -rw-r--r-- | scripts/update.rb | 26 | ||||
| -rw-r--r-- | views/album.erb | 13 |
5 files changed, 67 insertions, 13 deletions
@@ -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]) => `<dt>${k}</dt><dd>${v}</dd>`).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 %> <h1><%= @title %></h1> <% if @desc %><p class="album-desc"><%= @desc %></p><% end %> - <div class="album-actions"> - <% if @rel.empty? ? @albums.any? : @entries.any? { |e| %i[image video].include?(e[:type]) } %> - <a href="/slideshow/<%= @rel %>" id="ss-launch" data-base="/slideshow/<%= @rel %>" class="btn">Slideshow</a> + <% if @rel.empty? ? @albums.any? : @entries.any? { |e| %i[image video].include?(e[:type]) } %> + <div class="ss-launcher"> + <a href="/slideshow/<%= @rel %>" id="ss-launch" data-base="/slideshow/<%= @rel %>" class="btn">Slideshow</a> + <div class="ss-opts"> <label class="ss-opt-label"><input type="checkbox" id="ss-opt-shuffle"> Shuffle</label> <label class="ss-opt-label"><input type="checkbox" id="ss-opt-fullscreen"> Full screen</label> <label class="ss-opt-label">Interval <input type="number" id="ss-opt-interval" value="5" min="1" max="60" step="1"> s</label> - <% end %> + </div> </div> + <% end %> </div> <% unless @albums.empty? %> @@ -39,7 +41,7 @@ <div class="thumb-placeholder">📁</div> <% end %> </div> - <div class="album-label"><%= a[:title] %></div> + <div class="album-label"><%= a[:title] %><% if a[:count] && a[:count] > 0 %><span class="album-count"><%= a[:count] %></span><% end %></div> </a> <% end %> </div> @@ -62,6 +64,7 @@ <div class="thumb-wrap"> <img src="/thumb/<%= file_rel %>" alt="<%= ERB::Util.html_escape(e[:title]) %>" loading="lazy"> <% if e[:type] == :video %><span class="type-badge video-badge">▶</span><% end %> + <% if e[:type] == :video && e[:duration] %><span class="duration-badge"><%= format_duration(e[:duration]) %></span><% end %> <% if e[:type] == :audio %><span class="type-badge audio-badge">♪</span><% end %> </div> <% if e[:caption] %> |
