diff --git a/src/app.rs b/src/app.rs index ea9d391..28454ae 100644 --- a/src/app.rs +++ b/src/app.rs @@ -230,7 +230,9 @@ fn ResponsibleNote(question_id: String, responsible: Responsible) -> impl IntoVi view! {

- "Asked by " {responsible.name.clone()} " — " + "Asked by " {responsible.name.clone()} +
+ "— " "contact them" " 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, value: serde_json::Value, + data: Resource>, ) -> impl IntoView { if let serde_json::Value::Array(items) = &value { if let Ok(answers) = serde_json::from_value::>(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, answer: Answer, + data: Resource>, ) -> 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::::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> = RwSignal::new(None); + + Effect::new(move |_| { + if let Some(Ok(())) = transition.value().get() { + selected.set(None); + data.refetch(); + } + }); view! {

@@ -1170,44 +1189,64 @@ fn AnswerRow( move || is_open && !transitions.is_empty() }>
- + {t.label.clone()} } .into_any() } + /> +
+
diff --git a/src/resource.rs b/src/resource.rs index a6da68e..a25881b 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -133,13 +133,19 @@ async fn fetch_resource_value( let value = match &resource.source { ResourceSource::Kv { bucket } => { - let store = state - .jetstream - .get_key_value(bucket) - .await - .map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?; - match &resource.key { - Some(key) => { + // A bucket only ever gets created on first write + // (answers::store_answer's own create-on-first-use, or the + // event-sourcing projection upsert) - a bucket declared in + // content but never yet written to is a completely normal + // "nothing submitted here yet" state, not an error. Listing + // it reads the same as an empty bucket would; asking for + // 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 .get(key) .await @@ -147,7 +153,7 @@ async fn fetch_resource_value( .ok_or_else(|| ServerFnError::new("resource key not found"))?; serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))? } - None => { + (None, Some(store)) => { use futures::TryStreamExt; let keys: Vec = store .keys() diff --git a/style/main.css b/style/main.css index 7244669..a2ae5f2 100644 --- a/style/main.css +++ b/style/main.css @@ -153,6 +153,22 @@ main.loading, main.not-found { 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 { @@ -163,6 +179,29 @@ main.loading, main.not-found { 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 { background: var(--paper-raised); border: 1px solid var(--line); @@ -364,23 +403,28 @@ input:focus, textarea:focus { 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-weight: 600; font-size: 0.82rem; - color: var(--ink); - background: var(--paper); - border: 1px solid var(--line); + color: #0a0a0a; + background: var(--accent); + border: none; border-radius: 0.5rem; - padding: 0.4rem 0.8rem; + padding: 0.45rem 1rem; cursor: pointer; - transition: border-color 120ms, color 120ms; + transition: filter 120ms; } -.answer-action:hover { border-color: var(--accent); color: var(--accent); } -.answer-action:disabled { opacity: 0.5; cursor: wait; } +.answer-confirm:hover { filter: brightness(1.08); } +.answer-confirm:disabled { opacity: 0.5; cursor: not-allowed; filter: none; } .item-list { display: grid; gap: 0.8rem; }