Alternative.images: array of urls, rendered as a Swiper card deck
Deploy / deploy (push) Successful in 1m0s

image: Option<String> becomes images: Vec<String> (nothing in live
content used the old field). One url renders as the plain banner it
was; several become a swipeable cards-effect deck via Swiper Element
12.2.0, vendored into public/ (MIT) like prosekit-editor.js/yes.js
rather than pulled from a CDN - the bundle only loads on pages where
some alternative actually declares more than one image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-12 22:11:07 +02:00
co-authored by Claude Sonnet 5
parent 40694a8f3f
commit 3233d9a3db
5 changed files with 80 additions and 9 deletions
File diff suppressed because one or more lines are too long
+34 -3
View File
@@ -163,7 +163,13 @@ fn QuestionView(
.into_any();
}
// Swiper Element only registers its custom elements when its
// (vendored, ~180KB) script runs - loaded once per page, and only
// on pages where some alternative actually declares a deck.
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1);
view! {
{needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })}
<Hero
title=question.name.clone()
description=question.description.clone()
@@ -370,6 +376,33 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
}
}
/// An alternative's banner imagery: nothing, a plain image, or - for
/// several urls - a Swiper Element card deck. The `swiper-container`/
/// `swiper-slide` custom elements render inert server-side and upgrade
/// once the vendored bundle registers them (loaded per-page by
/// `QuestionView` only when some alternative actually needs it).
#[component]
fn AltImages(images: Vec<String>) -> impl IntoView {
match images.len() {
0 => ().into_any(),
1 => view! { <img class="alt-image" src=images[0].clone() alt="" /> }.into_any(),
_ => view! {
<swiper-container class="alt-image-deck" effect="cards" grab-cursor="true">
<For
each=move || images.clone()
key=|src| src.clone()
children=|src: String| view! {
<swiper-slide>
<img class="alt-image" src=src alt="" />
</swiper-slide>
}.into_any()
/>
</swiper-container>
}
.into_any(),
}
}
#[component]
fn AlternativeCard(
question_id: String,
@@ -593,9 +626,7 @@ fn AlternativeCard(
view! {
<section class="alt-card">
{alternative.image.clone().map(|src| view! {
<img class="alt-image" src=src alt="" />
})}
<AltImages images=alternative.images.clone() />
<h2>{alternative.name.clone()}</h2>
<p class="alt-description">{alternative.description.clone()}</p>
<div class="features">
+8 -6
View File
@@ -60,13 +60,15 @@ pub struct Alternative {
pub consequence: Vec<String>,
#[serde(default)]
pub encouragements: Vec<String>,
/// A banner image url, rendered above the description - purely
/// decorative, no upload/hosting mechanism of its own, just an
/// already-hosted url the browser fetches directly (unlike
/// `ResourceSource::Url`, this is never fetched server-side, so it
/// carries none of that variant's SSRF concern).
/// Banner image urls, rendered above the description - one renders
/// as a plain image, several as a swipeable card deck (Swiper
/// Element, vendored in `public/`). Purely decorative, no
/// upload/hosting mechanism of their own, just already-hosted urls
/// the browser fetches directly (unlike `ResourceSource::Url`,
/// never fetched server-side, so none of that variant's SSRF
/// concern).
#[serde(default)]
pub image: Option<String>,
pub images: Vec<String>,
#[serde(default)]
pub features: Vec<Feature>,
/// Names a NATS KV bucket to also durably store this submission
+5
View File
@@ -124,6 +124,7 @@ async fn main() -> anyhow::Result<()> {
let favicon_light_path = format!("{}/favicon-light.svg", leptos_options.site_root);
let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root);
let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root);
let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root);
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let app = Router::new()
@@ -139,6 +140,10 @@ async fn main() -> anyhow::Result<()> {
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
.route_service("/wordmark.svg", ServeFile::new(wordmark_path))
.route_service(
"/swiper-element-bundle.min.js",
ServeFile::new(swiper_path),
)
.leptos_routes_with_context(
&state,
routes,
+19
View File
@@ -270,6 +270,25 @@ main.not-found {
margin-bottom: 1.1rem;
}
/* Sized down from full card width so the cards effect's fanned-out
neighbors stay inside the card - swiper positions slides absolutely,
so the container needs its own height. Before the bundle upgrades
the element (or without JS) slides stack as plain blocks; capping
the container height keeps that fallback from towering. */
.alt-image-deck {
display: block;
width: min(100%, 22rem);
height: 14rem;
margin: 0 auto 1.1rem;
overflow: hidden;
}
.alt-image-deck .alt-image {
height: 100%;
max-height: none;
margin-bottom: 0;
}
.alt-card h2 {
font-size: 1.3rem;
}