Fix bucket-404 on empty resources, redesign review actions as select-then-confirm, CSS polish
Deploy / deploy (push) Successful in 35s
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:
co-authored by
Claude Sonnet 5
parent
11c6f3e8f7
commit
a200161334
+67
-28
@@ -230,7 +230,9 @@ fn ResponsibleNote(question_id: String, responsible: Responsible) -> impl IntoVi
|
|||||||
|
|
||||||
view! {
|
view! {
|
||||||
<p class="question-responsible">
|
<p class="question-responsible">
|
||||||
"Asked by " {responsible.name.clone()} " — "
|
"Asked by " {responsible.name.clone()}
|
||||||
|
<br/>
|
||||||
|
"— "
|
||||||
<a href=format!("mailto:{}", responsible.contact)>"contact them"</a>
|
<a href=format!("mailto:{}", responsible.contact)>"contact them"</a>
|
||||||
" if you get stuck."
|
" if you get stuck."
|
||||||
{move || {
|
{move || {
|
||||||
@@ -834,6 +836,7 @@ fn ResourceFeature(
|
|||||||
feature_name=feature_name
|
feature_name=feature_name
|
||||||
transitions=transitions
|
transitions=transitions
|
||||||
value=value
|
value=value
|
||||||
|
data=data
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
@@ -860,6 +863,7 @@ fn ResourceValue(
|
|||||||
feature_name: String,
|
feature_name: String,
|
||||||
transitions: Vec<Transition>,
|
transitions: Vec<Transition>,
|
||||||
value: serde_json::Value,
|
value: serde_json::Value,
|
||||||
|
data: Resource<Result<serde_json::Value, ServerFnError>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
if let serde_json::Value::Array(items) = &value {
|
if let serde_json::Value::Array(items) = &value {
|
||||||
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
|
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
|
||||||
@@ -886,6 +890,7 @@ fn ResourceValue(
|
|||||||
feature_name=feature_name.clone()
|
feature_name=feature_name.clone()
|
||||||
transitions=transitions.clone()
|
transitions=transitions.clone()
|
||||||
answer=answer
|
answer=answer
|
||||||
|
data=data
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
@@ -1127,6 +1132,7 @@ fn AnswerRow(
|
|||||||
feature_name: String,
|
feature_name: String,
|
||||||
transitions: Vec<Transition>,
|
transitions: Vec<Transition>,
|
||||||
answer: Answer,
|
answer: Answer,
|
||||||
|
data: Resource<Result<serde_json::Value, ServerFnError>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let is_open = answer.state == crate::answers::OPEN_STATE;
|
let is_open = answer.state == crate::answers::OPEN_STATE;
|
||||||
let responses = answer
|
let responses = answer
|
||||||
@@ -1139,6 +1145,19 @@ fn AnswerRow(
|
|||||||
let item_id = answer.id.clone();
|
let item_id = answer.id.clone();
|
||||||
|
|
||||||
let transition = ServerAction::<TransitionAnswer>::new();
|
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! {
|
view! {
|
||||||
<article class="answer-row" class:decided=move || !is_open>
|
<article class="answer-row" class:decided=move || !is_open>
|
||||||
@@ -1170,44 +1189,64 @@ fn AnswerRow(
|
|||||||
move || is_open && !transitions.is_empty()
|
move || is_open && !transitions.is_empty()
|
||||||
}>
|
}>
|
||||||
<div class="answer-actions">
|
<div class="answer-actions">
|
||||||
<For
|
<div class="select-options">
|
||||||
each={
|
<For
|
||||||
let transitions = transitions.clone();
|
each={
|
||||||
move || transitions.clone()
|
let transitions = transitions.clone();
|
||||||
}
|
move || transitions.clone()
|
||||||
key=|t| t.to.clone()
|
}
|
||||||
children={
|
key=|t| t.to.clone()
|
||||||
let question_id = question_id.clone();
|
children=move |t: Transition| {
|
||||||
let alternative = alternative.clone();
|
let to_for_selected = t.to.clone();
|
||||||
let feature_name = feature_name.clone();
|
let to_for_click = t.to.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();
|
|
||||||
view! {
|
view! {
|
||||||
<button
|
<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 |_| {
|
on:click=move |_| {
|
||||||
transition.dispatch(TransitionAnswer {
|
selected
|
||||||
question_id: question_id.clone(),
|
.update(|s| {
|
||||||
alternative: alternative.clone(),
|
*s = if s.as_deref() == Some(to_for_click.as_str()) {
|
||||||
feature_name: feature_name.clone(),
|
None
|
||||||
item_id: item_id.clone(),
|
} else {
|
||||||
to: to.clone(),
|
Some(to_for_click.clone())
|
||||||
});
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
disabled=move || transition.pending().get()
|
|
||||||
>
|
>
|
||||||
{t.label.clone()}
|
{t.label.clone()}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
.into_any()
|
.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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
+14
-8
@@ -133,13 +133,19 @@ async fn fetch_resource_value(
|
|||||||
|
|
||||||
let value = match &resource.source {
|
let value = match &resource.source {
|
||||||
ResourceSource::Kv { bucket } => {
|
ResourceSource::Kv { bucket } => {
|
||||||
let store = state
|
// A bucket only ever gets created on first write
|
||||||
.jetstream
|
// (answers::store_answer's own create-on-first-use, or the
|
||||||
.get_key_value(bucket)
|
// event-sourcing projection upsert) - a bucket declared in
|
||||||
.await
|
// content but never yet written to is a completely normal
|
||||||
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
|
// "nothing submitted here yet" state, not an error. Listing
|
||||||
match &resource.key {
|
// it reads the same as an empty bucket would; asking for
|
||||||
Some(key) => {
|
// one specific key that can't possibly exist yet still
|
||||||
|
// surfaces as an error, same as key-not-found.
|
||||||
|
let store = state.jetstream.get_key_value(bucket).await.ok();
|
||||||
|
match (&resource.key, store) {
|
||||||
|
(Some(_), None) => return Err(ServerFnError::new("resource key not found")),
|
||||||
|
(None, None) => serde_json::Value::Array(Vec::new()),
|
||||||
|
(Some(key), Some(store)) => {
|
||||||
let bytes = store
|
let bytes = store
|
||||||
.get(key)
|
.get(key)
|
||||||
.await
|
.await
|
||||||
@@ -147,7 +153,7 @@ async fn fetch_resource_value(
|
|||||||
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
|
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
|
||||||
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?
|
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
}
|
}
|
||||||
None => {
|
(None, Some(store)) => {
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
let keys: Vec<String> = store
|
let keys: Vec<String> = store
|
||||||
.keys()
|
.keys()
|
||||||
|
|||||||
+53
-9
@@ -153,6 +153,22 @@ main.loading, main.not-found {
|
|||||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.8);
|
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Softens the otherwise hard cut where the full-bleed canvas meets the
|
||||||
|
page background right below it - fades to the same --paper color
|
||||||
|
over the last few ems instead of stopping dead. Non-interactive so
|
||||||
|
it never steals clicks meant for the canvas underneath. */
|
||||||
|
.hero-yes::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 6em;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--paper));
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* alternatives */
|
/* alternatives */
|
||||||
|
|
||||||
.alternatives {
|
.alternatives {
|
||||||
@@ -163,6 +179,29 @@ main.loading, main.not-found {
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.question-responsible {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-responsible a { color: var(--ink-dim); }
|
||||||
|
.question-responsible a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.question-report {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-report:hover { color: var(--accent); }
|
||||||
|
.question-report:disabled { opacity: 0.5; cursor: wait; }
|
||||||
|
|
||||||
.alt-card {
|
.alt-card {
|
||||||
background: var(--paper-raised);
|
background: var(--paper-raised);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -364,23 +403,28 @@ input:focus, textarea:focus {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-actions { display: flex; gap: 0.6rem; }
|
.answer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.answer-action {
|
.answer-confirm {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--ink);
|
color: #0a0a0a;
|
||||||
background: var(--paper);
|
background: var(--accent);
|
||||||
border: 1px solid var(--line);
|
border: none;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 0.4rem 0.8rem;
|
padding: 0.45rem 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 120ms, color 120ms;
|
transition: filter 120ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.answer-action:hover { border-color: var(--accent); color: var(--accent); }
|
.answer-confirm:hover { filter: brightness(1.08); }
|
||||||
.answer-action:disabled { opacity: 0.5; cursor: wait; }
|
.answer-confirm:disabled { opacity: 0.5; cursor: not-allowed; filter: none; }
|
||||||
|
|
||||||
.item-list { display: grid; gap: 0.8rem; }
|
.item-list { display: grid; gap: 0.8rem; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user