feat: work on windows resizing
This commit is contained in:
118
src-tauri/Cargo.lock
generated
118
src-tauri/Cargo.lock
generated
@ -26,6 +26,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"version_check",
|
"version_check",
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
@ -766,6 +767,19 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser"
|
||||||
|
version = "0.31.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be"
|
||||||
|
dependencies = [
|
||||||
|
"cssparser-macros",
|
||||||
|
"dtoa-short",
|
||||||
|
"itoa",
|
||||||
|
"phf 0.11.3",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.36.0"
|
version = "0.36.0"
|
||||||
@ -1081,6 +1095,12 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ego-tree"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@ -1568,6 +1588,15 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.1.16"
|
version = "0.1.16"
|
||||||
@ -1880,6 +1909,20 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "html5ever"
|
||||||
|
version = "0.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"mac",
|
||||||
|
"markup5ever 0.12.1",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.29.1"
|
version = "0.29.1"
|
||||||
@ -2494,6 +2537,20 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markup5ever"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"phf 0.11.3",
|
||||||
|
"phf_codegen 0.11.3",
|
||||||
|
"string_cache 0.8.9",
|
||||||
|
"string_cache_codegen 0.5.4",
|
||||||
|
"tendril 0.4.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@ -3092,6 +3149,16 @@ dependencies = [
|
|||||||
"phf_shared 0.8.0",
|
"phf_shared 0.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_codegen"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator 0.10.0",
|
||||||
|
"phf_shared 0.10.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_codegen"
|
name = "phf_codegen"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@ -4093,6 +4160,22 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scraper"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0"
|
||||||
|
dependencies = [
|
||||||
|
"ahash 0.8.12",
|
||||||
|
"cssparser 0.31.2",
|
||||||
|
"ego-tree",
|
||||||
|
"getopts",
|
||||||
|
"html5ever 0.27.0",
|
||||||
|
"once_cell",
|
||||||
|
"selectors 0.25.0",
|
||||||
|
"tendril 0.4.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "seahash"
|
name = "seahash"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@ -4140,6 +4223,25 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "selectors"
|
||||||
|
version = "0.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"cssparser 0.31.2",
|
||||||
|
"derive_more 0.99.20",
|
||||||
|
"fxhash",
|
||||||
|
"log",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"phf 0.10.1",
|
||||||
|
"phf_codegen 0.10.0",
|
||||||
|
"precomputed-hash",
|
||||||
|
"servo_arc 0.3.0",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.36.1"
|
version = "0.36.1"
|
||||||
@ -4339,6 +4441,15 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "servo_arc"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "servo_arc"
|
name = "servo_arc"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@ -5592,6 +5703,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@ -5806,6 +5918,12 @@ version = "1.13.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|||||||
@ -26,6 +26,7 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
dirs-next = "2"
|
dirs-next = "2"
|
||||||
|
scraper = "0.20"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
webkit2gtk = { version = "2.0", features = ["v2_38"] }
|
webkit2gtk = { version = "2.0", features = ["v2_38"] }
|
||||||
|
|||||||
@ -173,6 +173,18 @@ pub fn has_guides(state: State<DbState>) -> Result<bool, String> {
|
|||||||
Ok(!guides.is_empty())
|
Ok(!guides.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_resource_inventory(state: State<DbState>, profile_id: String) -> Result<Vec<(String, i64)>, String> {
|
||||||
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
db::get_resource_inventory(&conn, &profile_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_resource_quantity(state: State<DbState>, profile_id: String, resource_name: String, quantity: i64) -> Result<(), String> {
|
||||||
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
db::set_resource_quantity(&conn, &profile_id, &resource_name, quantity).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_setting(state: State<DbState>, key: String) -> Result<Option<String>, String> {
|
pub fn get_setting(state: State<DbState>, key: String) -> Result<Option<String>, String> {
|
||||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
@ -194,6 +206,294 @@ pub fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct QuestStep {
|
||||||
|
pub index: usize,
|
||||||
|
pub text: String,
|
||||||
|
pub images: Vec<String>,
|
||||||
|
pub launch_position: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_quest_detail(url: String) -> Result<Vec<QuestStep>, String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(20))
|
||||||
|
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let html = client.get(&url)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("Erreur réseau : {}", e))?
|
||||||
|
.text()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
parse_quest_steps(&html)
|
||||||
|
}).await.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_quest_steps(html: &str) -> Result<Vec<QuestStep>, String> {
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://www.dofuspourlesnoobs.com";
|
||||||
|
|
||||||
|
let document = Html::parse_document(html);
|
||||||
|
|
||||||
|
let children_sel = Selector::parse("#wsite-content > div").unwrap();
|
||||||
|
let para_sel = Selector::parse("div.paragraph, div.paragraphe").unwrap();
|
||||||
|
let img_link_sel = Selector::parse("div.wsite-image a, div.wsite-image img").unwrap();
|
||||||
|
// Blue = Position de lancement label; red = other info labels (Prérequis, Niveau…)
|
||||||
|
let position_sel = Selector::parse("font[color='#3a96b8'], font[color='#3A96B8']").unwrap();
|
||||||
|
let info_block_sel = Selector::parse(
|
||||||
|
"font[color='#3a96b8'], font[color='#3A96B8'], font[color='#ff0000'], font[color='#FF0000']"
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
let mut steps: Vec<QuestStep> = Vec::new();
|
||||||
|
let mut first = true;
|
||||||
|
let mut header_done = false;
|
||||||
|
|
||||||
|
for child in document.select(&children_sel) {
|
||||||
|
if first { first = false; continue; }
|
||||||
|
|
||||||
|
let v = child.value();
|
||||||
|
|
||||||
|
// ── Case A: the child IS itself a paragraph ──────────────────────────
|
||||||
|
if v.classes().any(|c| c == "paragraph" || c == "paragraphe") {
|
||||||
|
let text = element_to_text(&child).trim().to_string();
|
||||||
|
if text.is_empty() || is_date_meta(&text) { continue; }
|
||||||
|
|
||||||
|
if !header_done {
|
||||||
|
header_done = true;
|
||||||
|
// Info block detected by colored font labels typical of DPLN info sections
|
||||||
|
if child.select(&info_block_sel).next().is_some() {
|
||||||
|
let pos = extract_launch_position(&child, &position_sel);
|
||||||
|
if pos.is_some() {
|
||||||
|
let images = collect_images_from(&child, &img_link_sel, BASE_URL);
|
||||||
|
steps.push(QuestStep { index: steps.len(), text: String::new(), images, launch_position: pos });
|
||||||
|
}
|
||||||
|
continue; // always skip as a regular step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push(QuestStep { index: steps.len(), text, images: vec![], launch_position: None });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cases B & C: the child is a wrapper div ───────────────────────
|
||||||
|
let inner_paras: Vec<_> = child.select(¶_sel).collect();
|
||||||
|
let images = collect_images_from(&child, &img_link_sel, BASE_URL);
|
||||||
|
|
||||||
|
if inner_paras.is_empty() {
|
||||||
|
if !images.is_empty() {
|
||||||
|
if let Some(last) = steps.last_mut() {
|
||||||
|
last.images.extend(images);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut first_para = true;
|
||||||
|
for para in &inner_paras {
|
||||||
|
let text = element_to_text(para).trim().to_string();
|
||||||
|
if text.is_empty() || is_date_meta(&text) { continue; }
|
||||||
|
|
||||||
|
if !header_done && first_para {
|
||||||
|
header_done = true;
|
||||||
|
if para.select(&info_block_sel).next().is_some() {
|
||||||
|
let pos = extract_launch_position(para, &position_sel);
|
||||||
|
if pos.is_some() {
|
||||||
|
let imgs = images.clone();
|
||||||
|
steps.push(QuestStep { index: steps.len(), text: String::new(), images: imgs, launch_position: pos });
|
||||||
|
}
|
||||||
|
first_para = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let imgs = if first_para { images.clone() } else { vec![] };
|
||||||
|
first_para = false;
|
||||||
|
steps.push(QuestStep { index: steps.len(), text, images: imgs, launch_position: None });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_launch_position(para: &scraper::ElementRef, position_sel: &scraper::Selector) -> Option<String> {
|
||||||
|
for font_el in para.select(position_sel) {
|
||||||
|
let label: String = font_el.text().collect();
|
||||||
|
if !label.to_lowercase().contains("position") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tree = font_el.tree();
|
||||||
|
let para_id = para.id();
|
||||||
|
let mut current_id = font_el.id();
|
||||||
|
|
||||||
|
// Walk up from font_el toward para, collecting text from siblings after each ancestor.
|
||||||
|
// Necessary because the value <span> is a sibling of <strong> (font's grandparent),
|
||||||
|
// not a sibling of <font> itself.
|
||||||
|
loop {
|
||||||
|
let current_node = tree.get(current_id)?;
|
||||||
|
let parent_node = current_node.parent()?;
|
||||||
|
let parent_id = parent_node.id();
|
||||||
|
|
||||||
|
let mut after_current = false;
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
for sibling in parent_node.children() {
|
||||||
|
if sibling.id() == current_id {
|
||||||
|
after_current = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !after_current { continue; }
|
||||||
|
|
||||||
|
// A new <strong> means a new field — stop collecting
|
||||||
|
if let scraper::Node::Element(e) = sibling.value() {
|
||||||
|
if e.name() == "strong" { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(elem) = scraper::ElementRef::wrap(sibling) {
|
||||||
|
let t: String = elem.text().collect();
|
||||||
|
let t = t.trim().to_string();
|
||||||
|
if !t.is_empty() {
|
||||||
|
if !result.is_empty() { result.push(' '); }
|
||||||
|
result.push_str(&t);
|
||||||
|
}
|
||||||
|
} else if let scraper::Node::Text(t) = sibling.value() {
|
||||||
|
let s = t.text.trim();
|
||||||
|
if !s.is_empty() {
|
||||||
|
if !result.is_empty() { result.push(' '); }
|
||||||
|
result.push_str(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos = result.trim().trim_end_matches('.').trim().to_string();
|
||||||
|
if !pos.is_empty() {
|
||||||
|
return Some(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent_id == para_id {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current_id = parent_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_images_from(
|
||||||
|
el: &scraper::ElementRef,
|
||||||
|
img_link_sel: &scraper::Selector,
|
||||||
|
base_url: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut images = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for img_el in el.select(img_link_sel) {
|
||||||
|
let url_opt = img_el.value().attr("href")
|
||||||
|
.filter(|u| is_image_url(u))
|
||||||
|
.or_else(|| img_el.value().attr("src").filter(|u| is_image_url(u)));
|
||||||
|
if let Some(url) = url_opt {
|
||||||
|
let absolute = if url.starts_with('/') {
|
||||||
|
format!("{}{}", base_url, url)
|
||||||
|
} else {
|
||||||
|
url.to_string()
|
||||||
|
};
|
||||||
|
if seen.insert(absolute.clone()) {
|
||||||
|
images.push(absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
images
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_date_meta(text: &str) -> bool {
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
// Matches Weebly blog metadata lines like "Publié le 01/01/2024" or "Mis à jour le …"
|
||||||
|
let is_meta_word = lower.starts_with("publi")
|
||||||
|
|| lower.starts_with("mis à jour")
|
||||||
|
|| lower.starts_with("mis en ligne")
|
||||||
|
|| lower.starts_with("modifi")
|
||||||
|
|| lower.starts_with("rédigé")
|
||||||
|
|| lower.starts_with("redige");
|
||||||
|
// Also catch bare date lines like "01/01/2024" or "2024-01-01"
|
||||||
|
let digit_count = text.chars().filter(|c| c.is_ascii_digit()).count();
|
||||||
|
let sep_count = text.chars().filter(|&c| c == '/' || c == '-').count();
|
||||||
|
let is_bare_date = text.len() < 40 && digit_count >= 6 && sep_count >= 2;
|
||||||
|
is_meta_word || is_bare_date
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_image_url(url: &str) -> bool {
|
||||||
|
url.contains("uploads")
|
||||||
|
|| url.ends_with(".jpg")
|
||||||
|
|| url.ends_with(".jpeg")
|
||||||
|
|| url.ends_with(".png")
|
||||||
|
|| url.ends_with(".webp")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn element_to_text(el: &scraper::ElementRef) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for node in el.descendants() {
|
||||||
|
match node.value() {
|
||||||
|
scraper::Node::Text(t) => {
|
||||||
|
let s = t.text.trim();
|
||||||
|
if !s.is_empty() {
|
||||||
|
if !out.is_empty() && !out.ends_with('\n') && !out.ends_with(' ') {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out.push_str(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scraper::Node::Element(e) => {
|
||||||
|
if matches!(e.name(), "script" | "style" | "noscript") { continue; }
|
||||||
|
if matches!(e.name(), "p" | "br" | "h1" | "h2" | "h3" | "h4" | "li" | "div" | "tr") {
|
||||||
|
if !out.is_empty() && !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collapse multiple blank lines
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut prev_empty = false;
|
||||||
|
for line in out.lines() {
|
||||||
|
let t = line.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
if !prev_empty { result.push('\n'); }
|
||||||
|
prev_empty = true;
|
||||||
|
} else {
|
||||||
|
result.push_str(t);
|
||||||
|
result.push('\n');
|
||||||
|
prev_empty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_completed_steps(
|
||||||
|
state: State<DbState>,
|
||||||
|
profile_id: String,
|
||||||
|
quest_name: String,
|
||||||
|
) -> Result<Vec<i64>, String> {
|
||||||
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
db::get_completed_steps(&conn, &profile_id, &quest_name).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn toggle_quest_step(
|
||||||
|
state: State<DbState>,
|
||||||
|
profile_id: String,
|
||||||
|
quest_name: String,
|
||||||
|
step_index: i64,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
db::toggle_quest_step(&conn, &profile_id, &quest_name, step_index).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
|
fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
for section in &data.sections {
|
for section in &data.sections {
|
||||||
|
|||||||
@ -60,6 +60,22 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS quest_step_progress (
|
||||||
|
profile_id TEXT NOT NULL,
|
||||||
|
quest_name TEXT NOT NULL,
|
||||||
|
step_index INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (profile_id, quest_name, step_index),
|
||||||
|
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS resource_inventory (
|
||||||
|
profile_id TEXT NOT NULL,
|
||||||
|
resource_name TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (profile_id, resource_name),
|
||||||
|
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
")?;
|
")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -145,6 +161,62 @@ pub fn get_guides(conn: &Connection) -> Result<Vec<GuideRow>> {
|
|||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_completed_steps(conn: &Connection, profile_id: &str, quest_name: &str) -> Result<Vec<i64>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT step_index FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2"
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![profile_id, quest_name], |row| row.get(0))?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_quest_step(conn: &Connection, profile_id: &str, quest_name: &str, step_index: i64) -> Result<bool> {
|
||||||
|
let exists: bool = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2 AND step_index = ?3",
|
||||||
|
params![profile_id, quest_name, step_index],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
).map(|c| c > 0)?;
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM quest_step_progress WHERE profile_id = ?1 AND quest_name = ?2 AND step_index = ?3",
|
||||||
|
params![profile_id, quest_name, step_index],
|
||||||
|
)?;
|
||||||
|
Ok(false)
|
||||||
|
} else {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO quest_step_progress (profile_id, quest_name, step_index) VALUES (?1, ?2, ?3)",
|
||||||
|
params![profile_id, quest_name, step_index],
|
||||||
|
)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_resource_inventory(conn: &Connection, profile_id: &str) -> Result<Vec<(String, i64)>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT resource_name, quantity FROM resource_inventory WHERE profile_id = ?1"
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![profile_id], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||||
|
})?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_resource_quantity(conn: &Connection, profile_id: &str, resource_name: &str, quantity: i64) -> Result<()> {
|
||||||
|
if quantity <= 0 {
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM resource_inventory WHERE profile_id = ?1 AND resource_name = ?2",
|
||||||
|
params![profile_id, resource_name],
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO resource_inventory (profile_id, resource_name, quantity) VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(profile_id, resource_name) DO UPDATE SET quantity=excluded.quantity",
|
||||||
|
params![profile_id, resource_name, quantity],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
|
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT value FROM settings WHERE key = ?1",
|
"SELECT value FROM settings WHERE key = ?1",
|
||||||
|
|||||||
@ -48,6 +48,11 @@ pub fn run() {
|
|||||||
commands::get_setting,
|
commands::get_setting,
|
||||||
commands::set_setting,
|
commands::set_setting,
|
||||||
commands::set_always_on_top,
|
commands::set_always_on_top,
|
||||||
|
commands::fetch_quest_detail,
|
||||||
|
commands::get_completed_steps,
|
||||||
|
commands::toggle_quest_step,
|
||||||
|
commands::get_resource_inventory,
|
||||||
|
commands::set_resource_quantity,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
"title": "TougliGui",
|
"title": "TougliGui",
|
||||||
"width": 1100,
|
"width": 1100,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"minWidth": 380,
|
"minWidth": 300,
|
||||||
"minHeight": 400,
|
"minHeight": 400,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
"transparent": false,
|
"transparent": false,
|
||||||
|
|||||||
50
src/App.tsx
50
src/App.tsx
@ -1,29 +1,64 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
|
||||||
import { useStore } from "./store";
|
import { useStore } from "./store";
|
||||||
import TitleBar from "./components/TitleBar";
|
import TitleBar from "./components/TitleBar";
|
||||||
import Sidebar from "./components/Sidebar";
|
|
||||||
import HomeView from "./components/HomeView";
|
import HomeView from "./components/HomeView";
|
||||||
import GuideView from "./components/GuideView";
|
import GuideView from "./components/GuideView";
|
||||||
import ProfileModal from "./components/ProfileModal";
|
import SettingsPanel from "./components/SettingsPanel";
|
||||||
import SyncOverlay from "./components/SyncOverlay";
|
import SyncOverlay from "./components/SyncOverlay";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadProfiles, loadGuides, view, syncing, syncGuides } = useStore();
|
const { loadProfiles, loadGuides, openGuide, setResourcesPanelCollapsed, view, syncing, syncGuides } = useStore();
|
||||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [needsSync, setNeedsSync] = useState(false);
|
const [needsSync, setNeedsSync] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
|
// Restore window size
|
||||||
|
const [savedW, savedH] = await Promise.all([
|
||||||
|
invoke<string | null>("get_setting", { key: "window_width" }),
|
||||||
|
invoke<string | null>("get_setting", { key: "window_height" }),
|
||||||
|
]);
|
||||||
|
if (savedW && savedH) {
|
||||||
|
await getCurrentWindow().setSize(new LogicalSize(parseInt(savedW), parseInt(savedH)));
|
||||||
|
}
|
||||||
|
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
|
|
||||||
const has = await invoke<boolean>("has_guides");
|
const has = await invoke<boolean>("has_guides");
|
||||||
if (!has) {
|
if (!has) {
|
||||||
setNeedsSync(true);
|
setNeedsSync(true);
|
||||||
} else {
|
} else {
|
||||||
await loadGuides();
|
await loadGuides();
|
||||||
|
// Restore last viewed guide
|
||||||
|
const lastGuide = await invoke<string | null>("get_setting", { key: "active_guide" });
|
||||||
|
if (lastGuide) {
|
||||||
|
try {
|
||||||
|
await openGuide(lastGuide);
|
||||||
|
setResourcesPanelCollapsed(true);
|
||||||
|
} catch { /* guide may no longer exist */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
init();
|
init();
|
||||||
|
|
||||||
|
// Persist window size on resize (debounced)
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const unlisten = win.onResized(async () => {
|
||||||
|
if (debounce !== null) clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(async () => {
|
||||||
|
const size = await win.innerSize();
|
||||||
|
const factor = await win.scaleFactor();
|
||||||
|
const w = Math.round(size.width / factor);
|
||||||
|
const h = Math.round(size.height / factor);
|
||||||
|
await invoke("set_setting", { key: "window_width", value: w.toString() });
|
||||||
|
await invoke("set_setting", { key: "window_height", value: h.toString() });
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { unlisten.then(f => f()); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleInitialSync() {
|
async function handleInitialSync() {
|
||||||
@ -33,16 +68,15 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<TitleBar onOpenProfiles={() => setShowProfileModal(true)} />
|
<TitleBar onOpenSettings={() => setShowSettings(s => !s)} />
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
<Sidebar />
|
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
{view === "home" ? <HomeView /> : <GuideView />}
|
{view === "home" ? <HomeView /> : <GuideView />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showProfileModal && <ProfileModal onClose={() => setShowProfileModal(false)} />}
|
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
||||||
{syncing && <SyncOverlay />}
|
{syncing && !showSettings && <SyncOverlay />}
|
||||||
{needsSync && (
|
{needsSync && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)",
|
position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)",
|
||||||
|
|||||||
@ -1,23 +1,51 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import { SectionItem, QuestItem, CombatType } from "../types";
|
import { SectionItem, QuestItem, CombatType } from "../types";
|
||||||
|
import QuestDetailPanel from "./QuestDetailPanel";
|
||||||
|
import { TextWithCoords } from "./TextWithCoords";
|
||||||
|
|
||||||
|
function useWindowWidth() {
|
||||||
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setWidth(window.innerWidth);
|
||||||
|
window.addEventListener("resize", handler);
|
||||||
|
return () => window.removeEventListener("resize", handler);
|
||||||
|
}, []);
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
function combatIcon(name: string): string {
|
function combatIcon(name: string): string {
|
||||||
const l = name.toLowerCase();
|
const l = name.toLowerCase();
|
||||||
if (l.includes("solo")) return "⚔️";
|
if (l.includes("solo") || l.includes("seul")) return "🗡️";
|
||||||
if (l.includes("group") || l.includes("groupe")) return "👥";
|
if (l.includes("group") || l.includes("groupe")) return "⚔️";
|
||||||
if (l.includes("boss")) return "💀";
|
if (l.includes("donjon") || l.includes("boss")) return "💀";
|
||||||
if (l.includes("arène") || l.includes("arene")) return "🏟️";
|
return "🗡️";
|
||||||
return "⚔️";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GuideView() {
|
export default function GuideView() {
|
||||||
const { activeGuideData, completedQuests, toggleQuest } = useStore();
|
const { activeGuideData, completedQuests, toggleQuest, activeProfileId, resourcesPanelCollapsed, setResourcesPanelCollapsed, resourceInventory, setResourceQuantity } = useStore();
|
||||||
const [resourcesCollapsed, setResourcesCollapsed] = useState(false);
|
const resourcesCollapsed = resourcesPanelCollapsed;
|
||||||
|
const setResourcesCollapsed = setResourcesPanelCollapsed;
|
||||||
|
const [selectedQuest, setSelectedQuest] = useState<{ name: string; url: string | null } | null>(null);
|
||||||
|
const windowWidth = useWindowWidth();
|
||||||
|
const resourcesIsOverlay = resourcesCollapsed || windowWidth < 500;
|
||||||
|
|
||||||
if (!activeGuideData) return null;
|
if (!activeGuideData) return null;
|
||||||
|
|
||||||
|
if (selectedQuest && activeProfileId) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
|
||||||
|
<QuestDetailPanel
|
||||||
|
questName={selectedQuest.name}
|
||||||
|
questUrl={selectedQuest.url}
|
||||||
|
profileId={activeProfileId}
|
||||||
|
onClose={() => setSelectedQuest(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { name, effect, recommended_level, resources, sections, combat_legend } = activeGuideData;
|
const { name, effect, recommended_level, resources, sections, combat_legend } = activeGuideData;
|
||||||
|
|
||||||
const allQuests = collectAllQuests(sections);
|
const allQuests = collectAllQuests(sections);
|
||||||
@ -26,7 +54,7 @@ export default function GuideView() {
|
|||||||
const isDone = pct === 100;
|
const isDone = pct === 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
|
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0, position: "relative" }}>
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
|
<div style={{ flex: 1, overflowY: "auto", padding: "20px 24px" }}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -87,7 +115,7 @@ export default function GuideView() {
|
|||||||
{section.name}
|
{section.name}
|
||||||
</h2>
|
</h2>
|
||||||
{section.items.map((item, ii) => (
|
{section.items.map((item, ii) => (
|
||||||
<SectionItemView key={ii} item={item} completedQuests={completedQuests} onToggle={toggleQuest} />
|
<SectionItemView key={ii} item={item} completedQuests={completedQuests} onToggle={toggleQuest} onSelect={setSelectedQuest} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -96,25 +124,41 @@ export default function GuideView() {
|
|||||||
{/* Resources panel */}
|
{/* Resources panel */}
|
||||||
{resources.length > 0 && (
|
{resources.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: resourcesCollapsed ? "32px" : "190px",
|
position: resourcesIsOverlay ? "absolute" : "relative",
|
||||||
flexShrink: 0, background: "#161b22",
|
right: 0,
|
||||||
borderLeft: "1px solid #2d3748",
|
top: 0,
|
||||||
display: "flex", flexDirection: "column",
|
bottom: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
width: resourcesCollapsed ? "36px" : "190px",
|
||||||
|
flexShrink: 0,
|
||||||
|
background: resourcesCollapsed ? "transparent" : "#161b22",
|
||||||
|
borderLeft: resourcesCollapsed ? "none" : "1px solid #2d3748",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transition: "width 0.2s ease",
|
transition: "width 0.2s ease, background 0.2s ease",
|
||||||
}}>
|
}}>
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setResourcesCollapsed(c => !c)}
|
onClick={() => setResourcesCollapsed(!resourcesCollapsed)}
|
||||||
title={resourcesCollapsed ? "Afficher les ressources" : "Masquer les ressources"}
|
title={resourcesCollapsed ? "Afficher les ressources" : "Masquer les ressources"}
|
||||||
style={{
|
style={{
|
||||||
width: "100%", height: "36px", flexShrink: 0,
|
width: "100%",
|
||||||
background: "transparent", border: "none",
|
height: "36px",
|
||||||
|
flexShrink: 0,
|
||||||
|
background: resourcesCollapsed ? "rgba(22,27,34,0.9)" : "transparent",
|
||||||
|
border: resourcesCollapsed ? "1px solid #2d3748" : "none",
|
||||||
|
borderRight: "none",
|
||||||
|
borderRadius: resourcesCollapsed ? "6px 0 0 6px" : "0",
|
||||||
borderBottom: "1px solid #2d3748",
|
borderBottom: "1px solid #2d3748",
|
||||||
color: "#4a5568", cursor: "pointer",
|
color: "#4a5568",
|
||||||
display: "flex", alignItems: "center",
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
justifyContent: resourcesCollapsed ? "center" : "flex-start",
|
justifyContent: resourcesCollapsed ? "center" : "flex-start",
|
||||||
padding: "0 10px", gap: "6px",
|
padding: "0 10px",
|
||||||
|
gap: "6px",
|
||||||
|
marginTop: resourcesCollapsed ? "8px" : "0",
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
|
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
|
||||||
@ -138,15 +182,49 @@ export default function GuideView() {
|
|||||||
{/* List */}
|
{/* List */}
|
||||||
{!resourcesCollapsed && (
|
{!resourcesCollapsed && (
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: "10px 14px", scrollbarWidth: "none" }}>
|
<div style={{ flex: 1, overflowY: "auto", padding: "10px 14px", scrollbarWidth: "none" }}>
|
||||||
{resources.map((r, i) => (
|
{resources.map((r, i) => {
|
||||||
<div key={i} style={{
|
const owned = resourceInventory[r.name] ?? 0;
|
||||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
const done = owned >= r.quantity;
|
||||||
padding: "5px 0", borderBottom: "1px solid #1f2937", fontSize: "12px",
|
return (
|
||||||
}}>
|
<div key={i} style={{
|
||||||
<span style={{ color: "#94a3b8", flex: 1, marginRight: "8px" }}>{r.name}</span>
|
padding: "6px 0", borderBottom: "1px solid #1f2937", fontSize: "12px",
|
||||||
<span style={{ color: "#f0c040", fontWeight: 700, flexShrink: 0 }}>×{r.quantity}</span>
|
}}>
|
||||||
</div>
|
<span style={{
|
||||||
))}
|
color: done ? "#4ade80" : "#94a3b8",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
marginBottom: "3px",
|
||||||
|
} as React.CSSProperties}>
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={owned === 0 ? "" : owned}
|
||||||
|
placeholder="0"
|
||||||
|
onChange={e => {
|
||||||
|
const v = parseInt(e.target.value);
|
||||||
|
setResourceQuantity(r.name, isNaN(v) ? 0 : Math.max(0, v));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "42px", background: "#0d1117",
|
||||||
|
border: `1px solid ${done ? "rgba(74,222,128,0.4)" : "#2d3748"}`,
|
||||||
|
borderRadius: "4px", padding: "2px 4px",
|
||||||
|
color: done ? "#4ade80" : "#e2e8f0",
|
||||||
|
fontSize: "11px", outline: "none", textAlign: "right",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: done ? "#4ade80" : "#f0c040", fontWeight: 700, flexShrink: 0 }}>
|
||||||
|
/ ×{r.quantity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -179,10 +257,11 @@ function Legend({ legend }: { legend: CombatType[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionItemView({ item, completedQuests, onToggle }: {
|
function SectionItemView({ item, completedQuests, onToggle, onSelect }: {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
completedQuests: Set<string>;
|
completedQuests: Set<string>;
|
||||||
onToggle: (name: string) => void;
|
onToggle: (name: string) => void;
|
||||||
|
onSelect: (quest: { name: string; url: string | null }) => void;
|
||||||
}) {
|
}) {
|
||||||
if (item.type === "Instruction") {
|
if (item.type === "Instruction") {
|
||||||
if (item.text.startsWith("__ZONE__:")) {
|
if (item.text.startsWith("__ZONE__:")) {
|
||||||
@ -209,7 +288,7 @@ function SectionItemView({ item, completedQuests, onToggle }: {
|
|||||||
<span style={{ color: "#4a9eff", fontSize: "10px", fontWeight: 600, display: "block", marginBottom: "2px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
<span style={{ color: "#4a9eff", fontSize: "10px", fontWeight: 600, display: "block", marginBottom: "2px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
Rappel
|
Rappel
|
||||||
</span>
|
</span>
|
||||||
{item.text}
|
<TextWithCoords text={item.text} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -226,32 +305,32 @@ function SectionItemView({ item, completedQuests, onToggle }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.quests.map((q, i) => (
|
{item.quests.map((q, i) => (
|
||||||
<QuestRow key={i} quest={q} indent completed={completedQuests.has(q.name)} onToggle={onToggle} />
|
<QuestRow key={i} quest={q} indent completed={completedQuests.has(q.name)} onToggle={onToggle} onSelect={onSelect} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === "Quest") {
|
if (item.type === "Quest") {
|
||||||
return <QuestRow quest={item} completed={completedQuests.has(item.name)} onToggle={onToggle} />;
|
return <QuestRow quest={item} completed={completedQuests.has(item.name)} onToggle={onToggle} onSelect={onSelect} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuestRow({ quest, completed, onToggle, indent }: {
|
function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
|
||||||
quest: QuestItem;
|
quest: QuestItem;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
onToggle: (name: string) => void;
|
onToggle: (name: string) => void;
|
||||||
|
onSelect: (quest: { name: string; url: string | null }) => void;
|
||||||
indent?: boolean;
|
indent?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onToggle(quest.name)}
|
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "flex-start", gap: "8px",
|
display: "flex", alignItems: "flex-start", gap: "8px",
|
||||||
padding: indent ? "3px 0" : "4px 6px",
|
padding: indent ? "3px 0" : "4px 6px",
|
||||||
borderRadius: "5px", cursor: "pointer",
|
borderRadius: "5px",
|
||||||
marginBottom: indent ? "1px" : "2px",
|
marginBottom: indent ? "1px" : "2px",
|
||||||
opacity: completed ? 0.5 : 1,
|
opacity: completed ? 0.5 : 1,
|
||||||
transition: "all 0.12s",
|
transition: "all 0.12s",
|
||||||
@ -263,21 +342,19 @@ function QuestRow({ quest, completed, onToggle, indent }: {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={completed}
|
checked={completed}
|
||||||
onChange={() => onToggle(quest.name)}
|
onChange={() => onToggle(quest.name)}
|
||||||
onClick={e => e.stopPropagation()}
|
style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }}
|
||||||
style={{ marginTop: "2px", flexShrink: 0 }}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: "flex", alignItems: "baseline", flexWrap: "wrap", gap: "2px 8px" }}>
|
<div style={{ display: "flex", alignItems: "baseline", flexWrap: "wrap", gap: "2px 8px" }}>
|
||||||
<span style={{
|
<span
|
||||||
fontSize: "12px", lineHeight: 1.4,
|
onClick={() => onSelect({ name: quest.name, url: quest.url })}
|
||||||
color: completed ? "#4a5568" : quest.url ? "#93c5fd" : "#e2e8f0",
|
style={{
|
||||||
textDecoration: completed ? "line-through" : quest.url ? "underline" : "none",
|
fontSize: "12px", lineHeight: 1.4,
|
||||||
textDecorationColor: "rgba(147,197,253,0.4)",
|
color: completed ? "#4a5568" : "#93c5fd",
|
||||||
cursor: quest.url ? "pointer" : "default",
|
textDecoration: completed ? "line-through" : "underline",
|
||||||
wordBreak: "break-word",
|
textDecorationColor: "rgba(147,197,253,0.3)",
|
||||||
}}
|
cursor: "pointer",
|
||||||
onClick={e => {
|
wordBreak: "break-word",
|
||||||
if (quest.url) { e.stopPropagation(); openUrl(quest.url); }
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{quest.name}
|
{quest.name}
|
||||||
@ -290,7 +367,7 @@ function QuestRow({ quest, completed, onToggle, indent }: {
|
|||||||
</div>
|
</div>
|
||||||
{quest.note && (
|
{quest.note && (
|
||||||
<div style={{ fontSize: "11px", color: "#4a5568", marginTop: "2px", fontStyle: "italic" }}>
|
<div style={{ fontSize: "11px", color: "#4a5568", marginTop: "2px", fontStyle: "italic" }}>
|
||||||
→ {quest.note}
|
→ <TextWithCoords text={quest.note} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
272
src/components/QuestDetailPanel.tsx
Normal file
272
src/components/QuestDetailPanel.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { QuestStep } from "../types";
|
||||||
|
import { TextWithCoords } from "./TextWithCoords";
|
||||||
|
|
||||||
|
const PREVIEW_LENGTH = 280;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
questName: string;
|
||||||
|
questUrl: string | null;
|
||||||
|
profileId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuestDetailPanel({ questName, questUrl, profileId, onClose }: Props) {
|
||||||
|
const [steps, setSteps] = useState<QuestStep[]>([]);
|
||||||
|
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
||||||
|
const [expandedSteps, setExpandedSteps] = useState<Set<number>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!questUrl) {
|
||||||
|
setError("Aucun lien disponible pour cette quête.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
invoke<QuestStep[]>("fetch_quest_detail", { url: questUrl }),
|
||||||
|
invoke<number[]>("get_completed_steps", { profileId, questName }),
|
||||||
|
]).then(([fetchedSteps, completedIndices]) => {
|
||||||
|
setSteps(fetchedSteps);
|
||||||
|
setCompletedSteps(new Set(completedIndices));
|
||||||
|
}).catch(e => {
|
||||||
|
setError(`Impossible de charger la page : ${e}`);
|
||||||
|
}).finally(() => setLoading(false));
|
||||||
|
}, [questUrl, questName, profileId]);
|
||||||
|
|
||||||
|
const toggleStep = async (index: number) => {
|
||||||
|
const isNow = await invoke<boolean>("toggle_quest_step", { profileId, questName, stepIndex: index });
|
||||||
|
setCompletedSteps(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (isNow) next.add(index); else next.delete(index);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpanded = (index: number) => {
|
||||||
|
setExpandedSteps(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(index)) next.delete(index); else next.add(index);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstIsHeader = steps.length > 0 && steps[0].launch_position != null;
|
||||||
|
const headerStep = firstIsHeader ? steps[0] : null;
|
||||||
|
const actionSteps = firstIsHeader ? steps.slice(1) : steps;
|
||||||
|
const completedCount = actionSteps.filter(s => completedSteps.has(s.index)).length;
|
||||||
|
const pct = actionSteps.length > 0 ? Math.round((completedCount / actionSteps.length) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minHeight: 0 }}>
|
||||||
|
{/* Title bar */}
|
||||||
|
<div style={{
|
||||||
|
padding: "12px 16px", borderBottom: "1px solid #2d3748",
|
||||||
|
background: "#161b22", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "6px" }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "transparent", border: "1px solid #2d3748",
|
||||||
|
borderRadius: "5px", color: "#94a3b8", cursor: "pointer",
|
||||||
|
fontSize: "11px", padding: "3px 8px", flexShrink: 0, transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "#f0c040"; (e.currentTarget as HTMLElement).style.color = "#f0c040"; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "#2d3748"; (e.currentTarget as HTMLElement).style.color = "#94a3b8"; }}
|
||||||
|
>
|
||||||
|
← Retour
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: "13px", fontWeight: 600, color: "#e2e8f0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{questName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionSteps.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "11px", color: "#94a3b8", marginBottom: "4px" }}>
|
||||||
|
<span>{completedCount}/{actionSteps.length} étapes</span>
|
||||||
|
<span style={{ color: pct === 100 ? "#4ade80" : "#f0c040", fontWeight: 600 }}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden" }}>
|
||||||
|
<div style={{
|
||||||
|
height: "100%", width: `${pct}%`,
|
||||||
|
background: pct === 100 ? "#4ade80" : "linear-gradient(90deg, #4a9eff, #f0c040)",
|
||||||
|
transition: "width 0.3s ease", borderRadius: "2px",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{questUrl && (
|
||||||
|
<button
|
||||||
|
onClick={() => openUrl(questUrl)}
|
||||||
|
style={{
|
||||||
|
marginTop: "6px", background: "transparent", border: "none",
|
||||||
|
color: "#4a5568", fontSize: "10px", cursor: "pointer", padding: 0,
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = "#93c5fd")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = "#4a5568")}
|
||||||
|
>
|
||||||
|
Ouvrir sur Dofus Pour Les Noobs ↗
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "12px 16px", scrollbarWidth: "none" }}>
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "120px", color: "#4a5568", fontSize: "13px" }}>
|
||||||
|
<span style={{ animation: "spin 1s linear infinite", display: "inline-block", marginRight: "8px" }}>↻</span>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: "12px", background: "rgba(248,113,113,0.08)",
|
||||||
|
border: "1px solid rgba(248,113,113,0.2)", borderRadius: "6px",
|
||||||
|
color: "#f87171", fontSize: "12px",
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && steps.length === 0 && (
|
||||||
|
<div style={{ color: "#4a5568", fontSize: "12px", textAlign: "center", paddingTop: "40px" }}>
|
||||||
|
Aucune étape trouvée sur la page.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quest info header (first step) */}
|
||||||
|
{!loading && headerStep && (
|
||||||
|
<QuestHeader step={headerStep} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action steps */}
|
||||||
|
{!loading && actionSteps.map((step) => {
|
||||||
|
const done = completedSteps.has(step.index);
|
||||||
|
const expanded = expandedSteps.has(step.index);
|
||||||
|
const needsTruncate = step.text.length > PREVIEW_LENGTH;
|
||||||
|
const displayText = needsTruncate && !expanded
|
||||||
|
? step.text.slice(0, PREVIEW_LENGTH).trimEnd() + "…"
|
||||||
|
: step.text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.index} style={{
|
||||||
|
marginBottom: "8px",
|
||||||
|
background: done ? "rgba(74,222,128,0.04)" : "rgba(255,255,255,0.02)",
|
||||||
|
border: `1px solid ${done ? "rgba(74,222,128,0.2)" : "#2d3748"}`,
|
||||||
|
borderRadius: "7px", padding: "10px 12px",
|
||||||
|
opacity: done ? 0.65 : 1, transition: "all 0.15s",
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", gap: "10px", alignItems: "flex-start" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={done}
|
||||||
|
onChange={() => toggleStep(step.index)}
|
||||||
|
style={{ marginTop: "2px", flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: "12px", color: done ? "#4a5568" : "#94a3b8",
|
||||||
|
lineHeight: 1.6, whiteSpace: "pre-wrap", wordBreak: "break-word",
|
||||||
|
textDecoration: done ? "line-through" : "none",
|
||||||
|
}}>
|
||||||
|
<TextWithCoords text={displayText} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsTruncate && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpanded(step.index)}
|
||||||
|
style={{
|
||||||
|
marginTop: "4px", background: "transparent", border: "none",
|
||||||
|
color: "#4a9eff", fontSize: "11px", cursor: "pointer",
|
||||||
|
padding: 0, textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? "Voir moins" : "Voir plus"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step.images.length > 0 && (
|
||||||
|
<div style={{ marginTop: "6px", display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||||
|
{step.images.map((src, j) => (
|
||||||
|
<button
|
||||||
|
key={j}
|
||||||
|
onClick={() => openUrl(src)}
|
||||||
|
style={{
|
||||||
|
background: "rgba(74,158,255,0.08)", border: "1px solid rgba(74,158,255,0.25)",
|
||||||
|
borderRadius: "4px", color: "#4a9eff", fontSize: "10px",
|
||||||
|
padding: "2px 8px", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = "rgba(74,158,255,0.18)")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = "rgba(74,158,255,0.08)")}
|
||||||
|
>
|
||||||
|
🖼 Image {j + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestHeader({ step }: { step: QuestStep }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: "14px",
|
||||||
|
border: "1px solid #2d3748",
|
||||||
|
borderRadius: "8px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: "10px 14px", display: "flex", alignItems: "baseline", gap: "8px", fontSize: "12px" }}>
|
||||||
|
<span style={{ fontSize: "13px", flexShrink: 0 }}>📍</span>
|
||||||
|
<span style={{
|
||||||
|
color: "#4a5568", fontWeight: 600, fontSize: "10px",
|
||||||
|
textTransform: "uppercase", letterSpacing: "0.05em",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
Position
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#cbd5e1", lineHeight: 1.5, wordBreak: "break-word" }}>
|
||||||
|
<TextWithCoords text={step.launch_position!} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step.images.length > 0 && (
|
||||||
|
<div style={{ padding: "0 14px 10px", display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||||
|
{step.images.map((src, j) => (
|
||||||
|
<button
|
||||||
|
key={j}
|
||||||
|
onClick={() => openUrl(src)}
|
||||||
|
style={{
|
||||||
|
background: "rgba(240,192,64,0.08)", border: "1px solid rgba(240,192,64,0.2)",
|
||||||
|
borderRadius: "4px", color: "#f0c040", fontSize: "10px",
|
||||||
|
padding: "2px 8px", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = "rgba(240,192,64,0.15)")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = "rgba(240,192,64,0.08)")}
|
||||||
|
>
|
||||||
|
🖼 Image {j + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/components/SettingsPanel.tsx
Normal file
204
src/components/SettingsPanel.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
|
||||||
|
export default function SettingsPanel({ onClose }: { onClose: () => void }) {
|
||||||
|
const {
|
||||||
|
profiles, activeProfileId, setActiveProfile, createProfile, deleteProfile,
|
||||||
|
syncGuides, syncing, syncProgress,
|
||||||
|
} = useStore();
|
||||||
|
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [profileError, setProfileError] = useState("");
|
||||||
|
const [syncErrors, setSyncErrors] = useState<string[]>([]);
|
||||||
|
const [syncDone, setSyncDone] = useState(false);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (profiles.find(p => p.name === name)) {
|
||||||
|
setProfileError("Un profil avec ce nom existe déjà.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createProfile(name);
|
||||||
|
setNewName("");
|
||||||
|
setProfileError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (profiles.length <= 1) {
|
||||||
|
setProfileError("Vous ne pouvez pas supprimer le dernier profil.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteProfile(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSync() {
|
||||||
|
setSyncErrors([]);
|
||||||
|
setSyncDone(false);
|
||||||
|
const result = await syncGuides();
|
||||||
|
setSyncErrors(result.errors);
|
||||||
|
setSyncDone(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current = 0, total = 0, label = "" } = syncProgress ?? {};
|
||||||
|
const syncPct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: "fixed", inset: "40px 0 0 0",
|
||||||
|
background: "#0d1117", zIndex: 50,
|
||||||
|
display: "flex", flexDirection: "column", overflow: "hidden",
|
||||||
|
borderTop: "1px solid #2d3748",
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
padding: "14px 20px", borderBottom: "1px solid #2d3748", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: "14px", fontWeight: 700, color: "#f0c040" }}>Paramètres</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: "none", border: "none", color: "#94a3b8", cursor: "pointer", fontSize: "16px", lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "20px", display: "flex", flexDirection: "column", gap: "28px", scrollbarWidth: "none" }}>
|
||||||
|
|
||||||
|
{/* ── Profils ── */}
|
||||||
|
<section>
|
||||||
|
<SectionTitle>Profils</SectionTitle>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
|
||||||
|
{profiles.map(profile => {
|
||||||
|
const isActive = profile.id === activeProfileId;
|
||||||
|
return (
|
||||||
|
<div key={profile.id} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: "8px",
|
||||||
|
background: isActive ? "rgba(240,192,64,0.07)" : "#161b22",
|
||||||
|
border: `1px solid ${isActive ? "rgba(240,192,64,0.35)" : "#2d3748"}`,
|
||||||
|
borderRadius: "8px", padding: "9px 12px",
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveProfile(profile.id); setProfileError(""); }}
|
||||||
|
style={{ flex: 1, background: "none", border: "none", textAlign: "left", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "13px", fontWeight: 600, color: isActive ? "#f0c040" : "#e2e8f0" }}>
|
||||||
|
{isActive && "✓ "}{profile.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "10px", color: "#4a5568", marginTop: "2px" }}>
|
||||||
|
Créé le {new Date(profile.created_at).toLocaleDateString("fr-FR")}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{profiles.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(profile.id)}
|
||||||
|
title="Supprimer ce profil"
|
||||||
|
style={{ background: "none", border: "none", color: "#f87171", cursor: "pointer", padding: "4px", borderRadius: "4px", fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={e => { setNewName(e.target.value); setProfileError(""); }}
|
||||||
|
onKeyDown={e => e.key === "Enter" && handleCreate()}
|
||||||
|
placeholder="Nouveau profil…"
|
||||||
|
style={{
|
||||||
|
flex: 1, background: "#161b22", border: "1px solid #2d3748",
|
||||||
|
borderRadius: "6px", padding: "7px 10px", color: "#e2e8f0",
|
||||||
|
fontSize: "12px", outline: "none",
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.target.style.borderColor = "#f0c040")}
|
||||||
|
onBlur={e => (e.target.style.borderColor = "#2d3748")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
style={{
|
||||||
|
background: "#f0c040", color: "#0d1117", border: "none",
|
||||||
|
borderRadius: "6px", padding: "7px 14px", fontWeight: 700,
|
||||||
|
fontSize: "12px", cursor: newName.trim() ? "pointer" : "default",
|
||||||
|
opacity: newName.trim() ? 1 : 0.4, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{profileError && <p style={{ fontSize: "11px", color: "#f87171", marginTop: "6px" }}>{profileError}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Synchronisation ── */}
|
||||||
|
<section>
|
||||||
|
<SectionTitle>Synchronisation</SectionTitle>
|
||||||
|
|
||||||
|
<p style={{ fontSize: "12px", color: "#4a5568", marginBottom: "12px", lineHeight: 1.5 }}>
|
||||||
|
Met à jour tous les guides depuis Google Sheets.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={syncing}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "9px", borderRadius: "7px",
|
||||||
|
background: syncing ? "rgba(74,158,255,0.08)" : "rgba(74,158,255,0.12)",
|
||||||
|
border: "1px solid rgba(74,158,255,0.3)",
|
||||||
|
color: syncing ? "#4a5568" : "#4a9eff",
|
||||||
|
fontSize: "13px", fontWeight: 600, cursor: syncing ? "default" : "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: "inline-block", animation: syncing ? "spin 1s linear infinite" : "none" }}>↻</span>
|
||||||
|
{syncing ? "Synchronisation…" : "Synchroniser maintenant"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{syncing && syncProgress && (
|
||||||
|
<div style={{ marginTop: "12px" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "11px", color: "#94a3b8", marginBottom: "6px" }}>
|
||||||
|
<span style={{ color: "#f0c040" }}>{label}</span>
|
||||||
|
<span>{current}/{total} — {syncPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: "3px", background: "#2d3748", borderRadius: "2px", overflow: "hidden" }}>
|
||||||
|
<div style={{
|
||||||
|
height: "100%", width: `${syncPct}%`,
|
||||||
|
background: "linear-gradient(90deg, #4a9eff, #f0c040)",
|
||||||
|
transition: "width 0.3s ease", borderRadius: "2px",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{syncDone && !syncing && (
|
||||||
|
<div style={{ marginTop: "10px", fontSize: "12px", color: syncErrors.length === 0 ? "#4ade80" : "#f87171" }}>
|
||||||
|
{syncErrors.length === 0
|
||||||
|
? "✓ Synchronisation terminée."
|
||||||
|
: `⚠ ${syncErrors.length} erreur(s) :\n${syncErrors.join("\n")}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
fontSize: "10px", fontWeight: 700, color: "#4a5568",
|
||||||
|
textTransform: "uppercase", letterSpacing: "0.1em",
|
||||||
|
marginBottom: "10px", paddingBottom: "6px",
|
||||||
|
borderBottom: "1px solid #2d3748",
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,23 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
|
|
||||||
|
function useWindowWidth() {
|
||||||
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setWidth(window.innerWidth);
|
||||||
|
window.addEventListener("resize", handler);
|
||||||
|
return () => window.removeEventListener("resize", handler);
|
||||||
|
}, []);
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { guides, openGuide, activeGuideGid, view } = useStore();
|
const { guides, openGuide, activeGuideGid, view, sidebarCollapsed, setSidebarCollapsed } = useStore();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const collapsed = sidebarCollapsed;
|
||||||
|
const setCollapsed = setSidebarCollapsed;
|
||||||
|
const windowWidth = useWindowWidth();
|
||||||
|
const isOverlay = collapsed || windowWidth < 500;
|
||||||
|
|
||||||
const filtered = guides.filter(g =>
|
const filtered = guides.filter(g =>
|
||||||
g.name.toLowerCase().includes(search.toLowerCase())
|
g.name.toLowerCase().includes(search.toLowerCase())
|
||||||
@ -12,27 +25,40 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside style={{
|
<aside style={{
|
||||||
width: collapsed ? "32px" : "220px",
|
position: isOverlay ? "absolute" : "relative",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
width: collapsed ? "36px" : "190px",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
background: "#161b22",
|
background: collapsed ? "transparent" : "#161b22",
|
||||||
borderRight: "1px solid #2d3748",
|
borderRight: collapsed ? "none" : "1px solid #2d3748",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transition: "width 0.2s ease",
|
transition: "width 0.2s ease, background 0.2s ease",
|
||||||
}}>
|
}}>
|
||||||
{/* Toggle button */}
|
{/* Toggle button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(c => !c)}
|
onClick={() => setCollapsed(c => !c)}
|
||||||
title={collapsed ? "Ouvrir le menu" : "Réduire le menu"}
|
title={collapsed ? "Ouvrir le menu" : "Réduire le menu"}
|
||||||
style={{
|
style={{
|
||||||
width: "100%", height: "36px", flexShrink: 0,
|
width: "100%",
|
||||||
background: "transparent", border: "none",
|
height: "36px",
|
||||||
borderBottom: "1px solid #2d3748",
|
flexShrink: 0,
|
||||||
color: "#4a5568", cursor: "pointer",
|
background: collapsed ? "rgba(22,27,34,0.9)" : "transparent",
|
||||||
display: "flex", alignItems: "center",
|
border: collapsed ? "1px solid #2d3748" : "none",
|
||||||
|
borderLeft: "none",
|
||||||
|
borderBottom: collapsed ? "1px solid #2d3748" : "1px solid #2d3748",
|
||||||
|
borderRadius: collapsed ? "0 6px 6px 0" : "0",
|
||||||
|
color: "#4a5568",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
justifyContent: collapsed ? "center" : "flex-end",
|
justifyContent: collapsed ? "center" : "flex-end",
|
||||||
padding: "0 10px",
|
padding: "0 10px",
|
||||||
|
marginTop: collapsed ? "8px" : "0",
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
|
onMouseEnter={e => (e.currentTarget.style.color = "#f0c040")}
|
||||||
@ -59,7 +85,7 @@ export default function Sidebar() {
|
|||||||
style={{
|
style={{
|
||||||
width: "100%", background: "#0d1117", border: "1px solid #2d3748",
|
width: "100%", background: "#0d1117", border: "1px solid #2d3748",
|
||||||
borderRadius: "6px", padding: "6px 10px", color: "#e2e8f0",
|
borderRadius: "6px", padding: "6px 10px", color: "#e2e8f0",
|
||||||
fontSize: "12px", outline: "none",
|
fontSize: "12px", outline: "none", boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
onFocus={e => (e.target.style.borderColor = "#f0c040")}
|
onFocus={e => (e.target.style.borderColor = "#f0c040")}
|
||||||
onBlur={e => (e.target.style.borderColor = "#2d3748")}
|
onBlur={e => (e.target.style.borderColor = "#2d3748")}
|
||||||
@ -96,13 +122,18 @@ export default function Sidebar() {
|
|||||||
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
|
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "4px" }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: "12px", fontWeight: isActive ? 600 : 400,
|
fontSize: "12px", fontWeight: isActive ? 600 : 400,
|
||||||
color: isActive ? "#f0c040" : "#e2e8f0",
|
color: isActive ? "#f0c040" : "#e2e8f0",
|
||||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
display: "-webkit-box",
|
||||||
maxWidth: "140px",
|
WebkitLineClamp: 2,
|
||||||
}}>
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
flex: 1,
|
||||||
|
} as React.CSSProperties}>
|
||||||
{guide.name}
|
{guide.name}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|||||||
53
src/components/TextWithCoords.tsx
Normal file
53
src/components/TextWithCoords.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const COORD_RE = /\[(-?\d+),\s*(-?\d+)\]/g;
|
||||||
|
|
||||||
|
export function TextWithCoords({ text, style }: { text: string; style?: React.CSSProperties }) {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
COORD_RE.lastIndex = 0;
|
||||||
|
while ((match = COORD_RE.exec(text)) !== null) {
|
||||||
|
if (match.index > last) parts.push(text.slice(last, match.index));
|
||||||
|
parts.push(<CoordBadge key={match.index} x={match[1]} y={match[2]} raw={match[0]} />);
|
||||||
|
last = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) parts.push(text.slice(last));
|
||||||
|
|
||||||
|
return <span style={style}>{parts}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoordBadge({ x, y, raw }: { x: string; y: string; raw: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function handleClick(e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
await navigator.clipboard.writeText(`/travel ${x},${y}`);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={handleClick}
|
||||||
|
title={`Copier /travel ${x},${y}`}
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: copied ? "rgba(74,222,128,0.15)" : "rgba(74,158,255,0.1)",
|
||||||
|
border: `1px solid ${copied ? "rgba(74,222,128,0.4)" : "rgba(74,158,255,0.3)"}`,
|
||||||
|
borderRadius: "3px",
|
||||||
|
padding: "0 5px",
|
||||||
|
color: copied ? "#4ade80" : "#93c5fd",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
userSelect: "none",
|
||||||
|
transition: "background 0.15s, color 0.15s, border-color 0.15s",
|
||||||
|
verticalAlign: "baseline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "✓ copié" : raw}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,14 +2,14 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
|||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onOpenProfiles: () => void;
|
onOpenSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TitleBar({ onOpenProfiles }: Props) {
|
export default function TitleBar({ onOpenSettings }: Props) {
|
||||||
const { alwaysOnTop, toggleAlwaysOnTop, syncing, syncGuides, view, closeGuide, activeGuideData } = useStore();
|
const { view, closeGuide, activeGuideData } = useStore();
|
||||||
|
|
||||||
function handleDragMouseDown(e: React.MouseEvent) {
|
function handleDragMouseDown(e: React.MouseEvent) {
|
||||||
if (e.button === 0 && !alwaysOnTop) {
|
if (e.button === 0) {
|
||||||
getCurrentWindow().startDragging();
|
getCurrentWindow().startDragging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -36,7 +36,7 @@ export default function TitleBar({ onOpenProfiles }: Props) {
|
|||||||
onMouseDown={handleDragMouseDown}
|
onMouseDown={handleDragMouseDown}
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", gap: "8px", flex: 1,
|
display: "flex", alignItems: "center", gap: "8px", flex: 1,
|
||||||
cursor: alwaysOnTop ? "default" : "grab", userSelect: "none",
|
cursor: "grab", userSelect: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src="/logo_tougli.png" style={{ pointerEvents: "none", width: "24px", height: "24px", objectFit: "contain" }} />
|
<img src="/logo_tougli.png" style={{ pointerEvents: "none", width: "24px", height: "24px", objectFit: "contain" }} />
|
||||||
@ -59,24 +59,8 @@ export default function TitleBar({ onOpenProfiles }: Props) {
|
|||||||
</TitleButton>
|
</TitleButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TitleButton onClick={onOpenProfiles} title="Gérer les profils">
|
<TitleButton onClick={onOpenSettings} title="Paramètres">
|
||||||
👤
|
⚙
|
||||||
</TitleButton>
|
|
||||||
|
|
||||||
<TitleButton
|
|
||||||
onClick={syncGuides}
|
|
||||||
title="Synchroniser avec Google Sheets"
|
|
||||||
disabled={syncing}
|
|
||||||
>
|
|
||||||
{syncing ? <SpinIcon /> : "↻"}
|
|
||||||
</TitleButton>
|
|
||||||
|
|
||||||
<TitleButton
|
|
||||||
onClick={toggleAlwaysOnTop}
|
|
||||||
title={alwaysOnTop ? "Désactiver fenêtre flottante" : "Activer fenêtre flottante"}
|
|
||||||
active={alwaysOnTop}
|
|
||||||
>
|
|
||||||
📌
|
|
||||||
</TitleButton>
|
</TitleButton>
|
||||||
|
|
||||||
<div style={{ width: "1px", height: "16px", background: "#2d3748", margin: "0 4px" }} />
|
<div style={{ width: "1px", height: "16px", background: "#2d3748", margin: "0 4px" }} />
|
||||||
@ -134,8 +118,3 @@ function TitleButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpinIcon() {
|
|
||||||
return (
|
|
||||||
<span style={{ display: "inline-block", animation: "spin 1s linear infinite" }}>↻</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
39
src/store.ts
39
src/store.ts
@ -9,11 +9,15 @@ interface AppState {
|
|||||||
activeGuideGid: string | null;
|
activeGuideGid: string | null;
|
||||||
activeGuideData: GuideData | null;
|
activeGuideData: GuideData | null;
|
||||||
completedQuests: Set<string>;
|
completedQuests: Set<string>;
|
||||||
alwaysOnTop: boolean;
|
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
syncProgress: { current: number; total: number; label: string } | null;
|
syncProgress: { current: number; total: number; label: string } | null;
|
||||||
view: "home" | "guide";
|
view: "home" | "guide";
|
||||||
|
resourcesPanelCollapsed: boolean;
|
||||||
|
resourceInventory: Record<string, number>;
|
||||||
|
|
||||||
|
setResourcesPanelCollapsed: (v: boolean) => void;
|
||||||
|
loadResourceInventory: () => Promise<void>;
|
||||||
|
setResourceQuantity: (name: string, qty: number) => Promise<void>;
|
||||||
loadProfiles: () => Promise<void>;
|
loadProfiles: () => Promise<void>;
|
||||||
setActiveProfile: (id: string) => Promise<void>;
|
setActiveProfile: (id: string) => Promise<void>;
|
||||||
createProfile: (name: string) => Promise<void>;
|
createProfile: (name: string) => Promise<void>;
|
||||||
@ -27,7 +31,6 @@ interface AppState {
|
|||||||
|
|
||||||
syncGuides: () => Promise<SyncResult>;
|
syncGuides: () => Promise<SyncResult>;
|
||||||
syncSingleGuide: (gid: string, name: string) => Promise<void>;
|
syncSingleGuide: (gid: string, name: string) => Promise<void>;
|
||||||
toggleAlwaysOnTop: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStore = create<AppState>((set, get) => ({
|
export const useStore = create<AppState>((set, get) => ({
|
||||||
@ -37,10 +40,29 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
activeGuideGid: null,
|
activeGuideGid: null,
|
||||||
activeGuideData: null,
|
activeGuideData: null,
|
||||||
completedQuests: new Set(),
|
completedQuests: new Set(),
|
||||||
alwaysOnTop: true,
|
|
||||||
syncing: false,
|
syncing: false,
|
||||||
syncProgress: null,
|
syncProgress: null,
|
||||||
view: "home",
|
view: "home",
|
||||||
|
resourcesPanelCollapsed: false,
|
||||||
|
resourceInventory: {},
|
||||||
|
|
||||||
|
setResourcesPanelCollapsed: (v) => set({ resourcesPanelCollapsed: v }),
|
||||||
|
|
||||||
|
loadResourceInventory: async () => {
|
||||||
|
const { activeProfileId } = get();
|
||||||
|
if (!activeProfileId) return;
|
||||||
|
const rows = await invoke<[string, number][]>("get_resource_inventory", { profileId: activeProfileId });
|
||||||
|
const inventory: Record<string, number> = {};
|
||||||
|
for (const [name, qty] of rows) inventory[name] = qty;
|
||||||
|
set({ resourceInventory: inventory });
|
||||||
|
},
|
||||||
|
|
||||||
|
setResourceQuantity: async (name, qty) => {
|
||||||
|
const { activeProfileId } = get();
|
||||||
|
if (!activeProfileId) return;
|
||||||
|
set(state => ({ resourceInventory: { ...state.resourceInventory, [name]: qty } }));
|
||||||
|
await invoke("set_resource_quantity", { profileId: activeProfileId, resourceName: name, quantity: qty });
|
||||||
|
},
|
||||||
|
|
||||||
loadProfiles: async () => {
|
loadProfiles: async () => {
|
||||||
const profiles = await invoke<Profile[]>("get_profiles");
|
const profiles = await invoke<Profile[]>("get_profiles");
|
||||||
@ -50,6 +72,7 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
if (activeId) {
|
if (activeId) {
|
||||||
const completed = await invoke<string[]>("get_completed_quests", { profileId: activeId });
|
const completed = await invoke<string[]>("get_completed_quests", { profileId: activeId });
|
||||||
set({ completedQuests: new Set(completed) });
|
set({ completedQuests: new Set(completed) });
|
||||||
|
await get().loadResourceInventory();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -57,7 +80,7 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
await invoke("set_setting", { key: "active_profile", value: id });
|
await invoke("set_setting", { key: "active_profile", value: id });
|
||||||
const completed = await invoke<string[]>("get_completed_quests", { profileId: id });
|
const completed = await invoke<string[]>("get_completed_quests", { profileId: id });
|
||||||
set({ activeProfileId: id, completedQuests: new Set(completed) });
|
set({ activeProfileId: id, completedQuests: new Set(completed) });
|
||||||
await get().loadGuides();
|
await Promise.all([get().loadGuides(), get().loadResourceInventory()]);
|
||||||
},
|
},
|
||||||
|
|
||||||
createProfile: async (name) => {
|
createProfile: async (name) => {
|
||||||
@ -84,10 +107,12 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
|
|
||||||
openGuide: async (gid) => {
|
openGuide: async (gid) => {
|
||||||
const data = await invoke<GuideData>("get_guide", { gid });
|
const data = await invoke<GuideData>("get_guide", { gid });
|
||||||
|
await invoke("set_setting", { key: "active_guide", value: gid });
|
||||||
set({ activeGuideGid: gid, activeGuideData: data, view: "guide" });
|
set({ activeGuideGid: gid, activeGuideData: data, view: "guide" });
|
||||||
},
|
},
|
||||||
|
|
||||||
closeGuide: () => {
|
closeGuide: () => {
|
||||||
|
invoke("set_setting", { key: "active_guide", value: "" });
|
||||||
set({ activeGuideGid: null, activeGuideData: null, view: "home" });
|
set({ activeGuideGid: null, activeGuideData: null, view: "home" });
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -133,10 +158,4 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
syncSingleGuide: async (gid, name) => {
|
syncSingleGuide: async (gid, name) => {
|
||||||
await invoke("sync_single_guide", { gid, name });
|
await invoke("sync_single_guide", { gid, name });
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleAlwaysOnTop: async () => {
|
|
||||||
const next = !get().alwaysOnTop;
|
|
||||||
await invoke("set_always_on_top", { value: next });
|
|
||||||
set({ alwaysOnTop: next });
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -68,3 +68,10 @@ export interface SyncResult {
|
|||||||
synced: number;
|
synced: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuestStep {
|
||||||
|
index: number;
|
||||||
|
text: string;
|
||||||
|
images: string[];
|
||||||
|
launch_position: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user