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
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")