sources: let a source render its items
Assisted-by: Claude Opus 5.5 via Claude Code
change ypvxlktwwrqttsopxtqrtknlslvlmwpl
commit ea9e6554d1723173e5d62a0497cdd16523cf9d65
author Alpha Chen <alpha@kejadlen.dev>
date
parent pmloszxp
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 8e3f8ae..d7ee830 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -19,7 +19,6 @@ module RankKing
     one_to_many :items, class: "RankKing::Item", conditions: { archived_at: nil }
     one_to_many :axes, class: "RankKing::Axis"
 
-
     # Archived items too, which the items association leaves out.
     def before_destroy
       Result.where(axis_id: axes_dataset.select(:id)).delete
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index 849eb39..b8ce5c8 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -45,6 +45,9 @@ module RankKing
       #: (csrf: Method) -> _Component
       def form(csrf:) = Views::Sources::Text.new(list: @list, csrf:)
 
+      #: (Item item) -> _Component
+      def item(item) = Views::Sources::TextItem.new(item:)
+
       # Applies the dialog: each submitted name renames the item with that
       # id, a blank name archives it, and each line of new_items adds one.
       #: (Hash[String, untyped] params, ?now: Time) -> void
diff --git a/lib/rank_king/sources/youtube.rb b/lib/rank_king/sources/youtube.rb
index 241611d..fc95585 100644
--- a/lib/rank_king/sources/youtube.rb
+++ b/lib/rank_king/sources/youtube.rb
@@ -81,6 +81,15 @@ module RankKing
       #: (csrf: Method) -> _Component
       def form(csrf:) = Views::Sources::YouTube.new(list: @list, csrf:)
 
+      # Each item as its video looked at the last sync. A page renders every
+      # item, so the first call reads the whole list's videos at once.
+      #: (Item item) -> _Component
+      def item(item)
+        @cached ||= DB[ITEMS].where(item_id: Item.where(list_id: @list.id).select(:id)).to_hash(:item_id)
+        video = @cached[item.id] || {}
+        Views::Sources::YouTubeItem.new(title: item.name, channel: video[:channel_title], thumbnail: video[:thumbnail_url])
+      end
+
       # The dialog posts the cookie to fetch with.
       #: (Hash[String, untyped] params) -> void
       def sync(params)
diff --git a/lib/rank_king/views/list/compare.rb b/lib/rank_king/views/list/compare.rb
index 9bc6579..b34c85c 100644
--- a/lib/rank_king/views/list/compare.rb
+++ b/lib/rank_king/views/list/compare.rb
@@ -10,8 +10,9 @@ module RankKing
       class Compare < Phlex::HTML
         RECENT = 5
 
-        def initialize(list:, axis:, csrf:)
+        def initialize(list:, source:, axis:, csrf:)
           @list = list
+          @source = source
           @axis = axis
           @csrf = csrf
         end
@@ -49,7 +50,7 @@ module RankKing
             input(type: "hidden", name: "axis_id", value: @axis.id)
             input(type: "hidden", name: "winner_id", value: winner.id)
             input(type: "hidden", name: "loser_id", value: loser.id)
-            button(type: "submit") { winner.name }
+            button(type: "submit") { render @source.item(winner) }
           end
         end
 
diff --git a/lib/rank_king/views/list/show.rb b/lib/rank_king/views/list/show.rb
index 43f6bf8..4d21f11 100644
--- a/lib/rank_king/views/list/show.rb
+++ b/lib/rank_king/views/list/show.rb
@@ -61,7 +61,7 @@ module RankKing
           end
 
           ul(class: "card") do
-            @list.items.each { |item| li { item.name } }
+            @list.items.each { |item| li { render @source.item(item) } }
           end
         end
 
@@ -91,13 +91,13 @@ module RankKing
             ranked.each.with_index(1) do |item, rank|
               li do
                 span(class: "type-label") { format("%02d", rank) }
-                plain item.name
+                render @source.item(item)
               end
             end
             unranked.each do |item|
               li do
                 span(class: "type-label") { "—" }
-                plain item.name
+                render @source.item(item)
                 span(class: "type-label") { "Unranked" }
               end
             end
diff --git a/lib/rank_king/views/sources/text.rb b/lib/rank_king/views/sources/text.rb
index 3bb6b96..9480b7d 100644
--- a/lib/rank_king/views/sources/text.rb
+++ b/lib/rank_king/views/sources/text.rb
@@ -16,6 +16,15 @@ module RankKing
         end
       end
 
+      # A typed item is its name.
+      class TextItem < Phlex::HTML
+        def initialize(item:)
+          @item = item
+        end
+
+        def view_template = plain(@item.name)
+      end
+
       # The Text source's sync dialog: every item's name, editable in place,
       # and a box for new ones. It fills a <dialog> as Gloss lays one out:
       # header, body, footer, so the form is the body and the footer's
diff --git a/lib/rank_king/views/sources/youtube.rb b/lib/rank_king/views/sources/youtube.rb
index f08612d..e68cfa4 100644
--- a/lib/rank_king/views/sources/youtube.rb
+++ b/lib/rank_king/views/sources/youtube.rb
@@ -63,6 +63,28 @@ module RankKing
         end
       end
 
+      # A video as its thumbnail, title, and channel, so two in a matchup can
+      # be told apart. A video synced without a thumbnail or channel shows
+      # whatever it has. The page lays it out: a row on the list, stacked
+      # in a matchup.
+      class YouTubeItem < Phlex::HTML
+        def initialize(title:, channel:, thumbnail:)
+          @title = title
+          @channel = channel
+          @thumbnail = thumbnail
+        end
+
+        def view_template
+          span(data_video: true) do
+            img(src: @thumbnail, alt: "", loading: "lazy") if @thumbnail
+            span do
+              span { @title }
+              small { @channel } if @channel
+            end
+          end
+        end
+      end
+
       # The YouTube playlist source's sync dialog: a fresh cookie to fetch
       # with, and what syncing does, shown working.
       class YouTube < Phlex::HTML
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 73c402e..ad911ce 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -135,7 +135,7 @@ module RankKing
             axis = list.axes_dataset.where(id: axis_id).first
             r.halt 404 unless axis
 
-            Views::ListViews::Compare.new(list: list, axis: axis, csrf: method(:csrf_token)).call
+            Views::ListViews::Compare.new(list: list, source: source(list), axis: axis, csrf: method(:csrf_token)).call
           end
 
           r.on "results" do
diff --git a/public/css/app.css b/public/css/app.css
index d8fe6b2..1978064 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -187,6 +187,50 @@ main > header [popovertarget] { anchor-name: --rk-list-actions; }
   color: var(--gl-color-text-tertiary);
 }
 
+/* -------------------- */
+/* Videos */
+/* -------------------- */
+
+/* A YouTube item (Views::Sources::YouTubeItem): a small thumbnail beside
+   the title and channel in a row, stacked full width in a matchup. */
+[data-video] {
+  display: flex;
+  align-items: center;
+  gap: var(--gl-space-xs);
+  min-width: 0;
+}
+
+[data-video] img {
+  flex: none;
+  width: 4.5rem;
+  aspect-ratio: 16 / 9;
+  object-fit: cover;
+  border-radius: var(--gl-radius-sm);
+}
+
+[data-video] > span {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+
+[data-video] small {
+  font-size: var(--gl-step--2);
+  color: var(--gl-color-text-secondary);
+}
+
+/* A thumbnail has no baseline for the rank to sit on. */
+.card > li:has(> .type-label:first-child):has(> [data-video]) { align-items: center; }
+
+/* Gloss centers a button's content, which would shrink the item to it. */
+[data-matchup] [data-video] {
+  flex-direction: column;
+  align-items: stretch;
+  width: 100%;
+}
+
+[data-matchup] [data-video] img { width: 100%; }
+
 /* -------------------- */
 /* Compare */
 /* -------------------- */
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
index eb6a6e9..7660f1d 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -20,6 +20,9 @@ module RankKing
 
       # Applies what that form posted.
       def sync: (Hash[String, untyped] params) -> void
+
+      # How one of the list's items shows wherever it is listed or compared.
+      def item: (Item item) -> _Component
     end
 
     # A source that fetches its items, as Sync.call needs it.
diff --git a/sig/rank_king/views.rbs b/sig/rank_king/views.rbs
index d7612a7..3fa8448 100644
--- a/sig/rank_king/views.rbs
+++ b/sig/rank_king/views.rbs
@@ -14,6 +14,11 @@ module RankKing
         def call: () -> String
       end
 
+      class TextItem
+        def initialize: (item: Item) -> void
+        def call: () -> String
+      end
+
       class TextFields
         def initialize: () -> void
         def call: () -> String
@@ -24,6 +29,11 @@ module RankKing
         def call: () -> String
       end
 
+      class YouTubeItem
+        def initialize: (title: String, channel: String?, thumbnail: String?) -> void
+        def call: () -> String
+      end
+
       class YouTubeFields
         def initialize: (csrf: Method) -> void
         def call: () -> String
@@ -47,7 +57,7 @@ module RankKing
       end
 
       class Compare
-        def initialize: (list: List, axis: Axis, csrf: Method) -> void
+        def initialize: (list: List, source: ::RankKing::Sources::_Source, axis: Axis, csrf: Method) -> void
         def call: () -> String
       end
 
diff --git a/test/test_web.rb b/test/test_web.rb
index d2d0a3e..f7da394 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -291,6 +291,18 @@ class TestWeb < Minitest::Test
     assert_includes last_response.body, "Sushi"
   end
 
+  def test_text_items_show_as_their_names
+    list, axis = create_list_with_axis(items: "Pizza\nSushi")
+
+    get "/lists/#{list.id}"
+    assert_includes last_response.body, %(<span class="type-label">—</span>Pizza<span class="type-label">Unranked</span>)
+    refute_includes last_response.body, "data-video"
+
+    get "/lists/#{list.id}/axes/#{axis.id}/compare"
+    assert_includes last_response.body, %(<button type="submit">Pizza</button>)
+    assert_includes last_response.body, %(<button type="submit">Sushi</button>)
+  end
+
   def test_compare_404_for_an_axis_on_another_list
     _, axis = create_list_with_axis(items: "Pizza\nSushi")
     other, = create_list_with_axis(items: "A\nB")
diff --git a/test/test_youtube.rb b/test/test_youtube.rb
index 71a6817..07a6dc1 100644
--- a/test/test_youtube.rb
+++ b/test/test_youtube.rb
@@ -4,6 +4,8 @@ require_relative "test_helper"
 
 require "minitest/autorun"
 require "rack/test"
+require "logger"
+require "stringio"
 
 require_relative "../lib/rank_king/web"
 
@@ -223,6 +225,42 @@ class TestYouTube < Minitest::Test
     assert_nil second.reload.archived_at
   end
 
+  def test_list_shows_each_video_from_one_read
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"], ["v3", "Third"]]
+    create_list(playlist: PLAYLIST)
+    list = RankKing::List.first
+
+    reads = youtube_item_reads { get "/lists/#{list.id}" }
+    assert_includes last_response.body, %(<img src="https://i.ytimg.com/vi/v1/mqdefault.jpg" alt="" loading="lazy">)
+    assert_includes last_response.body, "<span>Third</span><small>A channel</small>"
+    assert_equal 1, reads
+  end
+
+  def test_compare_shows_each_video
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
+    create_list(playlist: PLAYLIST)
+    list = RankKing::List.first
+    axis = RankKing::Axis.create(list_id: list.id, name: "Watch first")
+
+    get "/lists/#{list.id}/axes/#{axis.id}/compare"
+    buttons = last_response.body.scan(%r{<button type="submit">(.*?)</button>})
+    assert_equal 2, buttons.size
+    %w[v1 v2].each do |id|
+      assert(buttons.any? { |(html)| html.include?("vi/#{id}/mqdefault.jpg") && html.include?("<small>A channel</small>") })
+    end
+  end
+
+  def test_a_video_without_a_thumbnail_or_channel_shows_its_title
+    @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
+    create_list(playlist: PLAYLIST)
+    list = RankKing::List.first
+    RankKing::DB[:youtube_items].update(channel_title: nil, thumbnail_url: nil)
+
+    get "/lists/#{list.id}"
+    assert_includes last_response.body, %(<span data-video><span><span>First</span></span></span>)
+    refute_includes last_response.body, "<img"
+  end
+
   def test_sync_dialog_asks_for_a_cookie_and_shows_progress
     @inner_tube.videos = [["v1", "First"], ["v2", "Second"]]
     create_list(playlist: PLAYLIST)
@@ -284,6 +322,17 @@ class TestYouTube < Minitest::Test
 
   def names(list) = list.items_dataset.order(:id).map(:name)
 
+  # How many times the block reads youtube_items.
+  def youtube_item_reads
+    log = StringIO.new
+    logger = Logger.new(log)
+    RankKing::DB.loggers << logger
+    yield
+    log.string.scan(/SELECT .* FROM `youtube_items`/).size
+  ensure
+    RankKing::DB.loggers.delete(logger)
+  end
+
   # Posts the cookie as app.js does, with the token the new-list form
   # carries for it.
   def playlists(cookie)