Render generic resource lists as cards, not raw JSON dump
Deploy / deploy (push) Failing after 32s

A jq-shaped resource (e.g. the GiteaStarred showcase) previously fell
through to the raw <pre> JSON dump - same fallback path meant for
truly unstructured data. Added a card renderer for the common case
(array of plain objects): name/title as a linked heading, description
as body text, everything else as small key/value chips - same visual
language as AnswerRow's answer-fields, just for non-Answer data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-06 11:31:42 +02:00
co-authored by Claude Sonnet 5
parent 1db558974e
commit bd702bfbae
2 changed files with 133 additions and 0 deletions
+96
View File
@@ -836,11 +836,107 @@ fn ResourceValue(
if items.is_empty() {
return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any();
}
// A list of plain objects (e.g. a jq-shaped GiteaStarred/Url
// resource) - render as cards rather than dumping raw JSON.
// Generic on purpose, no content-declared "kind": `name`/`title`
// becomes the heading (linked, if `url`/`html_url` is present),
// `description` becomes the body text, and every other non-null
// field becomes a small key/value chip - same idea as
// `AnswerRow`'s `answer-fields`, just for data that isn't an
// `Answer`.
if items.iter().all(|i| i.is_object()) {
let items = items.clone();
return view! {
<div class="item-list">
<For
each=move || items.clone()
key=|item| item.to_string()
children=|item: serde_json::Value| view! { <ItemCard item=item/> }
/>
</div>
}
.into_any();
}
}
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
}
/// One card in a generic (non-`Answer`) resource list - see
/// `ResourceValue`.
#[component]
fn ItemCard(item: serde_json::Value) -> impl IntoView {
let obj = item.as_object().cloned().unwrap_or_default();
let text_field = |keys: &[&str]| {
keys.iter().find_map(|k| obj.get(*k)).and_then(|v| v.as_str()).map(str::to_string)
};
let name = text_field(&["name", "title"]);
let description = text_field(&["description"]);
let url = text_field(&["url", "html_url"]);
let known = ["name", "title", "description", "url", "html_url"];
let extra: Vec<(String, String)> = obj
.iter()
.filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null())
.map(|(k, v)| {
let text = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
(k.clone(), text)
})
.collect();
view! {
<article class="item-card">
{(name.is_some() || url.is_some())
.then(|| {
view! {
<div class="item-card-title">
{match (name.clone(), url.clone()) {
(Some(n), Some(u)) => {
view! {
<a href=u target="_blank" rel="noopener noreferrer">
{n}
</a>
}
.into_any()
}
(Some(n), None) => view! { <span>{n}</span> }.into_any(),
(None, Some(u)) => {
view! {
<a href=u.clone() target="_blank" rel="noopener noreferrer">
{u.clone()}
</a>
}
.into_any()
}
(None, None) => ().into_any(),
}}
</div>
}
})}
{description.map(|d| view! { <p class="item-card-description">{d}</p> })}
{(!extra.is_empty())
.then(|| {
view! {
<div class="item-card-fields">
<For
each=move || extra.clone()
key=|(k, _)| k.clone()
children=|(k, v)| {
view! {
<span class="item-card-field">
<span class="item-card-field-key">{k}</span>
{v}
</span>
}
}
/>
</div>
}
})}
</article>
}
}
#[component]
fn AnswerRow(
question_id: String,
+37
View File
@@ -374,6 +374,43 @@ input:focus, textarea:focus {
.answer-action:hover { border-color: var(--accent); color: var(--accent); }
.answer-action:disabled { opacity: 0.5; cursor: wait; }
.item-list { display: grid; gap: 0.8rem; }
.item-card {
border: 1px solid var(--line);
border-radius: 0.8rem;
padding: 1rem 1.1rem;
display: grid;
gap: 0.4rem;
}
.item-card-title {
font-family: var(--sans);
font-weight: 600;
font-size: 1rem;
}
.item-card-title a { color: var(--ink); text-decoration: none; }
.item-card-title a:hover { color: var(--accent); text-decoration: underline; }
.item-card-description { font-size: 0.9rem; color: var(--ink-dim); }
.item-card-fields {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
margin-top: 0.2rem;
}
.item-card-field { font-size: 0.82rem; color: var(--ink-dim); }
.item-card-field-key {
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.68rem;
margin-right: 0.3rem;
}
@media (max-width: 640px) {
.hero { padding: 3rem 1.25rem 2rem; }
.alt-card { padding: 1.4rem 1.3rem; }