feat(list): make spreadsheet like ui
This commit is contained in:
parent
cd7ad8d140
commit
23b63ebecd
|
@ -730,6 +730,7 @@ dependencies = [
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|
|
@ -24,6 +24,7 @@ tokio = "1"
|
||||||
gloo-net = "0.5"
|
gloo-net = "0.5"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
|
serde_json="1.0.133"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
||||||
|
|
26
src/app.rs
26
src/app.rs
|
@ -1,7 +1,7 @@
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_meta::*;
|
use leptos_meta::*;
|
||||||
use crate::components::{item_form::ItemForm, items_list::ItemsList};
|
use crate::components::items_list::ItemsList;
|
||||||
use crate::models::item::{Item, ReviewWithRating};
|
use crate::models::item::Item;
|
||||||
use crate::nostr::NostrClient;
|
use crate::nostr::NostrClient;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
@ -28,36 +28,28 @@ pub fn App() -> impl IntoView {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a new item and review using the unified form
|
// Function to add a new item from the grid
|
||||||
let add_item = move |name: String, description: String, tags: Vec<(String, String)>, review: String, rating: u8| {
|
let add_item_from_grid = move || {
|
||||||
let new_id = Uuid::new_v4().to_string();
|
let new_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
set_items.update(|items| {
|
set_items.update(|items| {
|
||||||
let item = Item {
|
let item = Item {
|
||||||
id: new_id.clone(),
|
id: new_id.clone(),
|
||||||
name,
|
name: String::new(),
|
||||||
description,
|
description: String::new(),
|
||||||
tags,
|
tags: vec![],
|
||||||
reviews: vec![ReviewWithRating { content: review.clone(), rating }],
|
reviews: vec![],
|
||||||
wikidata_id: None,
|
wikidata_id: None,
|
||||||
};
|
};
|
||||||
items.push(item);
|
items.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
spawn_local(async move {
|
|
||||||
let nostr_client = NostrClient::new("wss://relay.example.com").await.unwrap();
|
|
||||||
nostr_client.publish_item("New item added!".to_string(), "".to_string(), vec![]).await.unwrap();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Stylesheet href="/assets/style.css" />
|
<Stylesheet href="/assets/style.css" />
|
||||||
<div>
|
<div>
|
||||||
<h1>{ "CompareWare" }</h1>
|
<h1>{ "CompareWare" }</h1>
|
||||||
// Unified form for adding an item and its first review
|
<ItemsList items=items_signal set_items=set_items on_add_item=add_item_from_grid />
|
||||||
<ItemForm on_submit=Box::new(add_item) />
|
|
||||||
// Display all items, including reviews
|
|
||||||
<ItemsList items=items_signal />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,20 @@
|
||||||
/// Component to display a list of items.
|
|
||||||
/// Iterates through the items and renders their name, description, tags, and reviews.
|
|
||||||
use leptos::*;
|
|
||||||
use crate::models::item::Item;
|
use crate::models::item::Item;
|
||||||
use serde::Deserialize;
|
|
||||||
use futures::future;
|
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
|
use leptos::logging::log;
|
||||||
|
use leptos::*;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
// Define the structure for Wikidata API response
|
|
||||||
#[derive(Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
struct WikidataResponse {
|
struct WikidataResponse {
|
||||||
entities: std::collections::HashMap<String, WikidataEntity>,
|
entities: HashMap<String, WikidataEntity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
struct WikidataEntity {
|
struct WikidataEntity {
|
||||||
labels: Option<std::collections::HashMap<String, WikidataLabel>>,
|
labels: Option<HashMap<String, WikidataLabel>>,
|
||||||
descriptions: Option<std::collections::HashMap<String, WikidataLabel>>,
|
descriptions: Option<HashMap<String, WikidataLabel>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
@ -25,144 +23,115 @@ struct WikidataLabel {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ItemsList(items: ReadSignal<Vec<Item>>) -> impl IntoView {
|
pub fn ItemsList(items: ReadSignal<Vec<Item>>, set_items: WriteSignal<Vec<Item>>, on_add_item: impl Fn() + 'static, ) -> impl IntoView {
|
||||||
// Create a signal for selected items
|
|
||||||
let (selected_items_signal, set_selected_items) = create_signal(Vec::<usize>::new());
|
|
||||||
let (wikidata_data, set_wikidata_data) = create_signal(Vec::<Option<WikidataEntity>>::new());
|
let (wikidata_data, set_wikidata_data) = create_signal(Vec::<Option<WikidataEntity>>::new());
|
||||||
|
|
||||||
// Fetch additional data from Wikidata for selected items
|
// Fetch data from Wikidata for a given item name
|
||||||
let fetch_wikidata = move || {
|
let fetch_wikidata = move |item_name: String| {
|
||||||
let selected_indices = selected_items_signal.get();
|
|
||||||
let selected_items: Vec<Item> = selected_indices
|
|
||||||
.iter()
|
|
||||||
.map(|&i| items.get()[i].clone())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
|
|
||||||
// Wrap `selected_items` in an `Arc` so it can be cloned
|
|
||||||
let selected_items = Arc::new(selected_items);
|
|
||||||
|
|
||||||
// Clone selected_items before the async block to avoid borrowing issues
|
|
||||||
let selected_items_clone: Arc<Vec<Item>> = Arc::clone(&selected_items);
|
|
||||||
|
|
||||||
// For each selected item, fetch Wikidata attributes
|
|
||||||
let futures = selected_items_clone.iter().map(move |item| {
|
|
||||||
let wikidata_id = item.wikidata_id.clone();
|
|
||||||
async move {
|
|
||||||
if let Some(id) = wikidata_id {
|
|
||||||
let url = format!("https://www.wikidata.org/wiki/Special:EntityData/{}.json", id);
|
|
||||||
match Request::get(&url).send().await {
|
|
||||||
Ok(response) => match response.json::<WikidataResponse>().await {
|
|
||||||
Ok(parsed_data) => parsed_data.entities.get(&id).cloned(),
|
|
||||||
Err(_) => None,
|
|
||||||
},
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).collect::<Vec<_>>();
|
|
||||||
|
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let results = future::join_all(futures).await;
|
let url = format!("https://www.wikidata.org/w/api.php?action=wbsearchentities&search={}&language=en&limit=1&format=json", item_name);
|
||||||
set_wikidata_data.set(results);
|
match Request::get(&url).send().await {
|
||||||
|
Ok(response) => match response.json::<WikidataResponse>().await {
|
||||||
|
Ok(parsed_data) => {
|
||||||
|
if let Some(entity) = parsed_data.entities.values().next() {
|
||||||
|
set_wikidata_data.update(|current_data| {
|
||||||
|
current_data.push(Some(entity.clone()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => log!("Failed to parse response from Wikidata"),
|
||||||
|
},
|
||||||
|
Err(_) => log!("Failed to make request to Wikidata"),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to toggle selection of an item
|
// Handle updating grid cells
|
||||||
let toggle_selection = move |i: usize| {
|
let update_item = move |index: usize, column: &str, value: String| {
|
||||||
set_selected_items.update(|items| {
|
set_items.update(|items| {
|
||||||
if items.contains(&i) {
|
if let Some(item) = items.get_mut(index) {
|
||||||
items.retain(|&x| x != i);
|
match column {
|
||||||
} else {
|
"name" => item.name = value,
|
||||||
items.push(i);
|
"description" => item.description = value,
|
||||||
|
"tags" => item.tags.push((value.clone(), value)), // For simplicity, adding the same value as key and value.
|
||||||
|
"review" => item.reviews.push(crate::models::item::ReviewWithRating {
|
||||||
|
content: value.clone(),
|
||||||
|
rating: 5, // Assuming a default rating of 5
|
||||||
|
}),
|
||||||
|
"rating" => {
|
||||||
|
if let Ok(rating) = value.parse::<u8>() {
|
||||||
|
item.reviews.last_mut().map(|r| r.rating = rating);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Trigger add item event
|
||||||
|
let add_item = move |_| {
|
||||||
|
on_add_item(); // Call the passed closure from App to add a new item
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div>
|
<div>
|
||||||
<h2>{ "Items" }</h2>
|
<h1>{ "CompareWare" }</h1>
|
||||||
<ul>
|
|
||||||
{move || items.get().iter().enumerate().map(|(i, item)| view! {
|
|
||||||
<li key={i.to_string()}>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selected_items_signal.get().contains(&i)}
|
|
||||||
on:change=move |_| toggle_selection(i)
|
|
||||||
/>
|
|
||||||
{"Select item for comparison"}
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<strong>{ &item.name }</strong> - { &item.description }
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
<h4>{ "Tags:" }</h4>
|
|
||||||
{item.tags.iter().map(|(key, value)| view! {
|
|
||||||
<li>{ key.clone() + ": " + value }</li>
|
|
||||||
}).collect::<Vec<_>>()}
|
|
||||||
</ul>
|
|
||||||
<ul>
|
|
||||||
<h4>{ "Reviews:" }</h4>
|
|
||||||
{item.reviews.iter().map(|review| view! {
|
|
||||||
<li>{ format!("Rating: {}/5 - {}", review.rating, review.content) }</li>
|
|
||||||
}).collect::<Vec<_>>()}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
}).collect::<Vec<_>>() }
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
// Button to fetch Wikidata attributes
|
<button on:click=add_item>{ "Add New Item" }</button>
|
||||||
<button on:click=move |_| fetch_wikidata()>{ "Fetch External Data" }</button>
|
|
||||||
|
|
||||||
// Comparison Table
|
|
||||||
<h2>{ "Comparison Table" }</h2>
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{ "Item Name" }</th>
|
<th>{ "Name" }</th>
|
||||||
<th>{ "Description" }</th>
|
<th>{ "Description" }</th>
|
||||||
<th>{ "Tags" }</th>
|
<th>{ "Tags" }</th>
|
||||||
<th>{ "Reviews" }</th>
|
<th>{ "Review" }</th>
|
||||||
<th>{ "External Description (Wikidata)" }</th>
|
<th>{ "Rating" }</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{move || {
|
{move || {
|
||||||
let selected_indices = selected_items_signal.get();
|
items.get().iter().enumerate().map(|(index, item)| {
|
||||||
selected_indices.iter().enumerate().map(|(idx, &i)| {
|
|
||||||
let item = &items.get()[i];
|
|
||||||
let wikidata_entity = wikidata_data.get().get(idx).cloned().flatten();
|
|
||||||
view! {
|
view! {
|
||||||
<tr key={i.to_string()}>
|
<tr>
|
||||||
<td>{ &item.name }</td>
|
|
||||||
<td>{ &item.description }</td>
|
|
||||||
<td>
|
<td>
|
||||||
{item.tags.iter().map(|(key, value)| view! {
|
<input
|
||||||
<span>{ key.clone() + ": " + value + " " }</span>
|
type="text"
|
||||||
}).collect::<Vec<_>>()}
|
value={item.name.clone()}
|
||||||
</td>
|
on:input=move |e| {
|
||||||
<td>
|
update_item(index, "name", event_target_value(&e));
|
||||||
{item.reviews.iter().map(|review| view! {
|
fetch_wikidata(event_target_value(&e)); // Fetch Wikidata when name is entered
|
||||||
<span>{ format!("Rating: {}/5 ", review.rating) }</span>
|
|
||||||
}).collect::<Vec<_>>()}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{move || {
|
|
||||||
let entity = wikidata_entity.clone(); // Clone the value
|
|
||||||
match entity {
|
|
||||||
Some(entity) => entity
|
|
||||||
.descriptions
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|descriptions| descriptions.get("en"))
|
|
||||||
.map(|label| label.value.clone())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
None => String::from("No description"),
|
|
||||||
}
|
}
|
||||||
}}
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.description.clone()}
|
||||||
|
on:input=move |e| update_item(index, "description", event_target_value(&e))
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add tags"
|
||||||
|
on:input=move |e| update_item(index, "tags", event_target_value(&e))
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<textarea
|
||||||
|
value={item.reviews.iter().map(|review| format!("{}: {}", review.rating, review.content)).collect::<Vec<_>>().join("\n")}
|
||||||
|
on:input=move |e| update_item(index, "review", event_target_value(&e))
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.reviews.last().map(|r| r.rating).unwrap_or(5)}
|
||||||
|
min="1" max="5"
|
||||||
|
on:input=move |e| update_item(index, "rating", event_target_value(&e))
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue