Fix bucket-404 on empty resources, redesign review actions as select-then-confirm, CSS polish
Deploy / deploy (push) Successful in 35s

get_resource's Kv path treated a not-yet-created bucket (nothing
submitted there yet) as a hard error instead of an empty list -
projects bucket never got created since the backfill found nothing to
migrate, so /review's Projects alternative 404'd outright. Now matches
store_answer's own "doesn't exist yet is normal" posture; a specific
key request still errors, only listing degrades gracefully.

AnswerRow's Invite/Decline buttons fired transition_answer immediately
on click, with no staging step and no visible confirmation once it
landed (the resource list never refetched, so a click barely looked
like it did anything). Redesigned as toggle-select (reusing the
.select-option pattern from the resource-backed select requirement)
plus one explicit Confirm button; a successful transition now refetches
the parent Resource so the row actually reflects the change.

CSS: soften the hard cut where the hero canvas meets the page
background below it (gradient fade over the last few ems, matching
--paper); center the "Asked by X" responsible note with the em-dash
starting its own line; give it and the report button real styling
(previously unstyled default text).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-06 12:42:54 +02:00
co-authored by Claude Sonnet 5
parent 11c6f3e8f7
commit a200161334
3 changed files with 134 additions and 45 deletions
+67 -28
View File
@@ -230,7 +230,9 @@ fn ResponsibleNote(question_id: String, responsible: Responsible) -> impl IntoVi
view! {
<p class="question-responsible">
"Asked by " {responsible.name.clone()} ""
"Asked by " {responsible.name.clone()}
<br/>
""
<a href=format!("mailto:{}", responsible.contact)>"contact them"</a>
" if you get stuck."
{move || {
@@ -834,6 +836,7 @@ fn ResourceFeature(
feature_name=feature_name
transitions=transitions
value=value
data=data
/>
}
.into_any()
@@ -860,6 +863,7 @@ fn ResourceValue(
feature_name: String,
transitions: Vec<Transition>,
value: serde_json::Value,
data: Resource<Result<serde_json::Value, ServerFnError>>,
) -> impl IntoView {
if let serde_json::Value::Array(items) = &value {
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
@@ -886,6 +890,7 @@ fn ResourceValue(
feature_name=feature_name.clone()
transitions=transitions.clone()
answer=answer
data=data
/>
}
.into_any()
@@ -1127,6 +1132,7 @@ fn AnswerRow(
feature_name: String,
transitions: Vec<Transition>,
answer: Answer,
data: Resource<Result<serde_json::Value, ServerFnError>>,
) -> impl IntoView {
let is_open = answer.state == crate::answers::OPEN_STATE;
let responses = answer
@@ -1139,6 +1145,19 @@ fn AnswerRow(
let item_id = answer.id.clone();
let transition = ServerAction::<TransitionAnswer>::new();
// Toggle-select first (which transition, if any) - nothing is sent
// to the server until Confirm is pressed. Previously each button
// fired transition_answer immediately on click with no staging
// step and no visible confirmation once it landed (the list never
// refetched), so a click barely looked like it did anything.
let selected: RwSignal<Option<String>> = RwSignal::new(None);
Effect::new(move |_| {
if let Some(Ok(())) = transition.value().get() {
selected.set(None);
data.refetch();
}
});
view! {
<article class="answer-row" class:decided=move || !is_open>
@@ -1170,44 +1189,64 @@ fn AnswerRow(
move || is_open && !transitions.is_empty()
}>
<div class="answer-actions">
<For
each={
let transitions = transitions.clone();
move || transitions.clone()
}
key=|t| t.to.clone()
children={
let question_id = question_id.clone();
let alternative = alternative.clone();
let feature_name = feature_name.clone();
let item_id = item_id.clone();
move |t: Transition| {
let question_id = question_id.clone();
let alternative = alternative.clone();
let feature_name = feature_name.clone();
let item_id = item_id.clone();
let to = t.to.clone();
<div class="select-options">
<For
each={
let transitions = transitions.clone();
move || transitions.clone()
}
key=|t| t.to.clone()
children=move |t: Transition| {
let to_for_selected = t.to.clone();
let to_for_click = t.to.clone();
view! {
<button
class="answer-action"
type="button"
class="select-option"
class:selected=move || {
selected.get().as_deref() == Some(to_for_selected.as_str())
}
on:click=move |_| {
transition.dispatch(TransitionAnswer {
question_id: question_id.clone(),
alternative: alternative.clone(),
feature_name: feature_name.clone(),
item_id: item_id.clone(),
to: to.clone(),
});
selected
.update(|s| {
*s = if s.as_deref() == Some(to_for_click.as_str()) {
None
} else {
Some(to_for_click.clone())
};
});
}
disabled=move || transition.pending().get()
>
{t.label.clone()}
</button>
}
.into_any()
}
/>
</div>
<button
type="button"
class="answer-confirm"
disabled=move || selected.get().is_none() || transition.pending().get()
on:click={
let question_id = question_id.clone();
let alternative = alternative.clone();
let feature_name = feature_name.clone();
let item_id = item_id.clone();
move |_| {
let Some(to) = selected.get() else { return };
transition.dispatch(TransitionAnswer {
question_id: question_id.clone(),
alternative: alternative.clone(),
feature_name: feature_name.clone(),
item_id: item_id.clone(),
to,
});
}
}
/>
>
{move || if transition.pending().get() { "Confirming…" } else { "Confirm" }}
</button>
</div>
</Show>
</article>