A slideshow across a page: pictures the full width of the screen, captions over them
Test / test (pull_request) Failing after 1m5s
Test / test (pull_request) Failing after 1m5s
slideshow on a page: max_height in svh, slides with an image (a path in the content repo or https), a caption laid over the picture's foot, and optionally the picture's size so its place is held before it loads. Swiper upgrades it; before that it is a row that scrolls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fc708c3546
commit
cfb050d3ef
@@ -74,14 +74,19 @@ pub async fn site_asset_handler(
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("json") => "application/json",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
Some("avif") => "image/avif",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("woff2") => "font/woff2",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
// Photographs are large and do not change under a name, so a
|
||||
// browser may keep them for an hour; everything else revalidates,
|
||||
// so a content push shows on reload.
|
||||
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" };
|
||||
(
|
||||
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")],
|
||||
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
|
||||
bytes,
|
||||
)
|
||||
.into_response()
|
||||
|
||||
@@ -95,6 +95,12 @@ pub struct Question {
|
||||
/// rather than on the whole world.
|
||||
#[serde(default)]
|
||||
pub place: Option<Place>,
|
||||
/// Pictures across the page, under the question: a strip the full
|
||||
/// width of the screen that a visitor swipes through, each picture
|
||||
/// with its caption laid over it. What the business has built,
|
||||
/// shown rather than described.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slideshow: Option<Slideshow>,
|
||||
}
|
||||
|
||||
/// A point a page is about. `zoom` is a map zoom level, 0 (the globe)
|
||||
@@ -625,6 +631,50 @@ pub struct Requirement {
|
||||
/// listing may ask about a record's own contents: one field, one
|
||||
/// value, compared as strings. Anything more is a query language, and
|
||||
/// a jq filter is already there for shaping what comes back.
|
||||
/// A strip of pictures across a page (`Question.slideshow`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Slideshow {
|
||||
/// How tall the strip may be, in hundredths of the small viewport
|
||||
/// height (`svh`): 60 is six tenths of the screen. Every picture is
|
||||
/// that tall and as wide as its own shape makes it.
|
||||
#[serde(default = "default_slideshow_height")]
|
||||
pub max_height: u8,
|
||||
pub slides: Vec<Slide>,
|
||||
}
|
||||
|
||||
fn default_slideshow_height() -> u8 {
|
||||
60
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Slide {
|
||||
/// A path in the content repo (`images/built/01.webp`, served from
|
||||
/// this origin under `/site/`) or an `https://` address.
|
||||
pub image: String,
|
||||
/// Laid over the foot of the picture, and its alternative text.
|
||||
#[serde(default)]
|
||||
pub caption: String,
|
||||
/// The picture's own size in pixels, when known: the browser then
|
||||
/// holds its place in the strip before it has loaded, so pictures
|
||||
/// further along can wait until they are swiped to.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
impl Slide {
|
||||
/// Where the browser fetches the picture: https as given, anything
|
||||
/// else as a content-repo path under `/site/`.
|
||||
pub fn src(&self) -> String {
|
||||
if self.image.starts_with("https://") || self.image.starts_with("/site/") {
|
||||
self.image.clone()
|
||||
} else {
|
||||
format!("/site/{}", self.image.trim_start_matches('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A link from every listed record to a page about it (`ResourceSpec.link`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RowLink {
|
||||
@@ -1904,6 +1954,17 @@ alternatives:
|
||||
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slide_is_fetched_from_this_origin_unless_it_is_https() {
|
||||
let q: Question = serde_yaml::from_str("name: Q?\nslideshow:\n slides:\n - { image: images/built/01.webp, caption: The canteen, width: 3207, height: 1400 }\n - { image: \"https://cdn.example/a.jpg\" }\n").unwrap();
|
||||
let show = q.slideshow.unwrap();
|
||||
assert_eq!(show.max_height, 60);
|
||||
assert_eq!(show.slides[0].src(), "/site/images/built/01.webp");
|
||||
assert_eq!(show.slides[0].width, Some(3207));
|
||||
assert_eq!(show.slides[1].src(), "https://cdn.example/a.jpg");
|
||||
assert_eq!(show.slides[1].caption, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_drops_html_and_unsafe_links() {
|
||||
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
|
||||
|
||||
@@ -146,6 +146,25 @@ pub fn validate_questions(
|
||||
}
|
||||
}
|
||||
|
||||
// A slideshow shows something, at a height that leaves the page
|
||||
// its question: between a fifth and the whole of the screen.
|
||||
for question in questions.values() {
|
||||
if let Some(show) = &question.slideshow {
|
||||
if show.slides.is_empty() {
|
||||
anyhow::bail!("question {:?}: slideshow has no slides", question.id);
|
||||
}
|
||||
if !(20..=100).contains(&show.max_height) {
|
||||
anyhow::bail!("question {:?}: slideshow max_height is {} svh; between 20 and 100", question.id, show.max_height);
|
||||
}
|
||||
for slide in &show.slides {
|
||||
let ok = slide.image.starts_with("https://") || crate::content::is_safe_site_path(slide.image.trim_start_matches("/site/").trim_start_matches('/'));
|
||||
if !ok {
|
||||
anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mail to the record this one belongs to goes through a field of
|
||||
// the form: `{notice.email}` needs a `type: record` field called
|
||||
// `notice` on every form that records into the bucket, or the mail
|
||||
|
||||
Reference in New Issue
Block a user