summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-15 13:26:28 +0300
committerPaul Buetow <paul@buetow.org>2026-06-15 23:50:40 +0300
commit6e47246dbbc97821b97aa9c45352abc9c2a9aa4a (patch)
treeaa893f4ef633f76faab5731313de2d694a1de94e
parent3adeaa68af3bac73450a4156edb51fd1ec88347a (diff)
Stats/camera pages: blurred background, album-like camera thumbs, spacing
Three refinements to the stats site: - Stats and per-camera pages now get a random blurred background image (same seeded pick the album preview pages use) instead of a plain black page. Adds _stats_random_background + STATS_BLURS_DIR; falls back to black when no photos exist (e.g. isolated unit tests). - Per-camera thumbnails now behave like the main album: they link to each photo's album view page (navigation, details, EXIF tooltip) and carry the same seeded animation class, instead of linking to the raw image. render_album_pages records a photo -> "<page>-<preview>" map (ALBUM_VIEW_PAGE_BY_PHOTO) that the camera pages read; photos with no recorded view page fall back to the image. - Spacing: the camera leaderboard rows get padding + a separator line so the long wrapping camera names are distinguishable, and the stats sections, headings, bar rows, back-link, and camera grid get moderate extra spacing so nothing looks cramped. Tests updated for the new camera-thumbnail markup and extended to assert the stats background and the album view-page links; camera.tmpl comment corrected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rwxr-xr-xbin/shuriken78
-rw-r--r--share/templates/default/camera.tmpl9
-rw-r--r--share/templates/default/header.tmpl36
-rw-r--r--src/lib/album.source.sh15
-rw-r--r--src/lib/stats.source.sh63
-rwxr-xr-xtests/cli.sh27
6 files changed, 194 insertions, 34 deletions
diff --git a/bin/shuriken b/bin/shuriken
index 8fafb3b..b91a36d 100755
--- a/bin/shuriken
+++ b/bin/shuriken
@@ -1512,6 +1512,13 @@ maybe_shuffle() {
}
# Inlined from src/lib/album.source.sh
+# Maps each album photo filename to its view-page basename ("<page>-<preview>")
+# as assigned during render_album_pages. The stats camera pages read this so
+# their thumbnails link into the album view (with navigation, details, and the
+# EXIF tooltip) exactly like the main album, instead of the raw image. Declared
+# globally so it always exists for callers even when no album was rendered.
+declare -gA ALBUM_VIEW_PAGE_BY_PHOTO=()
+
album_photo_files() {
local -r photos_dir="$1"; shift
@@ -2255,6 +2262,10 @@ render_album_pages() {
# shellcheck disable=SC2034
local -a rendered_view_pages=()
+ # Rebuild the photo -> view-page map for this album from scratch so a
+ # re-generate (or a smaller incoming set) does not keep stale entries.
+ ALBUM_VIEW_PAGE_BY_PHOTO=()
+
name=$(album_page_name "$num")
start_preview_page \
@@ -2299,6 +2310,10 @@ render_album_pages() {
render_failed
record_rendered_view_page rendered_view_pages rendered_last_views \
"$num" "$i"
+ # Read later by the stats camera pages (render_camera_pages); shellcheck
+ # cannot see that cross-function use.
+ # shellcheck disable=SC2034
+ ALBUM_VIEW_PAGE_BY_PHOTO["$photo"]="$num-$i"
done < <(album_photo_files "$photos_dir")
finish_preview_page "$name" "$html_dir" "$backhref" "$tarball_name"
@@ -3391,15 +3406,22 @@ _stats_bar_row() {
}
# Open a <section> with an escaped heading and the <ul> bar container. Split from
-# the row emitters so every section shares identical chrome.
+# the row emitters so every section shares identical chrome. An optional second
+# argument adds an extra CSS class to the <ul> (e.g. the leaderboard uses it to
+# space out and separate its rows of long, wrapping camera names).
_stats_section_open() {
local -r heading="$1"; shift
+ local -r list_class="${1:-}"
local heading_html
+ local ul_class='stats-bars'
+ if [ -n "$list_class" ]; then
+ ul_class+=" $list_class"
+ fi
heading_html=$(_html_escape "$heading")
printf '<section class="stats-section">\n'
printf '<h2>%s</h2>\n' "$heading_html"
- printf '<ul class="stats-bars">\n'
+ printf '<ul class="%s">\n' "$ul_class"
}
_stats_section_close() {
@@ -3422,7 +3444,7 @@ _stats_render_camera_section() {
return
fi
max=$(_stats_max_count STATS_CAMERAS)
- _stats_section_open 'Camera leaderboard'
+ _stats_section_open 'Camera leaderboard' 'stats-leaderboard'
while IFS= read -r label; do
label_html=$(_html_escape "$label")
slug="${STATS_CAMERA_SLUGS[$label]}"
@@ -3601,18 +3623,31 @@ _stats_build_body() {
# html_dir is the dist-relative output directory (top-level album: "."),
# backhref is the relative path back to the album root ("." for a top-level
# stats.html), and page_name defaults to "stats" -> stats.html.
+# Pick a seeded-random photo for a stats/camera page's blurred background, the
+# same way the album preview pages do. The context seeds the choice so each page
+# gets a stable (per RANDOM_SEED) but varied background. Degrades to an empty
+# string (plain black background) when no photos exist, e.g. unit tests that
+# render the page without a populated photos directory.
+_stats_random_background() {
+ local -r context="$1"; shift
+
+ randomphoto "$STATS_CAMERA_PHOTOS_DIR" "$context" 2>/dev/null || true
+}
+
render_stats_page() {
local -r html_dir="$1"; shift
local -r backhref="$1"; shift
local -r page_name="${1:-stats}"
local stats_body
+ local background_image
stats_body=$(_stats_build_body)
+ background_image=$(_stats_random_background "$page_name.html")
template header "$page_name.html" \
html_dir "$html_dir" \
backhref "$backhref" \
- blurs_dir '' \
- background_image '' \
+ blurs_dir "$STATS_BLURS_DIR" \
+ background_image "$background_image" \
show_header_bar 'yes'
template stats "$page_name.html" \
html_dir "$html_dir" \
@@ -3651,6 +3686,9 @@ render_stats_page() {
# pages link to the same assets the album pages do.
declare -gr STATS_CAMERA_PHOTOS_DIR='photos'
declare -gr STATS_CAMERA_THUMBS_DIR='thumbs'
+# Dist-relative directory of blurred images used for the page background, the
+# same one render_album_pages passes to the album preview pages.
+declare -gr STATS_BLURS_DIR='blurs'
# Emit one thumbnail anchor for the camera grid: a thumb image (from thumbs/)
# wrapped in a link to the full-size image (from photos/), both resolved through
@@ -3661,12 +3699,28 @@ _stats_camera_thumbnail() {
local -r backhref_html="$1"; shift
local -r photo="$1"; shift
local photo_html
+ local animation_class
+ local view_page
photo_html=$(_html_escape "$photo")
- printf ' <a href="%s/%s/%s">' \
- "$backhref_html" "$STATS_CAMERA_PHOTOS_DIR" "$photo_html"
- printf '<img class="thumb" src="%s/%s/%s" /></a>\n' \
- "$backhref_html" "$STATS_CAMERA_THUMBS_DIR" "$photo_html"
+ # Same seeded animation the album thumbnail uses for this photo, so camera
+ # pages animate identically.
+ animation_class=$(random_animation_css_class slow "$photo")
+ # Link to the photo's album view page (navigation, details, EXIF tooltip)
+ # so a camera page behaves exactly like the main album. Fall back to the
+ # full-size image when the album mapping is unknown (e.g. unit tests that
+ # render camera pages without a rendered album).
+ view_page="${ALBUM_VIEW_PAGE_BY_PHOTO[$photo]:-}"
+ if [ -n "$view_page" ]; then
+ printf ' <a name="%s" href="%s/%s.html">' \
+ "$photo_html" "$backhref_html" "$view_page"
+ else
+ printf ' <a href="%s/%s/%s">' \
+ "$backhref_html" "$STATS_CAMERA_PHOTOS_DIR" "$photo_html"
+ fi
+ printf '<img class="thumb %s" src="%s/%s/%s" /></a>\n' \
+ "$animation_class" "$backhref_html" "$STATS_CAMERA_THUMBS_DIR" \
+ "$photo_html"
}
# Build the full thumbnail grid for one camera from its newline-separated photo
@@ -3696,15 +3750,17 @@ _stats_render_camera_page() {
local -r photos="$1"; shift
local backhref_html
local camera_thumbs
+ local background_image
local -r page="camera-$slug.html"
backhref_html=$(_html_escape "$backhref")
camera_thumbs=$(_stats_build_camera_thumbs "$backhref_html" "$photos")
+ background_image=$(_stats_random_background "$page")
template header "$page" \
html_dir "$html_dir" \
backhref "$backhref" \
- blurs_dir '' \
- background_image '' \
+ blurs_dir "$STATS_BLURS_DIR" \
+ background_image "$background_image" \
show_header_bar 'yes'
template camera "$page" \
html_dir "$html_dir" \
diff --git a/share/templates/default/camera.tmpl b/share/templates/default/camera.tmpl
index 39ca273..2e08461 100644
--- a/share/templates/default/camera.tmpl
+++ b/share/templates/default/camera.tmpl
@@ -7,10 +7,11 @@
# <html>/<head>/<body> and footer come from the header/footer templates, which
# render_camera_pages emits around this one (same pattern as stats/view/details).
#
-# Thumbnails link to the full-size image under photos/ rather than to a per-photo
-# view page: a camera page is built outside the album's pagination loop and so
-# cannot know which <page>-<preview>.html a given photo landed on. Linking to the
-# original image is the sensible derivable target (see render_camera_pages).
+# Thumbnails link to each photo's album view page (navigation, details, EXIF
+# tooltip) so a camera page behaves exactly like the main album.
+# render_camera_pages resolves the <page>-<preview>.html target from the map the
+# album build records; a photo with no recorded view page falls back to the
+# full-size image under photos/ (see render_camera_pages).
cat <<END
<div class="view stats camera">
<h1 class="stats-title">${render_title_html} &mdash; ${render_camera_name_html}</h1>
diff --git a/share/templates/default/header.tmpl b/share/templates/default/header.tmpl
index 6a163f9..411d106 100644
--- a/share/templates/default/header.tmpl
+++ b/share/templates/default/header.tmpl
@@ -98,23 +98,31 @@ cat <<END
div.stats {
margin: 0 auto;
max-width: 900px;
+ padding: 0 12px;
text-align: left;
}
h1.stats-title {
+ margin-bottom: 12px;
text-align: center;
}
+ /* Give the "Back to album/stats" link room before the first section. */
+ div.stats-back {
+ margin-bottom: 16px;
+ }
+
section.stats-section {
background-color: #000000;
border: 3px solid #ffffff;
- margin: 8px auto;
- padding: 10px;
+ margin: 16px auto;
+ padding: 14px 16px;
}
section.stats-section h2 {
font-size: 1.1em;
margin-top: 0;
+ margin-bottom: 12px;
}
/* Each bar row is a label, a proportional bar, and the count/percent.
@@ -129,8 +137,20 @@ cat <<END
ul.stats-bars li {
align-items: center;
display: flex;
- gap: 8px;
- margin: 2px 0;
+ gap: 10px;
+ margin: 4px 0;
+ }
+
+ /* The camera leaderboard labels are long, EXIF-derived names that wrap,
+ so give each row extra vertical padding and a separator line to make
+ the boundary between cameras clear. */
+ ul.stats-leaderboard li {
+ border-bottom: 1px solid #444444;
+ padding: 8px 0;
+ }
+
+ ul.stats-leaderboard li:last-child {
+ border-bottom: none;
}
span.stats-label {
@@ -160,12 +180,18 @@ cat <<END
padding: 5px;
}
- img.thumb {
+ img.thumb {
height: ${render_thumbheight_html}px;
width: ${render_thumbheight_html}px;
object-fit: cover;
}
+ /* Camera filter page grid: separate from the back link and let the
+ thumbnails (which already carry their own padding) breathe a little. */
+ div.camera-thumbs {
+ margin-top: 12px;
+ }
+
img.thumb:hover {
outline: 4px solid #ffffff;
-webkit-transform: scaleX(-1);
diff --git a/src/lib/album.source.sh b/src/lib/album.source.sh
index 4914642..65282f1 100644
--- a/src/lib/album.source.sh
+++ b/src/lib/album.source.sh
@@ -1,3 +1,10 @@
+# Maps each album photo filename to its view-page basename ("<page>-<preview>")
+# as assigned during render_album_pages. The stats camera pages read this so
+# their thumbnails link into the album view (with navigation, details, and the
+# EXIF tooltip) exactly like the main album, instead of the raw image. Declared
+# globally so it always exists for callers even when no album was rendered.
+declare -gA ALBUM_VIEW_PAGE_BY_PHOTO=()
+
album_photo_files() {
local -r photos_dir="$1"; shift
@@ -741,6 +748,10 @@ render_album_pages() {
# shellcheck disable=SC2034
local -a rendered_view_pages=()
+ # Rebuild the photo -> view-page map for this album from scratch so a
+ # re-generate (or a smaller incoming set) does not keep stale entries.
+ ALBUM_VIEW_PAGE_BY_PHOTO=()
+
name=$(album_page_name "$num")
start_preview_page \
@@ -785,6 +796,10 @@ render_album_pages() {
render_failed
record_rendered_view_page rendered_view_pages rendered_last_views \
"$num" "$i"
+ # Read later by the stats camera pages (render_camera_pages); shellcheck
+ # cannot see that cross-function use.
+ # shellcheck disable=SC2034
+ ALBUM_VIEW_PAGE_BY_PHOTO["$photo"]="$num-$i"
done < <(album_photo_files "$photos_dir")
finish_preview_page "$name" "$html_dir" "$backhref" "$tarball_name"
diff --git a/src/lib/stats.source.sh b/src/lib/stats.source.sh
index b0a1646..527ea1f 100644
--- a/src/lib/stats.source.sh
+++ b/src/lib/stats.source.sh
@@ -614,15 +614,22 @@ _stats_bar_row() {
}
# Open a <section> with an escaped heading and the <ul> bar container. Split from
-# the row emitters so every section shares identical chrome.
+# the row emitters so every section shares identical chrome. An optional second
+# argument adds an extra CSS class to the <ul> (e.g. the leaderboard uses it to
+# space out and separate its rows of long, wrapping camera names).
_stats_section_open() {
local -r heading="$1"; shift
+ local -r list_class="${1:-}"
local heading_html
+ local ul_class='stats-bars'
+ if [ -n "$list_class" ]; then
+ ul_class+=" $list_class"
+ fi
heading_html=$(_html_escape "$heading")
printf '<section class="stats-section">\n'
printf '<h2>%s</h2>\n' "$heading_html"
- printf '<ul class="stats-bars">\n'
+ printf '<ul class="%s">\n' "$ul_class"
}
_stats_section_close() {
@@ -645,7 +652,7 @@ _stats_render_camera_section() {
return
fi
max=$(_stats_max_count STATS_CAMERAS)
- _stats_section_open 'Camera leaderboard'
+ _stats_section_open 'Camera leaderboard' 'stats-leaderboard'
while IFS= read -r label; do
label_html=$(_html_escape "$label")
slug="${STATS_CAMERA_SLUGS[$label]}"
@@ -824,18 +831,31 @@ _stats_build_body() {
# html_dir is the dist-relative output directory (top-level album: "."),
# backhref is the relative path back to the album root ("." for a top-level
# stats.html), and page_name defaults to "stats" -> stats.html.
+# Pick a seeded-random photo for a stats/camera page's blurred background, the
+# same way the album preview pages do. The context seeds the choice so each page
+# gets a stable (per RANDOM_SEED) but varied background. Degrades to an empty
+# string (plain black background) when no photos exist, e.g. unit tests that
+# render the page without a populated photos directory.
+_stats_random_background() {
+ local -r context="$1"; shift
+
+ randomphoto "$STATS_CAMERA_PHOTOS_DIR" "$context" 2>/dev/null || true
+}
+
render_stats_page() {
local -r html_dir="$1"; shift
local -r backhref="$1"; shift
local -r page_name="${1:-stats}"
local stats_body
+ local background_image
stats_body=$(_stats_build_body)
+ background_image=$(_stats_random_background "$page_name.html")
template header "$page_name.html" \
html_dir "$html_dir" \
backhref "$backhref" \
- blurs_dir '' \
- background_image '' \
+ blurs_dir "$STATS_BLURS_DIR" \
+ background_image "$background_image" \
show_header_bar 'yes'
template stats "$page_name.html" \
html_dir "$html_dir" \
@@ -874,6 +894,9 @@ render_stats_page() {
# pages link to the same assets the album pages do.
declare -gr STATS_CAMERA_PHOTOS_DIR='photos'
declare -gr STATS_CAMERA_THUMBS_DIR='thumbs'
+# Dist-relative directory of blurred images used for the page background, the
+# same one render_album_pages passes to the album preview pages.
+declare -gr STATS_BLURS_DIR='blurs'
# Emit one thumbnail anchor for the camera grid: a thumb image (from thumbs/)
# wrapped in a link to the full-size image (from photos/), both resolved through
@@ -884,12 +907,28 @@ _stats_camera_thumbnail() {
local -r backhref_html="$1"; shift
local -r photo="$1"; shift
local photo_html
+ local animation_class
+ local view_page
photo_html=$(_html_escape "$photo")
- printf ' <a href="%s/%s/%s">' \
- "$backhref_html" "$STATS_CAMERA_PHOTOS_DIR" "$photo_html"
- printf '<img class="thumb" src="%s/%s/%s" /></a>\n' \
- "$backhref_html" "$STATS_CAMERA_THUMBS_DIR" "$photo_html"
+ # Same seeded animation the album thumbnail uses for this photo, so camera
+ # pages animate identically.
+ animation_class=$(random_animation_css_class slow "$photo")
+ # Link to the photo's album view page (navigation, details, EXIF tooltip)
+ # so a camera page behaves exactly like the main album. Fall back to the
+ # full-size image when the album mapping is unknown (e.g. unit tests that
+ # render camera pages without a rendered album).
+ view_page="${ALBUM_VIEW_PAGE_BY_PHOTO[$photo]:-}"
+ if [ -n "$view_page" ]; then
+ printf ' <a name="%s" href="%s/%s.html">' \
+ "$photo_html" "$backhref_html" "$view_page"
+ else
+ printf ' <a href="%s/%s/%s">' \
+ "$backhref_html" "$STATS_CAMERA_PHOTOS_DIR" "$photo_html"
+ fi
+ printf '<img class="thumb %s" src="%s/%s/%s" /></a>\n' \
+ "$animation_class" "$backhref_html" "$STATS_CAMERA_THUMBS_DIR" \
+ "$photo_html"
}
# Build the full thumbnail grid for one camera from its newline-separated photo
@@ -919,15 +958,17 @@ _stats_render_camera_page() {
local -r photos="$1"; shift
local backhref_html
local camera_thumbs
+ local background_image
local -r page="camera-$slug.html"
backhref_html=$(_html_escape "$backhref")
camera_thumbs=$(_stats_build_camera_thumbs "$backhref_html" "$photos")
+ background_image=$(_stats_random_background "$page")
template header "$page" \
html_dir "$html_dir" \
backhref "$backhref" \
- blurs_dir '' \
- background_image '' \
+ blurs_dir "$STATS_BLURS_DIR" \
+ background_image "$background_image" \
show_header_bar 'yes'
template camera "$page" \
html_dir "$html_dir" \
diff --git a/tests/cli.sh b/tests/cli.sh
index f01c2e0..14439bf 100755
--- a/tests/cli.sh
+++ b/tests/cli.sh
@@ -3060,6 +3060,7 @@ test::stats_identify_output() {
}
test_generate_stats_pages_created_and_nav_linked() {
+ local camera_html
local config_file
local fake_bin
local -i nav_links
@@ -3088,6 +3089,21 @@ test_generate_stats_pages_created_and_nav_linked() {
test::assert_contains 'Canon EOS R5' "$(<"$TEST_TMPDIR/dist/stats.html")"
test::assert_not_contains '<script' "$(<"$TEST_TMPDIR/dist/stats.html")"
+ # The stats page gets a random blurred background like the album pages.
+ test::assert_contains 'background-image: url("./blurs/' \
+ "$(<"$TEST_TMPDIR/dist/stats.html")"
+
+ # Camera-page thumbnails link into the album view pages (behave like the main
+ # album), not the raw image, and point at the thumbs.
+ camera_html=$(<"$TEST_TMPDIR/dist/camera-canon-eos-r5.html")
+ test::assert_contains 'src="./thumbs/' "$camera_html"
+ test::assert_not_contains 'href="./photos/' "$camera_html"
+ if ! grep -Eq 'href="\./[0-9]+-[0-9]+\.html"' <<< "$camera_html"; then
+ printf 'FAIL: camera page does not link thumbnails to album view pages\n' \
+ >&2
+ exit 1
+ fi
+
# The header bar links to the stats page on at least one generated page.
nav_links=$(grep -lF 'stats.html">Stats' "$TEST_TMPDIR"/dist/*.html | wc -l)
test "$nav_links" -gt 0
@@ -4001,6 +4017,8 @@ MAXPREVIEWS=40
ORIGINAL_BASEPATH=''
TARBALL_INCLUDE=no
SHURIKEN_OUTPUT_MODE=quiet
+# Seed so the per-thumbnail animation class is reproducible across runs.
+RANDOM_SEED=camera-test
apply_config_defaults
feed() {
@@ -4040,11 +4058,14 @@ BASH
test::assert_file_exists "$dist_dir/run1/camera-canon-eos-5d-2.html"
test::assert_file_exists "$dist_dir/run1/camera-nikon-co-z6.html"
- # The Canon EOS 5D page lists exactly its two photos as thumbnails linking to
- # the full images under photos/, and not the other camera's photo.
+ # The Canon EOS 5D page lists exactly its two photos as thumbnails, and not
+ # the other camera's photo. With no album rendered here, the thumbnail link
+ # falls back to the full image under photos/; the img carries a thumb class
+ # (plus a seeded animation class) pointing at thumbs/.
html=$(cat "$dist_dir/run1/camera-canon-eos-5d.html")
test::assert_contains '<a href="../photos/a.jpg">' "$html"
- test::assert_contains '<img class="thumb" src="../thumbs/a.jpg" />' "$html"
+ test::assert_contains 'class="thumb ' "$html"
+ test::assert_contains 'src="../thumbs/a.jpg" />' "$html"
test::assert_contains '<a href="../photos/b.jpg">' "$html"
test::assert_not_contains 'photos/c.png' "$html"
test::assert_not_contains 'photos/d.jpg' "$html"