Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
f35c7cd085 |
13 changed files with 120 additions and 581 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -771,9 +771,7 @@ 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",
|
||||||
|
|
|
@ -10,9 +10,7 @@ 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 }
|
||||||
|
@ -79,8 +77,8 @@ style-file = "style/main.scss"
|
||||||
#
|
#
|
||||||
# Optional. Env: LEPTOS_ASSETS_DIR.
|
# Optional. Env: LEPTOS_ASSETS_DIR.
|
||||||
assets-dir = "assets"
|
assets-dir = "assets"
|
||||||
# The IP and port (ex: 127.0.0.1:3004) where the server serves the content. Use it in your server setup.
|
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||||
site-addr = "127.0.0.1:3004"
|
site-addr = "127.0.0.1:3000"
|
||||||
# The port to use for automatic reload monitoring
|
# The port to use for automatic reload monitoring
|
||||||
reload-port = 3001
|
reload-port = 3001
|
||||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||||
|
|
|
@ -26,7 +26,7 @@ It combines Rust's **Leptos** for a modern, reactive frontend and **Nostr** for
|
||||||
```bash
|
```bash
|
||||||
cargo leptos serve
|
cargo leptos serve
|
||||||
```
|
```
|
||||||
3. Open your browser at [localhost:3004](http://localhost:3004)
|
3. Open your browser at [localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
## **Database Schema**
|
## **Database Schema**
|
||||||
### Key Concepts
|
### Key Concepts
|
||||||
|
@ -97,7 +97,7 @@ sequenceDiagram
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
3. Access the application at: [http://localhost:3004](http://localhost:3004)
|
3. Access the application at: [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
### **Collaboration**
|
### **Collaboration**
|
||||||
We welcome contributions! Here’s how you can help:
|
We welcome contributions! Here’s how you can help:
|
||||||
|
|
|
@ -156,51 +156,3 @@ 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;
|
|
||||||
}
|
|
|
@ -2,7 +2,7 @@ services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3004:3004"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./compareware.db:/app/compareware.db
|
- ./compareware.db:/app/compareware.db
|
||||||
environment:
|
environment:
|
||||||
|
|
|
@ -50,7 +50,7 @@ COPY assets /app/assets
|
||||||
|
|
||||||
# Configure container, expose port and set entrypoint
|
# Configure container, expose port and set entrypoint
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 3004
|
EXPOSE 3000
|
||||||
ENV LEPTOS_SITE_ADDR=0.0.0.0:3004
|
ENV LEPTOS_SITE_ADDR=0.0.0.0:3000
|
||||||
ENV LEPTOS_SITE_ROOT="site"
|
ENV LEPTOS_SITE_ROOT="site"
|
||||||
CMD ["./compareware"]
|
CMD ["./compareware"]
|
|
@ -35,7 +35,7 @@ export default defineConfig({
|
||||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
actionTimeout: 0,
|
actionTimeout: 0,
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
// baseURL: 'http://localhost:3004',
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
|
@ -99,6 +99,6 @@ export default defineConfig({
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
// webServer: {
|
// webServer: {
|
||||||
// command: 'npm run start',
|
// command: 'npm run start',
|
||||||
// port: 3004,
|
// port: 3000,
|
||||||
// },
|
// },
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
test("homepage has title and links to intro page", async ({ page }) => {
|
test("homepage has title and links to intro page", async ({ page }) => {
|
||||||
await page.goto("http://localhost:3004/");
|
await page.goto("http://localhost:3000/");
|
||||||
|
|
||||||
await expect(page).toHaveTitle("Welcome to Leptos");
|
await expect(page).toHaveTitle("Welcome to Leptos");
|
||||||
|
|
||||||
|
|
|
@ -29,10 +29,6 @@ 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>
|
|
||||||
</head>
|
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/*url" view=move || {
|
<Route path="/*url" view=move || {
|
||||||
|
|
|
@ -10,9 +10,12 @@ 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;
|
||||||
use crate::components::typeahead_input::TypeaheadInput;
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
use crate::models::item::WikidataSuggestion;
|
struct WikidataSuggestion {
|
||||||
use leptos::html::Input;
|
id: String,
|
||||||
|
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> {
|
||||||
|
@ -120,7 +123,6 @@ 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());
|
||||||
|
|
||||||
|
@ -141,8 +143,6 @@ 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;
|
||||||
|
@ -804,99 +804,118 @@ pub fn ItemsList(
|
||||||
<tbody>
|
<tbody>
|
||||||
{properties.into_iter().map(|property| {
|
{properties.into_iter().map(|property| {
|
||||||
let update_item_cloned = Arc::clone(&update_item);
|
let update_item_cloned = Arc::clone(&update_item);
|
||||||
let current_url_for_closure = Rc::clone(¤t_url);
|
|
||||||
log!("Rendering property: {}", property);
|
log!("Rendering property: {}", property);
|
||||||
view! {
|
view! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{ property }</td>
|
<td>{ property }</td>
|
||||||
{{
|
{move || items.get().iter().enumerate().map(|(index, item)| {
|
||||||
move || {
|
let update_item_clone = Arc::clone(&update_item_cloned);
|
||||||
let items = items.get();
|
|
||||||
items.iter().enumerate().map(|(index, item)| {
|
|
||||||
let update_item_clone = Arc::clone(&update_item_cloned);
|
|
||||||
let current_url_clone = Rc::clone(¤t_url_for_closure);
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<td>
|
<td>
|
||||||
{match property {
|
{match property {
|
||||||
"Name" => view! {
|
"Name" => view! {
|
||||||
<div class="typeahead-container">
|
<div class="editable-cell">
|
||||||
<TypeaheadInput
|
<EditableCell
|
||||||
value=item.name.clone()
|
value=item.name.clone()
|
||||||
fetch_suggestions=Callback::new(move |query: String| -> Vec<WikidataSuggestion> {
|
on_input=move |value| {
|
||||||
let key = format!("name-{}", index);
|
update_item_clone(index, "name", value.clone());
|
||||||
fetch_wikidata_suggestions(key.clone(), query.clone());
|
fetch_wikidata_suggestions(format!("name-{}", index), value);
|
||||||
|
}
|
||||||
// Add a small delay to ensure state is updated
|
key=Arc::new(format!("name-{}", index))
|
||||||
let suggestions = wikidata_suggestions.get();
|
focused_cell=focused_cell
|
||||||
suggestions.get(&key).cloned().unwrap_or_default()
|
set_focused_cell=set_focused_cell.clone()
|
||||||
})
|
on_focus=Some(Callback::new(move |_| {
|
||||||
on_select=Callback::new(move |suggestion: WikidataSuggestion| {
|
log!("Input focused, showing suggestions");
|
||||||
set_items.update(|items| {
|
set_show_suggestions.update(|suggestions| {
|
||||||
if let Some(item) = items.get_mut(index) {
|
suggestions.insert(format!("name-{}", index), true);
|
||||||
item.name = suggestion.display.label.value.clone();
|
});
|
||||||
item.description = suggestion.display.description.value.clone();
|
}))
|
||||||
item.wikidata_id = Some(suggestion.id.clone());
|
on_blur=Some(Callback::new(move |_| {
|
||||||
|
log!("Input blurred, delaying hiding suggestions");
|
||||||
// Automatically fetch properties when Wikidata ID is set
|
spawn_local(async move {
|
||||||
if let Some(wikidata_id) = &item.wikidata_id {
|
gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
spawn_local({
|
log!("Hiding suggestions after delay");
|
||||||
let set_property_labels = set_property_labels.clone();
|
set_show_suggestions.update(|suggestions| {
|
||||||
let property_cache = property_cache.clone();
|
suggestions.insert(format!("name-{}", index), false);
|
||||||
let set_property_cache = set_property_cache.clone();
|
|
||||||
let property_labels = property_labels.clone();
|
|
||||||
let wikidata_id = wikidata_id.clone();
|
|
||||||
|
|
||||||
async move {
|
|
||||||
fetch_item_properties(
|
|
||||||
&wikidata_id,
|
|
||||||
set_property_labels,
|
|
||||||
property_cache,
|
|
||||||
set_property_cache,
|
|
||||||
property_labels
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
is_last_row={index == items.len() - 1}
|
|
||||||
on_input=Callback::new({
|
|
||||||
// Clone items.len() before moving into the closure
|
|
||||||
let items_len = items.len();
|
|
||||||
let current_url_for_spawn = Rc::clone(¤t_url_clone);
|
|
||||||
move |value: String| {
|
|
||||||
if index == items_len - 1 && !value.is_empty() {
|
|
||||||
// Use the cloned current_url
|
|
||||||
let current_url_for_new_item = Rc::clone(¤t_url_for_spawn);
|
|
||||||
set_items.update(|items| {
|
|
||||||
let new_item = Item {
|
|
||||||
id: Uuid::new_v4().to_string(),
|
|
||||||
name: String::new(),
|
|
||||||
description: String::new(),
|
|
||||||
wikidata_id: None,
|
|
||||||
custom_properties: HashMap::new(),
|
|
||||||
};
|
|
||||||
items.push(new_item.clone());
|
|
||||||
|
|
||||||
// Save the new item to the database
|
|
||||||
spawn_local({
|
|
||||||
let current_url = Rc::clone(¤t_url_for_new_item);
|
|
||||||
let selected_properties = selected_properties;
|
|
||||||
async move {
|
|
||||||
save_item_to_db(new_item, selected_properties, current_url.to_string()).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}))
|
||||||
|
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| {
|
||||||
|
if let Some(item) = items.get_mut(index) {
|
||||||
|
item.description = description_for_click.clone();
|
||||||
|
item.wikidata_id = Some(id.clone());
|
||||||
|
item.name = label_for_click.clone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch additional properties from Wikidata
|
||||||
|
let wikidata_id = id.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
let properties = fetch_item_properties(&wikidata_id, set_property_labels.clone(), property_cache.clone(), set_property_cache.clone(), property_labels.clone()).await;
|
||||||
|
// log!("Fetched properties for Wikidata ID {}: {:?}", wikidata_id, properties);
|
||||||
|
|
||||||
|
// Populate the custom properties for the new item
|
||||||
|
set_items.update(|items| {
|
||||||
|
if let Some(item) = items.iter_mut().find(|item| item.wikidata_id.as_ref() == Some(&wikidata_id)) {
|
||||||
|
for (property, value) in properties {
|
||||||
|
item.custom_properties.insert(property, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}}
|
||||||
node_ref=create_node_ref()
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
}.into_view(),
|
}.into_view(),
|
||||||
|
|
||||||
"Description" => view! {
|
"Description" => view! {
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value=item.description.clone()
|
value=item.description.clone()
|
||||||
|
@ -919,9 +938,7 @@ pub fn ItemsList(
|
||||||
}}
|
}}
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()
|
}).collect::<Vec<_>>()}
|
||||||
}
|
|
||||||
}}
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()}
|
}).collect::<Vec<_>>()}
|
||||||
|
@ -963,8 +980,7 @@ pub fn ItemsList(
|
||||||
{move || {
|
{move || {
|
||||||
let update_item_cell = Arc::clone(&update_item_inner);
|
let update_item_cell = Arc::clone(&update_item_inner);
|
||||||
let property_clone_for_cells = normalized_property.clone();
|
let property_clone_for_cells = normalized_property.clone();
|
||||||
let items = items.get();
|
items.get().iter().enumerate().map(move |(index, item)| {
|
||||||
items.iter().enumerate().map(move |(index, item)| {
|
|
||||||
let update_item_cell = Arc::clone(&update_item_cell);
|
let update_item_cell = Arc::clone(&update_item_cell);
|
||||||
let property_clone_for_closure = property_clone_for_cells.clone();
|
let property_clone_for_closure = property_clone_for_cells.clone();
|
||||||
view! {
|
view! {
|
||||||
|
|
|
@ -1,3 +1,2 @@
|
||||||
pub mod items_list;
|
pub mod items_list;
|
||||||
pub mod editable_cell;
|
pub mod editable_cell;
|
||||||
pub mod typeahead_input;
|
|
|
@ -1,383 +0,0 @@
|
||||||
use leptos::*;
|
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
use crate::models::item::WikidataSuggestion;
|
|
||||||
use js_sys::{Object, Array, Function, 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>,
|
|
||||||
#[prop(optional)] is_last_row: bool,
|
|
||||||
#[prop(optional)] on_input: Option<Callback<String>>,
|
|
||||||
) -> 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());
|
|
||||||
|
|
||||||
// Store bloodhound globally
|
|
||||||
js_sys::Reflect::set(
|
|
||||||
&js_sys::global(),
|
|
||||||
&"bloodhoundInstance".into(),
|
|
||||||
&bloodhound
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
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! {
|
|
||||||
<style>
|
|
||||||
{r#"
|
|
||||||
.typeahead.tt-input {{
|
|
||||||
background: transparent !important;
|
|
||||||
}}
|
|
||||||
|
|
||||||
.tt-menu {{
|
|
||||||
width: 100% !important;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 5px 10px rgba(0,0,0,.2);
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
z-index: 1000 !important;
|
|
||||||
}}
|
|
||||||
|
|
||||||
.tt-dataset-suggestions {{
|
|
||||||
padding: 8px 0;
|
|
||||||
}}
|
|
||||||
|
|
||||||
.suggestion-item * {{
|
|
||||||
pointer-events: none; /* Prevent element interception */
|
|
||||||
white-space: nowrap; /* Prevent text wrapping */
|
|
||||||
overflow: hidden; /* Hide overflow */
|
|
||||||
text-overflow: ellipsis; /* Add ellipsis for long text */
|
|
||||||
}}
|
|
||||||
|
|
||||||
.suggestion-item {{
|
|
||||||
padding: 8px 15px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}}
|
|
||||||
|
|
||||||
.suggestion-item:hover {{
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
cursor: pointer;
|
|
||||||
}}
|
|
||||||
|
|
||||||
.label {{
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}}
|
|
||||||
|
|
||||||
.description {{
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 2px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
.empty-suggestion {{
|
|
||||||
padding: 8px 15px;
|
|
||||||
color: #999;
|
|
||||||
}}
|
|
||||||
"#}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="typeahead-input"
|
|
||||||
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 this is the last row and we have an on_input callback, call it
|
|
||||||
if is_last_row && !value.is_empty() {
|
|
||||||
if let Some(callback) = &on_input {
|
|
||||||
callback.call(value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = js_sys::eval("console.log('jQuery version:', $.fn.jquery)");
|
|
||||||
let _ = js_sys::eval("console.log('Typeahead version:', $.fn.typeahead ? 'loaded' : 'missing')");
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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();
|
|
||||||
|
|
||||||
// Create a closure that will be called by Bloodhound to fetch suggestions
|
|
||||||
let remote_fn = Closure::wrap(Box::new(move |query: JsValue, sync: Function, async_fn: Function| {
|
|
||||||
let query_str = query.as_string().unwrap_or_default();
|
|
||||||
log!("[BLOODHOUND] Fetching suggestions for: {}", query_str);
|
|
||||||
|
|
||||||
// Get suggestions from the callback
|
|
||||||
let suggestions = fetch.call(query_str.clone());
|
|
||||||
|
|
||||||
// Create a JavaScript array to hold the suggestions
|
|
||||||
let js_suggestions = Array::new();
|
|
||||||
|
|
||||||
// Convert each suggestion to a JavaScript object
|
|
||||||
for suggestion in suggestions {
|
|
||||||
let obj = Object::new();
|
|
||||||
|
|
||||||
// Store the original ID, label, and description
|
|
||||||
Reflect::set(&obj, &"id".into(), &JsValue::from_str(&suggestion.id)).unwrap();
|
|
||||||
Reflect::set(&obj, &"label".into(), &JsValue::from_str(&suggestion.label)).unwrap();
|
|
||||||
Reflect::set(&obj, &"description".into(), &JsValue::from_str(&suggestion.description)).unwrap();
|
|
||||||
|
|
||||||
// Store the display values directly on the object for easier access
|
|
||||||
Reflect::set(&obj, &"displayLabel".into(),
|
|
||||||
&JsValue::from_str(&suggestion.display.label.value)).unwrap();
|
|
||||||
Reflect::set(&obj, &"displayDescription".into(),
|
|
||||||
&JsValue::from_str(&suggestion.display.description.value)).unwrap();
|
|
||||||
|
|
||||||
// Store the full suggestion for later retrieval
|
|
||||||
let full_suggestion = JsValue::from_serde(&suggestion).unwrap();
|
|
||||||
Reflect::set(&obj, &"fullSuggestion".into(), &full_suggestion).unwrap();
|
|
||||||
|
|
||||||
// Add the object to the array
|
|
||||||
js_suggestions.push(&obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
log!("[BLOODHOUND] Processed suggestions: {:?}", js_suggestions);
|
|
||||||
|
|
||||||
// Call the sync function with the suggestions
|
|
||||||
let _ = sync.call1(&JsValue::NULL, &js_suggestions);
|
|
||||||
}) as Box<dyn Fn(JsValue, Function, Function)>);
|
|
||||||
|
|
||||||
// Configure the remote options
|
|
||||||
let remote_config = Object::new();
|
|
||||||
|
|
||||||
// Set transport function to avoid AJAX requests
|
|
||||||
let transport_fn = js_sys::Function::new_with_args(
|
|
||||||
"query, syncResults, asyncResults",
|
|
||||||
r#"
|
|
||||||
// Call our custom prepare function directly
|
|
||||||
window.bloodhoundPrepare(query, syncResults, asyncResults);
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
|
|
||||||
Reflect::set(
|
|
||||||
&remote_config,
|
|
||||||
&"transport".into(),
|
|
||||||
&transport_fn
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
// Set a dummy URL (not actually used with custom transport)
|
|
||||||
Reflect::set(
|
|
||||||
&remote_config,
|
|
||||||
&"url".into(),
|
|
||||||
&JsValue::from_str("/dummy?query=%QUERY")
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
// Store our prepare function globally
|
|
||||||
js_sys::Reflect::set(
|
|
||||||
&js_sys::global(),
|
|
||||||
&"bloodhoundPrepare".into(),
|
|
||||||
remote_fn.as_ref().unchecked_ref()
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
// Set rate limiting to prevent too many requests
|
|
||||||
Reflect::set(
|
|
||||||
&remote_config,
|
|
||||||
&"rateLimitWait".into(),
|
|
||||||
&JsValue::from(300)
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
// Set the wildcard for query replacement
|
|
||||||
Reflect::set(
|
|
||||||
&remote_config,
|
|
||||||
&"wildcard".into(),
|
|
||||||
&JsValue::from_str("%QUERY")
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
// Add the remote config to the options
|
|
||||||
Reflect::set(&bloodhound_options, &"remote".into(), &remote_config).unwrap();
|
|
||||||
|
|
||||||
// Set the tokenizers
|
|
||||||
let tokenizer = js_sys::Function::new_no_args(
|
|
||||||
r#"
|
|
||||||
return function(query) {
|
|
||||||
return query.trim().split(/\s+/);
|
|
||||||
}
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
|
|
||||||
Reflect::set(
|
|
||||||
&bloodhound_options,
|
|
||||||
&"datumTokenizer".into(),
|
|
||||||
&tokenizer
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
Reflect::set(
|
|
||||||
&bloodhound_options,
|
|
||||||
&"queryTokenizer".into(),
|
|
||||||
&tokenizer
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
// Create and initialize the Bloodhound instance
|
|
||||||
let bloodhound = Bloodhound::new(&bloodhound_options.into());
|
|
||||||
bloodhound.initialize(true);
|
|
||||||
|
|
||||||
// Prevent the closure from being garbage collected
|
|
||||||
remote_fn.forget();
|
|
||||||
|
|
||||||
// Return the Bloodhound instance
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Create selection handler closure
|
|
||||||
let closure = Closure::wrap(Box::new(move |_event: web_sys::Event, suggestion: JsValue| {
|
|
||||||
log!("[TYPEAHEAD] Selection made");
|
|
||||||
|
|
||||||
// Try to get the full suggestion from the suggestion object
|
|
||||||
if let Some(full_suggestion) = js_sys::Reflect::get(&suggestion, &"fullSuggestion".into()).ok() {
|
|
||||||
if let Ok(data) = full_suggestion.into_serde::<WikidataSuggestion>() {
|
|
||||||
log!("[TYPEAHEAD] Selected suggestion: {:?}", data);
|
|
||||||
on_select.call(data.clone());
|
|
||||||
if let Some(input) = node_ref.get() {
|
|
||||||
input.set_value(&data.label);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try to deserialize the suggestion directly
|
|
||||||
if let Ok(data) = suggestion.into_serde::<WikidataSuggestion>() {
|
|
||||||
log!("[TYPEAHEAD] Selected suggestion (fallback): {:?}", data);
|
|
||||||
on_select.call(data.clone());
|
|
||||||
if let Some(input) = node_ref.get() {
|
|
||||||
input.set_value(&data.label);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log!("[ERROR] Failed to deserialize suggestion");
|
|
||||||
}
|
|
||||||
}) as Box<dyn FnMut(web_sys::Event, JsValue)>);
|
|
||||||
|
|
||||||
// Register global handler
|
|
||||||
let handler_name = format!("handler_{}", input_id.replace("-", "_"));
|
|
||||||
|
|
||||||
log!("[TYPEAHEAD] Registering handler with name: {}", handler_name);
|
|
||||||
|
|
||||||
js_sys::Reflect::set(
|
|
||||||
&js_sys::global(),
|
|
||||||
&handler_name.clone().into(),
|
|
||||||
closure.as_ref().unchecked_ref(),
|
|
||||||
).unwrap();
|
|
||||||
closure.forget();
|
|
||||||
|
|
||||||
// Initialization script with enhanced logging
|
|
||||||
let init_script = format!(
|
|
||||||
r#"
|
|
||||||
console.log('[JS] Starting Typeahead init for #{id}');
|
|
||||||
try {{
|
|
||||||
var bloodhound = window.bloodhoundInstance;
|
|
||||||
|
|
||||||
// Define a custom source function that directly uses our Rust callback
|
|
||||||
var customSource = function(query, syncResults, asyncResults) {{
|
|
||||||
console.log('[JS] Custom source called with query:', query);
|
|
||||||
|
|
||||||
// Call our global prepare function directly
|
|
||||||
window.bloodhoundPrepare(query, function(suggestions) {{
|
|
||||||
console.log('[JS] Suggestions from custom source:', suggestions);
|
|
||||||
syncResults(suggestions);
|
|
||||||
}}, asyncResults);
|
|
||||||
}};
|
|
||||||
|
|
||||||
$('#{id}').typeahead(
|
|
||||||
{{
|
|
||||||
hint: true,
|
|
||||||
highlight: true,
|
|
||||||
minLength: 1
|
|
||||||
}},
|
|
||||||
{{
|
|
||||||
name: 'suggestions',
|
|
||||||
display: function(data) {{
|
|
||||||
console.log('[JS] Display function called with data:', data);
|
|
||||||
return data.displayLabel || data.label || '';
|
|
||||||
}},
|
|
||||||
source: customSource,
|
|
||||||
templates: {{
|
|
||||||
suggestion: function(data) {{
|
|
||||||
console.log('[JS] Rendering suggestion:', data);
|
|
||||||
return $('<div>')
|
|
||||||
.addClass('suggestion-item')
|
|
||||||
.append($('<div>').addClass('label').text(data.displayLabel || data.label))
|
|
||||||
.append($('<div>').addClass('description').text(data.displayDescription || data.description));
|
|
||||||
}},
|
|
||||||
empty: function() {{
|
|
||||||
console.log('[JS] No suggestions found');
|
|
||||||
return $('<div>').addClass('empty-suggestion').text('No matches found');
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
)
|
|
||||||
.on('typeahead:select', function(ev, suggestion) {{
|
|
||||||
console.log('[JS] Selection event received with suggestion:', suggestion);
|
|
||||||
window['{handler}'](ev, suggestion);
|
|
||||||
}});
|
|
||||||
console.log('[JS] Typeahead initialized successfully');
|
|
||||||
}} catch (e) {{
|
|
||||||
console.error('[JS] Typeahead init error:', e);
|
|
||||||
}}
|
|
||||||
"#,
|
|
||||||
id = input_id,
|
|
||||||
handler = handler_name
|
|
||||||
);
|
|
||||||
|
|
||||||
log!("[RUST] Initialization script: {}", init_script);
|
|
||||||
if let Err(e) = js_sys::eval(&init_script) {
|
|
||||||
log!("[RUST] Eval error: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -10,40 +10,3 @@ 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(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct WikidataSuggestion {
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub label: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: String,
|
|
||||||
#[serde(default, rename = "display")]
|
|
||||||
pub display: DisplayInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct DisplayInfo {
|
|
||||||
#[serde(default, rename = "label")]
|
|
||||||
pub label: LabelInfo,
|
|
||||||
#[serde(default, rename = "description")]
|
|
||||||
pub description: DescriptionInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct LabelInfo {
|
|
||||||
#[serde(default, rename = "value")]
|
|
||||||
pub value: String,
|
|
||||||
#[serde(default, rename = "language")]
|
|
||||||
pub language: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct DescriptionInfo {
|
|
||||||
#[serde(default, rename = "value")]
|
|
||||||
pub value: String,
|
|
||||||
#[serde(default, rename = "language")]
|
|
||||||
pub language: String,
|
|
||||||
}
|
|
Loading…
Add table
Reference in a new issue