Add optional Alternative.image and Feature.color/icon for richer layouts
Deploy / deploy (push) Successful in 1m6s
Deploy / deploy (push) Successful in 1m6s
image: a banner url rendered as <img>, client-fetched directly (never server-side, so none of ResourceSource::Url's SSRF concern applies). color: any CSS color, set as the feature's own --feature-accent custom property (never interpolated into a stylesheet) - draws a left-border accent; unset means no border, not a fallback to the global --accent. icon: an Iconify name (lucide:star etc.), rendered via Iconify's public SVG API - no icon library bundled, matching this app's zero-JS- dependency content otherwise keeps to. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a29c025a85
commit
3ccc50248f
+17
-1
@@ -600,6 +600,9 @@ fn AlternativeCard(
|
|||||||
|
|
||||||
view! {
|
view! {
|
||||||
<section class="alt-card">
|
<section class="alt-card">
|
||||||
|
{alternative.image.clone().map(|src| view! {
|
||||||
|
<img class="alt-image" src=src alt="" />
|
||||||
|
})}
|
||||||
<h2>{alternative.name.clone()}</h2>
|
<h2>{alternative.name.clone()}</h2>
|
||||||
<p class="alt-description">{alternative.description.clone()}</p>
|
<p class="alt-description">{alternative.description.clone()}</p>
|
||||||
<div class="features">
|
<div class="features">
|
||||||
@@ -625,8 +628,13 @@ fn AlternativeCard(
|
|||||||
let alt_name = alt_name.clone();
|
let alt_name = alt_name.clone();
|
||||||
let resource = feature.resource.clone();
|
let resource = feature.resource.clone();
|
||||||
let feature_name = feature.name.clone();
|
let feature_name = feature.name.clone();
|
||||||
|
let color = feature.color.clone();
|
||||||
|
let icon = feature.icon.clone();
|
||||||
view! {
|
view! {
|
||||||
<div class="feature">
|
<div class="feature" style:--feature-accent=color>
|
||||||
|
{icon.filter(|i| is_safe_icon_name(i)).map(|icon| view! {
|
||||||
|
<img class="feature-icon" src=format!("https://api.iconify.design/{icon}.svg") alt="" />
|
||||||
|
})}
|
||||||
<Show when={
|
<Show when={
|
||||||
let name = feature.name.clone();
|
let name = feature.name.clone();
|
||||||
move || !name.is_empty()
|
move || !name.is_empty()
|
||||||
@@ -1274,6 +1282,14 @@ fn AnswerRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Iconify names are `{prefix}:{name}`, both lowercase-alphanumeric-
|
||||||
|
/// with-hyphens by convention - this is just a character allow-list
|
||||||
|
/// before the value goes into a URL, not a lookup against Iconify's
|
||||||
|
/// real prefix/name list.
|
||||||
|
fn is_safe_icon_name(s: &str) -> bool {
|
||||||
|
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':')
|
||||||
|
}
|
||||||
|
|
||||||
fn format_ms(ms: i64) -> String {
|
fn format_ms(ms: i64) -> String {
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ pub struct Alternative {
|
|||||||
pub consequence: Vec<String>,
|
pub consequence: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub encouragements: Vec<String>,
|
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).
|
||||||
|
#[serde(default)]
|
||||||
|
pub image: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub features: Vec<Feature>,
|
pub features: Vec<Feature>,
|
||||||
/// Names a NATS KV bucket to also durably store this submission
|
/// Names a NATS KV bucket to also durably store this submission
|
||||||
@@ -99,6 +106,23 @@ pub struct Feature {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
/// Any valid CSS color (hex, named, rgb()/oklch()/...) - set as this
|
||||||
|
/// feature's own `--feature-accent` custom property rather than
|
||||||
|
/// interpolated into a stylesheet, so a bad value just fails to
|
||||||
|
/// apply instead of being live CSS content could inject arbitrary
|
||||||
|
/// rules into. Unset means no accent border at all (see
|
||||||
|
/// style/main.css's `.feature`), not a silent fallback to the
|
||||||
|
/// global `--accent` - a feature that never asked for a color
|
||||||
|
/// shouldn't suddenly gain a visible border.
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<String>,
|
||||||
|
/// An Iconify icon name (`{prefix}:{name}`, e.g. `lucide:star`),
|
||||||
|
/// rendered via Iconify's public SVG API
|
||||||
|
/// (`https://api.iconify.design/{icon}.svg`) - no icon library
|
||||||
|
/// bundled here, matching this app's zero-JS-dependency content
|
||||||
|
/// otherwise keeps to.
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub requirements: Vec<Requirement>,
|
pub requirements: Vec<Requirement>,
|
||||||
/// Live data this feature pulls in. Read-only unless `transitions`
|
/// Live data this feature pulls in. Read-only unless `transitions`
|
||||||
|
|||||||
@@ -280,6 +280,15 @@ main.not-found {
|
|||||||
padding: 1.75rem 1.9rem;
|
padding: 1.75rem 1.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alt-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 14rem;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: calc(var(--radius) - 0.3rem);
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.alt-card h2 {
|
.alt-card h2 {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
}
|
}
|
||||||
@@ -316,6 +325,18 @@ main.not-found {
|
|||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
border-left: 3px solid var(--feature-accent, transparent);
|
||||||
|
padding-left: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
float: left;
|
||||||
|
width: 1.3rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
margin: 0.1rem 0.6rem 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.feature h3 {
|
.feature h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin-bottom: 0.15em;
|
margin-bottom: 0.15em;
|
||||||
|
|||||||
Reference in New Issue
Block a user