Compare commits

..

3 commits
main ... dev

8 changed files with 305 additions and 107 deletions

2
Cargo.lock generated
View file

@ -771,7 +771,9 @@ dependencies = [
"futures", "futures",
"gloo-net 0.5.0", "gloo-net 0.5.0",
"gloo-timers", "gloo-timers",
"gloo-utils",
"http 1.2.0", "http 1.2.0",
"js-sys",
"leptos", "leptos",
"leptos_actix", "leptos_actix",
"leptos_meta", "leptos_meta",

View file

@ -10,7 +10,9 @@ crate-type = ["cdylib", "rlib"]
actix-files = { version = "0.6", optional = true } actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["macros"] } actix-web = { version = "4", optional = true, features = ["macros"] }
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
gloo-utils = "0.2"
http = { version = "1.0.0", optional = true } http = { version = "1.0.0", optional = true }
js-sys = "0.3"
leptos = { version = "0.6" } leptos = { version = "0.6" }
leptos_meta = { version = "0.6" } leptos_meta = { version = "0.6" }
leptos_actix = { version = "0.6", optional = true } leptos_actix = { version = "0.6", optional = true }

View file

@ -156,3 +156,51 @@ th {
resize: vertical; resize: vertical;
overflow: auto; overflow: auto;
} }
.typeahead-container {
position: relative;
}
.tt-menu {
width: 100%;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-top: 5px;
}
.tt-suggestion {
padding: 8px 12px;
cursor: pointer;
}
.tt-suggestion:hover {
background-color: #f5f5f5;
}
.tt-cursor {
background-color: #e9e9e9;
}
.tt-hint {
display: none !important;
}
.tt-menu {
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 5px 10px rgba(0,0,0,.2);
margin-top: 5px;
width: 100%;
}
.suggestion-item {
padding: 8px 12px;
cursor: pointer;
}
.suggestion-item.tt-cursor {
background-color: #f5f5f5;
}

View file

@ -29,6 +29,11 @@ pub fn App() -> impl IntoView {
// } // }
// }); // });
view! { view! {
<head>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://twitter.github.io/typeahead.js/releases/latest/typeahead.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/corejs-typeahead/1.3.1/typeahead.bundle.min.js"></script>
</head>
<Router> <Router>
<Routes> <Routes>
<Route path="/*url" view=move || { <Route path="/*url" view=move || {

View file

@ -10,12 +10,9 @@ use std::sync::Arc;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use std::rc::Rc; use std::rc::Rc;
use urlencoding::encode; use urlencoding::encode;
#[derive(Deserialize, Clone, Debug)] use crate::components::typeahead_input::TypeaheadInput;
struct WikidataSuggestion { use crate::models::item::WikidataSuggestion;
id: String, use leptos::html::Input;
label: String,
description: Option<String>,
}
//function to load items from database //function to load items from database
pub async fn load_items_from_db(current_url: &str) -> Result<Vec<Item>, String> { pub async fn load_items_from_db(current_url: &str) -> Result<Vec<Item>, String> {
@ -123,6 +120,7 @@ pub fn ItemsList(
items: ReadSignal<Vec<Item>>, items: ReadSignal<Vec<Item>>,
set_items: WriteSignal<Vec<Item>>, set_items: WriteSignal<Vec<Item>>,
) -> impl IntoView { ) -> impl IntoView {
let node_ref = create_node_ref::<Input>();
// State to track selected properties // State to track selected properties
let (selected_properties, set_selected_properties) = create_signal(HashMap::<String, bool>::new()); let (selected_properties, set_selected_properties) = create_signal(HashMap::<String, bool>::new());
@ -143,6 +141,8 @@ pub fn ItemsList(
// State to manage property cache // State to manage property cache
let (property_cache, set_property_cache) = create_signal(HashMap::<String, HashMap<String, String>>::new()); let (property_cache, set_property_cache) = create_signal(HashMap::<String, HashMap<String, String>>::new());
let (suggestions, set_suggestions) = create_signal(Vec::<WikidataSuggestion>::new());
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
fn get_current_url() -> String { fn get_current_url() -> String {
use leptos::use_context; use leptos::use_context;
@ -814,108 +814,52 @@ pub fn ItemsList(
<td> <td>
{match property { {match property {
"Name" => view! { "Name" => view! {
<div class="editable-cell"> <div class="typeahead-container">
<EditableCell <TypeaheadInput
value=item.name.clone() value=item.name.clone()
on_input=move |value| { fetch_suggestions=Callback::new(move |query: String| -> Vec<WikidataSuggestion> {
update_item_clone(index, "name", value.clone()); let key = format!("name-{}", index);
fetch_wikidata_suggestions(format!("name-{}", index), value); fetch_wikidata_suggestions(key.clone(), query.clone());
}
key=Arc::new(format!("name-{}", index)) // Add a small delay to ensure state is updated
focused_cell=focused_cell let suggestions = wikidata_suggestions.get();
set_focused_cell=set_focused_cell.clone() suggestions.get(&key).cloned().unwrap_or_default()
on_focus=Some(Callback::new(move |_| { })
log!("Input focused, showing suggestions"); on_select=Callback::new(move |suggestion: WikidataSuggestion| {
set_show_suggestions.update(|suggestions| {
suggestions.insert(format!("name-{}", index), true);
});
}))
on_blur=Some(Callback::new(move |_| {
log!("Input blurred, delaying hiding suggestions");
spawn_local(async move {
gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
log!("Hiding suggestions after delay");
set_show_suggestions.update(|suggestions| {
suggestions.insert(format!("name-{}", index), false);
});
});
}))
input_type=InputType::Text
/>
<button class="search-icon" on:click=move |_| {
log!("Search icon clicked, showing suggestions");
set_show_suggestions.update(|suggestions| {
suggestions.insert(format!("name-{}", index), true);
});
}>
<i class="fas fa-search"></i> Search Wiki
</button>
{move || {
if *show_suggestions.get().get(&format!("name-{}", index)).unwrap_or(&false) {
log!("Rendering suggestions list");
view! {
<ul class="editable-cell-suggestions">
{move || {
let suggestions = wikidata_suggestions.get()
.get(&format!("name-{}", index))
.cloned()
.unwrap_or_default();
log!("Suggestions for cell {}: {:?}", index, suggestions);
suggestions.into_iter().map(|suggestion| {
let label_for_click = suggestion.label.clone();
let label_for_display = suggestion.label.clone();
let description_for_click = suggestion.description.clone().unwrap_or_default();
let description_for_display = suggestion.description.clone().unwrap_or_default();
let id = suggestion.id.clone();
view! {
<li class="editable-cell-suggestions-li" on:click=move |_| {
// Update item with basic suggestion details
set_items.update(|items| { set_items.update(|items| {
if let Some(item) = items.get_mut(index) { if let Some(item) = items.get_mut(index) {
item.description = description_for_click.clone(); item.name = suggestion.label.clone();
item.wikidata_id = Some(id.clone()); item.description = suggestion.description.clone().unwrap_or_default();
item.name = label_for_click.clone(); item.wikidata_id = Some(suggestion.id.clone());
}
});
// Fetch additional properties from Wikidata // Automatically fetch properties when Wikidata ID is set
let wikidata_id = id.clone(); if let Some(wikidata_id) = &item.wikidata_id {
spawn_local(async move { spawn_local({
let properties = fetch_item_properties(&wikidata_id, set_property_labels.clone(), property_cache.clone(), set_property_cache.clone(), property_labels.clone()).await; let set_property_labels = set_property_labels.clone();
// log!("Fetched properties for Wikidata ID {}: {:?}", wikidata_id, properties); let property_cache = property_cache.clone();
let set_property_cache = set_property_cache.clone();
let property_labels = property_labels.clone();
let wikidata_id = wikidata_id.clone();
// Populate the custom properties for the new item async move {
set_items.update(|items| { fetch_item_properties(
if let Some(item) = items.iter_mut().find(|item| item.wikidata_id.as_ref() == Some(&wikidata_id)) { &wikidata_id,
for (property, value) in properties { set_property_labels,
item.custom_properties.insert(property, value); property_cache,
set_property_cache,
property_labels
).await;
}
});
} }
} }
}); });
}); })
node_ref=node_ref
// Hide the suggestion list />
set_show_suggestions.update(|suggestions| {
suggestions.insert(format!("name-{}", index), false);
log!("Updated show_suggestions: {:?}", suggestions);
});
}>
{ format!("{} - {}", label_for_display, description_for_display) }
</li>
}
}).collect::<Vec<_>>()
}}
</ul>
}
} else {
log!("Suggestions list hidden");
view! {
<ul></ul>
}
}
}}
</div> </div>
}.into_view(), }.into_view(),
"Description" => view! { "Description" => view! {
<EditableCell <EditableCell
value=item.description.clone() value=item.description.clone()

View file

@ -1,2 +1,3 @@
pub mod items_list; pub mod items_list;
pub mod editable_cell; pub mod editable_cell;
pub mod typeahead_input;

View file

@ -0,0 +1,189 @@
use leptos::*;
use wasm_bindgen::prelude::*;
use crate::models::item::WikidataSuggestion;
use js_sys::{Object, Array, Function, JSON, Reflect};
use leptos::html::Input;
use gloo_utils::format::JsValueSerdeExt;
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use leptos::logging::log;
use std::time::Duration;
#[component]
pub fn TypeaheadInput(
value: String,
on_select: Callback<WikidataSuggestion>,
fetch_suggestions: Callback<String, Vec<WikidataSuggestion>>,
node_ref: NodeRef<Input>,
) -> impl IntoView {
let (is_initialized, set_initialized) = create_signal(false);
spawn_local(async move {
log!("[INIT] Component mounted");
let mut retries = 0;
while retries < 10 {
if let Some(input) = node_ref.get() {
log!("[INIT] Input element found");
let bloodhound = initialize_bloodhound(fetch_suggestions.clone());
initialize_typeahead(&input, bloodhound, on_select.clone(), node_ref.clone());
set_initialized.set(true);
break;
}
gloo_timers::future::sleep(Duration::from_millis(100)).await;
retries += 1;
}
});
view! {
<input
type="text"
class="typeahead"
prop:value=value
node_ref=node_ref
on:focus=move |_| log!("[FOCUS] Name input focused")
on:blur=move |_| log!("[FOCUS] Name input blurred")
on:input=move |ev| {
let value = event_target_value(&ev);
log!("[INPUT] Value changed: {}", value);
if let Some(input) = node_ref.get() {
// Correct DOM element access using JsCast
let dom_input: &web_sys::HtmlInputElement = &*input;
let id = dom_input.id();
let _ = js_sys::eval(&format!("console.log('JS Value:', $('#{}').val())", id));
}
}
/>
}
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = "Bloodhound")]
type Bloodhound;
#[wasm_bindgen(constructor, js_namespace = window)]
fn new(options: &JsValue) -> Bloodhound;
#[wasm_bindgen(method)]
fn initialize(this: &Bloodhound, prefetch: bool);
#[wasm_bindgen(method, js_name = "ttAdapter")]
fn tt_adapter(this: &Bloodhound) -> JsValue;
}
fn initialize_bloodhound(fetch: Callback<String, Vec<WikidataSuggestion>>) -> JsValue {
let bloodhound_options = Object::new();
// Configure Bloodhound remote with proper parameters
let remote_fn = Closure::wrap(Box::new(move |query: String, sync: Function| {
log!("[BLOODHOUND] Fetching suggestions for: {}", query);
let suggestions = fetch.call(query.clone());
log!("[BLOODHOUND] Received {} suggestions", suggestions.len());
let array = Array::new();
for suggestion in &suggestions {
let obj = Object::new();
Reflect::set(&obj, &"label".into(), &suggestion.label.clone().into()).unwrap();
Reflect::set(&obj, &"value".into(), &suggestion.id.clone().into()).unwrap();
array.push(&obj);
}
sync.call1(&JsValue::NULL, &array).unwrap();
}) as Box<dyn Fn(String, Function)>);
let remote_config = Object::new();
Reflect::set(
&remote_config,
&"prepare".into(),
&js_sys::eval(&format!(
"function(query, callback) {{
return {}(query, callback);
}}",
remote_fn.as_ref().unchecked_ref::<js_sys::Function>().to_string()
)).unwrap()
).unwrap();
Reflect::set(
&remote_config,
&"wildcard".into(),
&JsValue::from_str("%QUERY")
).unwrap();
Reflect::set(&bloodhound_options, &"remote".into(), &remote_config).unwrap();
Reflect::set(&bloodhound_options, &"datumTokenizer".into(), &JsValue::from_str("whitespace")).unwrap();
Reflect::set(&bloodhound_options, &"queryTokenizer".into(), &JsValue::from_str("whitespace")).unwrap();
let bloodhound = Bloodhound::new(&bloodhound_options.into());
bloodhound.initialize(true);
remote_fn.forget();
bloodhound.into()
}
fn initialize_typeahead(
input: &HtmlInputElement,
bloodhound: JsValue,
on_select: Callback<WikidataSuggestion>,
node_ref: NodeRef<Input>,
) {
log!("[TYPEAHEAD] Initializing for input: {}", input.id());
let input_id = format!("typeahead-{}", uuid::Uuid::new_v4());
input.set_id(&input_id);
let dataset = Object::new();
let bloodhound_ref = bloodhound.unchecked_ref::<Bloodhound>();
Reflect::set(&dataset, &"source".into(), &bloodhound_ref.tt_adapter()).unwrap();
Reflect::set(&dataset, &"display".into(), &"label".into()).unwrap();
Reflect::set(&dataset, &"limit".into(), &JsValue::from(10)).unwrap();
// Create and register the closure
let closure = Closure::wrap(Box::new(move |_event: web_sys::Event, suggestion: JsValue| {
log!("[TYPEAHEAD] Selection made");
let data: WikidataSuggestion = suggestion.into_serde().unwrap();
on_select.call(data.clone());
if let Some(input) = node_ref.get() {
input.set_value(&data.label);
}
}) as Box<dyn FnMut(web_sys::Event, JsValue)>);
// Register the closure in the JS global scope
let handler_name = format!("handler_{}", input_id);
let handler_name_global = handler_name.clone();
let global = js_sys::global();
Reflect::set(
&global,
&handler_name_global.into(),
&closure.as_ref()
).unwrap();
// Typeahead initialization using jQuery
let init_script = format!(
r#"
(function() {{
console.log('[TYPEAHEAD] Initializing for #{id}');
$('#{id}').typeahead(
{{
hint: true,
highlight: true,
minLength: 1
}},
{dataset}
).on('typeahead:select', function(ev, suggestion) {{
console.log('[TYPEAHEAD] Select event triggered');
{handler}(ev, suggestion);
}});
console.log('[TYPEAHEAD] Initialization complete for #{id}');
}})();
"#,
id = input_id,
dataset = JSON::stringify(&dataset).unwrap(),
handler = handler_name
);
log!("[TYPEAHEAD] Init script: {}", init_script);
let _ = js_sys::eval(&init_script).unwrap();
closure.forget();
}

View file

@ -10,3 +10,10 @@ pub struct Item {
pub wikidata_id: Option<String>, pub wikidata_id: Option<String>,
pub custom_properties: HashMap<String, String>, pub custom_properties: HashMap<String, String>,
} }
#[derive(Deserialize, Clone, Debug)]
pub struct WikidataSuggestion {
pub id: String,
pub label: String,
pub description: Option<String>,
}