summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKen D'Ambrosio <ken@jots.org>2026-05-12 14:45:00 +0000
committerKen D'Ambrosio <ken@jots.org>2026-05-12 14:45:00 +0000
commitb47fdda4fe1bf6fe90d0ba30eedac435dde7c034 (patch)
tree81f1921bedaf9e86e65d511c6bacbddd4697c7df
parent67a19fed3ff7ff9a40d489863fcef432cdba0913 (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.rb30
-rw-r--r--public/css/style.css7
-rw-r--r--public/js/album.js4
-rw-r--r--scripts/update.rb26
-rw-r--r--views/album.erb13
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]) => `<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] %>