Show attached images on the asset detail page
Adds a captionless photo grid (the design's .photos/.shot atoms) to the
asset detail view, rendering each attached file as an <img>. Backed by a
new GET /files/:id route that streams the stored upload with its mime
type and an immutable cache header; unknown ids 404 via .sole. The route
loads an asset's files by joining asset_attachments to files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N92HLTepkg3AtRpDUyZ54o
change
commit da0d13d5cccb15a183516e171967140fcb26ac14
author Claude <noreply@anthropic.com>
date
parent a99f799b
diff --git a/lib/views/asset.rb b/lib/views/asset.rb
index 3ec7cef..eb5aba1 100644
--- a/lib/views/asset.rb
+++ b/lib/views/asset.rb
@@ -6,12 +6,13 @@ require_relative "layout"
 module Domus
   module Views
     # The asset detail page — the catalog card for a single tracked thing.
-    # First version: only the identifying title block (eyebrow + name) and the
-    # free-text description. Tags, photos, the maintenance log, info and
-    # documents are held for later.
+    # First version: the identifying title block (eyebrow + name), the
+    # free-text description, and the attached photos. Tags, the maintenance
+    # log, info and documents are held for later.
     class Asset < Phlex::HTML
-      def initialize(asset:)
+      def initialize(asset:, images: [])
         @asset = asset
+        @images = images
       end
 
       def view_template
@@ -22,6 +23,7 @@ module Domus
               h1(class: "title") { plain @asset[:name] }
             end
             description
+            photos
           end
         end
       end
@@ -40,6 +42,23 @@ module Domus
           end
         end
       end
+
+      # The captionless photo grid. Each tile is an <img> served by the
+      # GET /files/:id route. Omitted entirely when nothing is attached.
+      def photos
+        return if @images.empty?
+
+        section(class: "asset-photos") do
+          h2(class: "photos-h") { plain "Photos" }
+          div(class: "photos") do
+            @images.each do |file|
+              div(class: "shot") do
+                img(src: "/files/#{file[:id]}", alt: "Photo of #{@asset[:name]}", loading: "lazy")
+              end
+            end
+          end
+        end
+      end
     end
   end
 end
diff --git a/lib/web.rb b/lib/web.rb
index bdc0c0d..0db8b6c 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -2,6 +2,7 @@
 
 require "roda"
 require "fileutils"
+require "rack/mime"
 require_relative "views/layout"
 require_relative "views/home"
 require_relative "views/asset"
@@ -59,6 +60,21 @@ module Domus
           save_file(r.params)
           r.redirect "/"
         end
+
+        # GET /files/:id — stream a stored upload. .sole raises
+        # Sequel::NoMatchingRow (→ 404) for an unknown id.
+        r.is Integer do |id|
+          r.get do
+            file = db[:files].where(id:).sole
+            path = app.file_path(file)
+            raise ClientError.new("File not found.", status: 404) unless ::File.exist?(path)
+
+            response["Content-Type"] = Rack::Mime.mime_type(file[:extension], "application/octet-stream")
+            # Uploads are immutable once stored, so they cache indefinitely.
+            response["Cache-Control"] = "private, max-age=31536000, immutable"
+            ::File.binread(path)
+          end
+        end
       end
 
       r.on "assets" do
@@ -66,7 +82,8 @@ module Domus
           r.get do
             # .sole raises Sequel::NoMatchingRow when the id is unknown; the
             # error_handler above turns that into a 404.
-            Views::Asset.new(asset: db[:assets].where(id:).sole).call
+            asset = db[:assets].where(id:).sole
+            Views::Asset.new(asset:, images: asset_images(id)).call
           end
         end
       end
@@ -79,6 +96,16 @@ module Domus
     # : () -> Sequel::Database
     def db = app.db
 
+    # Files attached to an asset, oldest first, as {id:, extension:} rows.
+    def asset_images(asset_id)
+      db[:asset_attachments]
+        .where(asset_id:)
+        .join(:files, id: :file_id)
+        .order(Sequel[:asset_attachments][:created_at], Sequel[:files][:id])
+        .select(Sequel[:files][:id], Sequel[:files][:extension])
+        .all
+    end
+
     # Persists an uploaded image, raising ClientError when the upload is
     # rejected so the error_handler plugin can render the right status.
     # : (Hash[String, untyped]) -> void
diff --git a/public/app.css b/public/app.css
index 01a3163..f6ac49b 100644
--- a/public/app.css
+++ b/public/app.css
@@ -574,3 +574,35 @@ body {
 }
 .desc p { margin: 0; }
 .desc p + p { margin-top: var(--space-3xs); }
+
+/* ---- attached photos ---- */
+.asset-photos { margin-top: var(--space-l); }
+
+.asset-photos .photos-h {
+  margin: 0 0 var(--space-s);
+  font-family: var(--font-serif);
+  font-weight: 700;
+  font-size: var(--step-0);
+  letter-spacing: -0.01em;
+}
+
+.photos {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: var(--space-2xs);
+}
+
+.photos .shot {
+  aspect-ratio: 4 / 3;
+  overflow: hidden;
+  border: 1px solid var(--w-line-2);
+  border-radius: calc(var(--radius) - 4px);
+  background: var(--w-fill);
+}
+
+.photos .shot img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
diff --git a/test/test_app.rb b/test/test_app.rb
index c0c375a..5924363 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -99,6 +99,40 @@ class TestApp < Minitest::Test
     assert_equal 404, last_response.status
   end
 
+  def test_asset_detail_renders_attached_images
+    now = Time.now
+    asset_id = domus.db[:assets].insert(name: "Dishwasher", created_at: now)
+    file_id = domus.db[:files].insert(extension: ".png", created_at: now)
+    domus.db[:asset_attachments].insert(asset_id:, file_id:, created_at: now)
+
+    get "/assets/#{asset_id}"
+    assert_equal 200, last_response.status
+    assert_includes last_response.body, %(src="/files/#{file_id}")
+  end
+
+  def test_asset_detail_without_images_omits_photos
+    id = domus.db[:assets].insert(name: "Bare", created_at: Time.now)
+
+    get "/assets/#{id}"
+    assert_equal 200, last_response.status
+    refute_includes last_response.body, 'class="photos"'
+  end
+
+  def test_get_file_serves_stored_image
+    post "/files", "file" => upload("photo.png", "image/png", "fake-png-bytes")
+    file_id = domus.db[:files].first[:id]
+
+    get "/files/#{file_id}"
+    assert_equal 200, last_response.status
+    assert_equal "image/png", last_response.headers["Content-Type"]
+    assert_equal "fake-png-bytes", last_response.body
+  end
+
+  def test_get_missing_file_is_404
+    get "/files/999999"
+    assert_equal 404, last_response.status
+  end
+
   def test_upload_image_saves_file_and_redirects
     post "/files", "file" => upload("photo.png", "image/png", "fake-png-bytes")