Pages move the way their addresses do; a record with a picture is a card; the trainer says who a business is
Test / test (pull_request) Successful in 1m13s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 19:32:49 +02:00
co-authored by Claude Opus 5.5
parent 748241709a
commit 309e30f36d
10 changed files with 1008 additions and 451 deletions
+89 -2
View File
@@ -57,6 +57,8 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
// Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/>
// Before the router, so it sees a link's click first.
<script src="/page-motion.js" defer=true></script>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
@@ -1949,12 +1951,16 @@ fn ResourceValue(
}
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers;
// Cards are shown in the order they were put up, the way a
// team or a gallery is read; a list of cases, newest first.
let cards = transitions.is_empty() && answers.iter().all(|a| picture_of(a, bucket.as_deref()).is_some());
answers.sort_by(|a, b| {
let open = |s: &str| s != crate::answers::OPEN_STATE;
open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms))
let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
open(&a.state).cmp(&open(&b.state)).then(when)
});
return view! {
<div class="answer-list">
<div class="answer-list" class:answer-cards=cards>
<For
each=move || answers.clone()
key=|a| a.id.clone()
@@ -2232,6 +2238,23 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
}
/// Whether an uploaded file is a picture a browser shows as it is.
pub fn is_picture(filename: &str) -> bool {
let lower = filename.to_ascii_lowercase();
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"].iter().any(|ext| lower.ends_with(ext))
}
/// A record that carries a picture, in a list nobody works from, is
/// shown rather than read: the picture, then what it says, first field
/// as its heading. A team, a gallery of what has been built.
fn picture_of(answer: &Answer, bucket: Option<&str>) -> Option<String> {
let bucket = bucket?;
answer.responses.as_object()?.iter().find_map(|(key, value)| {
let name = uploaded_filename(value.as_str()?)?;
is_picture(name).then(|| format!("/attachment/{bucket}/{}/{key}", answer.id))
})
}
#[component]
fn AnswerRow(
feature_name: String,
@@ -2260,6 +2283,11 @@ fn AnswerRow(
} else {
available.is_empty()
};
if transitions.is_empty() {
if let Some(picture) = picture_of(&answer, bucket.as_deref()) {
return AnswerCard(answer, picture, link).into_any();
}
}
let responses = answer
.responses
.as_object()
@@ -2310,6 +2338,17 @@ fn AnswerRow(
// A stored file shows as its name, and opens.
if let (Some(base), Some(filename)) = (attachment_base.clone(), uploaded_filename(&text)) {
let href = format!("{base}/{key}");
if is_picture(filename) {
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<a class="answer-field-value" href=href.clone() rel="external">
<img class="answer-thumb" src=href.clone() alt=filename.to_string() loading="lazy" />
</a>
</div>
}
.into_any();
}
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
@@ -2412,6 +2451,36 @@ fn AnswerRow(
</Show>
</article>
}
.into_any()
}
/// A record shown as a card: its picture, its first field as the
/// heading and the others under it, and nothing a reader has no use
/// for - no field names, no state, no time.
#[allow(non_snake_case)]
fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl IntoView {
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
let texts: Vec<String> = answer
.responses
.as_object()
.into_iter()
.flatten()
.filter_map(|(_, v)| v.as_str())
.filter(|v| uploaded_filename(v).is_none() && !v.trim().is_empty())
.map(str::to_string)
.collect();
let heading = texts.first().cloned().unwrap_or_default();
let rest: Vec<String> = texts.into_iter().skip(1).collect();
view! {
<article class="answer-row answer-card">
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
<div class="answer-card-text">
<h4 class="answer-card-heading">{heading}</h4>
{rest.into_iter().map(|t| view! { <p>{t}</p> }).collect_view()}
</div>
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
</article>
}
}
/// Iconify names are `{prefix}:{name}`, both lowercase-alphanumeric-
@@ -3085,3 +3154,21 @@ mod responsible_tests {
assert!(!is_responsible(None, &board));
}
}
#[cfg(test)]
mod picture_tests {
use super::*;
#[test]
fn a_record_with_a_picture_is_shown_by_it() {
assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf"));
let answer: Answer = serde_json::from_value(serde_json::json!({
"id": "r1", "question_id": "kontor/ansatt", "alternative": "a", "state": "open", "submitted_ms": 1,
"responses": { "name": "Kari", "portrett": "0f8fad5b-d9cb-469f-a165-70867728950e-kari.jpg", "cv": "0f8fad5b-d9cb-469f-a165-70867728950e-cv.pdf" }
}))
.unwrap();
assert_eq!(picture_of(&answer, Some("ansatte")).as_deref(), Some("/attachment/ansatte/r1/portrett"));
// Without a bucket there is nowhere to fetch it from.
assert_eq!(picture_of(&answer, None), None);
}
}