Files
portal/src/app.rs
T

928 lines
38 KiB
Rust
Raw Normal View History

use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map},
path, NavigateOptions,
};
use serde::{Deserialize, Serialize};
use crate::answers::{Answer, TransitionAnswer};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Transition};
use crate::resource::get_resource;
/// The visible site name/wordmark - "portal" is just this codebase's
/// working title, not necessarily what any given deployment is called.
/// Baked in at *build* time (`option_env!`, not `std::env::var`) so the
/// ssr and hydrate targets - compiled separately - end up with the
/// identical value without a runtime round-trip or context-passing
/// between them; set `SITE_NAME` for both `cargo leptos` invocations
/// (ssr and hydrate) to change it, e.g. `SITE_NAME=uhhm`. CSS uppercases
/// the wordmark for display regardless of the source casing here.
const SITE_NAME: &str = match option_env!("SITE_NAME") {
Some(name) => name,
None => "portal",
};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/portal.css"/>
<Title text=SITE_NAME/>
<Router>
<Routes fallback=|| view! { <NotFound/> }>
<Route path=path!("") view=QuestionPage/>
<Route path=path!("/*any") view=QuestionPage/>
</Routes>
</Router>
}
}
#[component]
fn QuestionPage() -> impl IntoView {
let location = use_location();
let query = use_query_map();
let path = Memo::new(move |_| {
let p = location.pathname.get();
if p.is_empty() {
"/".to_string()
} else {
p
}
});
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
let question = Resource::new(move || path.get(), get_question);
let user = Resource::new(|| (), |_| current_user());
view! {
<Suspense fallback=|| {
view! {
<main class="loading">
<p>"loading…"</p>
</main>
}
}>
{move || {
// Both read unconditionally (not nested inside the match
// below) so Suspense tracks both from the first pass,
// rather than only discovering `user` once `question`
// has already resolved.
let user_res = user.get();
let question_res = question.get();
question_res
.map(|res| match res {
Ok(Some(q)) => {
let current = user_res.and_then(|r| r.ok()).flatten();
view! {
<QuestionView
question=q
parent_hash=parent_hash.get()
user=current
/>
}
.into_any()
}
_ => view! { <NotFound/> }.into_any(),
})
}}
</Suspense>
}
}
#[component]
fn QuestionView(
question: Question,
parent_hash: Option<String>,
user: Option<User>,
) -> impl IntoView {
let question_id = question.id.clone();
// Generic: any question with `qualifies` set renders this same gate
// instead of its alternatives - "/review" isn't a special case, it's
// just a question that happens to have `qualifies` set.
if !is_qualified(user.as_ref(), &question) {
let signed_in = user.is_some();
return view! {
<Hero
title=question.name.clone()
description=question.description.clone()
show_yes=question_id == "/"
/>
<div class="alternatives">
<section class="alt-card gate-card">
{if signed_in {
view! { <p>"This part of the site is for organizational owners — sign in with that account to take a look."</p> }
.into_any()
} else {
view! {
<a class="alt-submit" href="/auth/login" rel="external">
"Sign in"
</a>
}
.into_any()
}}
</section>
</div>
}
.into_any();
}
view! {
<Hero
title=question.name.clone()
description=question.description.clone()
show_yes=question_id == "/"
/>
<div class="alternatives">
<For
each=move || question.alternatives.clone()
key=|a| a.name.clone()
children=move |alt: Alternative| {
view! {
<AlternativeCard
question_id=question_id.clone()
alternative=alt
parent_hash=parent_hash.clone()
/>
}
.into_any()
}
/>
</div>
}
.into_any()
}
// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no
// (ported to `public/yes.js`, an ES module now instead of a page-owning
// script) as a typed JS class - the idiomatic wasm-bindgen way to drive
// an existing JS module from Rust, rather than re-implementing canvas
// animation logic here.
#[cfg(feature = "hydrate")]
mod yes {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/yes.js")]
extern "C" {
#[wasm_bindgen(js_name = RasterizedYES)]
pub type RasterizedYes;
#[wasm_bindgen(constructor, js_class = "RasterizedYES")]
pub fn new() -> RasterizedYes;
#[wasm_bindgen(method)]
pub fn stop(this: &RasterizedYes);
}
}
// Same idiomatic-typed-module approach as `yes` above, for the
// prosekit-backed rich-text requirement kind (`prosekit-editor.js`,
// project root - `#[wasm_bindgen(module = "/x.js")]` resolves relative
// to the crate root, not the `public/` static-asset dir, same as
// `yes.js` below). `mount_editor` hands the editor a container node
// plus the paired
// hidden `<input>` it mirrors its HTML into and fires real `input`
// events on - see `mount_editor`'s call site in `AlternativeCard`, and
// the JS module itself for why a hidden-input bridge rather than a
// bespoke Rust<->JS value channel.
#[cfg(feature = "hydrate")]
mod prosekit {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/prosekit-editor.js")]
extern "C" {
#[wasm_bindgen(js_name = mountEditor)]
pub fn mount_editor(
container: &web_sys::HtmlDivElement,
hidden: &web_sys::HtmlInputElement,
initial: &str,
);
}
}
#[component]
fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
// Only the landing page gets the full interactive piece - it's the
// one page this is actually "the" hero for; other pages (thank-you
// pages, /review) get the plain dark header below. Starts the
// animation once the canvas elements exist (post-mount, via
// Effect), and explicitly stops the requestAnimationFrame loop on
// unmount - the original page-owning script never needed this since
// navigating away meant a full document unload, which doesn't
// happen in an SPA.
#[cfg(feature = "hydrate")]
if show_yes {
// `on_cleanup` requires Send + Sync (even single-threaded, wasm),
// which a JS-backed value never is - `StoredValue`'s `LocalStorage`
// variant is the established way around this (same pattern
// cnats' WebRTC call state uses for its own non-Send peer map).
let instance: StoredValue<Option<yes::RasterizedYes>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
instance.set_value(Some(yes::RasterizedYes::new()));
});
on_cleanup(move || {
instance.update_value(|opt| {
if let Some(inst) = opt.take() {
inst.stop();
}
});
});
}
view! {
<header class="hero" class:hero-yes=show_yes>
{show_yes
.then(|| {
view! {
<div class="hero-canvas" aria-hidden="true">
<canvas id="lineCanvas"></canvas>
<canvas id="rasterCanvas"></canvas>
</div>
}
})}
<div class="hero-copy">
<a class="wordmark" href="/">
{SITE_NAME}
</a>
<h1>{title}</h1>
<p>{description}</p>
</div>
</header>
}
}
#[component]
fn AlternativeCard(
question_id: String,
alternative: Alternative,
parent_hash: Option<String>,
) -> impl IntoView {
let has_action = alternative.action.is_some();
// File fields don't fit a live-typed RwSignal<String> - they get
// their own map of element refs, read (and uploaded) only at submit
// time, and are excluded from `field_map` below.
let mut field_map: std::collections::HashMap<String, RwSignal<String>> =
std::collections::HashMap::new();
let mut file_refs: std::collections::HashMap<String, NodeRef<leptos::html::Input>> =
std::collections::HashMap::new();
for feature in &alternative.features {
for req in &feature.requirements {
if req.kind == "file" {
file_refs.entry(req.name.clone()).or_insert_with(NodeRef::new);
} else {
field_map
.entry(req.name.clone())
.or_insert_with(|| RwSignal::new(String::new()));
}
}
}
// Prefixes field DOM ids with the alternative too, not just the
// question - two alternatives on the same page (e.g. both asking for
// "email") would otherwise mint the same id twice, which is invalid
// HTML and makes <label for=…> pick the wrong input.
let field_prefix = format!(
"{question_id}-{}",
alternative.name.to_lowercase().replace(' ', "-")
);
let submit = ServerAction::<SubmitAnswer>::new();
let navigate = use_navigate();
let question_id_for_submit = question_id.clone();
let alt_name_for_submit = alternative.name.clone();
let parent_hash_for_submit = parent_hash.clone();
let field_map_for_submit = field_map.clone();
let file_refs_for_submit = file_refs.clone();
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let field_map_for_submit = field_map_for_submit.clone();
let file_refs_for_submit = file_refs_for_submit.clone();
let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone();
leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() {
map.insert(name.clone(), serde_json::Value::String(sig.get()));
}
// Only used under hydrate (file uploads are a browser-only
// concern); referenced unconditionally so an `ssr` build
// doesn't warn about it going unused.
let _ = &file_refs_for_submit;
#[cfg(feature = "hydrate")]
for (name, file_ref) in file_refs_for_submit.iter() {
let Some(input) = file_ref.get_untracked() else {
continue;
};
let Some(files) = input.files() else { continue };
let Some(file) = files.get(0) else { continue };
match upload_file(&file).await {
Ok(key) => {
map.insert(name.clone(), serde_json::Value::String(key));
}
Err(e) => {
leptos::logging::error!("upload of {name} failed: {e:?}");
}
}
}
let responses_json =
serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_default();
submit.dispatch(SubmitAnswer {
question_id: question_id_for_submit,
alternative: alt_name_for_submit,
parent_hash: parent_hash_for_submit,
responses_json,
});
});
};
Effect::new(move |_| {
if let Some(Ok(result)) = submit.value().get() {
if let Some(next) = result.next {
navigate(
&format!("{next}?chain={}", result.chain_hash),
NavigateOptions::default(),
);
}
}
});
let button_label = alternative
.consequence
.first()
.cloned()
.unwrap_or_else(|| "Send".to_string());
view! {
<section class="alt-card">
<h2>{alternative.name.clone()}</h2>
<p class="alt-description">{alternative.description.clone()}</p>
<For
each=move || alternative.encouragements.clone()
key=|e| e.clone()
children=move |e| view! { <p class="alt-encouragement">{e}</p> }
/>
<div class="features">
<For
each={
let features = alternative.features.clone();
move || features.clone()
}
key=|f| f.name.clone()
children={
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
move |feature| {
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
let resource = feature.resource.clone();
let feature_name = feature.name.clone();
view! {
<div class="feature">
<Show when={
let name = feature.name.clone();
move || !name.is_empty()
}>
<h3>{feature.name.clone()}</h3>
</Show>
<Show when={
let description = feature.description.clone();
move || !description.is_empty()
}>
<p>{feature.description.clone()}</p>
</Show>
{resource.map(|spec| {
view! {
<ResourceFeature
question_id=question_id.clone()
feature_name=feature_name.clone()
transitions=spec.transitions.clone()
/>
}
})}
<For
each={
let reqs = feature.requirements.clone();
move || reqs.clone()
}
key=|r| r.name.clone()
children={
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let field_prefix = field_prefix.clone();
move |req| {
let field_id = format!("{}-{}", field_prefix, req.name);
let label_for = field_id.clone();
let label_text = view! {
<span class="field-label">
{req.display_label()}
{(!req.optional).then(|| view! { <span class="required">"*"</span> })}
</span>
};
let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new()));
if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
return view! {
<label class="field" for=label_for>
{label_text}
<input
id=field_id
type="file"
accept=req.accept.clone().unwrap_or_default()
multiple=req.multiple
required=!req.optional
node_ref=file_ref
/>
</label>
}
.into_any();
}
if req.kind == "prosekit" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
#[cfg(feature = "hydrate")]
{
Effect::new(move |_| {
let (Some(container), Some(hidden)) =
(container_ref.get(), hidden_ref.get())
else {
return;
};
prosekit::mount_editor(&container, &hidden, &sig.get_untracked());
});
}
return view! {
<label class="field" for=label_for>
{label_text}
<input
id=field_id
type="hidden"
node_ref=hidden_ref
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
<div class="prosekit-editor" node_ref=container_ref></div>
</label>
}
.into_any();
}
view! {
<label class="field" for=label_for>
{label_text}
<Show
when={
let kind = req.kind.clone();
move || kind == "textarea"
}
fallback={
let kind = req.kind.clone();
let placeholder = req.placeholder.clone().unwrap_or_default();
let field_id = field_id.clone();
let required = !req.optional;
move || {
view! {
<input
id=field_id.clone()
type=kind.clone()
placeholder=placeholder.clone()
required=required
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
}
}
}
>
{
let field_id = field_id.clone();
let placeholder = req.placeholder.clone().unwrap_or_default();
let required = !req.optional;
view! {
<textarea
id=field_id.clone()
placeholder=placeholder.clone()
required=required
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
></textarea>
}
}
</Show>
</label>
}
.into_any()
}
}
/>
</div>
}
.into_any()
}
}
/>
</div>
{has_action
.then(|| {
view! {
<form on:submit=on_submit>
<button
type="submit"
class="alt-submit"
disabled=move || submit.pending().get()
>
{button_label.clone()}
</button>
</form>
}
})}
</section>
}
}
/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
/// Fully generic: it has no idea what "/review" or "applicants" are - it
/// just fetches `question_id`/`feature_name`'s data and renders it
/// according to its own shape (see `ResourceValue`), with one button
/// per `transitions` entry when the data looks like a list of answers.
#[component]
fn ResourceFeature(
question_id: String,
feature_name: String,
transitions: Vec<Transition>,
) -> impl IntoView {
let data = Resource::new(
{
let question_id = question_id.clone();
let feature_name = feature_name.clone();
move || (question_id.clone(), feature_name.clone())
},
|(q, f)| get_resource(q, f),
);
view! {
<div class="resource">
<Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }>
{move || {
let question_id = question_id.clone();
let feature_name = feature_name.clone();
let transitions = transitions.clone();
data.get()
.map(|res| match res {
Ok(value) => {
view! {
<ResourceValue
question_id=question_id
feature_name=feature_name
transitions=transitions
value=value
/>
}
.into_any()
}
Err(e) => {
view! { <p class="resource-error">{e.to_string()}</p> }.into_any()
}
})
}}
</Suspense>
</div>
}
}
/// Renders whatever `get_resource` returned, purely from its shape: an
/// array that parses as `answers::Answer` renders as a list (with
/// transition buttons, if any were declared); anything else renders as
/// a plain, read-only dump. No `kind`/render-mode tag anywhere - the
/// data decides.
#[component]
fn ResourceValue(
question_id: String,
feature_name: String,
transitions: Vec<Transition>,
value: serde_json::Value,
) -> impl IntoView {
if let serde_json::Value::Array(items) = &value {
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers;
answers.sort_by(|a, b| {
let open = |s: &str| s != crate::answers::OPEN_STATE;
open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms))
});
return view! {
<div class="answer-list">
<For
each=move || answers.clone()
key=|a| a.id.clone()
children={
let question_id = question_id.clone();
let feature_name = feature_name.clone();
let transitions = transitions.clone();
move |answer: Answer| {
view! {
<AnswerRow
question_id=question_id.clone()
feature_name=feature_name.clone()
transitions=transitions.clone()
answer=answer
/>
}
.into_any()
}
}
/>
</div>
}
.into_any();
}
if items.is_empty() {
return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any();
}
}
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
}
#[component]
fn AnswerRow(
question_id: String,
feature_name: String,
transitions: Vec<Transition>,
answer: Answer,
) -> impl IntoView {
let is_open = answer.state == crate::answers::OPEN_STATE;
let responses = answer
.responses
.as_object()
.cloned()
.unwrap_or_default();
let submitted_ms = answer.submitted_ms;
let state = answer.state.clone();
let item_id = answer.id.clone();
let transition = ServerAction::<TransitionAnswer>::new();
view! {
<article class="answer-row" class:decided=move || !is_open>
<div class="answer-fields">
<For
each={
let responses = responses.clone();
move || responses.clone().into_iter().collect::<Vec<_>>()
}
key=|(k, _)| k.clone()
children=move |(key, value)| {
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<span class="answer-field-value">{text}</span>
</div>
}
.into_any()
}
/>
</div>
<div class="answer-meta">
<span class="answer-state">{state}</span>
<span class="answer-time">{format_ms(submitted_ms)}</span>
</div>
<Show when={
let transitions = transitions.clone();
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 feature_name = feature_name.clone();
let item_id = item_id.clone();
move |t: Transition| {
let question_id = question_id.clone();
let feature_name = feature_name.clone();
let item_id = item_id.clone();
let to = t.to.clone();
view! {
<button
class="answer-action"
on:click=move |_| {
transition.dispatch(TransitionAnswer {
question_id: question_id.clone(),
feature_name: feature_name.clone(),
item_id: item_id.clone(),
to: to.clone(),
});
}
disabled=move || transition.pending().get()
>
{t.label.clone()}
</button>
}
.into_any()
}
}
/>
</div>
</Show>
</article>
}
}
fn format_ms(ms: i64) -> String {
#[cfg(feature = "ssr")]
{
use chrono::TimeZone;
chrono::Utc
.timestamp_millis_opt(ms)
.single()
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_else(|| ms.to_string())
}
#[cfg(not(feature = "ssr"))]
{
ms.to_string()
}
}
#[component]
fn NotFound() -> impl IntoView {
view! {
<main class="not-found">
<h1>"Nothing here"</h1>
<a href="/">"back to the start"</a>
</main>
}
}
#[server]
pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
Ok(state.questions.load().get(&path).cloned())
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubmitResult {
pub next: Option<String>,
pub chain_hash: String,
}
#[server]
pub async fn submit_answer(
question_id: String,
alternative: String,
parent_hash: Option<String>,
responses_json: String,
) -> Result<SubmitResult, ServerFnError> {
use crate::answers::store_answer;
use crate::auth::{User, SESSION_USER_KEY};
use crate::chain::hash_node;
use crate::content::is_qualified;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
let state = expect_context::<AppState>();
let question = state
.questions
.load()
.get(&question_id)
.cloned()
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !is_qualified(user.as_ref(), &question) {
return Err(ServerFnError::new("not authorized for this question"));
}
let alt = question
.alternatives
.iter()
.find(|a| a.name == alternative)
.ok_or_else(|| ServerFnError::new("unknown alternative"))?;
let next = alt.action.clone();
let record_as = alt.record_as.clone();
let responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
let parent_hashes: Vec<String> = parent_hash.into_iter().collect();
let timestamp_ms = chrono::Utc::now().timestamp_millis();
let chain_hash = hash_node(&question_id, &parent_hashes, &responses, timestamp_ms);
emit_answer_submitted(
&state.nats,
&AnswerSubmitted {
chain_hash: chain_hash.clone(),
parent_hashes,
question_id: question_id.clone(),
alternative: alternative.clone(),
responses: responses.clone(),
timestamp_ms,
},
)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
// Best-effort: a bucket-write hiccup shouldn't fail a submission the
// NATS event has already recorded.
if let Some(bucket) = &record_as {
if let Err(e) = store_answer(
&state.jetstream,
bucket,
chain_hash.clone(),
&question_id,
&alternative,
&responses,
timestamp_ms,
)
.await
{
tracing::error!("failed to store answer in {bucket}: {e}");
}
}
Ok(SubmitResult { next, chain_hash })
}
/// Uploads one file to `POST /upload` and returns its stored object key
/// - the value a `type: file` requirement contributes to
/// `responses_json`, same as any other field. A plain `fetch`, not a
/// Leptos server fn: those aren't built for binary bodies, and the
/// server side is a raw Axum multipart handler (`src/upload.rs`) for
/// the same reason.
#[cfg(feature = "hydrate")]
async fn upload_file(file: &web_sys::File) -> Result<String, wasm_bindgen::JsValue> {
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::{FormData, Request, RequestInit, Response};
let form = FormData::new()?;
form.append_with_blob("file", file)?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_body_opt_form_data(Some(&form));
let request = Request::new_with_str_and_init("/upload", &opts)?;
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
let resp: Response = JsFuture::from(window.fetch_with_request(&request))
.await?
.dyn_into()?;
if !resp.ok() {
return Err(JsValue::from_str(&format!(
"upload failed with status {}",
resp.status()
)));
}
let json = JsFuture::from(resp.json()?).await?;
js_sys::Reflect::get(&json, &JsValue::from_str("key"))?
.as_string()
.ok_or_else(|| JsValue::from_str("upload response had no key"))
}