sources: let a source render its items
Assisted-by: Claude Opus 5.5 via Claude Code
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)